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:
mesalogo
2026-08-20 10:11:06 +08:00
parent 20c15f74c6
commit f44a0dc907
129 changed files with 15511 additions and 2112 deletions
@@ -0,0 +1,195 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import {
requestProcessTreeTermination,
terminateProcessTreeAndWait,
waitForProcessExit,
type WaitableProcessTreeChild
} from './child-process-termination'
function fakeChild(
pid = 42
): WaitableProcessTreeChild & EventEmitter {
const child =
new EventEmitter() as WaitableProcessTreeChild & EventEmitter
child.exitCode = null
child.pid = pid
child.kill = vi.fn()
return child
}
describe('child process tree termination', () => {
it('uses taskkill /T /F on Windows and bounds both exit waits', async () => {
vi.useFakeTimers()
try {
const child = fakeChild(314)
const killer = fakeChild(315)
killer.unref = vi.fn()
const spawnMock = vi.fn(() => killer)
const termination = terminateProcessTreeAndWait(child, {
platform: 'win32',
spawn: spawnMock,
waitMs: 25
})
await vi.advanceTimersByTimeAsync(25)
await vi.advanceTimersByTimeAsync(25)
await expect(termination).resolves.toBeUndefined()
expect(spawnMock).toHaveBeenCalledWith(
'taskkill.exe',
['/PID', '314', '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
expect(killer.unref).toHaveBeenCalledOnce()
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
} finally {
vi.useRealTimers()
}
})
it('falls back to direct termination when Windows taskkill fails', async () => {
const child = fakeChild(314)
const killer = fakeChild(315)
const spawnMock = vi.fn(() => killer)
const termination = terminateProcessTreeAndWait(child, {
platform: 'win32',
spawn: spawnMock,
waitMs: 1_000
})
killer.exitCode = 1
killer.emit('close', 1, null)
await Promise.resolve()
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
child.exitCode = 0
child.emit('close', 0, null)
await expect(termination).resolves.toBeUndefined()
})
it('terminates a detached POSIX process group with the requested signal', () => {
const child = fakeChild(2718)
const killProcess = vi.fn()
requestProcessTreeTermination(child, {
platform: 'linux',
processGroup: true,
signal: 'SIGKILL',
killProcess
})
expect(killProcess).toHaveBeenCalledWith(-2718, 'SIGKILL')
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
})
it('falls back to the direct child when POSIX group termination fails', () => {
const child = fakeChild(2718)
requestProcessTreeTermination(child, {
platform: 'linux',
processGroup: true,
killProcess: vi.fn(() => {
throw new Error('not a group leader')
})
})
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
})
it('resolves exit waiting immediately on close', async () => {
const child = fakeChild()
const waiting = waitForProcessExit(child, 1_000)
child.emit('close', 0, null)
await expect(waiting).resolves.toBeUndefined()
})
it('does not terminate a child already marked as killed', () => {
const child = fakeChild()
child.killed = true
const spawnMock = vi.fn()
const killProcess = vi.fn()
expect(
requestProcessTreeTermination(child, {
platform: 'win32',
spawn: spawnMock,
killProcess
})
).toBeUndefined()
expect(spawnMock).not.toHaveBeenCalled()
expect(killProcess).not.toHaveBeenCalled()
expect(child.kill).not.toHaveBeenCalled()
})
it('supports utility-process handles without an exitCode', () => {
const child = {
killed: false,
pid: 99,
kill: vi.fn()
}
const killer = fakeChild(100)
const spawnMock = vi.fn(() => killer)
expect(
requestProcessTreeTermination(child, {
platform: 'win32',
spawn: spawnMock
})
).toBe(killer)
expect(spawnMock).toHaveBeenCalledWith(
'taskkill.exe',
['/PID', '99', '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
})
it('falls back asynchronously for a synchronous Windows caller', async () => {
vi.useFakeTimers()
try {
const child = {
pid: 99,
kill: vi.fn()
}
const killer = fakeChild(100)
requestProcessTreeTermination(child, {
platform: 'win32',
spawn: vi.fn(() => killer),
signal: 'SIGKILL',
waitMs: 25
})
await vi.advanceTimersByTimeAsync(25)
expect(child.kill).toHaveBeenCalledOnce()
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
} finally {
vi.useRealTimers()
}
})
it('does not directly kill after successful Windows tree termination', () => {
const child = {
pid: 99,
kill: vi.fn()
}
const killer = fakeChild(100)
requestProcessTreeTermination(child, {
platform: 'win32',
spawn: vi.fn(() => killer)
})
killer.exitCode = 0
killer.emit('close', 0, null)
expect(child.kill).not.toHaveBeenCalled()
})
})
+193
View File
@@ -0,0 +1,193 @@
import spawn from 'cross-spawn'
export type ProcessTreeChild = {
exitCode?: number | null
killed?: boolean
pid?: number
kill: (signal?: NodeJS.Signals) => unknown
unref?: () => unknown
}
export type WaitableProcessTreeChild = ProcessTreeChild & {
exitCode: number | null
once: (
event: 'close' | 'error',
listener: (...args: unknown[]) => void
) => unknown
removeListener?: (
event: 'close' | 'error',
listener: (...args: unknown[]) => void
) => unknown
}
export type ProcessTreeSpawn = (
command: string,
args: string[],
options: {
shell: false
stdio: 'ignore'
windowsHide: true
}
) => WaitableProcessTreeChild
export type ProcessGroupKill = (
pid: number,
signal: NodeJS.Signals
) => unknown
export type ProcessTreeTerminationOptions = {
platform?: NodeJS.Platform
spawn?: ProcessTreeSpawn
killProcess?: ProcessGroupKill
processGroup?: boolean
signal?: NodeJS.Signals
waitMs?: number
}
const DEFAULT_EXIT_WAIT_MS = 2_000
type ProcessExitResult = 'closed' | 'error' | 'timeout'
function monitorWindowsKiller(
killer: WaitableProcessTreeChild,
child: ProcessTreeChild,
signal: NodeJS.Signals,
waitMs: number
): void {
let settled = false
const fallback = (): void => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
killer.removeListener?.('close', onClose)
killer.removeListener?.('error', onError)
if (
!child.killed &&
(child.exitCode === undefined || child.exitCode === null)
) {
child.kill(signal)
}
}
const onClose = (): void => {
if (killer.exitCode === 0) {
settled = true
clearTimeout(timer)
killer.removeListener?.('error', onError)
return
}
fallback()
}
const onError = (): void => fallback()
const timer = setTimeout(fallback, waitMs)
timer.unref?.()
killer.once('close', onClose)
killer.once('error', onError)
}
function waitForProcessExitResult(
child: WaitableProcessTreeChild,
waitMs: number
): Promise<ProcessExitResult> {
if (child.exitCode !== null) {
return Promise.resolve('closed')
}
return new Promise((resolve) => {
const finish = (result: ProcessExitResult): void => {
clearTimeout(timer)
child.removeListener?.('close', onClose)
child.removeListener?.('error', onError)
resolve(result)
}
const onClose = (): void => finish('closed')
const onError = (): void => finish('error')
const timer = setTimeout(
() => finish('timeout'),
waitMs
)
timer.unref?.()
child.once('close', onClose)
child.once('error', onError)
})
}
export function waitForProcessExit(
child: WaitableProcessTreeChild,
waitMs = DEFAULT_EXIT_WAIT_MS
): Promise<void> {
return waitForProcessExitResult(child, waitMs).then(() => undefined)
}
export function requestProcessTreeTermination(
child: ProcessTreeChild,
options: ProcessTreeTerminationOptions = {}
): WaitableProcessTreeChild | undefined {
if (
(child.exitCode !== undefined && child.exitCode !== null) ||
child.killed
) {
return undefined
}
const platform = options.platform ?? process.platform
const signal = options.signal ?? 'SIGTERM'
if (platform === 'win32' && child.pid) {
try {
const killer = (options.spawn ?? spawn)(
'taskkill.exe',
['/PID', String(child.pid), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
killer.unref?.()
monitorWindowsKiller(
killer,
child,
signal,
options.waitMs ?? DEFAULT_EXIT_WAIT_MS
)
return killer
} catch {
child.kill(signal)
return undefined
}
}
if (options.processGroup && child.pid) {
try {
;(options.killProcess ?? process.kill)(-child.pid, signal)
if (child.exitCode === null) {
child.kill(signal)
}
return undefined
} catch {
// The child may not be a process-group leader. Fall back to the
// direct handle so cleanup is never weakened by that assumption.
}
}
child.kill(signal)
return undefined
}
export async function terminateProcessTreeAndWait(
child: WaitableProcessTreeChild,
options: ProcessTreeTerminationOptions = {}
): Promise<void> {
if (child.exitCode !== null) {
return
}
const exited = waitForProcessExit(
child,
options.waitMs ?? DEFAULT_EXIT_WAIT_MS
)
const killer = requestProcessTreeTermination(child, options)
if (killer) {
await waitForProcessExitResult(
killer,
options.waitMs ?? DEFAULT_EXIT_WAIT_MS
)
}
await exited
}
@@ -1124,6 +1124,263 @@ describe('ContinueHostAdapter', () => {
).rejects.toThrow('流式事件超过安全限制')
})
it.each([
{
label: 'event count',
limits: {
maximumStreamEvents: 1,
maximumStreamEventBytes: 10_000
}
},
{
label: 'event bytes',
limits: {
maximumStreamEvents: 10,
maximumStreamEventBytes: 60
}
}
])(
'enforces cumulative streamed $label across state polls',
async ({ limits }) => {
const distribution = await createDistribution()
let stateRequests = 0
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
if (String(input).endsWith('/state')) {
stateRequests += 1
return Response.json({
session: { history: [] },
isProcessing: stateRequests > 1,
messageQueueLength: 0,
pendingPermission: null,
goodbuddyEvents:
stateRequests > 1
? [{ type: 'text', delta: '1234567890' }]
: []
})
}
return Response.json({})
})
)
const forwarded: unknown[] = []
const adapter = new ContinueHostAdapter(
{
binaryPath: distribution.entryPath,
configPath: '',
workspace: process.cwd(),
cacheRoot: distribution.cacheRoot,
trustedBundleHashes: [distribution.sourceHash],
launchHost: () => ({
exitCode: null,
killed: false,
stderr: null,
once: () => undefined,
kill: () => true
}),
modelProfile: {
id: randomUUID(),
name: 'Local model',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none'
}
},
{
...limits,
maximumToolCalls: 100,
terminateProcessTree: vi.fn().mockResolvedValue(undefined)
}
)
await expect(
adapter.run(
'hello',
new AbortController().signal,
async () => 'deny',
{
onEvent: (event) => {
forwarded.push(event)
}
}
)
).rejects.toThrow('流式事件超过安全限制')
expect(forwarded).toEqual([
{ type: 'text', delta: '1234567890' }
])
}
)
it('enforces cumulative unique tool calls before forwarding a later batch', async () => {
const distribution = await createDistribution()
let stateRequests = 0
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
if (String(input).endsWith('/state')) {
stateRequests += 1
return Response.json({
session: { history: [] },
isProcessing: stateRequests > 1,
messageQueueLength: 0,
pendingPermission: null,
goodbuddyEvents:
stateRequests > 1
? [
{
type: 'tool',
callId: `call-${stateRequests}`,
name: 'Bash',
state: 'running'
}
]
: []
})
}
return Response.json({})
})
)
const forwarded: unknown[] = []
const adapter = new ContinueHostAdapter(
{
binaryPath: distribution.entryPath,
configPath: '',
workspace: process.cwd(),
cacheRoot: distribution.cacheRoot,
trustedBundleHashes: [distribution.sourceHash],
launchHost: () => ({
exitCode: null,
killed: false,
stderr: null,
once: () => undefined,
kill: () => true
}),
modelProfile: {
id: randomUUID(),
name: 'Local model',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none'
}
},
{
maximumStreamEvents: 10,
maximumStreamEventBytes: 10_000,
maximumToolCalls: 1,
terminateProcessTree: vi.fn().mockResolvedValue(undefined)
}
)
await expect(
adapter.run(
'hello',
new AbortController().signal,
async () => 'deny',
{
onEvent: (event) => {
forwarded.push(event)
}
}
)
).rejects.toThrow('工具调用超过 100 个')
expect(forwarded).toHaveLength(1)
expect(forwarded[0]).toMatchObject({
type: 'tool',
tool: { callId: 'call-2' }
})
})
it('awaits bounded process cleanup before deleting the run directory', async () => {
const distribution = await createDistribution()
let globalDirectory = ''
let stateRequests = 0
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
if (String(input).endsWith('/state')) {
stateRequests += 1
return Response.json({
session: {
history:
stateRequests === 1
? []
: [
{
message: {
role: 'assistant',
content: 'CLEANUP_OK'
}
}
]
},
isProcessing: false,
messageQueueLength: 0,
pendingPermission: null
})
}
return Response.json({})
})
)
let releaseTermination!: () => void
const terminateProcessTree = vi.fn(
() =>
new Promise<void>((resolve) => {
releaseTermination = resolve
})
)
const adapter = new ContinueHostAdapter(
{
binaryPath: distribution.entryPath,
configPath: '',
workspace: process.cwd(),
cacheRoot: distribution.cacheRoot,
trustedBundleHashes: [distribution.sourceHash],
launchHost: (_entry, _args, options) => {
globalDirectory =
options.env.CONTINUE_GLOBAL_DIR ?? ''
return {
exitCode: null,
killed: false,
stderr: null,
once: () => undefined,
kill: () => true
}
},
modelProfile: {
id: randomUUID(),
name: 'Local model',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none'
}
},
{
maximumStreamEvents: 10,
maximumStreamEventBytes: 10_000,
maximumToolCalls: 10,
terminateProcessTree
}
)
const run = adapter.run(
'hello',
new AbortController().signal,
async () => 'deny'
)
await vi.waitFor(() =>
expect(terminateProcessTree).toHaveBeenCalledOnce()
)
expect(globalDirectory).toBeTruthy()
expect(existsSync(globalDirectory)).toBe(true)
releaseTermination()
await expect(run).resolves.toEqual({ text: 'CLEANUP_OK' })
expect(existsSync(globalDirectory)).toBe(false)
})
it('uses auto mode and returns audit metadata for agent tools', async () => {
const distribution = await createDistribution()
let launchArgs: string[] = []
+105 -33
View File
@@ -50,6 +50,7 @@ import { stageRuntimeSkillPackages } from './runtime-skill-packages'
import { readBoundedResponseText } from './bounded-response'
import { scopedReadToolNames } from '../../shared/scoped-data-tools'
import { readBoundedFile } from '../workspace-file-access'
import { terminateProcessTreeAndWait } from './child-process-termination'
const supportedVersion = '1.5.47'
const supportedBundleHashes = new Set([
@@ -65,6 +66,7 @@ const maximumConfiguredRules = runtimeNativeInventoryLimits.rules
const maximumConfiguredPrompts = runtimeNativeInventoryLimits.prompts
const maximumStreamEvents = 5_000
const maximumStreamEventBytes = 2 * 1024 * 1024
const maximumToolCalls = 100
const maximumExecutionMilliseconds = 10 * 60_000
const knowledgeMcpName = 'goodbuddy-knowledge'
const customMcpName = 'goodbuddy-custom-mcp'
@@ -239,6 +241,13 @@ export type ContinueHostAdapterOptions = {
skillPackages?: RuntimeSkillPackage[]
}
export type ContinueHostAdapterDependencies = {
terminateProcessTree: typeof terminateProcessTreeAndWait
maximumStreamEvents: number
maximumStreamEventBytes: number
maximumToolCalls: number
}
export type ContinueHostRunOptions = {
workMode?: 'ask' | 'execute'
images?: AgentImage[]
@@ -504,8 +513,12 @@ export type ContinueHostChild = {
) => unknown
} | null
once: (
event: 'error',
listener: (error: Error) => void
event: 'error' | 'close',
listener: (error: Error | number | null) => void
) => unknown
removeListener?: (
event: 'error' | 'close',
listener: (error: Error | number | null) => void
) => unknown
kill: (signal?: NodeJS.Signals) => unknown
}
@@ -792,6 +805,10 @@ function extractUsageDelta(
export class ContinueHostAdapter {
private readonly children = new Set<ContinueHostChild>()
private readonly childTerminations = new WeakMap<
ContinueHostChild,
Promise<void>
>()
private readonly pendingQuestions = new Map<
string,
{
@@ -802,7 +819,20 @@ export class ContinueHostAdapter {
>()
private preparation?: Promise<PreparedHost>
constructor(private readonly options: ContinueHostAdapterOptions) {}
private readonly dependencies: ContinueHostAdapterDependencies
constructor(
private readonly options: ContinueHostAdapterOptions,
dependencies: Partial<ContinueHostAdapterDependencies> = {}
) {
this.dependencies = {
terminateProcessTree: terminateProcessTreeAndWait,
maximumStreamEvents,
maximumStreamEventBytes,
maximumToolCalls,
...dependencies
}
}
private async prepare(): Promise<PreparedHost> {
if (!isAbsolute(this.options.cacheRoot)) {
@@ -1515,17 +1545,20 @@ export class ContinueHostAdapter {
child.stderr?.on('data', (chunk: Buffer | string) => {
stderrBytes += Buffer.byteLength(chunk)
if (stderrBytes > 64 * 1024) {
this.terminate(child)
void this.terminate(child)
}
})
const abort = (): void => {
this.terminate(child)
void this.terminate(child)
}
signal.addEventListener('abort', abort, { once: true })
let observedTools: ContinueHostTool[] = []
const reportedQuestionIds = new Set<string>()
let streamedText = false
let streamEventCount = 0
let streamEventBytes = 0
const observedToolCallIds = new Set<string>()
let executionTimeoutSignal: AbortSignal | undefined
try {
const initialState = await this.waitForStartup(
@@ -1585,17 +1618,55 @@ export class ContinueHostAdapter {
if (state.goodbuddyEventsOverflow) {
throw new Error('Continue 宿主流式事件超过安全限制')
}
const streamEventBytes = Buffer.byteLength(
JSON.stringify(state.goodbuddyEvents ?? [])
const streamEvents = state.goodbuddyEvents ?? []
const batchStreamEventBytes = Buffer.byteLength(
JSON.stringify(streamEvents)
)
if (streamEventBytes > maximumStreamEventBytes) {
const nextStreamEventCount =
streamEventCount + streamEvents.length
const nextStreamEventBytes =
streamEventBytes + batchStreamEventBytes
const nextToolCallIds = new Set(observedToolCallIds)
for (const event of streamEvents) {
if (event.type === 'tool') {
nextToolCallIds.add(event.callId)
}
}
if (
nextStreamEventCount >
this.dependencies.maximumStreamEvents ||
nextStreamEventBytes >
this.dependencies.maximumStreamEventBytes
) {
throw new Error('Continue 宿主流式事件超过安全限制')
}
if (
nextToolCallIds.size > this.dependencies.maximumToolCalls
) {
throw new Error('Continue 单次运行的工具调用超过 100 个')
}
streamEventCount = nextStreamEventCount
streamEventBytes = nextStreamEventBytes
for (const callId of nextToolCallIds) {
observedToolCallIds.add(callId)
}
const historyTools = extractContinueTools(
state.session.history,
startIndex
)
for (const tool of historyTools) {
observedToolCallIds.add(tool.callId)
}
if (
observedToolCallIds.size > this.dependencies.maximumToolCalls
) {
throw new Error('Continue 单次运行的工具调用超过 100 个')
}
observedTools = mergeContinueTools(
observedTools,
extractContinueTools(state.session.history, startIndex)
historyTools
)
for (const event of state.goodbuddyEvents ?? []) {
for (const event of streamEvents) {
if (event.type === 'text') {
streamedText = true
await runOptions.onEvent?.(event)
@@ -1653,7 +1724,10 @@ export class ContinueHostAdapter {
}
const pending = state.pendingPermission
if (pending && !handledPermissionIds.has(pending.requestId)) {
if (handledPermissionIds.size >= 100) {
if (
handledPermissionIds.size >=
this.dependencies.maximumToolCalls
) {
throw new Error('Continue 单次运行的工具调用超过 100 个')
}
handledPermissionIds.add(pending.requestId)
@@ -1667,9 +1741,14 @@ export class ContinueHostAdapter {
if (
!observedTools.some((tool) => tool.callId === pendingCallId)
) {
if (observedTools.length >= 100) {
if (
!observedToolCallIds.has(pendingCallId) &&
observedToolCallIds.size >=
this.dependencies.maximumToolCalls
) {
throw new Error('Continue 单次运行的工具调用超过 100 个')
}
observedToolCallIds.add(pendingCallId)
observedTools = [
...observedTools,
{
@@ -1771,7 +1850,7 @@ export class ContinueHostAdapter {
signal: cleanupSignal
}).catch(() => undefined)
} finally {
this.terminate(child)
await this.terminate(child)
this.children.delete(child)
if (generatedConfigPath) {
await rm(generatedConfigPath, { force: true })
@@ -1795,31 +1874,24 @@ export class ContinueHostAdapter {
}
}
private terminate(child: ContinueHostChild): void {
if (child.exitCode !== null || child.killed) {
private async terminate(child: ContinueHostChild): Promise<void> {
const existing = this.childTerminations.get(child)
if (existing) {
await existing
return
}
if (process.platform === 'win32' && child.pid) {
const killer = spawn(
'taskkill.exe',
['/PID', String(child.pid), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
killer.unref()
} else {
child.kill('SIGTERM')
}
const termination = this.dependencies
.terminateProcessTree(child)
.catch(() => undefined)
this.childTerminations.set(child, termination)
await termination
}
dispose(): void {
async dispose(): Promise<void> {
this.pendingQuestions.clear()
for (const child of this.children) {
this.terminate(child)
}
await Promise.all(
[...this.children].map((child) => this.terminate(child))
)
this.children.clear()
}
}
+24
View File
@@ -83,6 +83,7 @@ describe('ContinueAgentRuntime', () => {
text: 'Continue response'
})
mocks.respondHostQuestion.mockResolvedValue(undefined)
mocks.disposeHost.mockResolvedValue(undefined)
})
it('does not launch the CLI for an already-cancelled request', async () => {
@@ -104,6 +105,29 @@ describe('ContinueAgentRuntime', () => {
expect(mocks.runHost).not.toHaveBeenCalled()
})
it('awaits host process cleanup during Runtime disposal', async () => {
let releaseDispose!: () => void
mocks.disposeHost.mockImplementation(
() =>
new Promise<void>((resolve) => {
releaseDispose = resolve
})
)
const runtime = createRuntime()
await collectEvents(runtime)
let disposed = false
const disposal = runtime.dispose().then(() => {
disposed = true
})
await Promise.resolve()
expect(disposed).toBe(false)
releaseDispose()
await disposal
expect(disposed).toBe(true)
})
it('uses the resolved binary through the Continue host adapter', async () => {
const runtime = createRuntime()
+3 -3
View File
@@ -784,9 +784,9 @@ export class ContinueAgentRuntime implements AgentRuntime {
async dispose(): Promise<void> {
this.pendingQuestions.clear()
for (const host of this.hostAdapters.values()) {
host.dispose()
}
await Promise.all(
[...this.hostAdapters.values()].map((host) => host.dispose())
)
this.hostAdapters.clear()
}
}
@@ -0,0 +1,88 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import {
createContinueUtilityProcessChild,
type ContinueUtilityProcessSource
} from './continue-utility-process-adapter'
import { waitForProcessExit } from './child-process-termination'
function createSource(): ContinueUtilityProcessSource & EventEmitter {
const emitter =
new EventEmitter() as ContinueUtilityProcessSource & EventEmitter
Object.defineProperties(emitter, {
pid: { value: 42 },
stderr: { value: undefined }
})
emitter.kill = vi.fn(() => true)
emitter.onExit = (listener) => {
emitter.on('exit', listener)
}
emitter.onceExit = (listener) => {
emitter.once('exit', listener)
}
emitter.onceError = (listener) => {
emitter.once('utility-error', listener)
}
emitter.removeExitListener = (listener) => {
emitter.removeListener('exit', listener)
}
emitter.removeErrorListener = (listener) => {
emitter.removeListener('utility-error', listener)
}
return emitter
}
describe('Continue utility process adapter', () => {
it('maps Electron exit to close and completes helper waits immediately', async () => {
const source = createSource()
const child = createContinueUtilityProcessChild(source)
const close = vi.fn()
child.once('close', close)
const waiting = waitForProcessExit(child)
source.emit('exit', 0)
expect(close).toHaveBeenCalledWith(0)
expect(child.exitCode).toBe(0)
await expect(waiting).resolves.toBeUndefined()
})
it('maps utility errors and removes both listener types', () => {
const source = createSource()
const child = createContinueUtilityProcessChild(source)
const close = vi.fn()
const error = vi.fn()
child.once('close', close)
child.once('error', error)
child.removeListener?.('close', close)
child.removeListener?.('error', error)
source.emit('exit', 0)
source.emit('utility-error', 'FatalError', 'worker.js:1', 'report')
expect(close).not.toHaveBeenCalled()
expect(error).not.toHaveBeenCalled()
})
it('converts utility error details to a bounded Error', () => {
const source = createSource()
const child = createContinueUtilityProcessChild(source)
const error = vi.fn()
child.once('error', error)
source.emit(
'utility-error',
'FatalError',
'worker.js:1',
'x'.repeat(1_000)
)
expect(error).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringMatching(
/^Continue 宿主进程异常(worker\.js:1):x{500}$/u
)
})
)
})
})
@@ -0,0 +1,96 @@
import type { ContinueHostChild } from './continue-host-adapter'
type UtilityErrorListener = (
type: 'FatalError',
location: string,
report: string
) => void
export type ContinueUtilityProcessSource = {
readonly pid?: number
readonly stderr?: ContinueHostChild['stderr']
kill(): boolean
onExit(listener: (code: number) => void): void
onceExit(listener: (code: number) => void): void
onceError(listener: UtilityErrorListener): void
removeExitListener(listener: (code: number) => void): void
removeErrorListener(listener: UtilityErrorListener): void
}
export function createContinueUtilityProcessChild(
utility: ContinueUtilityProcessSource
): ContinueHostChild {
let exitCode: number | null = null
let killed = false
const closeListeners = new Map<
(value: Error | number | null) => void,
(code: number) => void
>()
const errorListeners = new Map<
(value: Error | number | null) => void,
UtilityErrorListener
>()
utility.onExit((code) => {
exitCode = code
})
const child: ContinueHostChild = {
get exitCode() {
return exitCode
},
get killed() {
return killed
},
get pid() {
return utility.pid
},
stderr: utility.stderr,
once: (event, listener) => {
if (event === 'close') {
const wrapped = (code: number): void => {
closeListeners.delete(listener)
listener(code)
}
closeListeners.set(listener, wrapped)
utility.onceExit(wrapped)
} else {
const wrapped: UtilityErrorListener = (
_type,
location,
report
): void => {
errorListeners.delete(listener)
listener(
new Error(
`Continue 宿主进程异常(${location}):${report.slice(0, 500)}`
)
)
}
errorListeners.set(listener, wrapped)
utility.onceError(wrapped)
}
return child
},
removeListener: (event, listener) => {
if (event === 'close') {
const wrapped = closeListeners.get(listener)
if (wrapped) {
closeListeners.delete(listener)
utility.removeExitListener(wrapped)
}
} else {
const wrapped = errorListeners.get(listener)
if (wrapped) {
errorListeners.delete(listener)
utility.removeErrorListener(wrapped)
}
}
return child
},
kill: () => {
killed = true
return utility.kill()
}
}
return child
}
+6 -52
View File
@@ -26,6 +26,7 @@ import type {
RuntimeExtensionCatalog,
RuntimeExtensionStoreDependencies
} from './runtime-extension-store'
import { terminateProcessTreeAndWait } from './child-process-termination'
const NPM_REGISTRY_URL = 'https://registry.npmjs.org'
const NPM_SEARCH_PAGE_SIZE = 250
@@ -167,61 +168,14 @@ export type PackageManagerRunner = (
}
) => Promise<PackageManagerRunResult>
function waitForProcessClose(
child: ReturnType<typeof spawn>
): Promise<void> {
if (child.exitCode !== null) {
return Promise.resolve()
}
return new Promise((resolve) => {
const finish = (): void => {
clearTimeout(timer)
child.removeListener('close', finish)
resolve()
}
const timer = setTimeout(finish, 5_000)
child.once('close', finish)
})
}
async function terminatePackageManager(
child: ReturnType<typeof spawn>
): Promise<void> {
const closed = waitForProcessClose(child)
if (process.platform === 'win32' && child.pid) {
const killer = spawn(
'taskkill.exe',
['/PID', String(child.pid), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
await new Promise<void>((resolve) => {
const finish = (): void => {
clearTimeout(timer)
killer.removeListener('close', finish)
killer.removeListener('error', finish)
resolve()
}
const timer = setTimeout(finish, 5_000)
killer.once('close', finish)
killer.once('error', finish)
})
} else if (child.pid) {
try {
process.kill(-child.pid, 'SIGKILL')
} catch {
child.kill('SIGKILL')
}
} else {
child.kill('SIGKILL')
}
if (child.exitCode === null) {
child.kill('SIGKILL')
}
await closed
await terminateProcessTreeAndWait(child, {
processGroup: true,
signal: 'SIGKILL',
waitMs: 5_000
})
}
function boundedAppend(current: string, chunk: unknown): string {
+436 -93
View File
@@ -1351,19 +1351,22 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
expect.any(Array),
expect.any(AbortSignal)
)
expect(setup.client.mcp.add).toHaveBeenCalledWith({
directory: process.cwd(),
name: expect.stringMatching(/^goodbuddy-custom-[a-f0-9]{20}$/u),
config: {
type: 'remote',
url: 'http://127.0.0.1:4567/mcp',
enabled: true,
headers: {
Authorization: 'Bearer custom-capability'
},
oauth: false
}
})
expect(setup.client.mcp.add).toHaveBeenCalledWith(
{
directory: process.cwd(),
name: expect.stringMatching(/^goodbuddy-custom-[a-f0-9]{20}$/u),
config: {
type: 'remote',
url: 'http://127.0.0.1:4567/mcp',
enabled: true,
headers: {
Authorization: 'Bearer custom-capability'
},
oauth: false
}
},
{ signal: expect.any(AbortSignal) }
)
expect(JSON.stringify(
(setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>)
.mock.calls
@@ -1500,21 +1503,24 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' }
})
await expect(stream.next()).resolves.toMatchObject({
value: {
type: 'question',
questionId: 'question-1',
questions: [
{
header: '实现方式',
question: '请选择实现方式',
multiple: false,
custom: true
}
]
}
const questionEvent = await stream.next()
expect(questionEvent.value).toMatchObject({
type: 'question',
questionId: expect.stringMatching(/^opencode-[a-f0-9]{48}$/u),
questions: [
{
header: '实现方式',
question: '请选择实现方式',
multiple: false,
custom: true
}
]
})
await runtime.respondToQuestion('question-1', [['先写测试']])
const questionId =
questionEvent.value?.type === 'question'
? questionEvent.value.questionId
: ''
await runtime.respondToQuestion(questionId, [['先写测试']])
expect(setup.questionReply).toHaveBeenCalledWith({
requestID: 'question-1',
directory: process.cwd(),
@@ -1526,6 +1532,318 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await runtime.dispose()
})
it('namespaces identical upstream question IDs across concurrent external conversations', async () => {
const setup = runClient([])
;(
setup.event.subscribe as unknown as ReturnType<typeof vi.fn>
).mockImplementation(async () => ({
stream: (async function* () {
yield {
id: 'question-event',
type: 'question.asked',
properties: {
id: 'shared-question',
sessionID: 'session-1',
questions: [
{
header: 'Choice',
question: 'Choose',
options: [],
multiple: false,
custom: true
}
]
}
}
yield {
id: 'idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
})()
}))
const runtime = new OpenCodeRuntime(
options({
baseUrl: 'http://127.0.0.1:4096',
embedded: false
}),
dependencies(fakeChild(), {
createClient: vi.fn(
() => setup.client
) as unknown as typeof createOpencodeClient
}).deps
)
const first = runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'first',
workMode: 'execute'
},
new AbortController().signal
)
const second = runtime.run(
{
requestId: '4f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-2',
prompt: 'second',
workMode: 'execute'
},
new AbortController().signal
)
await Promise.all([first.next(), second.next()])
const [firstQuestion, secondQuestion] = await Promise.all([
first.next(),
second.next()
])
const firstId =
firstQuestion.value?.type === 'question'
? firstQuestion.value.questionId
: ''
const secondId =
secondQuestion.value?.type === 'question'
? secondQuestion.value.questionId
: ''
expect(firstId).toMatch(/^opencode-[a-f0-9]{48}$/u)
expect(secondId).toMatch(/^opencode-[a-f0-9]{48}$/u)
expect(firstId).not.toBe(secondId)
await Promise.all([
runtime.respondToQuestion(firstId, [['first answer']]),
runtime.respondToQuestion(secondId, [['second answer']])
])
expect(setup.questionReply).toHaveBeenCalledTimes(2)
expect(setup.questionReply).toHaveBeenNthCalledWith(1, {
requestID: 'shared-question',
directory: process.cwd(),
answers: [['first answer']]
})
expect(setup.questionReply).toHaveBeenNthCalledWith(2, {
requestID: 'shared-question',
directory: process.cwd(),
answers: [['second answer']]
})
await Promise.all([first.next(), second.next()])
await runtime.dispose()
})
it('aborts an OpenCode run at its total execution deadline', async () => {
const setup = runClient([])
;(
setup.event.subscribe as unknown as ReturnType<typeof vi.fn>
).mockImplementation(
async (
_input: unknown,
options: { signal: AbortSignal }
) => ({
stream: (async function* () {
await new Promise<void>((_resolve, reject) => {
options.signal.addEventListener(
'abort',
() => reject(options.signal.reason),
{ once: true }
)
})
yield {
id: 'unreachable',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
})()
})
)
const runtime = new OpenCodeRuntime(
options({
baseUrl: 'http://127.0.0.1:4096',
embedded: false
}),
dependencies(fakeChild(), {
createClient: vi.fn(
() => setup.client
) as unknown as typeof createOpencodeClient,
executionTimeoutMs: 20
}).deps
)
const stream = runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'never finish',
workMode: 'execute'
},
new AbortController().signal
)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' }
})
await expect(stream.next()).rejects.toThrow(
'OpenCode 执行超过 20 毫秒总时限'
)
expect(setup.session.abort).toHaveBeenCalled()
await runtime.dispose()
})
it('applies the total deadline while agent discovery is stalled', async () => {
const setup = runClient([])
const agents = vi.fn(
() => new Promise<never>(() => undefined)
)
Object.assign(setup.client, {
app: { agents }
})
const child = fakeChild()
const runtime = new OpenCodeRuntime(
options({
customization: { defaultAgent: 'build' }
}),
dependencies(child, {
createClient: vi.fn(
() => setup.client
) as unknown as typeof createOpencodeClient,
executionTimeoutMs: 20
}).deps
)
setTimeout(() => {
stdoutOf(child).write(
'opencode server listening on http://127.0.0.1:4010\n'
)
}, 0)
const stream = runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'never reach the stream',
workMode: 'execute'
},
new AbortController().signal
)
await expect(stream.next()).rejects.toThrow(
'OpenCode 执行超过 20 毫秒总时限'
)
expect(agents).toHaveBeenCalledWith(
{ directory: process.cwd() },
{ signal: expect.any(AbortSignal) }
)
expect(setup.event.subscribe).not.toHaveBeenCalled()
await runtime.dispose()
})
it('deletes a session created after timeout and does not reuse it', async () => {
const setup = runClient([
{
id: 'idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
])
let resolveStalledCreation!: (value: {
data: { id: string }
error: undefined
}) => void
;(
setup.session.create as unknown as ReturnType<typeof vi.fn>
).mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveStalledCreation = resolve
})
)
const runtime = new OpenCodeRuntime(
options({
baseUrl: 'http://127.0.0.1:4096',
embedded: false
}),
dependencies(fakeChild(), {
createClient: vi.fn(
() => setup.client
) as unknown as typeof createOpencodeClient,
executionTimeoutMs: 20
}).deps
)
const first = runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'stalled creation',
workMode: 'execute'
},
new AbortController().signal
)
await expect(first.next()).rejects.toThrow(
'OpenCode 执行超过 20 毫秒总时限'
)
resolveStalledCreation({
data: { id: 'stale-session' },
error: undefined
})
await vi.waitFor(() =>
expect(setup.session.delete).toHaveBeenCalledWith(
{
sessionID: 'stale-session',
directory: process.cwd()
},
{ signal: expect.any(AbortSignal) }
)
)
const secondEvents = await collectRun(runtime)
expect(secondEvents.at(-1)).toMatchObject({ type: 'done' })
expect(setup.session.create).toHaveBeenCalledTimes(2)
expect(setup.session.update).not.toHaveBeenCalled()
await runtime.dispose()
})
it('fails before emitting text that exceeds the aggregate output budget', async () => {
const setup = runClient([
{
id: 'first-text',
type: 'message.part.delta',
properties: {
sessionID: 'session-1',
partID: 'part-1',
field: 'text',
delta: 'a'.repeat(600_000)
}
},
{
id: 'second-text',
type: 'message.part.delta',
properties: {
sessionID: 'session-1',
partID: 'part-1',
field: 'text',
delta: 'b'.repeat(600_000)
}
}
])
const runtime = embeddedRuntime(setup.client)
const stream = runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'test',
workMode: 'execute'
},
new AbortController().signal
)
await stream.next()
const firstText = await stream.next()
expect(firstText.value).toMatchObject({
type: 'text',
delta: expect.stringMatching(/^a+$/u)
})
await expect(stream.next()).rejects.toThrow(
'文本与推理输出超过 1 MB 安全限制'
)
expect(setup.session.abort).toHaveBeenCalled()
await runtime.dispose()
})
it('adds only request-scoped built-in read tools for Ask and disconnects them', async () => {
const setup = runClient([
{
@@ -1592,19 +1910,22 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
events.push(event)
}
expect(setup.client.mcp.add).toHaveBeenCalledWith({
directory: process.cwd(),
name: expect.stringMatching(/^goodbuddy-data-[a-f0-9]{20}$/u),
config: {
type: 'remote',
url: 'http://127.0.0.1:4567/mcp',
enabled: true,
headers: {
Authorization: 'Bearer secret-capability'
},
oauth: false
}
})
expect(setup.client.mcp.add).toHaveBeenCalledWith(
{
directory: process.cwd(),
name: expect.stringMatching(/^goodbuddy-data-[a-f0-9]{20}$/u),
config: {
type: 'remote',
url: 'http://127.0.0.1:4567/mcp',
enabled: true,
headers: {
Authorization: 'Bearer secret-capability'
},
oauth: false
}
},
{ signal: expect.any(AbortSignal) }
)
const knowledgeMcpName = (
(
setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>
@@ -1621,7 +1942,8 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
action: 'allow'
}
]
})
}),
{ signal: expect.any(AbortSignal) }
)
expect(setup.session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1634,10 +1956,13 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
}),
expect.anything()
)
expect(setup.client.mcp.disconnect).toHaveBeenCalledWith({
name: expect.stringMatching(/^goodbuddy-data-/u),
directory: process.cwd()
})
expect(setup.client.mcp.disconnect).toHaveBeenCalledWith(
{
name: expect.stringMatching(/^goodbuddy-data-/u),
directory: process.cwd()
},
{ signal: expect.any(AbortSignal) }
)
expect(events.at(-1)).toMatchObject({ type: 'done' })
await runtime.dispose()
})
@@ -1911,19 +2236,22 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
try {
await collectRun(runtime, 'ask')
expect(setup.session.create).toHaveBeenCalledWith({
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'deny' },
{ permission: 'skill', pattern: '*', action: 'deny' },
{
permission: 'skill',
pattern: 'longdoc-docx',
action: 'allow'
}
]
})
expect(setup.session.create).toHaveBeenCalledWith(
{
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'deny' },
{ permission: 'skill', pattern: '*', action: 'deny' },
{
permission: 'skill',
pattern: 'longdoc-docx',
action: 'allow'
}
]
},
{ signal: expect.any(AbortSignal) }
)
expect(setup.session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
system: undefined,
@@ -2001,13 +2329,16 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
const events = await collectRun(runtime, 'execute')
expect(callOrder).toEqual(['subscribe', 'prompt'])
expect(session.create).toHaveBeenCalledWith({
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'allow' }
]
})
expect(session.create).toHaveBeenCalledWith(
{
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'allow' }
]
},
{ signal: expect.any(AbortSignal) }
)
expect(permissionReply).toHaveBeenCalledOnce()
expect(permissionReply).toHaveBeenCalledWith({
requestID: 'permission-1',
@@ -2341,16 +2672,20 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await collectRun(runtime, 'ask')
expect(session.create).toHaveBeenCalledWith({
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'deny' }
]
})
expect(tool.ids).toHaveBeenCalledWith({
directory: process.cwd()
})
expect(session.create).toHaveBeenCalledWith(
{
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'deny' }
]
},
{ signal: expect.any(AbortSignal) }
)
expect(tool.ids).toHaveBeenCalledWith(
{ directory: process.cwd() },
{ signal: expect.any(AbortSignal) }
)
expect(session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
tools: {
@@ -2378,13 +2713,16 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await collectRun(runtime, 'execute')
await collectRun(runtime, 'ask')
expect(session.update).toHaveBeenCalledWith({
sessionID: 'session-1',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'deny' }
]
})
expect(session.update).toHaveBeenCalledWith(
{
sessionID: 'session-1',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'deny' }
]
},
{ signal: expect.any(AbortSignal) }
)
await runtime.dispose()
})
@@ -2411,13 +2749,16 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await collectRun(runtime, 'execute')
expect(runtime.requiresToolApproval).toBe(false)
expect(session.create).toHaveBeenCalledWith({
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'allow' }
]
})
expect(session.create).toHaveBeenCalledWith(
{
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'allow' }
]
},
{ signal: expect.any(AbortSignal) }
)
expect(permissionReply).not.toHaveBeenCalled()
await runtime.dispose()
})
@@ -2814,7 +3155,8 @@ describe('OpenCodeRuntime native customization', () => {
}
expect(setup.session.create).toHaveBeenCalledWith(
expect.objectContaining({ agent: 'plan' })
expect.objectContaining({ agent: 'plan' }),
{ signal: expect.any(AbortSignal) }
)
expect(setup.session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({ agent: 'plan' }),
@@ -2853,7 +3195,8 @@ describe('OpenCodeRuntime native customization', () => {
await collectRun(runtime)
expect(setup.session.create).toHaveBeenCalledWith(
expect.objectContaining({ agent: 'build' })
expect.objectContaining({ agent: 'build' }),
{ signal: expect.any(AbortSignal) }
)
expect(setup.session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({ agent: 'build' }),
@@ -3099,7 +3442,7 @@ describe('OpenCodeRuntime native customization', () => {
})
expect(context).toHaveBeenCalledWith(
{ sessionID: 'session-1' },
{ signal }
{ signal: expect.any(AbortSignal) }
)
expect(summarize).toHaveBeenCalledWith(
{
@@ -3109,7 +3452,7 @@ describe('OpenCodeRuntime native customization', () => {
modelID: 'claude-sonnet',
auto: false
},
{ signal }
{ signal: expect.any(AbortSignal) }
)
await runtime.dispose()
})
+291 -114
View File
@@ -56,6 +56,10 @@ import type {
RuntimeSkillPackage
} from '../capabilities/capability-service'
import { stageRuntimeSkillPackages } from './runtime-skill-packages'
import {
requestProcessTreeTermination,
waitForProcessExit
} from './child-process-termination'
const MAX_STARTUP_OUTPUT_BYTES = 64 * 1024
const STARTUP_TIMEOUT_MS = 10_000
@@ -65,6 +69,8 @@ const MAX_PERMISSION_PATTERN_LENGTH = 1_024
const MAX_PERMISSION_PATTERNS_BYTES = 8 * 1_024
const MAX_PERMISSION_METADATA_BYTES = 8 * 1_024
const MAX_TOOL_CALLS_PER_RUN = 100
const MAX_EXECUTION_OUTPUT_BYTES = 1024 * 1024
const MAX_EXECUTION_MILLISECONDS = 10 * 60_000
const MAX_QUESTION_REQUEST_BYTES = 32 * 1_024
const MAX_QUESTIONS_PER_REQUEST = 4
const MAX_QUESTION_OPTIONS = 20
@@ -243,6 +249,44 @@ function byteLengthWithin(value: string, maximum: number): boolean {
return Buffer.byteLength(value) <= maximum
}
function createPublicQuestionId(
requestId: string,
sessionId: string,
upstreamQuestionId: string
): string {
return `opencode-${createHash('sha256')
.update(`${requestId}\0${sessionId}\0${upstreamQuestionId}`)
.digest('hex')
.slice(0, 48)}`
}
function executionDeadlineLabel(milliseconds: number): string {
return milliseconds % 60_000 === 0
? `${milliseconds / 60_000} 分钟`
: `${milliseconds} 毫秒`
}
function awaitWithAbort<T>(
operation: Promise<T>,
signal: AbortSignal
): Promise<T> {
signal.throwIfAborted()
return new Promise<T>((resolveOperation, rejectOperation) => {
const abort = (): void => rejectOperation(signal.reason)
signal.addEventListener('abort', abort, { once: true })
operation.then(
(value) => {
signal.removeEventListener('abort', abort)
resolveOperation(value)
},
(error: unknown) => {
signal.removeEventListener('abort', abort)
rejectOperation(error)
}
)
})
}
function areBoundedPatterns(value: unknown): value is string[] {
return (
Array.isArray(value) &&
@@ -416,6 +460,7 @@ export type OpenCodeRuntimeDependencies = {
createClient: typeof createOpencodeClient
platform: NodeJS.Platform
startupTimeoutMs: number
executionTimeoutMs: number
}
export type OpenCodeRuntimeOptions = {
@@ -678,6 +723,7 @@ export class OpenCodeRuntime implements AgentRuntime {
client: OpencodeClient
directory: string
questionCount: number
upstreamQuestionId: string
}
>()
private embeddedRunTail: Promise<void> = Promise.resolve()
@@ -694,6 +740,7 @@ export class OpenCodeRuntime implements AgentRuntime {
createClient: createOpencodeClient,
platform: process.platform,
startupTimeoutMs: STARTUP_TIMEOUT_MS,
executionTimeoutMs: MAX_EXECUTION_MILLISECONDS,
...dependencies
}
}
@@ -782,36 +829,14 @@ export class OpenCodeRuntime implements AgentRuntime {
}
private terminate(child: SpawnedProcess): void {
if (child.exitCode !== null) {
return
}
if (this.dependencies.platform === 'win32' && child.pid) {
const killer = this.dependencies.spawn(
'taskkill.exe',
['/PID', String(child.pid), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
killer.unref()
} else {
child.kill('SIGTERM')
}
requestProcessTreeTermination(child, {
platform: this.dependencies.platform,
spawn: this.dependencies.spawn
})
}
private waitForExit(child: SpawnedProcess): Promise<void> {
if (child.exitCode !== null) {
return Promise.resolve()
}
return new Promise((resolveExit) => {
const timeout = setTimeout(resolveExit, 2_000)
child.once('close', () => {
clearTimeout(timeout)
resolveExit()
})
})
return waitForProcessExit(child)
}
private getNativeSkillIds(): string[] {
@@ -1083,9 +1108,12 @@ export class OpenCodeRuntime implements AgentRuntime {
if (this.client) {
return this.client
}
const existingInitialization = this.clientInitialization
this.clientInitialization ??= this.initializeClient(signal)
try {
return await this.clientInitialization
return signal && existingInitialization
? await awaitWithAbort(this.clientInitialization, signal)
: await this.clientInitialization
} catch (error) {
this.clientInitialization = undefined
throw error
@@ -1154,7 +1182,8 @@ export class OpenCodeRuntime implements AgentRuntime {
}
private async discoverAgents(
client: OpencodeClient
client: OpencodeClient,
signal?: AbortSignal
): Promise<
Array<{
id: string
@@ -1165,9 +1194,15 @@ export class OpenCodeRuntime implements AgentRuntime {
hidden: boolean
}>
> {
const response = await client.app.agents({
directory: this.options.defaultWorkspace
})
const operation = client.app.agents(
{
directory: this.options.defaultWorkspace
},
signal ? { signal } : undefined
)
const response = signal
? await awaitWithAbort(operation, signal)
: await operation
if (response.error || !response.data) {
throw new Error('OpenCode Agent 清单不可用')
}
@@ -1197,7 +1232,8 @@ export class OpenCodeRuntime implements AgentRuntime {
private async resolveSelectedAgent(
client: OpencodeClient,
request: AgentExecutionRequest
request: AgentExecutionRequest,
signal: AbortSignal
): Promise<string | undefined> {
const control =
request.runtimeControl?.provider === 'opencode'
@@ -1213,7 +1249,7 @@ export class OpenCodeRuntime implements AgentRuntime {
'外部 OpenCode Server 不支持由 GoodBuddy 选择 Agent'
)
}
const agents = await this.discoverAgents(client)
const agents = await this.discoverAgents(client, signal)
if (
!agents.some(
(agent) =>
@@ -1592,6 +1628,7 @@ export class OpenCodeRuntime implements AgentRuntime {
client: OpencodeClient,
request: AgentExecutionRequest,
directory: string,
signal: AbortSignal,
agent?: string,
permission?: PermissionRuleset
): Promise<{ id: string; created: boolean }> {
@@ -1603,27 +1640,62 @@ export class OpenCodeRuntime implements AgentRuntime {
request.conversationId
)
if (pending) {
return { id: await pending, created: false }
return {
id: await awaitWithAbort(pending, signal),
created: false
}
}
const creation = client.session
.create({
title: 'GoodBuddy 对话',
directory,
...(agent ? { agent } : {}),
...(permission ? { permission } : {})
})
const creation: Promise<string> = client.session
.create(
{
title: 'GoodBuddy 对话',
directory,
...(agent ? { agent } : {}),
...(permission ? { permission } : {})
},
{ signal }
)
.then((response) => {
if (!response.data) {
throw new Error('OpenCode 会话创建失败')
}
this.sessions.set(request.conversationId, response.data.id)
return response.data.id
const sessionId = response.data.id
const stillCurrent =
this.sessionInitializations.get(request.conversationId) ===
creation
if (signal.aborted || !stillCurrent) {
if (this.sessions.get(request.conversationId) !== sessionId) {
void client.session
.delete(
{
sessionID: sessionId,
directory
},
{ signal: AbortSignal.timeout(1_000) }
)
.catch(() => undefined)
}
if (signal.aborted) {
throw signal.reason
}
throw new Error('OpenCode 会话初始化已失效')
}
this.sessions.set(request.conversationId, sessionId)
return sessionId
})
this.sessionInitializations.set(request.conversationId, creation)
try {
return { id: await creation, created: true }
return {
id: await awaitWithAbort(creation, signal),
created: true
}
} finally {
this.sessionInitializations.delete(request.conversationId)
if (
this.sessionInitializations.get(request.conversationId) ===
creation
) {
this.sessionInitializations.delete(request.conversationId)
}
}
}
@@ -1631,17 +1703,42 @@ export class OpenCodeRuntime implements AgentRuntime {
request: AgentExecutionRequest,
signal: AbortSignal
): AsyncGenerator<RuntimeEvent, void, void> {
const releaseEmbedded = this.usesEmbeddedPermissionMediation()
? await this.acquireEmbeddedRun(signal)
: undefined
const releaseConversation = await this.acquireConversationRun(
request.conversationId,
signal
const deadline = new AbortController()
const deadlineTimer = setTimeout(
() =>
deadline.abort(
new Error(
`OpenCode 执行超过 ${executionDeadlineLabel(
this.dependencies.executionTimeoutMs
)}总时限`
)
),
this.dependencies.executionTimeoutMs
)
deadlineTimer.unref?.()
const executionSignal = AbortSignal.any([
signal,
deadline.signal
])
let releaseEmbedded: (() => void) | undefined
let releaseConversation: (() => void) | undefined
try {
yield* this.runUnlocked(request, signal)
releaseEmbedded = this.usesEmbeddedPermissionMediation()
? await this.acquireEmbeddedRun(executionSignal)
: undefined
releaseConversation = await this.acquireConversationRun(
request.conversationId,
executionSignal
)
yield* this.runUnlocked(request, executionSignal)
} catch (error) {
if (deadline.signal.aborted && !signal.aborted) {
throw deadline.signal.reason
}
throw error
} finally {
releaseConversation()
clearTimeout(deadlineTimer)
releaseConversation?.()
releaseEmbedded?.()
}
}
@@ -1674,7 +1771,8 @@ export class OpenCodeRuntime implements AgentRuntime {
}
const selectedAgent = await this.resolveSelectedAgent(
client,
request
request,
signal
)
let selectedCommand:
| {
@@ -1683,7 +1781,10 @@ export class OpenCodeRuntime implements AgentRuntime {
}
| undefined
if (runtimeControl?.command) {
const commandResponse = await client.command.list({ directory })
const commandResponse = await awaitWithAbort(
client.command.list({ directory }, { signal }),
signal
)
if (commandResponse.error || !commandResponse.data) {
throw new Error('OpenCode 无法验证原生命令')
}
@@ -1725,19 +1826,25 @@ export class OpenCodeRuntime implements AgentRuntime {
.update(`${request.conversationId}\0${request.requestId}`)
.digest('hex')
.slice(0, 20)}`
const added = await client.mcp.add({
directory,
name: knowledgeMcpName,
config: {
type: 'remote',
url: this.options.knowledgeGateway.getEndpoint()!,
enabled: true,
headers: {
Authorization: `Bearer ${request.knowledgeCapabilityToken}`
const added = await awaitWithAbort(
client.mcp.add(
{
directory,
name: knowledgeMcpName,
config: {
type: 'remote',
url: this.options.knowledgeGateway.getEndpoint()!,
enabled: true,
headers: {
Authorization: `Bearer ${request.knowledgeCapabilityToken}`
},
oauth: false
}
},
oauth: false
}
})
{ signal }
),
signal
)
if (added.error || !added.data) {
throw new Error('OpenCode 内置只读工具连接失败')
}
@@ -1776,19 +1883,25 @@ export class OpenCodeRuntime implements AgentRuntime {
.update(`${request.conversationId}\0${request.requestId}`)
.digest('hex')
.slice(0, 20)}`
const added = await client.mcp.add({
directory,
name: customMcpName,
config: {
type: 'remote',
url: this.options.knowledgeGateway.getEndpoint()!,
enabled: true,
headers: {
Authorization: `Bearer ${customMcpToken}`
const added = await awaitWithAbort(
client.mcp.add(
{
directory,
name: customMcpName,
config: {
type: 'remote',
url: this.options.knowledgeGateway.getEndpoint()!,
enabled: true,
headers: {
Authorization: `Bearer ${customMcpToken}`
},
oauth: false
}
},
oauth: false
}
})
{ signal }
),
signal
)
const addedStatus = added.data?.[customMcpName]
if (
added.error ||
@@ -1834,9 +1947,10 @@ export class OpenCodeRuntime implements AgentRuntime {
]
let disabledTools: Record<string, boolean> | undefined
if (request.workMode !== 'execute') {
const tools = await client.tool.ids({
directory
})
const tools = await awaitWithAbort(
client.tool.ids({ directory }, { signal }),
signal
)
if (tools.error || !tools.data) {
throw new Error('OpenCode 无法确认工具已禁用,已阻止只读请求')
}
@@ -1854,16 +1968,23 @@ export class OpenCodeRuntime implements AgentRuntime {
client,
request,
directory,
signal,
selectedAgent,
permission
)
const sessionId = session.id
if (!session.created) {
const update = await client.session.update({
sessionID: sessionId,
directory,
permission
})
const update = await awaitWithAbort(
client.session.update(
{
sessionID: sessionId,
directory,
permission
},
{ signal }
),
signal
)
if (update.error || !update.data) {
throw new Error('OpenCode 会话权限配置失败')
}
@@ -1875,9 +1996,10 @@ export class OpenCodeRuntime implements AgentRuntime {
message: 'OpenCode 正在处理请求'
}
const subscription = await client.event.subscribe({
directory
}, { signal })
const subscription = await awaitWithAbort(
client.event.subscribe({ directory }, { signal }),
signal
)
const abortSession = (): void => {
void client.session.abort({
@@ -1898,7 +2020,16 @@ export class OpenCodeRuntime implements AgentRuntime {
}
>()
const reasoningPartIds = new Set<string>()
const reportedQuestionIds = new Set<string>()
const reportedQuestionIds = new Map<string, string>()
let aggregateOutputBytes = 0
const consumeOutputBudget = (delta: string): void => {
aggregateOutputBytes += Buffer.byteLength(delta)
if (aggregateOutputBytes > MAX_EXECUTION_OUTPUT_BYTES) {
throw new Error(
'OpenCode 单次运行的文本与推理输出超过 1 MB 安全限制'
)
}
}
let hasResponseTextAfterFailure = false
try {
const promptText =
@@ -1998,6 +2129,7 @@ export class OpenCodeRuntime implements AgentRuntime {
'thinking'
].includes(event.properties.field)
if (reasoning || event.properties.field === 'text') {
consumeOutputBudget(event.properties.delta)
if (
!reasoning &&
/\S/u.test(event.properties.delta) &&
@@ -2077,6 +2209,7 @@ export class OpenCodeRuntime implements AgentRuntime {
event.properties.sessionID === sessionId &&
event.properties.delta
) {
consumeOutputBudget(event.properties.delta)
yield {
requestId: request.requestId,
type: 'reasoning',
@@ -2096,16 +2229,28 @@ export class OpenCodeRuntime implements AgentRuntime {
questionRequest &&
!reportedQuestionIds.has(questionRequest.id)
) {
reportedQuestionIds.add(questionRequest.id)
this.pendingQuestions.set(questionRequest.id, {
const publicQuestionId = createPublicQuestionId(
request.requestId,
sessionId,
questionRequest.id
)
if (this.pendingQuestions.has(publicQuestionId)) {
throw new Error('OpenCode 提问公开 ID 与另一活动请求冲突')
}
reportedQuestionIds.set(
questionRequest.id,
publicQuestionId
)
this.pendingQuestions.set(publicQuestionId, {
client,
directory,
questionCount: questionRequest.questions.length
questionCount: questionRequest.questions.length,
upstreamQuestionId: questionRequest.id
})
yield {
requestId: request.requestId,
type: 'question',
questionId: questionRequest.id,
questionId: publicQuestionId,
questions: questionRequest.questions.map((question) => ({
header: question.header,
question: question.question,
@@ -2125,7 +2270,12 @@ export class OpenCodeRuntime implements AgentRuntime {
event.type === 'question.rejected') &&
event.properties.sessionID === sessionId
) {
this.pendingQuestions.delete(event.properties.requestID)
const publicQuestionId = reportedQuestionIds.get(
event.properties.requestID
)
if (publicQuestionId) {
this.pendingQuestions.delete(publicQuestionId)
}
}
if (
@@ -2320,20 +2470,29 @@ export class OpenCodeRuntime implements AgentRuntime {
throw error
} finally {
signal.removeEventListener('abort', abortSession)
for (const questionId of reportedQuestionIds) {
for (const questionId of reportedQuestionIds.values()) {
this.pendingQuestions.delete(questionId)
}
}
} finally {
const cleanupSignal = AbortSignal.timeout(1_000)
if (knowledgeMcpName) {
await client.mcp
.disconnect({ name: knowledgeMcpName, directory })
.catch(() => undefined)
await awaitWithAbort(
client.mcp.disconnect(
{ name: knowledgeMcpName, directory },
{ signal: cleanupSignal }
),
cleanupSignal
).catch(() => undefined)
}
if (customMcpName) {
await client.mcp
.disconnect({ name: customMcpName, directory })
.catch(() => undefined)
await awaitWithAbort(
client.mcp.disconnect(
{ name: customMcpName, directory },
{ signal: cleanupSignal }
),
cleanupSignal
).catch(() => undefined)
}
if (customMcpToken) {
this.options.knowledgeGateway?.revoke(customMcpToken)
@@ -2352,13 +2511,13 @@ export class OpenCodeRuntime implements AgentRuntime {
const response = answers
? answers.length === pending.questionCount
? await pending.client.question.reply({
requestID: questionId,
requestID: pending.upstreamQuestionId,
directory: pending.directory,
answers
})
: undefined
: await pending.client.question.reject({
requestID: questionId,
requestID: pending.upstreamQuestionId,
directory: pending.directory
})
if (!response) {
@@ -2382,12 +2541,24 @@ export class OpenCodeRuntime implements AgentRuntime {
'外部 OpenCode Server 不支持由 GoodBuddy 执行原生 Compact'
)
}
const releaseEmbedded = await this.acquireEmbeddedRun(signal)
const deadline = new AbortController()
const deadlineTimer = setTimeout(
() =>
deadline.abort(new Error('OpenCode 原生 Compact 执行超时')),
this.dependencies.executionTimeoutMs
)
deadlineTimer.unref?.()
const executionSignal = AbortSignal.any([
signal,
deadline.signal
])
let releaseEmbedded: (() => void) | undefined
let releaseConversation: (() => void) | undefined
try {
releaseEmbedded = await this.acquireEmbeddedRun(executionSignal)
releaseConversation = await this.acquireConversationRun(
request.conversationId,
signal
executionSignal
)
const sessionId = this.sessions.get(request.conversationId)
if (!sessionId) {
@@ -2400,10 +2571,10 @@ export class OpenCodeRuntime implements AgentRuntime {
}
}
}
const client = await this.getClient(signal)
const client = await this.getClient(executionSignal)
const context = await client.v2.session.context(
{ sessionID: sessionId },
{ signal }
{ signal: executionSignal }
)
if (context.error || !context.data) {
throw new Error('OpenCode 原生上下文不可用,无法执行 Compact')
@@ -2434,13 +2605,13 @@ export class OpenCodeRuntime implements AgentRuntime {
}
}
}
signal.throwIfAborted()
executionSignal.throwIfAborted()
const subscriptionController = new AbortController()
const subscription = await client.event.subscribe(
{ directory: this.options.defaultWorkspace },
{
signal: AbortSignal.any([
signal,
executionSignal,
subscriptionController.signal
])
}
@@ -2482,7 +2653,7 @@ export class OpenCodeRuntime implements AgentRuntime {
modelID: configuredModel.modelID,
auto: false
},
{ signal }
{ signal: executionSignal }
)
if (compact.error || compact.data !== true) {
throw new Error(
@@ -2522,9 +2693,15 @@ export class OpenCodeRuntime implements AgentRuntime {
subscriptionController.abort()
await usageCapture.catch(() => undefined)
}
} catch (error) {
if (deadline.signal.aborted && !signal.aborted) {
throw deadline.signal.reason
}
throw error
} finally {
clearTimeout(deadlineTimer)
releaseConversation?.()
releaseEmbedded()
releaseEmbedded?.()
}
}
+53 -2
View File
@@ -1,10 +1,13 @@
import { EventEmitter } from 'node:events'
import { realpath } from 'node:fs/promises'
import { basename, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
detectAgentRuntimes,
detectRuntimeBinary
detectRuntimeBinary,
validateRuntimeVersion,
type RuntimeVersionProcess
} from './runtime-discovery'
const originalPath = process.env.PATH
@@ -24,6 +27,54 @@ afterEach(() => {
})
describe('runtime discovery', () => {
it('launches a detached POSIX version probe and awaits bounded tree cleanup', async () => {
const child =
new EventEmitter() as RuntimeVersionProcess & EventEmitter
child.exitCode = null
child.pid = 2718
child.kill = vi.fn()
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
const spawnProcess = vi.fn(() => child)
let releaseCleanup!: () => void
const cleanupBlocked = new Promise<void>((resolve) => {
releaseCleanup = resolve
})
const terminateProcessTree = vi.fn(async () => {
await cleanupBlocked
})
const validation = validateRuntimeVersion('runtime', {
platform: 'linux',
spawnProcess,
terminateProcessTree,
outputLimit: 4,
terminationWaitMs: 25
})
;(child.stdout as EventEmitter).emit('data', '12345')
let completed = false
void validation.then(() => {
completed = true
})
await Promise.resolve()
expect(completed).toBe(false)
expect(spawnProcess).toHaveBeenCalledWith(
'runtime',
['--version'],
expect.objectContaining({ detached: true })
)
expect(terminateProcessTree).toHaveBeenCalledWith(child, {
platform: 'linux',
processGroup: true,
signal: 'SIGKILL',
waitMs: 25
})
releaseCleanup()
await expect(validation).resolves.toEqual({ valid: false })
})
it('canonicalizes and validates a configured ordinary file first', async () => {
process.env.PATH = ''
process.env.Path = ''
+103 -44
View File
@@ -8,6 +8,10 @@ import {
normalize
} from 'node:path'
import spawn from 'cross-spawn'
import {
terminateProcessTreeAndWait,
type WaitableProcessTreeChild
} from './child-process-termination'
import { buildRuntimeEnvironment } from './process-environment'
import type {
AgentRuntimeDetection,
@@ -16,6 +20,7 @@ import type {
const VERSION_TIMEOUT_MS = 3_000
const VERSION_OUTPUT_LIMIT = 8 * 1024
const VERSION_TERMINATION_WAIT_MS = 500
export type RuntimeBinaryDiscoveryInput = {
binaryPath: string
@@ -31,6 +36,39 @@ type VersionValidation =
| { valid: true; version?: string }
| { valid: false }
type RuntimeVersionOutput = {
on(
event: 'data',
listener: (chunk: Buffer | string) => void
): unknown
}
export type RuntimeVersionProcess = WaitableProcessTreeChild & {
stdout?: RuntimeVersionOutput | null
stderr?: RuntimeVersionOutput | null
}
export type RuntimeVersionSpawn = (
command: string,
args: string[],
options: {
detached: boolean
env: NodeJS.ProcessEnv
shell: false
stdio: ['ignore', 'pipe', 'pipe']
windowsHide: true
}
) => RuntimeVersionProcess
export type RuntimeVersionValidationDependencies = {
platform?: NodeJS.Platform
spawnProcess?: RuntimeVersionSpawn
terminateProcessTree?: typeof terminateProcessTreeAndWait
timeoutMs?: number
outputLimit?: number
terminationWaitMs?: number
}
function stripUnsafeCharacters(value: string): string {
let result = ''
let inEscapeSequence = false
@@ -66,45 +104,36 @@ function safeVersion(output: string): string | undefined {
return (semanticVersion?.[1] ?? firstLine).slice(0, 160)
}
function terminate(child: ReturnType<typeof spawn>): void {
if (child.exitCode !== null || child.killed) {
return
}
if (process.platform === 'win32' && child.pid) {
const killer = spawn(
'taskkill.exe',
['/PID', String(child.pid), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
killer.unref()
return
}
child.kill('SIGKILL')
}
function validateVersion(binaryPath: string): Promise<VersionValidation> {
export function validateRuntimeVersion(
binaryPath: string,
dependencies: RuntimeVersionValidationDependencies = {}
): Promise<VersionValidation> {
return new Promise((resolve) => {
let settled = false
let cleanupStarted = false
let stdout = ''
let stderr = ''
let stdoutBytes = 0
let stderrBytes = 0
const platform = dependencies.platform ?? process.platform
const timeoutMs = dependencies.timeoutMs ?? VERSION_TIMEOUT_MS
const outputLimit =
dependencies.outputLimit ?? VERSION_OUTPUT_LIMIT
const child = spawn(binaryPath, ['--version'], {
env: buildRuntimeEnvironment({}),
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
const child = (dependencies.spawnProcess ?? spawn)(
binaryPath,
['--version'],
{
detached: platform !== 'win32',
env: buildRuntimeEnvironment({}),
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
}
)
const finish = (result: VersionValidation): void => {
if (settled) {
if (settled || cleanupStarted) {
return
}
settled = true
@@ -112,21 +141,43 @@ function validateVersion(binaryPath: string): Promise<VersionValidation> {
resolve(result)
}
const exceedLimit = (): void => {
terminate(child)
finish({ valid: false })
const failAfterCleanup = (): void => {
if (settled || cleanupStarted) {
return
}
cleanupStarted = true
clearTimeout(timeout)
void (
dependencies.terminateProcessTree ??
terminateProcessTreeAndWait
)(child, {
platform,
processGroup: platform !== 'win32',
signal: 'SIGKILL',
waitMs:
dependencies.terminationWaitMs ??
VERSION_TERMINATION_WAIT_MS
}).then(
() => {
settled = true
resolve({ valid: false })
},
() => {
settled = true
resolve({ valid: false })
}
)
}
const timeout = setTimeout(() => {
terminate(child)
finish({ valid: false })
}, VERSION_TIMEOUT_MS)
failAfterCleanup()
}, timeoutMs)
child.stdout?.on('data', (chunk: Buffer | string) => {
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
stdoutBytes += value.byteLength
if (stdoutBytes > VERSION_OUTPUT_LIMIT) {
exceedLimit()
if (stdoutBytes > outputLimit) {
failAfterCleanup()
return
}
stdout += value.toString('utf8')
@@ -134,14 +185,22 @@ function validateVersion(binaryPath: string): Promise<VersionValidation> {
child.stderr?.on('data', (chunk: Buffer | string) => {
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
stderrBytes += value.byteLength
if (stderrBytes > VERSION_OUTPUT_LIMIT) {
exceedLimit()
if (stderrBytes > outputLimit) {
failAfterCleanup()
return
}
stderr += value.toString('utf8')
})
child.once('error', () => finish({ valid: false }))
child.once('error', () => {
if (cleanupStarted) {
return
}
finish({ valid: false })
})
child.once('close', (code) => {
if (cleanupStarted) {
return
}
if (code !== 0) {
finish({ valid: false })
return
@@ -286,7 +345,7 @@ export async function detectRuntimeBinary(
'bundled'
)
}
const validation = await validateVersion(canonicalPath)
const validation = await validateRuntimeVersion(canonicalPath)
return validation.valid
? availableDetection(
input.label,
@@ -305,7 +364,7 @@ export async function detectRuntimeBinary(
if (!canonicalPath) {
configuredPathProblem = 'invalid'
} else {
const validation = await validateVersion(canonicalPath)
const validation = await validateRuntimeVersion(canonicalPath)
if (validation.valid) {
return availableDetection(
input.label,
@@ -332,7 +391,7 @@ export async function detectRuntimeBinary(
continue
}
foundAutomaticCandidate = true
const validation = await validateVersion(canonicalPath)
const validation = await validateRuntimeVersion(canonicalPath)
if (validation.valid) {
return availableDetection(
input.label,
@@ -3,7 +3,9 @@ import {
mkdtemp,
readFile,
readdir,
rename,
rm,
symlink,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -302,6 +304,177 @@ describe('RuntimeExtensionStore', () => {
})
})
it('rolls back an interrupted upgrade whose state was not committed', async () => {
const first = catalogEntry('1.0.0')
const second = catalogEntry('2.0.0')
const { userDataPath, store, dependencies } = await fixture({
entries: [first],
temporaryIds: ['install-one']
})
await store.apply({
type: 'install',
extensionId: first.id,
package: first.package
})
const root = join(userDataPath, 'runtime-extensions')
const statePath = join(root, 'store.json')
const before = JSON.parse(await readFile(statePath, 'utf8')) as {
version: 2
marketplaceEnabled: boolean
installed: Array<Record<string, unknown>>
}
const after = structuredClone(before)
after.installed[0] = {
...after.installed[0],
package: second.package,
installedAt: '2026-08-17T00:00:00.000Z',
integrity: `sha512-${Buffer.from('new').toString('base64')}`
}
const extensionDirectory = join(
root,
'extensions',
first.id
)
const backupDirectory = join(
root,
'.staging',
'upgrade-crash-previous'
)
await rename(extensionDirectory, backupDirectory)
await mkdir(join(extensionDirectory, 'dist'), { recursive: true })
await writeFile(
join(extensionDirectory, 'dist', 'index.js'),
'export default "new"'
)
await writeFile(
join(root, '.mutation-journal.json'),
JSON.stringify({
version: 1,
kind: 'install',
extensionId: first.id,
temporaryId: 'upgrade-crash',
before,
after
})
)
const recovered = new RuntimeExtensionStore(
userDataPath,
dependencies
)
await expect(recovered.getSnapshot()).resolves.toMatchObject({
installed: [
expect.objectContaining({ package: first.package })
]
})
await expect(
readFile(join(extensionDirectory, 'dist', 'index.js'), 'utf8')
).resolves.toBe('export default {}')
await expect(readdir(join(root, '.staging'))).resolves.toEqual([])
await expect(readdir(root)).resolves.not.toContain(
'.mutation-journal.json'
)
})
it('finishes an interrupted committed removal on initialization', async () => {
const { userDataPath, store, dependencies } = await fixture({
temporaryIds: ['install-one']
})
const entry = catalogEntry()
await store.apply({
type: 'install',
extensionId: entry.id,
package: entry.package
})
const root = join(userDataPath, 'runtime-extensions')
const statePath = join(root, 'store.json')
const before = JSON.parse(await readFile(statePath, 'utf8')) as {
version: 2
marketplaceEnabled: boolean
installed: Array<Record<string, unknown>>
}
const after = { ...before, installed: [] }
const trashDirectory = join(
root,
'.staging',
'remove-crash-removed'
)
await rename(
join(root, 'extensions', entry.id),
trashDirectory
)
await writeFile(statePath, JSON.stringify(after))
await writeFile(
join(root, '.mutation-journal.json'),
JSON.stringify({
version: 1,
kind: 'remove',
extensionId: entry.id,
temporaryId: 'remove-crash',
before,
after
})
)
const recovered = new RuntimeExtensionStore(
userDataPath,
dependencies
)
await expect(recovered.getSnapshot()).resolves.toMatchObject({
installed: []
})
await expect(readdir(join(root, '.staging'))).resolves.toEqual([])
await expect(readdir(join(root, 'extensions'))).resolves.toEqual([])
await expect(readdir(root)).resolves.not.toContain(
'.mutation-journal.json'
)
})
it('cleans only safe unjournaled managed staging directories', async () => {
const { userDataPath, dependencies } = await fixture({
marketplaceEnabled: false
})
const root = join(userDataPath, 'runtime-extensions')
const staging = join(root, '.staging')
const abandoned =
'00000000-0000-4000-8000-000000000101'
const abandonedBackup =
'00000000-0000-4000-8000-000000000102-previous'
const unrelated = 'user-staging-backup'
const outside = join(userDataPath, 'outside-staging')
const linked =
'00000000-0000-4000-8000-000000000103-removed'
await mkdir(staging, { recursive: true })
await Promise.all([
mkdir(join(staging, abandoned)),
mkdir(join(staging, abandonedBackup)),
mkdir(join(staging, unrelated)),
mkdir(outside)
])
await writeFile(join(staging, abandoned, 'partial.js'), 'stale')
await writeFile(join(staging, unrelated, 'keep.txt'), 'keep')
await writeFile(join(outside, 'keep.txt'), 'outside')
await symlink(outside, join(staging, linked), 'junction')
const recovered = new RuntimeExtensionStore(
userDataPath,
dependencies
)
await recovered.getSnapshot()
expect(await readdir(staging)).toEqual(
expect.arrayContaining([unrelated, linked])
)
expect(await readdir(staging)).not.toContain(abandoned)
expect(await readdir(staging)).not.toContain(abandonedBackup)
await expect(
readFile(join(staging, unrelated, 'keep.txt'), 'utf8')
).resolves.toBe('keep')
await expect(
readFile(join(outside, 'keep.txt'), 'utf8')
).resolves.toBe('outside')
})
it('rejects installer entrypoints outside the managed package', async () => {
const entry = catalogEntry()
const fixtureValue = await fixture({
+239 -33
View File
@@ -33,6 +33,9 @@ import {
const managedDirectoryName = 'runtime-extensions'
const stateFileName = 'store.json'
const journalFileName = '.mutation-journal.json'
const unjournaledStagingDirectoryPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(?:-previous|-removed)?$/iu
const version1StoredStateSchema = z
.object({
@@ -56,6 +59,19 @@ const storedStateFileSchema = z.union([
type StoredState = z.infer<typeof storedStateSchema>
const mutationJournalSchema = z
.object({
version: z.literal(1),
kind: z.enum(['install', 'remove']),
extensionId: runtimeExtensionIdSchema,
temporaryId: runtimeExtensionIdSchema,
before: storedStateSchema,
after: storedStateSchema
})
.strict()
type MutationJournal = z.infer<typeof mutationJournalSchema>
export interface RuntimeExtensionCatalog {
list(): Promise<readonly RuntimeExtensionCatalogEntry[]>
}
@@ -128,6 +144,7 @@ export class RuntimeExtensionStore {
readonly managedRoot: string
private readonly statePath: string
private readonly journalPath: string
private state?: StoredState
private stateLoad?: Promise<StoredState>
private canonicalRoot?: string
@@ -144,6 +161,7 @@ export class RuntimeExtensionStore {
}
this.managedRoot = resolve(userDataPath, managedDirectoryName)
this.statePath = join(this.managedRoot, stateFileName)
this.journalPath = join(this.managedRoot, journalFileName)
}
async getSnapshot(): Promise<RuntimeExtensionMarketplaceSnapshot> {
@@ -176,6 +194,7 @@ export class RuntimeExtensionStore {
): Promise<RuntimeExtensionApplyResult> {
const parsed = runtimeExtensionActionSchema.parse(action)
const changed = await this.serialize(async () => {
await this.reconcileMutationJournal()
switch (parsed.type) {
case 'set-marketplace-enabled':
return this.setMarketplaceEnabled(parsed.enabled)
@@ -306,6 +325,8 @@ export class RuntimeExtensionStore {
this.canonicalRoot = await realpath(this.managedRoot)
await this.createManagedDirectory('extensions')
await this.createManagedDirectory('.staging')
await this.reconcileMutationJournal()
await this.cleanupUnjournaledStagingDirectories()
}
private async loadCatalog(): Promise<RuntimeExtensionCatalogEntry[]> {
@@ -354,9 +375,12 @@ export class RuntimeExtensionStore {
`${temporaryId}-previous`
)
const finalDirectory = this.extensionDirectory(extensionId)
let previousMoved = false
let stagedMoved = false
try {
if (await this.pathExists(backupDirectory)) {
throw new Error(
'Extension upgrade backup path already exists'
)
}
const installedPackage = await this.dependencies.install({
entry,
destinationDirectory: stagedDirectory
@@ -366,12 +390,6 @@ export class RuntimeExtensionStore {
installedPackage.entrypoint
)
if (await this.pathExists(finalDirectory)) {
await rename(finalDirectory, backupDirectory)
previousMoved = true
}
await rename(stagedDirectory, finalDirectory)
stagedMoved = true
const entrypoint = resolve(
finalDirectory,
installedPackage.entrypoint
@@ -392,19 +410,29 @@ export class RuntimeExtensionStore {
? { integrity: installedPackage.integrity }
: {})
}
await this.persistAndSet(
this.replaceInstalled(state, installed)
)
if (previousMoved) {
await this.removeManagedTree(backupDirectory).catch(() => undefined)
const nextState = this.replaceInstalled(state, installed)
const journal: MutationJournal = {
version: 1,
kind: 'install',
extensionId,
temporaryId,
before: state,
after: nextState
}
await this.writeMutationJournal(journal)
if (await this.pathExists(finalDirectory)) {
await this.assertRealManagedDirectory(finalDirectory)
}
if (await this.pathExists(finalDirectory)) {
await rename(finalDirectory, backupDirectory)
}
await rename(stagedDirectory, finalDirectory)
await this.persistAndSet(nextState)
await this.removeManagedTree(backupDirectory)
await this.clearMutationJournal()
} catch (error) {
if (stagedMoved) {
await this.removeManagedTree(finalDirectory)
}
if (previousMoved) {
await rename(backupDirectory, finalDirectory)
}
this.state = undefined
await this.reconcileMutationJournal().catch(() => undefined)
throw error
} finally {
await this.removeManagedTree(stagedDirectory).catch(() => undefined)
@@ -488,27 +516,40 @@ export class RuntimeExtensionStore {
'.staging',
`${randomUUID()}-removed`
)
let moved = false
const temporaryId = trashDirectory
.slice(trashDirectory.lastIndexOf(sep) + 1)
.replace(/-removed$/u, '')
runtimeExtensionIdSchema.parse(temporaryId)
if (await this.pathExists(trashDirectory)) {
throw new Error('Extension removal staging path already exists')
}
const nextState: StoredState = {
...state,
installed: state.installed.filter(
(extension) => extension.id !== extensionId
)
}
await this.writeMutationJournal({
version: 1,
kind: 'remove',
extensionId,
temporaryId,
before: state,
after: nextState
})
if (await this.pathExists(finalDirectory)) {
await this.assertRealManagedDirectory(finalDirectory)
await rename(finalDirectory, trashDirectory)
moved = true
}
try {
await this.persistAndSet({
...state,
installed: state.installed.filter(
(extension) => extension.id !== extensionId
)
})
await this.persistAndSet(nextState)
} catch (error) {
if (moved) {
await rename(trashDirectory, finalDirectory)
}
this.state = undefined
await this.reconcileMutationJournal().catch(() => undefined)
throw error
}
if (moved) {
await this.removeManagedTree(trashDirectory).catch(() => undefined)
}
await this.removeManagedTree(trashDirectory)
await this.clearMutationJournal()
}
private requireInstalled(
@@ -549,6 +590,171 @@ export class RuntimeExtensionStore {
)
}
private writeMutationJournal(journal: MutationJournal): Promise<void> {
return writeJsonFileAtomically(
this.journalPath,
mutationJournalSchema.parse(journal)
)
}
private async clearMutationJournal(): Promise<void> {
await unlink(this.journalPath).catch((error: unknown) => {
if (!isMissingFileError(error)) {
throw error
}
})
}
private async reconcileMutationJournal(): Promise<void> {
let journal: MutationJournal
try {
const status = await lstat(this.journalPath)
if (
!status.isFile() ||
status.isSymbolicLink() ||
status.nlink > 1
) {
throw new Error(
'Extension mutation journal must be a regular file'
)
}
await this.assertExistingPathContained(this.journalPath)
journal = mutationJournalSchema.parse(
JSON.parse(await readFile(this.journalPath, 'utf8')) as unknown
)
} catch (error) {
if (isMissingFileError(error)) {
return
}
throw error
}
const stateStatus = await lstat(this.statePath)
if (
!stateStatus.isFile() ||
stateStatus.isSymbolicLink() ||
stateStatus.nlink > 1
) {
throw new Error('Extension store state must be a regular file')
}
await this.assertExistingPathContained(this.statePath)
const stored = storedStateFileSchema.parse(
JSON.parse(await readFile(this.statePath, 'utf8')) as unknown
)
if (stored.version !== 2) {
throw new Error(
'Extension mutation journal requires current store state'
)
}
const committed = isDeepStrictEqual(stored, journal.after)
const rolledBack = isDeepStrictEqual(stored, journal.before)
if (!committed && !rolledBack) {
throw new Error(
'Extension mutation journal does not match store state'
)
}
const finalDirectory = this.extensionDirectory(journal.extensionId)
const stagedDirectory = this.managedPath(
'.staging',
journal.temporaryId
)
const auxiliaryDirectory = this.managedPath(
'.staging',
journal.kind === 'install'
? `${journal.temporaryId}-previous`
: `${journal.temporaryId}-removed`
)
if (journal.kind === 'install') {
if (committed) {
await this.assertInstalledStateOnDisk(
this.requireInstalled(journal.after, journal.extensionId)
)
await this.removeManagedTree(auxiliaryDirectory)
await this.removeManagedTree(stagedDirectory)
} else {
const hadPrevious = journal.before.installed.some(
(extension) => extension.id === journal.extensionId
)
if (await this.pathExists(auxiliaryDirectory)) {
await this.assertRealManagedDirectory(auxiliaryDirectory)
await this.removeManagedTree(finalDirectory)
await rename(auxiliaryDirectory, finalDirectory)
} else if (!hadPrevious) {
await this.removeManagedTree(finalDirectory)
}
if (hadPrevious) {
await this.assertInstalledStateOnDisk(
this.requireInstalled(
journal.before,
journal.extensionId
)
)
}
await this.removeManagedTree(stagedDirectory)
}
} else if (committed) {
await this.removeManagedTree(finalDirectory)
await this.removeManagedTree(auxiliaryDirectory)
} else {
if (await this.pathExists(auxiliaryDirectory)) {
await this.assertRealManagedDirectory(auxiliaryDirectory)
await this.removeManagedTree(finalDirectory)
await rename(auxiliaryDirectory, finalDirectory)
}
await this.assertInstalledStateOnDisk(
this.requireInstalled(journal.before, journal.extensionId)
)
}
await this.clearMutationJournal()
this.state = stored
}
private async assertRealManagedDirectory(path: string): Promise<void> {
this.assertContained(this.managedRoot, path)
const status = await lstat(path)
if (!status.isDirectory() || status.isSymbolicLink()) {
throw new Error(
'Extension package path must be a real directory'
)
}
await this.assertExistingPathContained(path)
}
private async cleanupUnjournaledStagingDirectories(): Promise<void> {
const stagingDirectory = this.managedPath('.staging')
await this.assertRealManagedDirectory(stagingDirectory)
const entries = await readdir(stagingDirectory, {
withFileTypes: true
})
for (const entry of entries) {
if (
!entry.isDirectory() ||
entry.isSymbolicLink() ||
!unjournaledStagingDirectoryPattern.test(entry.name)
) {
continue
}
const directory = this.managedPath('.staging', entry.name)
await this.assertRealManagedDirectory(directory)
await this.removeManagedTree(directory)
}
}
private async assertInstalledStateOnDisk(
extension: RuntimeExtensionInstalledState
): Promise<void> {
this.assertExtensionEntrypoint(extension)
const directory = this.extensionDirectory(extension.id)
await this.assertRealManagedDirectory(directory)
const relativeEntrypoint = relative(
directory,
extension.entrypoint
).split(sep).join('/')
await this.resolveEntrypoint(directory, relativeEntrypoint)
}
private assertExtensionEntrypoint(
extension: RuntimeExtensionInstalledState
): void {
+421 -6
View File
@@ -3,6 +3,11 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName,
isUntouchedBuiltInDefaultProject
} from '../../shared/assistant-contracts'
import { AssistantDatabase } from './assistant-database'
const temporaryDirectories: string[] = []
@@ -156,7 +161,7 @@ describe('AssistantDatabase', () => {
database.close()
})
it('migrates existing databases to schema version 23', async () => {
it('migrates existing databases to schema version 25', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-migration-')
)
@@ -185,7 +190,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(23)
).toBe(25)
expect(
current
.prepare(
@@ -200,7 +205,12 @@ describe('AssistantDatabase', () => {
.all()
).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'runtime_selection_json' })
expect.objectContaining({ name: 'runtime_selection_json' }),
expect.objectContaining({
name: 'built_in_default',
notnull: 1,
dflt_value: '0'
})
])
)
const foreignKeys = current
@@ -268,6 +278,174 @@ describe('AssistantDatabase', () => {
current.close()
})
it('backfills one exact legacy built-in default candidate', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-default-project-migration-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
initial.close()
const legacy = new DatabaseSync(databasePath)
legacy.exec(`
DROP INDEX projects_built_in_default_unique;
ALTER TABLE projects DROP COLUMN built_in_default;
PRAGMA user_version = 24;
`)
legacy.close()
const migrated = new AssistantDatabase(databasePath)
migrated.initialize('C:\\Workspace')
const [project] = migrated.listProjects()
expect(project).toMatchObject({
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
builtInDefault: true
})
expect(
project && isUntouchedBuiltInDefaultProject(project)
).toBe(true)
migrated.close()
})
it('does not backfill an ambiguous legacy default identity', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-ambiguous-default-project-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
const independent = initial.createProject({
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: 'D:\\Independent',
defaultWorkMode: 'ask'
})
expect(independent.builtInDefault).toBe(false)
expect(isUntouchedBuiltInDefaultProject(independent)).toBe(false)
initial.close()
const legacy = new DatabaseSync(databasePath)
legacy.exec(`
DROP INDEX projects_built_in_default_unique;
ALTER TABLE projects DROP COLUMN built_in_default;
PRAGMA user_version = 24;
`)
legacy.close()
const migrated = new AssistantDatabase(databasePath)
migrated.initialize('C:\\Workspace')
expect(
migrated
.listProjects()
.map((project) => project.builtInDefault)
).toEqual([false, false])
migrated.close()
})
it('does not mark a later exact clone after the original default was edited', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-edited-default-project-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
const original = initial.listProjects()[0]!
initial.updateProject(original.id, {
name: '已编辑默认项目',
description: builtInDefaultProjectSeedDescription,
rootPath: original.rootPath,
defaultWorkMode: 'ask'
})
const clone = initial.createProject({
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: original.rootPath,
defaultWorkMode: 'ask'
})
initial.close()
const legacy = new DatabaseSync(databasePath)
legacy.exec(`
DROP INDEX projects_built_in_default_unique;
ALTER TABLE projects DROP COLUMN built_in_default;
PRAGMA user_version = 24;
`)
legacy.close()
const migrated = new AssistantDatabase(databasePath)
migrated.initialize('C:\\Workspace')
expect(
migrated
.listProjects()
.filter((project) => project.builtInDefault)
).toEqual([])
expect(migrated.getProject(clone.id).builtInDefault).toBe(false)
migrated.close()
})
it('does not mark a later exact clone after the original default was deleted', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-deleted-default-project-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
const original = initial.listProjects()[0]!
const clone = initial.createProject({
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: original.rootPath,
defaultWorkMode: 'ask'
})
initial.deleteProject(original.id, original.name)
initial.close()
const legacy = new DatabaseSync(databasePath)
legacy.exec(`
DROP INDEX projects_built_in_default_unique;
ALTER TABLE projects DROP COLUMN built_in_default;
PRAGMA user_version = 24;
`)
legacy.close()
const migrated = new AssistantDatabase(databasePath)
migrated.initialize('C:\\Workspace')
expect(migrated.getProject(clone.id).builtInDefault).toBe(false)
migrated.close()
})
it('does not backfill when no exact legacy candidate exists', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-missing-default-project-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
initial.close()
const legacy = new DatabaseSync(databasePath)
legacy.exec(`
UPDATE projects
SET updated_at = created_at || '-edited';
DROP INDEX projects_built_in_default_unique;
ALTER TABLE projects DROP COLUMN built_in_default;
PRAGMA user_version = 24;
`)
legacy.close()
const migrated = new AssistantDatabase(databasePath)
migrated.initialize('C:\\Workspace')
expect(migrated.listProjects()[0]?.builtInDefault).toBe(false)
migrated.close()
})
it('idempotently migrates version 5 databases to computer control audit schema', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-control-audit-migration-')
@@ -298,7 +476,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(23)
).toBe(25)
expect(
current
.prepare(
@@ -436,7 +614,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(
inspected.prepare('PRAGMA user_version').get()
).toEqual({ user_version: 23 })
).toEqual({ user_version: 25 })
expect(
inspected
.prepare(
@@ -566,11 +744,34 @@ describe('AssistantDatabase', () => {
const database = await createDatabase()
const [defaultProject] = database.listProjects()
expect(defaultProject).toMatchObject({
name: '默认项目',
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: 'C:\\Workspace',
defaultWorkMode: 'ask',
kind: 'user',
builtInDefault: true,
status: 'active'
})
expect(defaultProject?.runtimeSelection).toBeUndefined()
expect(defaultProject?.createdAt).toBe(defaultProject?.updatedAt)
expect(
defaultProject &&
isUntouchedBuiltInDefaultProject(defaultProject)
).toBe(true)
const reconfiguredDefault = database.updateProject(
defaultProject!.id,
{
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: 'D:\\Moved',
defaultWorkMode: 'execute',
runtimeSelection: { provider: 'continue' }
}
)
expect(reconfiguredDefault.builtInDefault).toBe(true)
expect(
isUntouchedBuiltInDefaultProject(reconfiguredDefault)
).toBe(true)
expect(database.listExperts()).toHaveLength(3)
const project = database.createProject({
@@ -579,6 +780,7 @@ describe('AssistantDatabase', () => {
rootPath: 'C:\\Release',
defaultWorkMode: 'ask'
})
expect(project.builtInDefault).toBe(false)
expect(database.listProjects()).toHaveLength(2)
const updated = database.updateProject(project.id, {
@@ -863,6 +1065,192 @@ describe('AssistantDatabase', () => {
reopened.close()
})
it('keeps exhausted channel results terminal and observable', async () => {
const database = await createDatabase()
const entry = database.enqueueChannelResult({
channel: 'weixin',
eventId: 'terminal-event',
conversationId: 'conversation-1',
recipientId: 'sender-1',
status: 'completed',
output: '已完成',
attachments: [
{
name: 'result.txt',
mimeType: 'text/plain',
size: 1,
kind: 'file',
dataBase64: 'eA=='
}
]
})
for (let attempt = 0; attempt < 5; attempt += 1) {
database.markChannelResult(entry.id, 'failed')
}
const terminal = database.listUndeliveredChannelResults()
expect(terminal).toEqual([
expect.objectContaining({
id: entry.id,
state: 'terminal',
attempts: 5,
message: expect.objectContaining({
eventId: 'terminal-event',
output: '已完成'
})
})
])
expect(terminal[0]?.message).not.toHaveProperty('attachments')
database.close()
})
it('migrates exhausted legacy outbox failures to terminal state', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-channel-terminal-migration-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
const entry = initial.enqueueChannelResult({
channel: 'weixin',
eventId: 'legacy-terminal-event',
conversationId: 'conversation-1',
recipientId: 'sender-1',
status: 'completed',
output: '已完成',
attachments: [
{
name: 'legacy.txt',
mimeType: 'text/plain',
size: 1,
kind: 'file',
dataBase64: 'eA=='
}
]
})
initial.close()
const legacy = new DatabaseSync(databasePath)
legacy
.prepare(
`UPDATE channel_outbox
SET state = 'failed', attempts = 5
WHERE id = ?`
)
.run(entry.id)
legacy.exec('PRAGMA user_version = 23')
legacy.close()
const migrated = new AssistantDatabase(databasePath)
migrated.initialize('C:\\Workspace')
const terminal = migrated.listUndeliveredChannelResults()
expect(terminal).toEqual([
expect.objectContaining({
id: entry.id,
state: 'terminal',
attempts: 5
})
])
expect(terminal[0]?.message).not.toHaveProperty('attachments')
migrated.close()
})
it('rolls back both heartbeat failure updates atomically', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-heartbeat-rollback-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(databasePath)
database.initialize('C:\\Workspace')
const config = database.createHeartbeatConfig(
{
scope: { kind: 'global' },
name: '事务心跳',
timezone: 'UTC',
recurrence: { type: 'daily', localTime: '09:00' },
enabled: true,
lookbackHours: 24,
retentionDays: 30
},
new Date('2026-08-16T00:00:00.000Z')
)
const claim = database.claimHeartbeatNow(
config.id,
'heartbeat-rollback',
'test-owner',
new Date('2026-08-16T01:00:00.000Z')
)
const raw = new DatabaseSync(databasePath)
raw.exec(`
CREATE TRIGGER reject_heartbeat_config_failure
BEFORE UPDATE OF last_status ON heartbeat_configs
BEGIN
SELECT RAISE(ABORT, 'forced config update failure');
END;
`)
raw.close()
expect(() =>
database.failHeartbeatRun(
claim,
'runtime failed',
new Date('2026-08-16T01:01:00.000Z')
)
).toThrow('forced config update failure')
expect(database.getHeartbeatRun(claim.run.id)).toMatchObject({
status: 'claimed',
attemptCount: 1,
completedAt: undefined,
error: undefined
})
expect(database.getHeartbeatConfig(config.id).lastStatus).toBe(
'claimed'
)
database.close()
})
it('rejects heartbeat failure after its lease expires', async () => {
const database = await createDatabase()
const config = database.createHeartbeatConfig(
{
scope: { kind: 'global' },
name: '租约过期心跳',
timezone: 'UTC',
recurrence: { type: 'daily', localTime: '09:00' },
enabled: true,
lookbackHours: 24,
retentionDays: 30
},
new Date('2026-08-16T00:00:00.000Z')
)
const claim = database.claimHeartbeatNow(
config.id,
'expired-heartbeat',
'expired-owner',
new Date('2026-08-16T01:00:00.000Z'),
60_000
)
expect(() =>
database.failHeartbeatRun(
claim,
'late worker failure',
new Date('2026-08-16T01:01:00.001Z')
)
).toThrow('Heartbeat lease is no longer active')
expect(database.getHeartbeatRun(claim.run.id)).toMatchObject({
status: 'claimed',
completedAt: undefined,
error: undefined
})
expect(database.getHeartbeatConfig(config.id).lastStatus).toBe(
'claimed'
)
database.close()
})
it('preserves legacy channel event claims while adding account identity', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-channel-event-migration-')
@@ -2299,6 +2687,7 @@ describe('AssistantDatabase', () => {
it('explicitly deletes only local conversations and cascades messages', async () => {
const database = await createDatabase()
const localId = '00000000-0000-4000-8000-000000000521'
const localTaskId = '00000000-0000-4000-8000-000000000523'
database.replaceConversations([
{
id: localId,
@@ -2341,6 +2730,28 @@ describe('AssistantDatabase', () => {
recurrence: 'daily',
nextRunAt: '2027-01-01T00:00:00.000Z'
})
database.createTask({
id: localTaskId,
conversationId: localId,
title: '本地对话任务',
instructions: '生成仅属于对话的回复',
workMode: 'ask'
})
database.updateTaskStatus(localTaskId, 'completed')
const hiddenReply = database.createTextArtifact({
taskId: localTaskId,
title: '本地对话回复',
content: '删除对话后不得进入成果列表'
})
database.saveDelegationResult(localTaskId, {
status: 'completed',
output: '不应残留'
})
expect(
database
.listArtifacts()
.some((artifact) => artifact.id === hiddenReply.id)
).toBe(false)
expect(database.deleteLocalConversation(localId)).toBe(true)
expect(database.deleteLocalConversation(localId)).toBe(false)
@@ -2357,6 +2768,10 @@ describe('AssistantDatabase', () => {
expect(() =>
database.getConversation(localId)
).toThrow('对话不存在')
expect(() => database.getArtifact(hiddenReply.id)).toThrow(
'成果不存在'
)
expect(database.listPendingDelegationResults()).toEqual([])
expect(() =>
database.deleteLocalConversation(remote.id)
).toThrow('远程对话不能作为本地对话删除')
+217 -41
View File
@@ -1,6 +1,8 @@
import { randomUUID } from 'node:crypto'
import { DatabaseSync } from 'node:sqlite'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName,
conversationSnapshotSchema,
expertCreateSchema,
normalizeInteractiveWorkMode,
@@ -82,6 +84,7 @@ type ProjectRow = {
runtime_selection_json: string | null
kind: AssistantProject['kind']
channel: ProjectChannel | null
built_in_default: number
status: AssistantProject['status']
created_at: string
updated_at: string
@@ -427,6 +430,7 @@ function toProject(row: ProjectRow): AssistantProject {
: parseRuntimeSelection(row.runtime_selection_json),
kind: row.kind,
channel: row.channel ?? undefined,
builtInDefault: row.built_in_default === 1,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at
@@ -982,12 +986,15 @@ export class AssistantDatabase {
.prepare('SELECT COUNT(*) AS count FROM projects')
.get() as { count: number }
if (count.count === 0) {
this.createProject({
name: '默认项目',
description: 'GoodBuddy 默认工作区',
rootPath: defaultRootPath,
defaultWorkMode: 'ask'
})
this.createLocalProject(
{
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: defaultRootPath,
defaultWorkMode: 'ask'
},
true
)
}
const expertCount = database
.prepare('SELECT COUNT(*) AS count FROM experts')
@@ -1283,6 +1290,13 @@ export class AssistantDatabase {
}
createProject(input: ProjectCreateInput): AssistantProject {
return this.createLocalProject(input, false)
}
private createLocalProject(
input: ProjectCreateInput,
builtInDefault: boolean
): AssistantProject {
const database = this.requireDatabase()
const id = randomUUID()
const now = new Date().toISOString()
@@ -1290,9 +1304,9 @@ export class AssistantDatabase {
.prepare(
`INSERT INTO projects
(id, name, description, root_path, default_work_mode,
runtime_selection_json, kind, channel, status, created_at,
updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'user', NULL, 'active', ?, ?)`
runtime_selection_json, kind, channel, built_in_default,
status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'user', NULL, ?, 'active', ?, ?)`
)
.run(
id,
@@ -1303,6 +1317,7 @@ export class AssistantDatabase {
input.runtimeSelection
? JSON.stringify(input.runtimeSelection)
: null,
builtInDefault ? 1 : 0,
now,
now
)
@@ -1913,6 +1928,32 @@ export class AssistantDatabase {
)`
)
.run(conversationId)
database
.prepare(
`DELETE FROM delegation_outbox
WHERE task_id IN (
SELECT id FROM tasks WHERE conversation_id = ?
)`
)
.run(conversationId)
database
.prepare(
`DELETE FROM artifacts
WHERE kind = 'markdown'
AND task_id IN (
SELECT id
FROM tasks
WHERE conversation_id = ?
AND (
origin = 'user'
OR (
origin = 'delegation'
AND conversation_id NOT LIKE 'delegation:%'
)
)
)`
)
.run(conversationId)
database
.prepare('DELETE FROM tasks WHERE conversation_id = ?')
.run(conversationId)
@@ -2214,7 +2255,11 @@ export class AssistantDatabase {
this.requireDatabase()
.prepare(
`UPDATE channel_outbox
SET state = ?,
SET state = CASE
WHEN ? = 'failed' AND attempts + 1 >= 5
THEN 'terminal'
ELSE ?
END,
attempts = attempts + 1,
message_json = CASE
WHEN ? = 'delivered' OR attempts + 1 >= 5
@@ -2223,7 +2268,7 @@ export class AssistantDatabase {
END
WHERE id = ?`
)
.run(state, state, id)
.run(state, state, state, id)
}
listUndeliveredChannelResults(
@@ -2232,7 +2277,7 @@ export class AssistantDatabase {
): Array<{
id: string
message: ChannelResultMessage
state: 'pending' | 'failed'
state: 'pending' | 'failed' | 'terminal'
attempts: number
createdAt: number
}> {
@@ -2252,7 +2297,6 @@ export class AssistantDatabase {
) AS cumulative_bytes
FROM channel_outbox
WHERE state != 'delivered'
AND attempts < 5
${channel === undefined ? '' : 'AND channel = ?'}
)
SELECT id, message_json, state, attempts, created_at
@@ -2272,7 +2316,7 @@ export class AssistantDatabase {
) as Array<{
id: string
message_json: string
state: 'pending' | 'failed'
state: 'pending' | 'failed' | 'terminal'
attempts: number
created_at: number
}>
@@ -5340,32 +5384,46 @@ export class AssistantDatabase {
]!
).toISOString()
: null
const result = database
.prepare(
`UPDATE heartbeat_runs
SET status = 'failed', next_attempt_at = ?,
completed_at = ?, error = ?, lease_owner = NULL,
lease_expires_at = NULL, updated_at = ?
WHERE id = ? AND status = 'claimed' AND lease_owner = ?`
)
.run(
nextAttemptAt,
timestamp,
error.slice(0, 2_000),
timestamp,
claim.run.id,
claim.leaseOwner
)
if (result.changes !== 1) {
throw new Error('Heartbeat lease is no longer active')
database.exec('BEGIN IMMEDIATE')
try {
const result = database
.prepare(
`UPDATE heartbeat_runs
SET status = 'failed', next_attempt_at = ?,
completed_at = ?, error = ?, lease_owner = NULL,
lease_expires_at = NULL, updated_at = ?
WHERE id = ? AND config_id = ?
AND status = 'claimed' AND lease_owner = ?
AND lease_expires_at > ?`
)
.run(
nextAttemptAt,
timestamp,
error.slice(0, 2_000),
timestamp,
claim.run.id,
claim.config.id,
claim.leaseOwner,
timestamp
)
if (result.changes !== 1) {
throw new Error('Heartbeat lease is no longer active')
}
const configResult = database
.prepare(
`UPDATE heartbeat_configs
SET last_status = 'failed', updated_at = ?
WHERE id = ?`
)
.run(timestamp, claim.config.id)
if (configResult.changes !== 1) {
throw new Error('Heartbeat config no longer exists')
}
database.exec('COMMIT')
} catch (transactionError) {
database.exec('ROLLBACK')
throw transactionError
}
database
.prepare(
`UPDATE heartbeat_configs
SET last_status = 'failed', updated_at = ?
WHERE id = ?`
)
.run(timestamp, claim.config.id)
return this.getHeartbeatRun(claim.run.id)
}
@@ -5741,12 +5799,12 @@ export class AssistantDatabase {
const version = database
.prepare('PRAGMA user_version')
.get() as { user_version: number }
if (version.user_version > 23) {
if (version.user_version > 25) {
throw new Error(
`当前 GoodBuddy 不支持助理数据库版本 ${version.user_version},请升级应用后重试`
)
}
if (version.user_version === 23) {
if (version.user_version === 25) {
return
}
if (version.user_version < 1) {
@@ -5760,6 +5818,8 @@ export class AssistantDatabase {
default_work_mode TEXT NOT NULL
CHECK(default_work_mode IN ('ask', 'execute')),
runtime_selection_json TEXT,
built_in_default INTEGER NOT NULL DEFAULT 0
CHECK(built_in_default IN (0, 1)),
status TEXT NOT NULL CHECK(status IN ('active', 'archived')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
@@ -7001,6 +7061,122 @@ export class AssistantDatabase {
throw error
}
}
if (version.user_version < 24) {
database.exec('BEGIN IMMEDIATE')
try {
database.exec(`
ALTER TABLE channel_outbox
RENAME TO channel_outbox_legacy;
DROP INDEX IF EXISTS channel_outbox_state_created;
CREATE TABLE channel_outbox (
id TEXT PRIMARY KEY,
channel TEXT NOT NULL,
event_id TEXT NOT NULL,
message_json TEXT NOT NULL,
state TEXT NOT NULL
CHECK(
state IN (
'pending', 'delivered', 'failed', 'terminal'
)
),
attempts INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
INSERT INTO channel_outbox
(id, channel, event_id, message_json, state, attempts,
created_at)
SELECT id, channel, event_id,
CASE
WHEN state = 'failed' AND attempts >= 5
THEN json_remove(message_json, '$.attachments')
ELSE message_json
END,
CASE
WHEN state = 'failed' AND attempts >= 5
THEN 'terminal'
ELSE state
END,
attempts, created_at
FROM channel_outbox_legacy;
DROP TABLE channel_outbox_legacy;
CREATE INDEX channel_outbox_state_created
ON channel_outbox(state, created_at);
PRAGMA user_version = 24;
COMMIT;
`)
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
if (version.user_version < 25) {
const projectColumns = new Set(
(
database.prepare('PRAGMA table_info(projects)').all() as Array<{
name: string
}>
).map((column) => column.name)
)
database.exec('BEGIN IMMEDIATE')
try {
if (!projectColumns.has('built_in_default')) {
database.exec(`
ALTER TABLE projects
ADD COLUMN built_in_default INTEGER NOT NULL DEFAULT 0
CHECK(built_in_default IN (0, 1));
`)
}
database.exec('UPDATE projects SET built_in_default = 0')
const legacyCandidates = database
.prepare(
`SELECT id
FROM projects
WHERE name = ?
AND description = ?
AND kind = 'user'
AND channel IS NULL
AND status = 'active'
AND default_work_mode = 'ask'
AND runtime_selection_json IS NULL
AND created_at = updated_at
LIMIT 2`
)
.all(
builtInDefaultProjectSeedName,
builtInDefaultProjectSeedDescription
) as Array<{ id: string }>
const originalProject = database
.prepare(
`SELECT id
FROM projects
WHERE rowid = 1`
)
.get() as { id: string } | undefined
if (
legacyCandidates.length === 1 &&
legacyCandidates[0]!.id === originalProject?.id
) {
database
.prepare(
`UPDATE projects
SET built_in_default = 1
WHERE id = ?`
)
.run(legacyCandidates[0]!.id)
}
database.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS
projects_built_in_default_unique
ON projects(built_in_default)
WHERE built_in_default = 1;
PRAGMA user_version = 25;
COMMIT;
`)
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
}
private requireDatabase(): DatabaseSync {
@@ -102,7 +102,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
).count
check.close()
migrated.close()
expect(version).toBe(23)
expect(version).toBe(25)
expect(heartbeatTableCount).toBe(4)
})
+2 -1
View File
@@ -74,7 +74,7 @@ export class MemoryDedupStore implements DedupStore {
export type OutboxEntry = {
id: string
message: ChannelResultMessage
state: 'pending' | 'delivered' | 'failed'
state: 'pending' | 'delivered' | 'failed' | 'terminal'
attempts: number
createdAt: number
}
@@ -129,6 +129,7 @@ export class MemoryOutbox implements Outbox {
entry.state = 'failed'
entry.attempts += 1
if (entry.attempts >= 5) {
entry.state = 'terminal'
entry.message = this.withoutAttachments(entry.message)
}
}
+43
View File
@@ -432,6 +432,49 @@ describe('ChannelService', () => {
await service.stop()
})
it('reports terminal outbox entries without retrying them', async () => {
const driver = new FakeChannelDriver()
const outbox = new MemoryOutbox()
const entry = outbox.enqueue({
channel: driver.channel,
eventId: 'terminal-delivery',
conversationId: 'conversation-1',
recipientId: 'allowed-user',
status: 'completed',
output: '完成'
})
for (let attempt = 0; attempt < 5; attempt += 1) {
outbox.markFailed(entry.id)
}
const deliveryFailure = vi.fn()
const service = new ChannelService(
driver,
async () => ({ status: 'completed' }),
{
allowedSenderIds: ['allowed-user'],
outbox,
onDeliveryFailure: deliveryFailure
}
)
await service.start()
expect(driver.sent).toEqual([])
expect(deliveryFailure).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('已达到重试上限')
})
)
expect(await outbox.listUndelivered()).toEqual([
expect.objectContaining({
id: entry.id,
state: 'terminal',
attempts: 5
})
])
await service.stop()
})
it('releases the event claim when no durable result can be queued', async () => {
const driver = new FakeChannelDriver()
const store = new MemoryDedupStore()
+6 -1
View File
@@ -198,7 +198,12 @@ export class ChannelService {
if (this.state !== 'running') {
return
}
if (entry.attempts >= 5) {
if (entry.state === 'terminal' || entry.attempts >= 5) {
this.onDeliveryFailure?.(
new Error(
`通道结果已达到重试上限,发件箱记录 ${entry.id} 已终止`
)
)
continue
}
try {
+123
View File
@@ -2,6 +2,8 @@ import { createHash } from 'node:crypto'
import {
mkdtemp,
mkdir,
readFile,
readdir,
rm,
writeFile
} from 'node:fs/promises'
@@ -186,6 +188,26 @@ afterEach(async () => {
})
describe('DocumentOcrModelManager', () => {
it('reads active progress without creating or scanning model storage', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-document-ocr-progress-')
)
temporaryDirectories.push(directory)
const getDownloadSource = vi.fn(() => 'modelscope' as const)
const manager = new DocumentOcrModelManager({
userDataDirectory: directory,
fetch: vi.fn<typeof fetch>(),
catalog: [],
getDownloadSource
})
expect(manager.getProgressSnapshot()).toEqual({ operations: [] })
expect(getDownloadSource).not.toHaveBeenCalled()
await expect(
readdir(join(directory, 'models', 'document-ocr'))
).rejects.toMatchObject({ code: 'ENOENT' })
})
it('reports a removed catalog model as unavailable', async () => {
const { manager } = await createManager()
@@ -323,6 +345,107 @@ describe('DocumentOcrModelManager', () => {
expect(JSON.stringify(snapshot.catalog)).not.toContain('/resolve/')
})
it('revalidates externally changed OCR files after a successful status check', async () => {
const { directory, manager, modelBytes } = await createManager()
await manager.install('pp-ocrv6-tiny')
await expect(manager.getStatus('pp-ocrv6-tiny')).resolves.toMatchObject({
available: true,
verified: true
})
await writeFile(
join(
directory,
'models',
'document-ocr',
'pp-ocrv6-tiny',
'detection.onnx'
),
Buffer.alloc(modelBytes.detection.byteLength, 0x7f)
)
await expect(manager.getStatus('pp-ocrv6-tiny')).resolves.toMatchObject({
available: false,
verified: false
})
})
it('does not let an invalidated verification survive remove and reinstall', async () => {
const { manager } = await createManager()
await manager.install('pp-ocrv6-tiny')
const checking = manager.getStatus('pp-ocrv6-tiny')
await manager.remove('pp-ocrv6-tiny')
await expect(checking).resolves.toMatchObject({
available: false,
verified: false
})
await expect(manager.getStatus('pp-ocrv6-tiny')).resolves.toMatchObject({
available: false,
verified: false
})
await manager.install('pp-ocrv6-tiny')
await expect(manager.getStatus('pp-ocrv6-tiny')).resolves.toMatchObject({
available: true,
verified: true
})
})
it('cleans only manager-owned stale staging and partial artifacts', async () => {
const { directory, manager, modelBytes } = await createManager()
await manager.install('pp-ocrv6-tiny')
const root = join(directory, 'models', 'document-ocr')
const modelDirectory = join(root, 'pp-ocrv6-tiny')
const staleStaging =
'.install-pp-ocrv6-tiny-00000000-0000-4000-8000-000000000001'
const unrelatedStaging = '.install-pp-ocrv6-tiny-user-backup'
await mkdir(join(root, staleStaging))
await writeFile(
join(root, staleStaging, 'detection.onnx.partial'),
'stale'
)
await mkdir(join(root, unrelatedStaging))
await writeFile(join(root, unrelatedStaging, 'keep.txt'), 'keep')
await writeFile(
join(modelDirectory, 'detection.onnx.partial'),
'interrupted'
)
await writeFile(join(modelDirectory, 'notes.partial'), 'keep')
await writeFile(join(root, 'user.partial'), 'keep')
await expect(manager.getSnapshot()).resolves.toMatchObject({
installed: [expect.objectContaining({ id: 'pp-ocrv6-tiny' })]
})
expect(await readdir(root)).toEqual(
expect.arrayContaining([
'pp-ocrv6-tiny',
unrelatedStaging,
'user.partial'
])
)
expect(await readdir(root)).not.toContain(staleStaging)
expect(await readdir(modelDirectory)).toEqual(
expect.arrayContaining([
'manifest.json',
'detection.onnx',
'recognition.onnx',
'dictionary.yml',
'notes.partial'
])
)
expect(await readdir(modelDirectory)).not.toContain(
'detection.onnx.partial'
)
await expect(
readFile(join(modelDirectory, 'detection.onnx'))
).resolves.toEqual(Buffer.from(modelBytes.detection))
await expect(
readFile(join(root, unrelatedStaging, 'keep.txt'), 'utf8')
).resolves.toBe('keep')
})
it('downloads the same canonical package from Hugging Face', async () => {
const { manager } = await createManager()
+117 -95
View File
@@ -1,4 +1,4 @@
import { createHash, randomUUID } from 'node:crypto'
import { createHash } from 'node:crypto'
import {
copyFile,
lstat,
@@ -11,11 +11,12 @@ import {
stat,
writeFile
} from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { resolve } from 'node:path'
import {
documentOcrAssetsSchema,
documentOcrModelCatalogEntrySchema,
documentOcrModelCatalogViewEntrySchema,
documentOcrModelProgressSnapshotSchema,
documentOcrModelSnapshotSchema,
documentParsingModelStatusSchema,
installedDocumentOcrModelSchema,
@@ -25,6 +26,7 @@ import {
type DocumentOcrModelCatalogViewEntry,
type DocumentOcrModelFile,
type DocumentOcrModelOperation,
type DocumentOcrModelProgressSnapshot,
type DocumentOcrModelSnapshot,
type InstalledDocumentOcrModel
} from '../shared/document-parsing-contracts'
@@ -41,10 +43,20 @@ import {
extractModelArchive
} from './model-archive'
import { fetchModelDownloadResponse } from './model-download-transport'
import {
MODEL_PARTIAL_SUFFIX,
attachModelAbortSignal,
cleanupStaleModelInstallArtifacts,
createModelStagingDirectory,
ensureModelOperationNotAborted,
hashModelFile,
managedModelChild,
writeModelBuffer
} from './model-package-utils'
import { isMissingFileError } from './settings-file-utils'
const DEFAULT_MAX_FILE_BYTES = 96 * 1024 * 1024
const MANIFEST_FILE_NAME = 'manifest.json'
const PARTIAL_SUFFIX = '.partial'
const MAXIMUM_ARCHIVE_BYTES = 512 * 1024 * 1024
const ARCHIVE_OVERHEAD_BYTES = 1024 * 1024
const executableExtensionPattern =
@@ -55,6 +67,11 @@ type ActiveOperation = {
progress: DocumentOcrModelOperation
}
type ActiveVerification = {
generation: number
promise: Promise<void>
}
export type DocumentOcrModelManagerOptions = {
userDataDirectory: string
fetch: typeof fetch
@@ -65,16 +82,6 @@ export type DocumentOcrModelManagerOptions = {
maxFileBytes?: number
}
function abortError(): DOMException {
return new DOMException('The operation was aborted', 'AbortError')
}
function ensureNotAborted(signal: AbortSignal): void {
if (signal.aborted) {
throw abortError()
}
}
function cloneCatalogEntry(
entry: DocumentOcrModelCatalogEntry
): DocumentOcrModelCatalogEntry {
@@ -99,43 +106,17 @@ function toCatalogView(entry: DocumentOcrModelCatalogEntry) {
}
function safeChild(parent: string, name: string): string {
const child = resolve(parent, name)
if (dirname(child) !== resolve(parent)) {
throw new Error('OCR 模型路径超出受管目录')
}
return child
return managedModelChild(
parent,
name,
'OCR 模型路径超出受管目录'
)
}
function toArrayBuffer(buffer: Buffer): ArrayBuffer {
return Uint8Array.from(buffer).buffer
}
async function hashFile(
path: string,
signal?: AbortSignal
): Promise<{ size: number; sha256: string }> {
const handle = await open(path, 'r')
const hash = createHash('sha256')
const buffer = Buffer.allocUnsafe(64 * 1024)
let size = 0
try {
while (true) {
if (signal) {
ensureNotAborted(signal)
}
const { bytesRead } = await handle.read(buffer, 0, buffer.length)
if (bytesRead === 0) {
break
}
hash.update(buffer.subarray(0, bytesRead))
size += bytesRead
}
} finally {
await handle.close()
}
return { size, sha256: hash.digest('hex') }
}
function parseYamlScalar(value: string): string {
if (value.startsWith("'") && value.endsWith("'")) {
return value.slice(1, -1).replace(/''/gu, "'")
@@ -184,7 +165,8 @@ export class DocumentOcrModelManager {
| Promise<ModelDownloadSource>
private readonly maxFileBytes: number
private readonly operations = new Map<string, ActiveOperation>()
private readonly verifiedModels = new Map<string, Promise<void>>()
private readonly verifiedModels = new Map<string, ActiveVerification>()
private readonly verificationGenerations = new Map<string, number>()
constructor(options: DocumentOcrModelManagerOptions) {
if (!options.userDataDirectory.trim()) {
@@ -220,6 +202,7 @@ export class DocumentOcrModelManager {
async getSnapshot(): Promise<DocumentOcrModelSnapshot> {
await this.ensureRoot()
await this.cleanupStaleArtifacts()
const [selectedDownloadSource, installed] = await Promise.all([
this.getDownloadSource(),
this.readInstalled()
@@ -235,6 +218,14 @@ export class DocumentOcrModelManager {
})
}
getProgressSnapshot(): DocumentOcrModelProgressSnapshot {
return documentOcrModelProgressSnapshotSchema.parse({
operations: [...this.operations.values()].map((operation) => ({
...operation.progress
}))
})
}
async getStatus(
modelId: string
): Promise<ReturnType<typeof documentParsingModelStatusSchema.parse>> {
@@ -317,7 +308,7 @@ export class DocumentOcrModelManager {
await this.assertNotInstalled(entry.id)
stagingDirectory = await this.createStagingDirectory(entry.id)
for (const file of resolvedPackage.files) {
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
operation.progress.phase = 'transferring'
operation.progress.currentFile = file.name
await this.downloadFile(
@@ -335,10 +326,10 @@ export class DocumentOcrModelManager {
stagingDirectory,
operation.controller.signal
)
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
await rename(stagingDirectory, this.modelDirectory(entry.id))
stagingDirectory = undefined
this.verifiedModels.delete(entry.id)
this.invalidateVerification(entry.id)
return installed
} finally {
detachAbort()
@@ -373,7 +364,7 @@ export class DocumentOcrModelManager {
stagingDirectory = await this.createStagingDirectory(entry.id)
operation.progress.phase = 'transferring'
for (const file of entry.files) {
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
operation.progress.currentFile = file.name
const sourceFile = safeChild(source, file.name)
const destination = safeChild(stagingDirectory, file.name)
@@ -391,10 +382,10 @@ export class DocumentOcrModelManager {
stagingDirectory,
operation.controller.signal
)
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
await rename(stagingDirectory, this.modelDirectory(entry.id))
stagingDirectory = undefined
this.verifiedModels.delete(entry.id)
this.invalidateVerification(entry.id)
return installed
} finally {
detachAbort()
@@ -521,10 +512,10 @@ export class DocumentOcrModelManager {
`${JSON.stringify(installed, null, 2)}\n`,
{ encoding: 'utf8', flag: 'wx' }
)
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
await rename(stagingDirectory, this.modelDirectory(entry.id))
stagingDirectory = undefined
this.verifiedModels.delete(entry.id)
this.invalidateVerification(entry.id)
return installed
} finally {
this.operations.delete(entry.id)
@@ -547,7 +538,7 @@ export class DocumentOcrModelManager {
async remove(modelId: string): Promise<void> {
const id = localOcrModelIdSchema.parse(modelId)
this.cancel(id)
this.verifiedModels.delete(id)
this.invalidateVerification(id)
await rm(this.modelDirectory(id), {
recursive: true,
force: true
@@ -560,6 +551,7 @@ export class DocumentOcrModelManager {
}
this.operations.clear()
this.verifiedModels.clear()
this.verificationGenerations.clear()
}
private async ensureRoot(): Promise<void> {
@@ -613,16 +605,7 @@ export class DocumentOcrModelManager {
signal: AbortSignal | undefined,
controller: AbortController
): () => void {
if (!signal) {
return () => undefined
}
const abort = (): void => controller.abort()
if (signal.aborted) {
controller.abort()
} else {
signal.addEventListener('abort', abort, { once: true })
}
return () => signal.removeEventListener('abort', abort)
return attachModelAbortSignal(signal, controller)
}
private async assertNotInstalled(modelId: string): Promise<void> {
@@ -630,11 +613,7 @@ export class DocumentOcrModelManager {
await lstat(this.modelDirectory(modelId))
throw new Error('OCR 模型已安装')
} catch (error) {
if (
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
if (isMissingFileError(error)) {
return
}
throw error
@@ -642,12 +621,11 @@ export class DocumentOcrModelManager {
}
private async createStagingDirectory(modelId: string): Promise<string> {
const directory = safeChild(
return createModelStagingDirectory(
this.rootDirectory,
`.install-${modelId}-${randomUUID()}`
modelId,
'OCR 模型路径超出受管目录'
)
await mkdir(directory, { recursive: false })
return directory
}
private async downloadFile(
@@ -682,29 +660,34 @@ export class DocumentOcrModelManager {
throw new Error(`OCR 模型文件大小不匹配:${file.name}`)
}
const partialPath = `${destination}${PARTIAL_SUFFIX}`
const partialPath = `${destination}${MODEL_PARTIAL_SUFFIX}`
const handle = await open(partialPath, 'wx')
const reader = response.body.getReader()
const hash = createHash('sha256')
let written = 0
try {
while (true) {
ensureNotAborted(signal)
ensureModelOperationNotAborted(signal)
const result = await reader.read()
if (result.done) {
break
}
written += result.value.byteLength
if (
written > file.size ||
written > this.maxFileBytes
written + result.value.byteLength > file.size ||
written + result.value.byteLength > this.maxFileBytes
) {
await reader.cancel()
throw new RangeError(`OCR 模型文件过大:${file.name}`)
}
await handle.write(result.value)
hash.update(result.value)
operation.progress.completedBytes += result.value.byteLength
const persistedBytes = await writeModelBuffer(
handle,
result.value,
(persisted) => {
hash.update(persisted)
operation.progress.completedBytes += persisted.byteLength
}
)
written += persistedBytes
}
} catch (error) {
await reader.cancel().catch(() => undefined)
@@ -732,7 +715,7 @@ export class DocumentOcrModelManager {
}
const entries = await readdir(sourceDirectory, { withFileTypes: true })
for (const localEntry of entries) {
ensureNotAborted(signal)
ensureModelOperationNotAborted(signal)
if (
localEntry.isSymbolicLink() ||
executableExtensionPattern.test(localEntry.name)
@@ -741,13 +724,13 @@ export class DocumentOcrModelManager {
}
}
for (const file of entry.files) {
ensureNotAborted(signal)
ensureModelOperationNotAborted(signal)
const path = safeChild(sourceDirectory, file.name)
const info = await lstat(path)
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`OCR 模型文件必须是普通文件:${file.name}`)
}
const actual = await hashFile(path, signal)
const actual = await hashModelFile(path, signal)
if (
actual.size !== file.size ||
actual.sha256 !== file.sha256
@@ -765,11 +748,11 @@ export class DocumentOcrModelManager {
): Promise<InstalledDocumentOcrModel> {
const files = []
for (const file of entry.files) {
ensureNotAborted(signal)
ensureModelOperationNotAborted(signal)
files.push({
name: file.name,
role: file.role,
...(await hashFile(
...(await hashModelFile(
safeChild(stagingDirectory, file.name),
signal
))
@@ -854,7 +837,9 @@ export class DocumentOcrModelManager {
candidate.name === file.name &&
candidate.role === file.role
)
const actual = await hashFile(safeChild(directory, file.name))
const actual = await hashModelFile(
safeChild(directory, file.name)
)
if (
!installed ||
actual.size !== file.size ||
@@ -870,15 +855,37 @@ export class DocumentOcrModelManager {
private getVerifiedStatus(
entry: DocumentOcrModelCatalogEntry
): Promise<void> {
let verification = this.verifiedModels.get(entry.id)
if (!verification) {
verification = this.verifyInstalledModel(entry).catch((error) => {
this.verifiedModels.delete(entry.id)
throw error
})
this.verifiedModels.set(entry.id, verification)
const generation = this.verificationGenerations.get(entry.id) ?? 0
const active = this.verifiedModels.get(entry.id)
if (active?.generation === generation) {
return active.promise
}
return verification
const verification = this.verifyInstalledModel(entry).then(() => {
if (
(this.verificationGenerations.get(entry.id) ?? 0) !==
generation
) {
throw new Error('OCR 模型在校验期间已发生变化')
}
})
const tracked = verification.finally(() => {
if (this.verifiedModels.get(entry.id)?.promise === tracked) {
this.verifiedModels.delete(entry.id)
}
})
this.verifiedModels.set(entry.id, {
generation,
promise: tracked
})
return tracked
}
private invalidateVerification(modelId: string): void {
this.verificationGenerations.set(
modelId,
(this.verificationGenerations.get(modelId) ?? 0) + 1
)
this.verifiedModels.delete(modelId)
}
private async loadVerifiedAssets(
@@ -932,4 +939,19 @@ export class DocumentOcrModelManager {
dictionary: loaded.get('dictionary')
})
}
private cleanupStaleArtifacts(): Promise<void> {
return cleanupStaleModelInstallArtifacts({
rootDirectory: this.rootDirectory,
isModelId: (value) =>
localOcrModelIdSchema.safeParse(value).success,
activeModelIds: new Set(this.operations.keys()),
partialFileNames: new Set(
this.catalog.flatMap((entry) =>
entry.files.map((file) => file.name)
)
),
escapeMessage: 'OCR 模型路径超出受管目录'
})
}
}
+165 -74
View File
@@ -49,10 +49,7 @@ import {
} from './window'
import { createTrayIcon } from './tray-icon'
import { resolveBundledRuntimePaths } from './agent/bundled-runtimes'
import type {
ContinueHostChild,
ContinueHostLauncher
} from './agent/continue-host-adapter'
import type { ContinueHostLauncher } from './agent/continue-host-adapter'
import { resolvePortableUserDataPath } from './portable-user-data'
import { BrowserService } from './browser/browser-service'
import { SubagentService } from './assistant/subagent-service'
@@ -83,7 +80,11 @@ import {
type DeepSeekHarnessFork
} from './agent/deepseek-harness-utility-launcher'
import { buildControlledHarnessEnvironment } from './agent/process-environment'
import { runStartupPrerequisites } from './startup-prerequisites'
import {
createStartupFailureDiagnostic,
formatStartupFailureMessage,
runStartupPrerequisites
} from './startup-prerequisites'
import { RuntimeExtensionStore } from './agent/runtime-extension-store'
import {
DshNpmExtensionInstaller,
@@ -95,8 +96,14 @@ import {
repairStaleWindowsNotificationShortcuts,
resolveWindowsAppUserModelId
} from './windows-notification-identity'
import { ShortcutSettingsStore } from './shortcut-settings-store'
import { ShortcutSettingsService } from './shortcut-settings-service'
import { defaultGlobalShortcutSettings } from '../shared/shortcut'
import { requestProcessTreeTermination } from './agent/child-process-termination'
import { createContinueUtilityProcessChild } from './agent/continue-utility-process-adapter'
const shortcut = 'CommandOrControl+Shift+Space'
const legacyDefaultShortcut =
defaultGlobalShortcutSettings.accelerator
const mainModuleDirectory = dirname(fileURLToPath(import.meta.url))
const portableUserDataPath = resolvePortableUserDataPath({
packaged: app.isPackaged,
@@ -205,39 +212,28 @@ const launchContinueHost: ContinueHostLauncher = (
stdio: 'pipe'
}
)
let exitCode: number | null = null
let killed = false
utilityChild.on('exit', (code) => {
exitCode = code
})
const child: ContinueHostChild = {
get exitCode() {
return exitCode
},
get killed() {
return killed
},
return createContinueUtilityProcessChild({
get pid() {
return utilityChild.pid
},
stderr: utilityChild.stderr,
once: (_event, listener) => {
utilityChild.once('error', (_type, location, report) => {
listener(
new Error(
`Continue 宿主进程异常(${location}):${report.slice(0, 500)}`
)
)
})
return child
kill: () => utilityChild.kill(),
onExit: (listener) => {
utilityChild.on('exit', listener)
},
kill: () => {
killed = true
return utilityChild.kill()
onceExit: (listener) => {
utilityChild.once('exit', listener)
},
onceError: (listener) => {
utilityChild.once('error', listener)
},
removeExitListener: (listener) => {
utilityChild.removeListener('exit', listener)
},
removeErrorListener: (listener) => {
utilityChild.removeListener('error', listener)
}
}
return child
})
}
const forkDeepSeekHarness: DeepSeekHarnessFork = (
@@ -254,20 +250,7 @@ const forkDeepSeekHarness: DeepSeekHarnessFork = (
function terminateHarnessUtilityProcess(
child: ReturnType<DeepSeekHarnessFork>
): void {
if (process.platform === 'win32' && child.pid) {
const killer = spawn(
'taskkill.exe',
['/PID', String(child.pid), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true
}
)
killer.unref()
return
}
child.kill()
requestProcessTreeTermination(child, { spawn })
}
const launchWechatSidecar: WechatSidecarLauncher = () => {
@@ -521,6 +504,12 @@ if (hasSingleInstanceLock) {
parseDocument: documentParsingService.parse
})
knowledgeService = startupKnowledgeService
let activeEmbeddingProvider:
| ReturnType<typeof createEmbeddingProvider>
| undefined
let activeRerankProvider:
| ReturnType<typeof createRerankProvider>
| undefined
const startupAssistantDatabase = new AssistantDatabase(
join(app.getPath('userData'), 'assistant.sqlite')
)
@@ -617,13 +606,29 @@ if (hasSingleInstanceLock) {
},
initializeKnowledgeAndGateway: async () => {
await startupKnowledgeService.initialize()
const embeddingProvider = createEmbeddingProvider(
initialResolvedSettings
)
const rerankProvider = createRerankProvider(
initialResolvedSettings
)
await Promise.all([
startupKnowledgeService.setEmbeddingProvider(
createEmbeddingProvider(initialResolvedSettings)
).catch(() => undefined),
embeddingProvider
).then(
() => {
activeEmbeddingProvider = embeddingProvider
},
() => undefined
),
startupKnowledgeService.setRerankProvider(
createRerankProvider(initialResolvedSettings)
).catch(() => undefined)
rerankProvider
).then(
() => {
activeRerankProvider = rerankProvider
},
() => undefined
)
])
await startupKnowledgeGateway.start()
},
@@ -663,11 +668,19 @@ if (hasSingleInstanceLock) {
})
const approvalBroker = new ToolApprovalBroker()
const shortcutRegistered = globalShortcut.register(shortcut, () => {
if (mainWindow) {
toggleWindow(mainWindow)
}
})
const shortcutSettingsService = new ShortcutSettingsService(
new ShortcutSettingsStore(
join(app.getPath('userData'), 'shortcut-settings.json')
),
globalShortcut,
() => {
if (mainWindow) {
toggleWindow(mainWindow)
}
},
process.platform
)
await shortcutSettingsService.initialize()
let runtimeReconfigurationQueue: Promise<void> = Promise.resolve()
let runtimeReconfigurationClosing = false
@@ -677,24 +690,97 @@ if (hasSingleInstanceLock) {
throw new Error('Runtime 配置正在关闭')
}
const settings = await settingsStore.getResolvedSettings()
if (knowledgeService) {
await knowledgeService.setEmbeddingProvider(
createEmbeddingProvider(settings)
)
await knowledgeService.setRerankProvider(
createRerankProvider(settings)
const nextEmbeddingProvider =
createEmbeddingProvider(settings)
const nextRerankProvider = createRerankProvider(settings)
let nextRuntime: AgentRuntime | undefined
let nextSubagentRuntime: AgentRuntime | undefined
let nextSubagentProfileRuntimes:
| ReadonlyMap<string, AgentRuntime>
| undefined
try {
nextSubagentRuntime = createDefaultModelRuntime(
defaultWorkspace,
settings
)
nextSubagentProfileRuntimes =
createSubagentProfileRuntimes(
defaultWorkspace,
settings
)
if (runtime) {
nextRuntime = await createConfiguredRuntime(settings)
}
} catch (error) {
await Promise.allSettled([
nextRuntime?.dispose(),
nextSubagentRuntime?.dispose(),
...[
...(nextSubagentProfileRuntimes?.values() ?? [])
].map((candidate) => candidate.dispose())
])
throw error
}
if (runtime) {
await runtime.replace(
await createConfiguredRuntime(settings)
let runtimeConsumed = false
let subagentRuntimesConsumed = false
try {
if (knowledgeService) {
await Promise.all([
knowledgeService.setEmbeddingProvider(
nextEmbeddingProvider
),
knowledgeService.setRerankProvider(
nextRerankProvider
)
])
}
if (runtime && nextRuntime) {
runtimeConsumed = true
await runtime.replace(nextRuntime)
}
subagentRuntimesConsumed = true
await subagentService.replaceRuntimes(
nextSubagentRuntime,
nextSubagentProfileRuntimes
)
await selectedRuntimeManager?.reset()
activeEmbeddingProvider = nextEmbeddingProvider
activeRerankProvider = nextRerankProvider
} catch (activationError) {
const rollbackResults = knowledgeService
? await Promise.allSettled([
knowledgeService.setEmbeddingProvider(
activeEmbeddingProvider
),
knowledgeService.setRerankProvider(
activeRerankProvider
)
])
: []
await Promise.allSettled([
runtimeConsumed ? undefined : nextRuntime?.dispose(),
subagentRuntimesConsumed
? undefined
: nextSubagentRuntime.dispose(),
...(subagentRuntimesConsumed
? []
: [...nextSubagentProfileRuntimes.values()].map(
(candidate) => candidate.dispose()
))
])
const rollbackErrors = rollbackResults.flatMap((result) =>
result.status === 'rejected' ? [result.reason] : []
)
if (rollbackErrors.length > 0) {
throw new AggregateError(
[activationError, ...rollbackErrors],
'Runtime 激活失败,且模型服务回滚未能完成',
{ cause: activationError }
)
}
throw activationError
}
await selectedRuntimeManager?.reset()
await subagentService.replaceRuntimes(
createDefaultModelRuntime(defaultWorkspace, settings),
createSubagentProfileRuntimes(defaultWorkspace, settings)
)
})
runtimeReconfigurationQueue = operation.catch(() => undefined)
return operation
@@ -707,7 +793,7 @@ if (hasSingleInstanceLock) {
removeIpcHandlers = registerIpcHandlers(
mainWindow,
runtime,
shortcutRegistered ? shortcut : '未注册',
legacyDefaultShortcut,
settingsStore,
capabilityService,
contextManager,
@@ -735,7 +821,8 @@ if (hasSingleInstanceLock) {
documentOcrBroker,
releaseNotesService,
goodbuddyConfigService,
runtimeExtensionStore
runtimeExtensionStore,
shortcutSettingsService
)
loadMainWindow(mainWindow)
setImmediate(() => {
@@ -786,10 +873,14 @@ if (hasSingleInstanceLock) {
showWindow(mainWindow)
}
})
}).catch(() => {
}).catch((error: unknown) => {
console.error(
'GoodBuddy startup failed',
createStartupFailureDiagnostic(error)
)
dialog.showErrorBox(
'GoodBuddy 启动失败',
'本地数据或 Runtime 服务初始化失败。请重启应用;若问题持续,请备份后清理应用数据。'
formatStartupFailureMessage(error)
)
app.quit()
})
+1076 -14
View File
File diff suppressed because it is too large Load Diff
+280 -125
View File
@@ -16,7 +16,10 @@ import { randomUUID } from 'node:crypto'
import { homedir } from 'node:os'
import { basename, extname, isAbsolute, join } from 'node:path'
import { z } from 'zod'
import { formatShortcutForDisplay } from '../shared/shortcut'
import {
formatShortcutForDisplay,
globalShortcutSettingsUpdateSchema
} from '../shared/shortcut'
import { readBoundedFile } from './workspace-file-access'
import {
approvalDecisionSchema,
@@ -203,6 +206,7 @@ import {
type RuntimeExtensionMarketplaceSnapshot
} from '../shared/runtime-extension-contracts'
import type { RuntimeExtensionStore } from './agent/runtime-extension-store'
import type { ShortcutSettingsService } from './shortcut-settings-service'
import type { ContextManager } from './context-manager'
import type { KnowledgeService } from './knowledge/knowledge-service'
import {
@@ -274,6 +278,7 @@ import {
import { AgentEventBuffer } from './agent-event-buffer'
const requestIdSchema = z.string().uuid()
const BACKGROUND_QUESTION_REJECTION_TIMEOUT_MS = 1_000
const runtimeConfigFileMetadata = {
opencode: {
filterName: 'OpenCode 配置',
@@ -415,6 +420,31 @@ function safeRuntimeError(error: unknown, fallback: string): string {
return safeToolErrorDetail(error, 2_000) ?? fallback
}
async function activateOrRollback<T>(input: {
previous: T
persistCandidate(): Promise<T>
activate(): Promise<void>
persistPrevious(previous: T): Promise<unknown>
}): Promise<T> {
const saved = await input.persistCandidate()
try {
await input.activate()
return saved
} catch (activationError) {
try {
await input.persistPrevious(input.previous)
await input.activate()
} catch (rollbackError) {
throw new AggregateError(
[activationError, rollbackError],
'Runtime 配置激活失败,且回滚未能完成',
{ cause: rollbackError }
)
}
throw activationError
}
}
function createPromiseTracker(): {
track<T>(operation: Promise<T>): Promise<T>
drain(): Promise<void>
@@ -883,10 +913,35 @@ export function registerIpcHandlers(
documentOcrBroker?: DocumentOcrBroker,
releaseNotesService?: ReleaseNotesService,
goodbuddyConfigService?: GoodBuddyConfigService,
runtimeExtensionStore?: RuntimeExtensionStore
runtimeExtensionStore?: RuntimeExtensionStore,
shortcutSettingsService?: ShortcutSettingsService
): () => Promise<void> {
const activeRequests = new Map<string, AbortController>()
const activeRequestConversations = new Map<string, string>()
type ActiveRequestLease = {
controller: AbortController
conversationId: string
}
const activeRequests = new Map<string, ActiveRequestLease>()
const activeRequestConversations = new Map<
string,
ActiveRequestLease
>()
const leaseActiveRequest = (
requestId: string,
conversationId: string,
controller: AbortController
): (() => void) => {
const lease = { controller, conversationId }
activeRequests.set(requestId, lease)
activeRequestConversations.set(requestId, lease)
return (): void => {
if (activeRequests.get(requestId) === lease) {
activeRequests.delete(requestId)
}
if (activeRequestConversations.get(requestId) === lease) {
activeRequestConversations.delete(requestId)
}
}
}
const activeEventBuffers = new Map<string, { flush(): void }>()
const pendingAgentQuestions = new Map<
string,
@@ -900,6 +955,17 @@ export function registerIpcHandlers(
const pendingRendererPersistence = new Map<string, () => void>()
let pendingGoodBuddyConfigReload = false
let goodBuddyConfigReloadQueue: Promise<void> = Promise.resolve()
let runtimeSettingsUpdateQueue: Promise<void> = Promise.resolve()
const enqueueRuntimeSettingsUpdate = <T>(
transaction: () => Promise<T>
): Promise<T> => {
const result = runtimeSettingsUpdateQueue.then(transaction)
runtimeSettingsUpdateQueue = result.then(
() => undefined,
() => undefined
)
return result
}
const executionTracker = createPromiseTracker()
const maintenanceTracker = createPromiseTracker()
const trackExecution = executionTracker.track
@@ -1010,8 +1076,8 @@ export function registerIpcHandlers(
}
})
const abortActiveRequests = (reason: string): void => {
for (const controller of activeRequests.values()) {
controller.abort(new Error(reason))
for (const lease of activeRequests.values()) {
lease.controller.abort(new Error(reason))
}
activeRequests.clear()
}
@@ -1316,7 +1382,7 @@ export function registerIpcHandlers(
(candidate) => candidate === conversationId
) ||
[...activeRequestConversations.values()].some(
(candidate) => candidate === conversationId
(candidate) => candidate.conversationId === conversationId
)
const pumpConversationQueue = async (
@@ -1538,14 +1604,17 @@ export function registerIpcHandlers(
externalSignal?.addEventListener('abort', abortFromExternal, {
once: true
})
activeRequests.set(requestId, controller)
const runtimeConversationId =
remoteContext?.conversationId ??
(input.origin === 'schedule'
? input.schedule.conversationId
: undefined) ??
`${origin}:${schedule.id}`
activeRequestConversations.set(requestId, runtimeConversationId)
const releaseActiveRequest = leaseActiveRequest(
requestId,
runtimeConversationId,
controller
)
if (input.origin !== 'delegation') {
assistantDatabase.updateTaskStatus(taskId, 'running')
} else {
@@ -1565,6 +1634,7 @@ export function registerIpcHandlers(
}
let output = ''
let completed = false
let backgroundQuestionError: Error | undefined
let knowledgeCapabilityToken: string | undefined
const resultAttachments: ChannelMediaAttachment[] = []
const artifactIds: string[] = []
@@ -1766,6 +1836,37 @@ export function registerIpcHandlers(
if (taskEvent.type === 'artifact') {
artifactIds.push(taskEvent.artifactId)
}
if (taskEvent.type === 'question') {
const error = new Error(
'后台任务无法回答 Runtime 交互提问。请改为在 GoodBuddy 对话中运行,或调整提示词和工具配置以避免交互提问。'
)
backgroundQuestionError = error
const rejection =
requestRuntime
.respondToQuestion?.(taskEvent.questionId)
.catch(() => undefined) ?? Promise.resolve()
let rejectionTimeout:
| ReturnType<typeof setTimeout>
| undefined
try {
await Promise.race([
rejection,
new Promise<void>((resolveTimeout) => {
rejectionTimeout = setTimeout(
resolveTimeout,
BACKGROUND_QUESTION_REJECTION_TIMEOUT_MS
)
rejectionTimeout.unref?.()
})
])
} finally {
if (rejectionTimeout) {
clearTimeout(rejectionTimeout)
}
}
controller.abort(error)
throw error
}
eventBuffer.push(taskEvent)
if (taskEvent.type === 'tool' && remoteContext) {
publishRemoteActivity({
@@ -1879,8 +1980,11 @@ export function registerIpcHandlers(
}
} catch (error) {
eventBuffer.flush()
const message = safeRuntimeError(error, '定时任务执行失败')
const cancelled = controller.signal.aborted
const message = backgroundQuestionError
? backgroundQuestionError.message
: safeRuntimeError(error, '定时任务执行失败')
const cancelled =
controller.signal.aborted && !backgroundQuestionError
assistantDatabase.updateTaskStatus(
taskId,
cancelled ? 'cancelled' : 'failed',
@@ -1924,8 +2028,7 @@ export function registerIpcHandlers(
)
knowledgeGateway?.revoke(knowledgeCapabilityToken)
goodbuddyConfigService?.revokeRequest(requestId)
activeRequests.delete(requestId)
activeRequestConversations.delete(requestId)
releaseActiveRequest()
await flushGoodBuddyConfigReload().catch(() => undefined)
}
}
@@ -2297,6 +2400,34 @@ export function registerIpcHandlers(
detail: parsed.prompt,
status: 'running'
})
const finalizeExecutePreflightFailure = (
unavailable: string
): { status: 'failed'; error: string } => {
assistantDatabase.updateTaskStatus(
remoteTaskId,
'failed',
unavailable
)
assistantDatabase.appendRemoteConversationMessage({
conversationId: remoteConversation.id,
role: 'assistant',
content: unavailable,
status: '执行不可用'
})
publishRemoteConversationChange()
publishRemoteActivity({
requestId: remoteTaskId,
conversationId: remoteConversation.id,
projectId: project.id,
projectName: project.name,
channel,
kind: 'result',
title: `${channelLabel}远程执行不可用`,
detail: unavailable,
status: 'failed'
})
return { status: 'failed', error: unavailable }
}
let executionRuntime: AgentRuntime | undefined
if (parsed.workMode === 'execute') {
@@ -2316,30 +2447,7 @@ export function registerIpcHandlers(
error,
'远程 Execute Runtime 不可用'
)
assistantDatabase.updateTaskStatus(
remoteTaskId,
'failed',
unavailable
)
assistantDatabase.appendRemoteConversationMessage({
conversationId: remoteConversation.id,
role: 'assistant',
content: unavailable,
status: '执行不可用'
})
publishRemoteConversationChange()
publishRemoteActivity({
requestId: remoteTaskId,
conversationId: remoteConversation.id,
projectId: project.id,
projectName: project.name,
channel,
kind: 'result',
title: `${channelLabel}远程执行不可用`,
detail: unavailable,
status: 'failed'
})
return { status: 'failed', error: unavailable }
return finalizeExecutePreflightFailure(unavailable)
}
if (
!executionStatus.available ||
@@ -2349,30 +2457,7 @@ export function registerIpcHandlers(
? '所选处理后端不支持工具执行,请在消息通道设置中选择 OpenCode、Continue 或支持工具的直连模型'
: executionStatus.detail?.trim() ||
'所选处理后端当前不可用,请在消息通道设置中检查 Runtime 或模型连接'
assistantDatabase.updateTaskStatus(
remoteTaskId,
'failed',
unavailable
)
assistantDatabase.appendRemoteConversationMessage({
conversationId: remoteConversation.id,
role: 'assistant',
content: unavailable,
status: '执行不可用'
})
publishRemoteConversationChange()
publishRemoteActivity({
requestId: remoteTaskId,
conversationId: remoteConversation.id,
projectId: project.id,
projectName: project.name,
channel,
kind: 'result',
title: `${channelLabel}远程执行不可用`,
detail: unavailable,
status: 'failed'
})
return { status: 'failed', error: unavailable }
return finalizeExecutePreflightFailure(unavailable)
}
}
@@ -2499,12 +2584,20 @@ export function registerIpcHandlers(
registerHandler(ipcChannels.appInfo, (event): AppInfo => {
assertTrustedSender(event, window)
const shortcutSnapshot = shortcutSettingsService?.getSnapshot()
return {
name: app.getName(),
version: app.getVersion(),
platform: process.platform,
arch: process.arch,
shortcut: formatShortcutForDisplay(shortcut, process.platform)
shortcut: shortcutSnapshot?.registered
? shortcutSnapshot.displayAccelerator
: shortcutSnapshot
? ''
: formatShortcutForDisplay(shortcut, process.platform),
...(shortcutSnapshot
? { shortcutStatus: shortcutSnapshot.status }
: {})
}
})
@@ -2673,8 +2766,8 @@ export function registerIpcHandlers(
reservedConversationQueueItems.get(parsedInput.conversationId)
if (
[...activeRequestConversations.values()].some(
(conversationId) =>
conversationId === parsedInput.conversationId
(lease) =>
lease.conversationId === parsedInput.conversationId
) ||
[...preparingRequestConversations.values()].some(
(conversationId) =>
@@ -2730,39 +2823,49 @@ export function registerIpcHandlers(
const enrichedRequest = contextManager.enrichRequest(
parsedRequest
)
const hasKnowledgeScope = knowledgeLibraryIds.length > 0
const magicNotesToolEnabled =
(await applicationSettingsStore?.get())?.magicNotesEnabled ?? false
const webSearchEnabled =
!agentRuntimeSelected &&
(
await capabilityService.getWebSearchCapabilityStatus?.()
)?.enabled === true
if (activeRequests.has(enrichedRequest.requestId)) {
throw new Error('请求正在执行')
}
const controller = new AbortController()
const hasKnowledgeScope = knowledgeLibraryIds.length > 0
const configAccess =
goodbuddyConfigService && !imageGeneration
? enrichedRequest.workMode === 'execute'
? 'write'
: 'read'
: 'none'
const selectedRuntimeTarget = runtimeTargetFor(selectedRuntime)
const [
applicationSettings,
webSearchCapability,
resolvedRuntimeSettings,
enabledBuiltinMcpServers
] = await Promise.all([
applicationSettingsStore?.get(),
!agentRuntimeSelected
? capabilityService.getWebSearchCapabilityStatus?.()
: undefined,
configAccess !== 'none' && !enrichedRequest.projectId
? settingsStore.getResolvedSettings()
: undefined,
selectedRuntimeTarget
? capabilityService.getEnabledBuiltinMcpServerIds
? capabilityService.getEnabledBuiltinMcpServerIds(
selectedRuntimeTarget
)
: [...builtinMcpServerIdSchema.options]
: []
])
const magicNotesToolEnabled =
applicationSettings?.magicNotesEnabled ?? false
const webSearchEnabled =
webSearchCapability?.enabled === true
const configWorkspacePath =
configAccess === 'none'
? undefined
: enrichedRequest.projectId
? assistantDatabase.getProject(enrichedRequest.projectId).rootPath
: (await settingsStore.getResolvedSettings()).workspacePath
const selectedRuntimeTarget = runtimeTargetFor(selectedRuntime)
const enabledBuiltinMcpServers = selectedRuntimeTarget
? capabilityService.getEnabledBuiltinMcpServerIds
? await capabilityService.getEnabledBuiltinMcpServerIds(
selectedRuntimeTarget
)
: [...builtinMcpServerIdSchema.options]
: []
: resolvedRuntimeSettings?.workspacePath
const controller = new AbortController()
const scopedCapability = grantScopedDataCapability({
gateway: knowledgeGateway,
runtime: selectedRuntime,
@@ -2823,10 +2926,10 @@ export function registerIpcHandlers(
knowledgeGateway?.revoke(knowledgeCapabilityToken)
throw error
}
activeRequests.set(request.requestId, controller)
activeRequestConversations.set(
const releaseActiveRequest = leaseActiveRequest(
request.requestId,
request.conversationId
request.conversationId,
controller
)
if (parsedInput.queueItemId) {
const dispatchTimeout = queueDispatchTimers.get(
@@ -2848,8 +2951,7 @@ export function registerIpcHandlers(
)
publishConversationQueueChange(request.conversationId)
} catch (error) {
activeRequests.delete(request.requestId)
activeRequestConversations.delete(request.requestId)
releaseActiveRequest()
assistantDatabase.updateTaskStatus(
request.requestId,
'cancelled',
@@ -3336,8 +3438,7 @@ export function registerIpcHandlers(
}
}
knowledgeGateway?.revoke(request.knowledgeCapabilityToken)
activeRequests.delete(request.requestId)
activeRequestConversations.delete(request.requestId)
releaseActiveRequest()
const configReload =
goodbuddyConfigService?.takePendingReload(request.requestId) ??
'none'
@@ -3360,7 +3461,9 @@ export function registerIpcHandlers(
registerHandler(ipcChannels.agentCancel, (event, input: unknown) => {
assertTrustedSender(event, window)
const requestId = requestIdSchema.parse(input)
activeRequests.get(requestId)?.abort(new Error('用户取消了请求'))
activeRequests
.get(requestId)
?.controller.abort(new Error('用户取消了请求'))
})
registerHandler(ipcChannels.agentApprovalRespond, (event, input: unknown) => {
@@ -3453,10 +3556,10 @@ export function registerIpcHandlers(
),
5 * 60_000
)
activeRequests.set(request.requestId, controller)
activeRequestConversations.set(
const releaseActiveRequest = leaseActiveRequest(
request.requestId,
request.conversationId
request.conversationId,
controller
)
assistantDatabase.createTask({
id: request.requestId,
@@ -3541,8 +3644,7 @@ export function registerIpcHandlers(
throw error
} finally {
clearTimeout(timeout)
activeRequests.delete(request.requestId)
activeRequestConversations.delete(request.requestId)
releaseActiveRequest()
readyConversationQueues.add(request.conversationId)
void pumpConversationQueue(request.conversationId)
}
@@ -3571,12 +3673,23 @@ export function registerIpcHandlers(
assertTrustedSender(event, window)
const settings =
runtimeCustomizationSettingsSchema.parse(input)
const saved =
await settingsStore.updateRuntimeCustomization(settings)
abortActiveRequests('Runtime 定制设置已更改')
approvalBroker.clear()
await onRuntimeSettingsChanged()
return saved
return enqueueRuntimeSettingsUpdate(async () => {
const previous =
await settingsStore.getRuntimeCustomization()
return activateOrRollback({
previous,
persistCandidate: async () => {
const saved =
await settingsStore.updateRuntimeCustomization(settings)
abortActiveRequests('Runtime 定制设置已更改')
approvalBroker.clear()
return saved
},
activate: onRuntimeSettingsChanged,
persistPrevious: (previousSettings) =>
settingsStore.updateRuntimeCustomization(previousSettings)
})
})
}
)
@@ -3611,28 +3724,39 @@ export function registerIpcHandlers(
async (event, input: unknown): Promise<RuntimeSettings> => {
assertTrustedSender(event, window)
const settings = runtimeSettingsInputSchema.parse(input)
let workspacePath: string
try {
workspacePath = await realpath(settings.workspacePath)
if (!(await stat(workspacePath)).isDirectory()) {
throw new Error('Not a directory')
return enqueueRuntimeSettingsUpdate(async () => {
let workspacePath: string
try {
workspacePath = await realpath(settings.workspacePath)
if (!(await stat(workspacePath)).isDirectory()) {
throw new Error('Not a directory')
}
} catch {
throw new Error('所选工作区不存在、不可访问或不是文件夹')
}
} catch {
throw new Error('所选工作区不存在、不可访问或不是文件夹')
}
const savedSettings = await settingsStore.update({
...settings,
workspacePath
})
channelSettingsStore?.reportRuntimeSelectionRepairs(
assistantDatabase.repairConversationRuntimeSelections(
savedSettings
const rollback = await settingsStore.captureRollback()
const previousSettings = rollback.publicSettings
const savedSettings = await activateOrRollback({
previous: previousSettings,
persistCandidate: async () => {
const saved = await settingsStore.update({
...settings,
workspacePath
})
abortActiveRequests('运行时设置已更改')
approvalBroker.clear()
return saved
},
activate: onRuntimeSettingsChanged,
persistPrevious: () => rollback.restore()
})
channelSettingsStore?.reportRuntimeSelectionRepairs(
assistantDatabase.repairConversationRuntimeSelections(
savedSettings
)
)
)
abortActiveRequests('运行时设置已更改')
approvalBroker.clear()
await onRuntimeSettingsChanged()
return savedSettings
return savedSettings
})
}
)
@@ -3903,6 +4027,27 @@ export function registerIpcHandlers(
}
)
registerHandler(ipcChannels.shortcutSettingsGet, (event) => {
assertTrustedSender(event, window)
if (!shortcutSettingsService) {
throw new Error('快捷键设置服务不可用')
}
return shortcutSettingsService.getSnapshot()
})
registerHandler(
ipcChannels.shortcutSettingsUpdate,
(event, input: unknown) => {
assertTrustedSender(event, window)
if (!shortcutSettingsService) {
throw new Error('快捷键设置服务不可用')
}
return shortcutSettingsService.update(
globalShortcutSettingsUpdateSchema.parse(input)
)
}
)
registerHandler(ipcChannels.documentParsingGet, (event) => {
assertTrustedSender(event, window)
if (!documentParsingService) {
@@ -3911,6 +4056,14 @@ export function registerIpcHandlers(
return documentParsingService.snapshot()
})
registerHandler(ipcChannels.documentOcrModelsProgress, (event) => {
assertTrustedSender(event, window)
if (!documentOcrModelManager) {
throw new Error('本地 OCR 模型服务不可用')
}
return documentOcrModelManager.getProgressSnapshot()
})
registerHandler(
ipcChannels.documentParsingUpdate,
(event, input: unknown) => {
@@ -4596,11 +4749,13 @@ export function registerIpcHandlers(
}
preferredConversationQueueItems.set(item.conversationId, item.id)
readyConversationQueues.add(item.conversationId)
for (const [requestId, conversationId] of activeRequestConversations) {
if (conversationId === item.conversationId) {
for (const [requestId, lease] of activeRequestConversations) {
if (lease.conversationId === item.conversationId) {
activeRequests
.get(requestId)
?.abort(new Error('用户中断当前回复并插入队列项'))
?.controller.abort(
new Error('用户中断当前回复并插入队列项')
)
}
}
if (!isConversationExecuting(item.conversationId)) {
+41 -1
View File
@@ -77,6 +77,7 @@ describe('model archive', () => {
}
})
const progress: number[] = []
await expect(
extractModelArchive({
archivePath: archive,
@@ -89,7 +90,10 @@ describe('model archive', () => {
],
maximumArchiveBytes: 1024 * 1024,
maximumFileBytes: 1024,
maximumTotalBytes: 2048
maximumTotalBytes: 2048,
onProgress: (completedBytes) => {
progress.push(completedBytes)
}
})
).resolves.toMatchObject({
kind: 'speech',
@@ -101,6 +105,7 @@ describe('model archive', () => {
await expect(readFile(join(extracted, 'tokens.txt'))).resolves.toEqual(
tokens
)
expect(progress.at(-1)).toBe(model.byteLength + tokens.byteLength)
})
it('preserves an existing archive when source verification fails', async () => {
@@ -208,4 +213,39 @@ describe('model archive', () => {
})
).rejects.toThrow('模型 ID 不匹配')
})
it('handles malformed entry rejection without an unhandled promise', async () => {
const directory = await temporaryDirectory()
const archive = join(directory, 'truncated.zip')
const extracted = join(directory, 'extracted')
await mkdir(extracted)
const complete = zipSync({
'goodbuddy-model.json': Buffer.from('{}'),
'model.onnx': Buffer.alloc(128 * 1024, 7)
})
await writeFile(archive, complete.subarray(0, complete.length - 17))
const unhandled: unknown[] = []
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason)
}
process.on('unhandledRejection', onUnhandled)
try {
await expect(
extractModelArchive({
archivePath: archive,
destinationDirectory: extracted,
expectedKind: 'speech',
expectedModelId: 'test-model',
expectedFiles: [{ name: 'model.onnx', role: 'model' }],
maximumArchiveBytes: 1024 * 1024,
maximumFileBytes: 1024 * 1024,
maximumTotalBytes: 1024 * 1024
})
).rejects.toThrow()
await new Promise<void>((resolve) => setImmediate(resolve))
expect(unhandled).toEqual([])
} finally {
process.removeListener('unhandledRejection', onUnhandled)
}
})
})
+57 -52
View File
@@ -7,7 +7,7 @@ import {
rm,
type FileHandle
} from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { resolve } from 'node:path'
import {
Unzip,
UnzipInflate,
@@ -16,6 +16,13 @@ import {
ZipPassThrough
} from 'fflate'
import { z } from 'zod'
import {
ensureModelOperationNotAborted,
hashModelFile,
managedModelChild,
writeModelBuffer
} from './model-package-utils'
import { isMissingFileError } from './settings-file-utils'
const ARCHIVE_MANIFEST_NAME = 'goodbuddy-model.json'
const ARCHIVE_FORMAT = 'goodbuddy-model-archive'
@@ -108,14 +115,6 @@ type ExtractModelArchiveOptions = {
onProgress?: (completedBytes: number) => void
}
function safeChild(parent: string, name: string): string {
const child = resolve(parent, name)
if (dirname(child) !== resolve(parent)) {
throw new Error('模型 ZIP 路径超出临时目录')
}
return child
}
function ensureArchiveName(name: string): string {
return archiveFileNameSchema.parse(name)
}
@@ -127,24 +126,6 @@ function ensureUniqueFiles(files: ModelArchiveExpectedFile[]): void {
}
}
async function hashFile(path: string): Promise<ModelArchiveFile['sha256']> {
const handle = await open(path, 'r')
const hash = createHash('sha256')
const buffer = Buffer.allocUnsafe(64 * 1024)
try {
while (true) {
const { bytesRead } = await handle.read(buffer, 0, buffer.length)
if (bytesRead === 0) {
break
}
hash.update(buffer.subarray(0, bytesRead))
}
} finally {
await handle.close()
}
return hash.digest('hex')
}
function checkedLimit(value: number, label: string): number {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new RangeError(`${label}无效`)
@@ -152,14 +133,6 @@ function checkedLimit(value: number, label: string): number {
return value
}
function ensureNotAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw signal.reason instanceof Error
? signal.reason
: new Error('模型 ZIP 导入已取消')
}
}
async function pushFileIntoArchive(
archive: Zip,
file: ModelArchiveFile,
@@ -233,7 +206,7 @@ async function replaceArchiveFile(
throw new Error('模型 ZIP 导出目标必须是普通文件')
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
if (!isMissingFileError(error)) {
throw error
}
}
@@ -277,7 +250,7 @@ export async function exportModelArchive(
}
writeChain = writeChain.then(async () => {
if (data.byteLength > 0) {
await output.write(data)
await writeModelBuffer(output, data)
}
})
if (final) {
@@ -310,7 +283,11 @@ export async function exportModelArchive(
await pushFileIntoArchive(
archive,
file,
safeChild(sourceDirectory, file.name),
managedModelChild(
sourceDirectory,
file.name,
'模型 ZIP 路径超出临时目录'
),
waitForOutput
)
}
@@ -334,7 +311,7 @@ function closeHandle(handle: FileHandle): Promise<void> {
export async function extractModelArchive(
options: ExtractModelArchiveOptions
): Promise<ModelArchiveDescriptor> {
ensureNotAborted(options.signal)
ensureModelOperationNotAborted(options.signal)
const maximumArchiveBytes = checkedLimit(
options.maximumArchiveBytes,
'模型 ZIP 大小限制'
@@ -399,7 +376,9 @@ export async function extractModelArchive(
const destination = resolve(options.destinationDirectory)
const seenNames = new Set<string>()
const openHandles = new Set<FileHandle>()
const completions: Promise<void>[] = []
const completions: Promise<
{ ok: true } | { ok: false; error: Error }
>[] = []
const pendingWrites = new Set<Promise<void>>()
let entryCount = 0
let totalBytes = 0
@@ -442,7 +421,11 @@ export async function extractModelArchive(
throw new Error(`模型 ZIP 条目大小超出限制:${name}`)
}
const handlePromise = open(
safeChild(destination, name),
managedModelChild(
destination,
name,
'模型 ZIP 路径超出临时目录'
),
'wx'
).then((handle) => {
openHandles.add(handle)
@@ -456,7 +439,12 @@ export async function extractModelArchive(
resolveEntry = resolveEntryPromise
rejectEntry = rejectEntryPromise
})
completions.push(completion)
completions.push(
completion.then(
() => ({ ok: true as const }),
(error: Error) => ({ ok: false as const, error })
)
)
file.ondata = (error, data, final) => {
if (error) {
rejectEntry?.(fail(error))
@@ -480,10 +468,6 @@ export async function extractModelArchive(
}
written += data.byteLength
totalBytes += data.byteLength
if (name !== ARCHIVE_MANIFEST_NAME) {
completedModelBytes += data.byteLength
options.onProgress?.(completedModelBytes)
}
if (
written > entryMaximum ||
totalBytes > maximumTotalBytes
@@ -497,7 +481,12 @@ export async function extractModelArchive(
writeChain = writeChain.then(async () => {
const handle = await handlePromise
if (data.byteLength > 0) {
await handle.write(data)
await writeModelBuffer(handle, data, (persisted) => {
if (name !== ARCHIVE_MANIFEST_NAME) {
completedModelBytes += persisted.byteLength
options.onProgress?.(completedModelBytes)
}
})
}
})
const pendingWrite = writeChain
@@ -529,7 +518,7 @@ export async function extractModelArchive(
const buffer = Buffer.allocUnsafe(16 * 1024)
try {
while (true) {
ensureNotAborted(options.signal)
ensureModelOperationNotAborted(options.signal)
if (fatalError) {
throw fatalError
}
@@ -544,7 +533,13 @@ export async function extractModelArchive(
)
await Promise.all([...pendingWrites])
}
await Promise.all(completions)
const completionResults = await Promise.all(completions)
const failedCompletion = completionResults.find(
(result) => !result.ok
)
if (failedCompletion && !failedCompletion.ok) {
throw failedCompletion.error
}
if (fatalError) {
throw fatalError
}
@@ -571,7 +566,11 @@ export async function extractModelArchive(
manifest = modelArchiveManifestSchema.parse(
JSON.parse(
await readFile(
safeChild(destination, ARCHIVE_MANIFEST_NAME),
managedModelChild(
destination,
ARCHIVE_MANIFEST_NAME,
'模型 ZIP 路径超出临时目录'
),
'utf8'
)
) as unknown
@@ -597,13 +596,19 @@ export async function extractModelArchive(
throw new Error('模型 ZIP 清单与当前模型目录不匹配')
}
for (const archived of manifest.files) {
const path = safeChild(destination, archived.name)
const path = managedModelChild(
destination,
archived.name,
'模型 ZIP 路径超出临时目录'
)
const metadata = await lstat(path)
const hash = await hashModelFile(path)
if (
!metadata.isFile() ||
metadata.isSymbolicLink() ||
metadata.size !== archived.size ||
(await hashFile(path)) !== archived.sha256
hash.size !== archived.size ||
hash.sha256 !== archived.sha256
) {
throw new Error(`模型 ZIP 文件校验失败:${archived.name}`)
}
+3 -3
View File
@@ -1,3 +1,5 @@
import { ensureModelOperationNotAborted } from './model-package-utils'
const MAX_REDIRECTS = 3
const redirectStatuses = new Set([301, 302, 303, 307, 308])
@@ -28,9 +30,7 @@ export async function fetchModelDownloadResponse(options: {
const initialHost = url.hostname
const allowedRedirectHosts = new Set(options.redirectHosts)
for (let redirectCount = 0; ; redirectCount += 1) {
if (options.signal.aborted) {
throw new DOMException('The operation was aborted', 'AbortError')
}
ensureModelOperationNotAborted(options.signal)
const response = await options.transport(url, {
method: 'GET',
redirect: 'manual',
+154
View File
@@ -0,0 +1,154 @@
import { createHash } from 'node:crypto'
import {
mkdtemp,
mkdir,
readFile,
readdir,
rename,
rm,
unlink,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
cleanupStaleModelInstallArtifacts,
writeModelBuffer
} from './model-package-utils'
const temporaryDirectories: string[] = []
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
describe('cleanupStaleModelInstallArtifacts', () => {
it('preserves active staging and names outside the manager contract', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-model-cleanup-'))
temporaryDirectories.push(root)
const active =
'.install-active-model-00000000-0000-4000-8000-000000000001'
const stale =
'.install-owned-model-00000000-0000-4000-8000-000000000002'
const userDirectory = '.install-owned-model-backup'
await Promise.all([
mkdir(join(root, active)),
mkdir(join(root, stale)),
mkdir(join(root, userDirectory)),
mkdir(join(root, 'owned-model'))
])
await writeFile(join(root, userDirectory, 'keep.txt'), 'keep')
await writeFile(join(root, 'owned-model', 'package.bin.partial'), 'stale')
await writeFile(join(root, 'owned-model', 'notes.partial'), 'keep')
await cleanupStaleModelInstallArtifacts({
rootDirectory: root,
isModelId: (value) =>
value === 'active-model' || value === 'owned-model',
activeModelIds: new Set(['active-model']),
partialFileNames: new Set(['package.bin']),
escapeMessage: 'escaped'
})
expect(await readdir(root)).toEqual(
expect.arrayContaining([active, userDirectory, 'owned-model'])
)
expect(await readdir(root)).not.toContain(stale)
await expect(
readFile(join(root, userDirectory, 'keep.txt'), 'utf8')
).resolves.toBe('keep')
await expect(
readFile(join(root, 'owned-model', 'notes.partial'), 'utf8')
).resolves.toBe('keep')
await expect(
readFile(
join(root, 'owned-model', 'package.bin.partial'),
'utf8'
)
).rejects.toMatchObject({ code: 'ENOENT' })
})
it('ignores a selection partial renamed after enumeration', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-model-cleanup-'))
temporaryDirectories.push(root)
const partialName =
'.selection.json.00000000-0000-4000-8000-000000000001.partial'
const partialPath = join(root, partialName)
const renamedPath = join(root, 'selection-completed')
await writeFile(partialPath, 'selection')
await expect(
cleanupStaleModelInstallArtifacts({
rootDirectory: root,
isModelId: () => false,
activeModelIds: new Set(),
partialFileNames: new Set(),
cleanSelectionPartials: true,
activeSelectionPartialNames: new Set(),
escapeMessage: 'escaped',
operations: {
unlinkFile: async (path) => {
await rename(path, renamedPath)
await unlink(path)
}
}
})
).resolves.toBeUndefined()
await expect(readFile(renamedPath, 'utf8')).resolves.toBe(
'selection'
)
})
})
describe('writeModelBuffer', () => {
it('retries short writes until every byte is persisted', async () => {
const persisted: number[] = []
const write = vi.fn(
async (
buffer: Uint8Array,
offset = 0,
length = buffer.byteLength - offset
) => {
const bytesWritten = Math.min(2, length)
persisted.push(
...buffer.subarray(offset, offset + bytesWritten)
)
return { bytesWritten, buffer }
}
)
const value = Uint8Array.from([1, 2, 3, 4, 5])
const hash = createHash('sha256')
const onPersisted = vi.fn((buffer: Uint8Array) => {
hash.update(buffer)
})
await expect(
writeModelBuffer({ write } as never, value, onPersisted)
).resolves.toBe(value.byteLength)
expect(persisted).toEqual([...value])
expect(write).toHaveBeenCalledTimes(3)
expect(onPersisted).toHaveBeenCalledOnce()
expect(hash.digest('hex')).toBe(
createHash('sha256').update(value).digest('hex')
)
})
it('fails closed when a write makes no progress', async () => {
await expect(
writeModelBuffer(
{
write: vi.fn(async (buffer: Uint8Array) => ({
bytesWritten: 0,
buffer
}))
} as never,
Uint8Array.from([1])
)
).rejects.toThrow('写入不完整')
})
})
+260
View File
@@ -0,0 +1,260 @@
import { createHash, randomUUID } from 'node:crypto'
import {
lstat,
mkdir,
open,
readdir,
rm,
unlink,
type FileHandle
} from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { isMissingFileError } from './settings-file-utils'
export const MODEL_PARTIAL_SUFFIX = '.partial'
export type ModelFileFingerprint = {
dev: bigint
ino: bigint
size: bigint
mode: bigint
mtimeNs: bigint
ctimeNs: bigint
isFile: boolean
isSymbolicLink: boolean
}
const uuidPattern =
'[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'
const stagingNamePattern = new RegExp(
`^\\.install-(.+)-(${uuidPattern})$`,
'iu'
)
const selectionPartialPattern = new RegExp(
`^\\.selection\\.json\\.(${uuidPattern})\\.partial$`,
'iu'
)
export function ensureModelOperationNotAborted(
signal?: AbortSignal
): void {
if (signal?.aborted) {
throw new DOMException('The operation was aborted', 'AbortError')
}
}
export function managedModelChild(
parent: string,
name: string,
escapeMessage: string
): string {
const child = resolve(parent, name)
if (dirname(child) !== resolve(parent)) {
throw new Error(escapeMessage)
}
return child
}
export async function fingerprintModelFile(
path: string
): Promise<ModelFileFingerprint> {
const status = await lstat(path, { bigint: true })
return {
dev: status.dev,
ino: status.ino,
size: status.size,
mode: status.mode,
mtimeNs: status.mtimeNs,
ctimeNs: status.ctimeNs,
isFile: status.isFile(),
isSymbolicLink: status.isSymbolicLink()
}
}
export function modelFileFingerprintMatches(
left: ModelFileFingerprint,
right: ModelFileFingerprint
): boolean {
return (
left.dev === right.dev &&
left.ino === right.ino &&
left.size === right.size &&
left.mode === right.mode &&
left.mtimeNs === right.mtimeNs &&
left.ctimeNs === right.ctimeNs &&
left.isFile === right.isFile &&
left.isSymbolicLink === right.isSymbolicLink
)
}
export async function writeModelBuffer(
handle: Pick<FileHandle, 'write'>,
buffer: Uint8Array,
onPersisted?: (buffer: Uint8Array) => void
): Promise<number> {
let offset = 0
while (offset < buffer.byteLength) {
const { bytesWritten } = await handle.write(
buffer,
offset,
buffer.byteLength - offset
)
if (
!Number.isSafeInteger(bytesWritten) ||
bytesWritten <= 0 ||
bytesWritten > buffer.byteLength - offset
) {
throw new Error('模型文件写入不完整')
}
offset += bytesWritten
}
onPersisted?.(buffer)
return offset
}
export async function hashModelFile(
path: string,
signal?: AbortSignal
): Promise<{ size: number; sha256: string }> {
const handle = await open(path, 'r')
const hash = createHash('sha256')
const buffer = Buffer.allocUnsafe(64 * 1024)
let size = 0
try {
while (true) {
if (signal) {
ensureModelOperationNotAborted(signal)
}
const { bytesRead } = await handle.read(buffer, 0, buffer.length)
if (bytesRead === 0) {
break
}
hash.update(buffer.subarray(0, bytesRead))
size += bytesRead
}
} finally {
await handle.close()
}
return { size, sha256: hash.digest('hex') }
}
export function attachModelAbortSignal(
signal: AbortSignal | undefined,
controller: AbortController
): () => void {
if (!signal) {
return () => undefined
}
const abort = (): void => controller.abort()
if (signal.aborted) {
controller.abort()
} else {
signal.addEventListener('abort', abort, { once: true })
}
return () => signal.removeEventListener('abort', abort)
}
export async function createModelStagingDirectory(
rootDirectory: string,
modelId: string,
escapeMessage: string
): Promise<string> {
const directory = managedModelChild(
rootDirectory,
`.install-${modelId}-${randomUUID()}`,
escapeMessage
)
await mkdir(directory, { recursive: false })
return directory
}
export async function cleanupStaleModelInstallArtifacts(input: {
rootDirectory: string
isModelId: (value: string) => boolean
activeModelIds: ReadonlySet<string>
partialFileNames: ReadonlySet<string>
cleanSelectionPartials?: boolean
activeSelectionPartialNames?: ReadonlySet<string>
escapeMessage: string
operations?: {
unlinkFile?: (path: string) => Promise<void>
}
}): Promise<void> {
const unlinkFile = input.operations?.unlinkFile ?? unlink
const entries = await readdir(input.rootDirectory, {
withFileTypes: true
})
for (const entry of entries) {
const stagingMatch = stagingNamePattern.exec(entry.name)
if (stagingMatch) {
const modelId = stagingMatch[1]!
if (
input.isModelId(modelId) &&
!input.activeModelIds.has(modelId) &&
entry.isDirectory() &&
!entry.isSymbolicLink()
) {
await rm(
managedModelChild(
input.rootDirectory,
entry.name,
input.escapeMessage
),
{ recursive: true, force: true }
)
}
continue
}
if (
input.cleanSelectionPartials &&
selectionPartialPattern.test(entry.name) &&
!input.activeSelectionPartialNames?.has(entry.name) &&
entry.isFile() &&
!entry.isSymbolicLink()
) {
try {
await unlinkFile(
managedModelChild(
input.rootDirectory,
entry.name,
input.escapeMessage
)
)
} catch (error) {
if (!isMissingFileError(error)) {
throw error
}
}
continue
}
if (
!entry.isDirectory() ||
entry.isSymbolicLink() ||
!input.isModelId(entry.name)
) {
continue
}
const modelDirectory = managedModelChild(
input.rootDirectory,
entry.name,
input.escapeMessage
)
for (const partialName of input.partialFileNames) {
const partialPath = managedModelChild(
modelDirectory,
`${partialName}${MODEL_PARTIAL_SUFFIX}`,
input.escapeMessage
)
try {
const status = await lstat(partialPath)
if (status.isFile() && !status.isSymbolicLink()) {
await unlinkFile(partialPath)
}
} catch (error) {
if (!isMissingFileError(error)) {
throw error
}
}
}
}
}
+28
View File
@@ -78,6 +78,34 @@ afterEach(async () => {
})
describe('RuntimeSettingsStore', () => {
it('restores an exact credential-bearing snapshot after a failed activation', async () => {
const { store } = await createStore()
await store.update(
settings({
apiKey: { action: 'replace', value: 'previous-key' }
})
)
const rollback = await store.captureRollback()
await store.update(
settings({
modelBaseUrl: 'https://candidate.example/v1',
modelName: 'candidate',
apiKey: { action: 'replace', value: 'candidate-key' }
})
)
await expect(rollback.restore()).resolves.toMatchObject({
modelBaseUrl: 'https://bigtoken.ai',
modelName: 'sonnet-5',
apiKeyConfigured: true
})
await expect(store.getResolvedSettings()).resolves.toMatchObject({
modelBaseUrl: 'https://bigtoken.ai',
modelName: 'sonnet-5',
apiKey: 'previous-key'
})
})
it('migrates version 17 to empty Runtime customization', async () => {
const { filePath, store } = await createStore()
await store.update(settings())
+37
View File
@@ -235,6 +235,10 @@ const storedSettingsSchema = version17StoredSettingsSchema
})
type StoredSettings = z.infer<typeof storedSettingsSchema>
export type RuntimeSettingsRollback = {
publicSettings: RuntimeSettings
restore(): Promise<RuntimeSettings>
}
type Version17StoredSettings = z.infer<
typeof version17StoredSettingsSchema
>
@@ -1481,6 +1485,39 @@ export class RuntimeSettingsStore {
return this.toPublicSettings(await this.load())
}
captureRollback(): Promise<RuntimeSettingsRollback> {
let result: RuntimeSettingsRollback | undefined
const operation = this.updateQueue.then(async () => {
const snapshot = structuredClone(await this.load())
result = {
publicSettings: this.toPublicSettings(snapshot),
restore: () => this.restoreSnapshot(snapshot)
}
})
this.updateQueue = operation.then(
() => undefined,
() => undefined
)
return operation.then(() => result!)
}
private restoreSnapshot(
snapshot: StoredSettings
): Promise<RuntimeSettings> {
const operation = this.updateQueue.then(async () => {
const restored = structuredClone(snapshot)
await writeJsonFileAtomically(this.filePath, restored)
this.settings = restored
this.loadWarnings = []
return this.toPublicSettings(restored)
})
this.updateQueue = operation.then(
() => undefined,
() => undefined
)
return operation
}
async getPolicySettings(): Promise<RuntimePolicySettings> {
const settings = await this.load()
return {
+199
View File
@@ -0,0 +1,199 @@
import { describe, expect, it, vi } from 'vitest'
import {
areShortcutAcceleratorsEquivalent,
type GlobalShortcutSettings
} from '../shared/shortcut'
import { ShortcutSettingsService } from './shortcut-settings-service'
function createFixture(
initial: GlobalShortcutSettings = {
enabled: true,
accelerator: 'CommandOrControl+Shift+Space'
},
platform = 'win32'
) {
let persisted = { ...initial }
const store = {
get: vi.fn(async () => ({ ...persisted })),
update: vi.fn(async (input: unknown) => {
persisted = input as GlobalShortcutSettings
return { ...persisted }
})
}
const registered = new Set<string>()
const conflicts = new Set<string>()
const registry = {
register: vi.fn((accelerator: string) => {
if (
conflicts.has(accelerator) ||
[...registered].some((current) =>
areShortcutAcceleratorsEquivalent(
current,
accelerator,
platform
)
)
) {
return false
}
registered.add(accelerator)
return true
}),
unregister: vi.fn((accelerator: string) => {
registered.delete(accelerator)
})
}
const service = new ShortcutSettingsService(
store,
registry,
vi.fn(),
platform
)
return {
service,
store,
registry,
registered,
conflicts,
getPersisted: () => persisted
}
}
describe('ShortcutSettingsService', () => {
it('registers the persisted shortcut at startup and reports display state', async () => {
const { service, registered } = createFixture()
await expect(service.initialize()).resolves.toMatchObject({
registered: true,
registeredAccelerator: 'CommandOrControl+Shift+Space',
displayAccelerator: 'Ctrl+Shift+Space',
status: 'registered'
})
expect(registered).toEqual(
new Set(['CommandOrControl+Shift+Space'])
)
})
it('keeps the old working registration and setting after a conflict', async () => {
const fixture = createFixture()
await fixture.service.initialize()
fixture.conflicts.add('Control+Alt+K')
await expect(
fixture.service.update({
enabled: true,
accelerator: 'Control+Alt+K'
})
).resolves.toMatchObject({
ok: false,
error: 'conflict',
snapshot: {
settings: {
enabled: true,
accelerator: 'CommandOrControl+Shift+Space'
},
registered: true,
status: 'registered'
}
})
expect(fixture.store.update).not.toHaveBeenCalled()
expect(fixture.registered).toEqual(
new Set(['CommandOrControl+Shift+Space'])
)
})
it.each([
[
'win32',
'CommandOrControl+Shift+Space',
'Control+Shift+Space'
],
[
'linux',
'CmdOrCtrl+Shift+Space',
'Control+Shift+Space'
],
[
'darwin',
'CommandOrControl+Shift+Space',
'Command+Shift+Space'
]
])(
'updates physically equivalent aliases on %s without self-conflict',
async (platform, initialAccelerator, nextAccelerator) => {
const fixture = createFixture(
{
enabled: true,
accelerator: initialAccelerator
},
platform
)
await fixture.service.initialize()
fixture.registry.register.mockClear()
await expect(
fixture.service.update({
enabled: true,
accelerator: nextAccelerator
})
).resolves.toMatchObject({
ok: true,
snapshot: {
settings: { accelerator: nextAccelerator },
registeredAccelerator: initialAccelerator,
status: 'registered'
}
})
expect(fixture.registry.register).not.toHaveBeenCalled()
expect(fixture.getPersisted().accelerator).toBe(nextAccelerator)
expect(fixture.registered).toEqual(
new Set([initialAccelerator])
)
}
)
it('rolls back a newly registered shortcut when persistence fails', async () => {
const fixture = createFixture()
await fixture.service.initialize()
fixture.store.update.mockRejectedValueOnce(new Error('disk full'))
await expect(
fixture.service.update({
enabled: true,
accelerator: 'Control+Alt+K'
})
).resolves.toMatchObject({
ok: false,
error: 'save-failed',
snapshot: {
settings: {
accelerator: 'CommandOrControl+Shift+Space'
},
registeredAccelerator: 'CommandOrControl+Shift+Space'
}
})
expect(fixture.registered).toEqual(
new Set(['CommandOrControl+Shift+Space'])
)
})
it('persists disabling before removing the working registration', async () => {
const fixture = createFixture()
await fixture.service.initialize()
await expect(
fixture.service.update({
enabled: false,
accelerator: 'CommandOrControl+Shift+Space'
})
).resolves.toMatchObject({
ok: true,
snapshot: {
registered: false,
status: 'disabled'
}
})
expect(fixture.getPersisted().enabled).toBe(false)
expect(fixture.registered).toEqual(new Set())
})
})
+173
View File
@@ -0,0 +1,173 @@
import {
areShortcutAcceleratorsEquivalent,
defaultGlobalShortcutSettings,
formatShortcutForDisplay,
globalShortcutSettingsSchema,
type GlobalShortcutRegistrationStatus,
type GlobalShortcutSettings,
type GlobalShortcutSettingsSnapshot,
type GlobalShortcutSettingsUpdateResult
} from '../shared/shortcut'
export interface GlobalShortcutRegistry {
register(accelerator: string, callback: () => void): boolean
unregister(accelerator: string): void
}
export interface ShortcutSettingsPersistence {
get(): Promise<GlobalShortcutSettings>
update(input: unknown): Promise<GlobalShortcutSettings>
}
export class ShortcutSettingsService {
private settings: GlobalShortcutSettings = {
...defaultGlobalShortcutSettings
}
private registeredAccelerator?: string
private status: GlobalShortcutRegistrationStatus = 'disabled'
private updateQueue: Promise<void> = Promise.resolve()
constructor(
private readonly store: ShortcutSettingsPersistence,
private readonly registry: GlobalShortcutRegistry,
private readonly callback: () => void,
private readonly platform: string
) {}
async initialize(): Promise<GlobalShortcutSettingsSnapshot> {
this.settings = await this.store.get()
if (!this.settings.enabled) {
this.status = 'disabled'
return this.snapshot()
}
const result = this.tryRegister(this.settings.accelerator)
if (result === 'registered') {
this.registeredAccelerator = this.settings.accelerator
}
this.status = result
return this.snapshot()
}
private tryRegister(
accelerator: string
): Extract<
GlobalShortcutRegistrationStatus,
'registered' | 'conflict' | 'failed'
> {
try {
return this.registry.register(accelerator, this.callback)
? 'registered'
: 'conflict'
} catch {
return 'failed'
}
}
snapshot(): GlobalShortcutSettingsSnapshot {
return {
settings: { ...this.settings },
defaultSettings: { ...defaultGlobalShortcutSettings },
platform: this.platform,
displayAccelerator: formatShortcutForDisplay(
this.settings.accelerator,
this.platform
),
registered: this.registeredAccelerator !== undefined,
...(this.registeredAccelerator
? { registeredAccelerator: this.registeredAccelerator }
: {}),
status: this.status
}
}
getSnapshot(): GlobalShortcutSettingsSnapshot {
return this.snapshot()
}
update(input: unknown): Promise<GlobalShortcutSettingsUpdateResult> {
const operation = this.updateQueue.then(
async (): Promise<GlobalShortcutSettingsUpdateResult> => {
const next = globalShortcutSettingsSchema.parse(input)
const previous = this.settings
const previousRegistered = this.registeredAccelerator
const previousStatus = this.status
if (!next.enabled) {
try {
await this.store.update(next)
} catch {
return {
ok: false,
error: 'save-failed',
snapshot: this.snapshot()
}
}
if (previousRegistered) {
this.registry.unregister(previousRegistered)
}
this.settings = next
this.registeredAccelerator = undefined
this.status = 'disabled'
return { ok: true, snapshot: this.snapshot() }
}
const keepsWorkingRegistration =
previousRegistered !== undefined &&
areShortcutAcceleratorsEquivalent(
previousRegistered,
next.accelerator,
this.platform
)
if (!keepsWorkingRegistration) {
const registration = this.tryRegister(next.accelerator)
if (registration !== 'registered') {
this.status = previousRegistered
? 'registered'
: registration
return {
ok: false,
error:
registration === 'conflict'
? 'conflict'
: 'registration-failed',
snapshot: this.snapshot()
}
}
}
try {
await this.store.update(next)
} catch {
if (!keepsWorkingRegistration) {
this.registry.unregister(next.accelerator)
}
this.settings = previous
this.registeredAccelerator = previousRegistered
this.status = previousRegistered
? 'registered'
: previousStatus
return {
ok: false,
error: 'save-failed',
snapshot: this.snapshot()
}
}
if (previousRegistered && !keepsWorkingRegistration) {
this.registry.unregister(previousRegistered)
}
this.settings = next
this.registeredAccelerator = keepsWorkingRegistration
? previousRegistered
: next.accelerator
this.status = 'registered'
return { ok: true, snapshot: this.snapshot() }
}
)
this.updateQueue = operation.then(
() => undefined,
() => undefined
)
return operation
}
}
+79
View File
@@ -0,0 +1,79 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
defaultGlobalShortcutSettings
} from '../shared/shortcut'
import { ShortcutSettingsStore } from './shortcut-settings-store'
const directories: string[] = []
afterEach(async () => {
await Promise.all(
directories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
async function createStore(): Promise<{
filePath: string
store: ShortcutSettingsStore
}> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-shortcut-'))
directories.push(directory)
const filePath = join(directory, 'shortcut-settings.json')
return { filePath, store: new ShortcutSettingsStore(filePath) }
}
describe('ShortcutSettingsStore', () => {
it('preserves the legacy shortcut as the non-persisted default', async () => {
const { filePath, store } = await createStore()
await expect(store.get()).resolves.toEqual(
defaultGlobalShortcutSettings
)
await expect(readFile(filePath, 'utf8')).rejects.toMatchObject({
code: 'ENOENT'
})
})
it('persists a validated versioned shortcut and reloads it', async () => {
const { filePath, store } = await createStore()
await expect(
store.update({
enabled: false,
accelerator: 'ctrl+alt+k'
})
).resolves.toEqual({
enabled: false,
accelerator: 'Control+Alt+K'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 1,
enabled: false,
accelerator: 'Control+Alt+K'
})
await expect(
new ShortcutSettingsStore(filePath).get()
).resolves.toEqual({
enabled: false,
accelerator: 'Control+Alt+K'
})
})
it('rejects invalid accelerators without replacing saved state', async () => {
const { filePath, store } = await createStore()
await store.update({
enabled: true,
accelerator: 'Control+Alt+K'
})
const saved = await readFile(filePath, 'utf8')
await expect(
store.update({ enabled: true, accelerator: 'K' })
).rejects.toThrow()
expect(await readFile(filePath, 'utf8')).toBe(saved)
})
})
+122
View File
@@ -0,0 +1,122 @@
import { readFile } from 'node:fs/promises'
import { z } from 'zod'
import {
defaultGlobalShortcutSettings,
globalShortcutSettingsSchema,
type GlobalShortcutSettings
} from '../shared/shortcut'
import {
assertSupportedSettingsVersion,
isolateCorruptSettingsFile,
isMissingFileError,
UnsupportedSettingsVersionError,
writeJsonFileAtomically
} from './settings-file-utils'
const CURRENT_SETTINGS_VERSION = 1
const storedShortcutSettingsSchema = globalShortcutSettingsSchema
.extend({ version: z.literal(CURRENT_SETTINGS_VERSION) })
.strict()
type StoredShortcutSettings = z.infer<
typeof storedShortcutSettingsSchema
>
export class ShortcutSettingsStore {
private settings?: StoredShortcutSettings
private loadOperation?: Promise<StoredShortcutSettings>
private updateQueue: Promise<void> = Promise.resolve()
constructor(private readonly filePath: string) {}
private async readStored(): Promise<StoredShortcutSettings> {
try {
const contents = await readFile(this.filePath, 'utf8')
let parsed: unknown
try {
parsed = JSON.parse(contents) as unknown
} catch {
await isolateCorruptSettingsFile(
this.filePath,
'Shortcut settings are corrupt and could not be isolated'
)
return {
version: CURRENT_SETTINGS_VERSION,
...defaultGlobalShortcutSettings
}
}
assertSupportedSettingsVersion(
parsed,
CURRENT_SETTINGS_VERSION,
(version) =>
`当前 GoodBuddy 不支持快捷键设置版本 ${version},请升级应用后重试`
)
const result = storedShortcutSettingsSchema.safeParse(parsed)
if (!result.success) {
await isolateCorruptSettingsFile(
this.filePath,
'Shortcut settings are corrupt and could not be isolated'
)
return {
version: CURRENT_SETTINGS_VERSION,
...defaultGlobalShortcutSettings
}
}
return result.data
} catch (error) {
if (error instanceof UnsupportedSettingsVersionError) {
throw error
}
if (!isMissingFileError(error)) {
throw new Error('Shortcut settings could not be read', {
cause: error
})
}
return {
version: CURRENT_SETTINGS_VERSION,
...defaultGlobalShortcutSettings
}
}
}
private async load(): Promise<StoredShortcutSettings> {
if (this.settings) {
return this.settings
}
if (!this.loadOperation) {
this.loadOperation = this.readStored()
.then((settings) => {
this.settings = settings
return settings
})
.finally(() => {
this.loadOperation = undefined
})
}
return this.loadOperation
}
async get(): Promise<GlobalShortcutSettings> {
const { enabled, accelerator } = await this.load()
return { enabled, accelerator }
}
update(input: unknown): Promise<GlobalShortcutSettings> {
const operation = this.updateQueue.then(async () => {
const settings = globalShortcutSettingsSchema.parse(input)
const stored: StoredShortcutSettings = {
version: CURRENT_SETTINGS_VERSION,
...settings
}
await writeJsonFileAtomically(this.filePath, stored)
this.settings = stored
return settings
})
this.updateQueue = operation.then(
() => undefined,
() => undefined
)
return operation
}
}
@@ -5,6 +5,8 @@ import {
readFile,
readdir,
rm,
stat,
utimes,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -249,6 +251,147 @@ describe('speech model catalog', () => {
})
describe('SpeechModelManager downloads', () => {
it('rebuilds a stale selected runtime after valid files are restored', async () => {
const userData = await temporaryDirectory()
const modelBytes = new TextEncoder().encode('verified model bytes')
const tokenBytes = new TextEncoder().encode('verified tokens')
const root = join(userData, 'models', 'speech')
const staleStaging =
'.install-download-test-model-00000000-0000-4000-8000-000000000001'
await mkdir(root, { recursive: true })
await Promise.all([
mkdir(join(root, staleStaging)),
writeFile(
join(root, '.selection.json'),
`${JSON.stringify({
selectedModelId: 'download-test-model'
})}\n`
)
])
const getDownloadSource = vi.fn(() => 'modelscope' as const)
const manager = new SpeechModelManager({
userDataDirectory: userData,
fetch: vi.fn<typeof fetch>(async (input) => {
const bytes = String(input).endsWith('model.onnx')
? modelBytes
: tokenBytes
return new Response(bytes, {
headers: { 'content-length': String(bytes.byteLength) }
})
}),
catalog: downloadableCatalog(modelBytes, tokenBytes),
getDownloadSource
})
await expect(manager.getSelectedRuntimeModel()).resolves.toBeUndefined()
await manager.install('download-test-model', 'modelscope')
const selected = await manager.getSelectedRuntimeModel()
expect(selected).toMatchObject({ id: 'download-test-model' })
const modelPath = join(
root,
'download-test-model',
'model.onnx'
)
const originalModelStat = await stat(modelPath)
await writeFile(
modelPath,
Buffer.alloc(modelBytes.byteLength, 0x7f)
)
await utimes(
modelPath,
originalModelStat.atime,
originalModelStat.mtime
)
await expect(manager.getSelectedRuntimeModel()).resolves.toBeUndefined()
await writeFile(modelPath, modelBytes)
await utimes(
modelPath,
originalModelStat.atime,
originalModelStat.mtime
)
const rebuilt = await Promise.all([
manager.getSelectedRuntimeModel(),
manager.getSelectedRuntimeModel()
])
expect(rebuilt).toEqual([
expect.objectContaining({ id: 'download-test-model' }),
expect.objectContaining({ id: 'download-test-model' })
])
const manifestPath = join(
root,
'download-test-model',
'manifest.json'
)
const manifest = await readFile(manifestPath)
await rm(manifestPath)
await expect(manager.getSelectedRuntimeModel()).resolves.toBeUndefined()
await writeFile(manifestPath, manifest)
await expect(manager.getSelectedRuntimeModel()).resolves.toMatchObject({
id: 'download-test-model'
})
expect(await readdir(root)).toContain(staleStaging)
expect(getDownloadSource).not.toHaveBeenCalled()
await writeFile(
join(root, '.selection.json'),
'{"selectedModelId":null}\n'
)
await expect(manager.getSelectedRuntimeModel()).resolves.toBeUndefined()
})
it('preserves an active selection partial during concurrent snapshot cleanup', async () => {
const userData = await temporaryDirectory()
const modelBytes = new TextEncoder().encode('verified model bytes')
const tokenBytes = new TextEncoder().encode('verified tokens')
let markWriteStarted: (() => void) | undefined
let releaseWrite: (() => void) | undefined
const writeStarted = new Promise<void>((resolveStarted) => {
markWriteStarted = resolveStarted
})
const writeGate = new Promise<void>((resolveWrite) => {
releaseWrite = resolveWrite
})
const manager = new SpeechModelManager({
userDataDirectory: userData,
fetch: vi.fn<typeof fetch>(async (input) => {
const bytes = String(input).endsWith('model.onnx')
? modelBytes
: tokenBytes
return new Response(bytes, {
headers: { 'content-length': String(bytes.byteLength) }
})
}),
catalog: downloadableCatalog(modelBytes, tokenBytes),
selectionFileOperations: {
writeFile: async (path, data, options) => {
await writeFile(path, data, options)
markWriteStarted?.()
await writeGate
}
}
})
await manager.install('download-test-model')
const selecting = manager.select('download-test-model')
await writeStarted
const root = join(userData, 'models', 'speech')
const activePartial = (await readdir(root)).find(
(name) =>
name.startsWith('.selection.json.') &&
name.endsWith('.partial')
)
expect(activePartial).toBeDefined()
await manager.snapshot()
expect(await readdir(root)).toContain(activePartial)
releaseWrite?.()
await selecting
await expect(manager.snapshot()).resolves.toMatchObject({
selectedModelId: 'download-test-model'
})
expect(await readdir(root)).not.toContain(activePartial)
})
it('downloads to partial files, verifies hashes, and atomically installs', async () => {
const userData = await temporaryDirectory()
const modelBytes = new TextEncoder().encode('verified model bytes')
@@ -330,6 +473,75 @@ describe('SpeechModelManager downloads', () => {
})
})
it('cleans only manager-owned stale staging and partial artifacts', async () => {
const userData = await temporaryDirectory()
const modelBytes = new TextEncoder().encode('verified model bytes')
const tokenBytes = new TextEncoder().encode('verified tokens')
const manager = new SpeechModelManager({
userDataDirectory: userData,
fetch: vi.fn<typeof fetch>(async (input) => {
const bytes = String(input).endsWith('model.onnx')
? modelBytes
: tokenBytes
return new Response(bytes, {
headers: { 'content-length': String(bytes.byteLength) }
})
}),
catalog: downloadableCatalog(modelBytes, tokenBytes)
})
await manager.install('download-test-model')
const root = join(userData, 'models', 'speech')
const modelDirectory = join(root, 'download-test-model')
const staleStaging =
'.install-download-test-model-00000000-0000-4000-8000-000000000001'
const unrelatedStaging = '.install-download-test-model-user-backup'
const selectionPartial =
'.selection.json.00000000-0000-4000-8000-000000000002.partial'
await mkdir(join(root, staleStaging))
await writeFile(join(root, staleStaging, 'model.onnx.partial'), 'stale')
await mkdir(join(root, unrelatedStaging))
await writeFile(join(root, unrelatedStaging, 'keep.txt'), 'keep')
await writeFile(
join(modelDirectory, 'model.onnx.partial'),
'interrupted'
)
await writeFile(join(modelDirectory, 'notes.partial'), 'keep')
await writeFile(join(root, selectionPartial), 'interrupted')
await writeFile(join(root, 'user.partial'), 'keep')
await expect(manager.snapshot()).resolves.toMatchObject({
installed: [expect.objectContaining({ id: 'download-test-model' })]
})
expect(await readdir(root)).toEqual(
expect.arrayContaining([
'download-test-model',
unrelatedStaging,
'user.partial'
])
)
expect(await readdir(root)).not.toEqual(
expect.arrayContaining([staleStaging, selectionPartial])
)
expect(await readdir(modelDirectory)).toEqual(
expect.arrayContaining([
'manifest.json',
'model.onnx',
'tokens.txt',
'notes.partial'
])
)
expect(await readdir(modelDirectory)).not.toContain(
'model.onnx.partial'
)
await expect(
readFile(join(modelDirectory, 'model.onnx'))
).resolves.toEqual(Buffer.from(modelBytes))
await expect(
readFile(join(root, unrelatedStaging, 'keep.txt'), 'utf8')
).resolves.toBe('keep')
})
it('freezes the operation source when the global setting changes', async () => {
const userData = await temporaryDirectory()
const modelBytes = new TextEncoder().encode('expected')
+304 -113
View File
@@ -11,7 +11,7 @@ import {
stat,
writeFile
} from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { resolve } from 'node:path'
import { z } from 'zod'
import {
installedSpeechModelSchema,
@@ -39,11 +39,24 @@ import {
extractModelArchive
} from '../model-archive'
import { fetchModelDownloadResponse } from '../model-download-transport'
import {
MODEL_PARTIAL_SUFFIX,
attachModelAbortSignal,
cleanupStaleModelInstallArtifacts,
createModelStagingDirectory,
ensureModelOperationNotAborted,
fingerprintModelFile,
hashModelFile,
managedModelChild,
modelFileFingerprintMatches,
type ModelFileFingerprint,
writeModelBuffer
} from '../model-package-utils'
import { isMissingFileError } from '../settings-file-utils'
const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024
const MANIFEST_FILE_NAME = 'manifest.json'
const SELECTION_FILE_NAME = '.selection.json'
const PARTIAL_SUFFIX = '.partial'
const MAXIMUM_ARCHIVE_BYTES = 4 * 1024 * 1024 * 1024 - 1
const ARCHIVE_OVERHEAD_BYTES = 1024 * 1024
@@ -61,6 +74,17 @@ type ActiveOperation = {
progress: SpeechModelOperation
}
type SpeechSelectionFileOperations = {
writeFile: typeof writeFile
rename: typeof rename
}
type CachedSelectedSpeechRuntimeModel = {
model: SelectedSpeechRuntimeModel
manifestFingerprint: ModelFileFingerprint
fileFingerprints: Map<string, ModelFileFingerprint>
}
export type SpeechModelManagerOptions = {
userDataDirectory: string
fetch: typeof fetch
@@ -69,6 +93,7 @@ export type SpeechModelManagerOptions = {
| ModelDownloadSource
| Promise<ModelDownloadSource>
maxFileBytes?: number
selectionFileOperations?: Partial<SpeechSelectionFileOperations>
}
export type SelectedSpeechRuntimeModel = {
@@ -101,16 +126,6 @@ function toCatalogView(entry: SpeechModelCatalogEntry) {
})
}
function abortError(): DOMException {
return new DOMException('The operation was aborted', 'AbortError')
}
function ensureNotAborted(signal: AbortSignal): void {
if (signal.aborted) {
throw abortError()
}
}
function validateMaximumBytes(value: number | undefined): number {
const maximum = value ?? DEFAULT_MAX_FILE_BYTES
if (
@@ -124,40 +139,7 @@ function validateMaximumBytes(value: number | undefined): number {
}
function safeChild(parent: string, name: string): string {
const child = resolve(parent, name)
if (dirname(child) !== resolve(parent)) {
throw new Error('模型路径超出受管目录')
}
return child
}
async function hashFile(
path: string,
signal?: AbortSignal
): Promise<{
size: number
sha256: string
}> {
const handle = await open(path, 'r')
const hash = createHash('sha256')
let size = 0
const buffer = Buffer.allocUnsafe(64 * 1024)
try {
while (true) {
if (signal) {
ensureNotAborted(signal)
}
const { bytesRead } = await handle.read(buffer, 0, buffer.length)
if (bytesRead === 0) {
break
}
hash.update(buffer.subarray(0, bytesRead))
size += bytesRead
}
} finally {
await handle.close()
}
return { size, sha256: hash.digest('hex') }
return managedModelChild(parent, name, '模型路径超出受管目录')
}
export class SpeechModelManager {
@@ -170,7 +152,13 @@ export class SpeechModelManager {
| ModelDownloadSource
| Promise<ModelDownloadSource>
private readonly maxFileBytes: number
private readonly selectionFileOperations: SpeechSelectionFileOperations
private readonly operations = new Map<string, ActiveOperation>()
private readonly activeSelectionPartialNames = new Set<string>()
private selectedRuntimeModel?: Promise<
CachedSelectedSpeechRuntimeModel | undefined
>
private selectedRuntimeGeneration = 0
constructor(options: SpeechModelManagerOptions) {
if (!options.userDataDirectory.trim()) {
@@ -192,10 +180,16 @@ export class SpeechModelManager {
}
this.catalogViews = this.catalog.map(toCatalogView)
this.maxFileBytes = validateMaximumBytes(options.maxFileBytes)
this.selectionFileOperations = {
writeFile,
rename,
...options.selectionFileOperations
}
}
async snapshot(): Promise<SpeechModelSnapshot> {
await this.ensureRoot()
await this.cleanupStaleArtifacts()
const [installed, selected, selectedDownloadSource] =
await Promise.all([
this.readInstalled(),
@@ -236,25 +230,52 @@ export class SpeechModelManager {
async getSelectedRuntimeModel(): Promise<
SelectedSpeechRuntimeModel | undefined
> {
const snapshot = await this.snapshot()
if (!snapshot.selectedModelId) {
const selectedModelId = await this.readSelection()
if (!selectedModelId) {
this.invalidateSelectedRuntimeModel()
return undefined
}
const catalogEntry = this.catalog.find(
(entry) => entry.id === snapshot.selectedModelId
)
const installed = snapshot.installed.find(
(entry) => entry.id === snapshot.selectedModelId
)
if (!catalogEntry || !installed) {
return undefined
const cachedPromise = this.selectedRuntimeModel
if (cachedPromise) {
const cached = await cachedPromise
if (
cached?.model.id === selectedModelId &&
(await this.selectedRuntimeFingerprintsMatch(cached))
) {
return this.cloneSelectedRuntimeModel(cached.model)
}
if (this.selectedRuntimeModel === cachedPromise) {
this.invalidateSelectedRuntimeModel()
}
}
return {
id: installed.id,
family: catalogEntry.family,
directory: this.modelDirectory(installed.id),
files: installed.files.map((file) => ({ ...file }))
const selected = await this.getOrCreateSelectedRuntimeModel(
selectedModelId
)
return selected
? this.cloneSelectedRuntimeModel(selected.model)
: undefined
}
private getOrCreateSelectedRuntimeModel(
selectedModelId: string
): Promise<CachedSelectedSpeechRuntimeModel | undefined> {
const current = this.selectedRuntimeModel
if (current) {
return current
}
const generation = this.selectedRuntimeGeneration
const resolution = this.resolveSelectedRuntimeModel(
selectedModelId,
generation
)
const tracked = resolution.catch((error) => {
if (this.selectedRuntimeModel === tracked) {
this.selectedRuntimeModel = undefined
}
throw error
})
this.selectedRuntimeModel = tracked
return tracked
}
async install(
@@ -290,7 +311,7 @@ export class SpeechModelManager {
await this.assertNotInstalled(entry.id)
stagingDirectory = await this.createStagingDirectory(entry.id)
for (const file of resolvedPackage.files) {
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
operation.progress.phase = 'transferring'
operation.progress.currentFile = file.name
const destination = safeChild(stagingDirectory, file.name)
@@ -309,12 +330,13 @@ export class SpeechModelManager {
stagingDirectory,
operation.controller.signal
)
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
await rename(
stagingDirectory,
this.modelDirectory(entry.id)
)
stagingDirectory = undefined
this.invalidateSelectedRuntimeModel()
return installed
} finally {
detachExternalAbort()
@@ -338,6 +360,7 @@ export class SpeechModelManager {
async remove(modelId: string): Promise<void> {
speechModelIdSchema.parse(modelId)
this.cancel(modelId)
this.invalidateSelectedRuntimeModel()
await this.ensureRoot()
const target = this.modelDirectory(modelId)
await rm(target, { recursive: true, force: true })
@@ -356,6 +379,7 @@ export class SpeechModelManager {
}
}
await this.writeSelection(modelId)
this.invalidateSelectedRuntimeModel()
}
async registerLocalDirectory(
@@ -382,12 +406,12 @@ export class SpeechModelManager {
stagingDirectory = await this.createStagingDirectory(entry.id)
operation.progress.phase = 'transferring'
for (const file of entry.files) {
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
operation.progress.currentFile = file.name
const sourceFile = safeChild(source, file.name)
const destination = safeChild(stagingDirectory, file.name)
await copyFile(sourceFile, destination)
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
const copied = await stat(destination)
if (copied.size > this.maxFileBytes) {
throw new RangeError(`模型文件过大:${file.name}`)
@@ -404,12 +428,13 @@ export class SpeechModelManager {
stagingDirectory,
operation.controller.signal
)
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
await rename(
stagingDirectory,
this.modelDirectory(entry.id)
)
stagingDirectory = undefined
this.invalidateSelectedRuntimeModel()
return installed
} finally {
detachExternalAbort()
@@ -542,9 +567,10 @@ export class SpeechModelManager {
`${JSON.stringify(installed, null, 2)}\n`,
{ encoding: 'utf8', flag: 'wx' }
)
ensureNotAborted(operation.controller.signal)
ensureModelOperationNotAborted(operation.controller.signal)
await rename(stagingDirectory, this.modelDirectory(entry.id))
stagingDirectory = undefined
this.invalidateSelectedRuntimeModel()
return installed
} finally {
this.operations.delete(entry.id)
@@ -558,6 +584,163 @@ export class SpeechModelManager {
await mkdir(this.rootDirectory, { recursive: true })
}
private invalidateSelectedRuntimeModel(): void {
this.selectedRuntimeGeneration += 1
this.selectedRuntimeModel = undefined
}
private async resolveSelectedRuntimeModel(
selectedModelId: string,
generation: number
): Promise<CachedSelectedSpeechRuntimeModel | undefined> {
await this.ensureRoot()
const catalogEntry = this.catalog.find(
(entry) => entry.id === selectedModelId
)
if (!catalogEntry) {
return undefined
}
try {
const directory = this.modelDirectory(selectedModelId)
const manifestPath = safeChild(directory, MANIFEST_FILE_NAME)
const manifestFingerprintBefore = await fingerprintModelFile(
manifestPath
)
if (
!manifestFingerprintBefore.isFile ||
manifestFingerprintBefore.isSymbolicLink
) {
return undefined
}
const installed = installedSpeechModelSchema.parse(
JSON.parse(
await readFile(manifestPath, 'utf8')
) as unknown
)
const manifestFingerprint = await fingerprintModelFile(manifestPath)
if (
!modelFileFingerprintMatches(
manifestFingerprintBefore,
manifestFingerprint
)
) {
return undefined
}
if (installed.id !== selectedModelId) {
return undefined
}
if (
installed.files.length !== catalogEntry.files.length ||
catalogEntry.files.some((expected) => {
const recorded = installed.files.find(
(file) =>
file.name === expected.name &&
file.role === expected.role
)
return (
!recorded ||
recorded.size !== expected.size ||
recorded.sha256 !== expected.sha256
)
})
) {
return undefined
}
const fileFingerprints = new Map<string, ModelFileFingerprint>()
for (const file of installed.files) {
const path = safeChild(directory, file.name)
const fingerprintBefore = await fingerprintModelFile(path)
if (
!fingerprintBefore.isFile ||
fingerprintBefore.isSymbolicLink ||
fingerprintBefore.size !== BigInt(file.size)
) {
return undefined
}
const actual = await hashModelFile(path)
const fingerprint = await fingerprintModelFile(path)
if (
actual.size !== file.size ||
actual.sha256 !== file.sha256 ||
!modelFileFingerprintMatches(
fingerprintBefore,
fingerprint
)
) {
return undefined
}
fileFingerprints.set(file.name, fingerprint)
}
if (
generation !== this.selectedRuntimeGeneration ||
(await this.readSelection()) !== selectedModelId
) {
return undefined
}
return {
model: {
id: installed.id,
family: catalogEntry.family,
directory,
files: installed.files.map((file) => ({ ...file }))
},
manifestFingerprint,
fileFingerprints
}
} catch {
return undefined
}
}
private cloneSelectedRuntimeModel(
model: SelectedSpeechRuntimeModel
): SelectedSpeechRuntimeModel {
return {
...model,
files: model.files.map((file) => ({ ...file }))
}
}
private async selectedRuntimeFingerprintsMatch(
cached: CachedSelectedSpeechRuntimeModel
): Promise<boolean> {
try {
const manifestFingerprint = await fingerprintModelFile(
safeChild(cached.model.directory, MANIFEST_FILE_NAME)
)
if (
!manifestFingerprint.isFile ||
manifestFingerprint.isSymbolicLink ||
!modelFileFingerprintMatches(
manifestFingerprint,
cached.manifestFingerprint
)
) {
return false
}
for (const file of cached.model.files) {
const expected = cached.fileFingerprints.get(file.name)
if (!expected) {
return false
}
const actual = await fingerprintModelFile(
safeChild(cached.model.directory, file.name)
)
if (
!actual.isFile ||
actual.isSymbolicLink ||
actual.size !== BigInt(file.size) ||
!modelFileFingerprintMatches(actual, expected)
) {
return false
}
}
return true
} catch {
return false
}
}
private modelDirectory(modelId: string): string {
const parsedId = speechModelIdSchema.parse(modelId)
return safeChild(this.rootDirectory, parsedId)
@@ -601,16 +784,7 @@ export class SpeechModelManager {
signal: AbortSignal | undefined,
controller: AbortController
): () => void {
if (!signal) {
return () => undefined
}
const abort = (): void => controller.abort()
if (signal.aborted) {
controller.abort()
} else {
signal.addEventListener('abort', abort, { once: true })
}
return () => signal.removeEventListener('abort', abort)
return attachModelAbortSignal(signal, controller)
}
private async assertNotInstalled(modelId: string): Promise<void> {
@@ -618,11 +792,7 @@ export class SpeechModelManager {
await lstat(this.modelDirectory(modelId))
throw new Error('语音模型已安装')
} catch (error) {
if (
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
if (isMissingFileError(error)) {
return
}
throw error
@@ -630,12 +800,11 @@ export class SpeechModelManager {
}
private async createStagingDirectory(modelId: string): Promise<string> {
const directory = safeChild(
return createModelStagingDirectory(
this.rootDirectory,
`.install-${modelId}-${randomUUID()}`
modelId,
'模型路径超出受管目录'
)
await mkdir(directory, { recursive: false })
return directory
}
private async downloadFile(
@@ -676,29 +845,34 @@ export class SpeechModelManager {
}
}
const partialPath = `${destination}${PARTIAL_SUFFIX}`
const partialPath = `${destination}${MODEL_PARTIAL_SUFFIX}`
const handle = await open(partialPath, 'wx')
const reader = response.body.getReader()
const hash = createHash('sha256')
let written = 0
try {
while (true) {
ensureNotAborted(signal)
ensureModelOperationNotAborted(signal)
const result = await reader.read()
if (result.done) {
break
}
written += result.value.byteLength
if (
written > file.size ||
written > this.maxFileBytes
written + result.value.byteLength > file.size ||
written + result.value.byteLength > this.maxFileBytes
) {
await reader.cancel()
throw new RangeError(`模型文件过大:${file.name}`)
}
await handle.write(result.value)
hash.update(result.value)
operation.progress.completedBytes += result.value.byteLength
const persistedBytes = await writeModelBuffer(
handle,
result.value,
(persisted) => {
hash.update(persisted)
operation.progress.completedBytes += persisted.byteLength
}
)
written += persistedBytes
}
} catch (error) {
await reader.cancel().catch(() => undefined)
@@ -728,7 +902,7 @@ export class SpeechModelManager {
visited: 0
})
for (const expectedFile of entry.files) {
ensureNotAborted(signal)
ensureModelOperationNotAborted(signal)
const sourceFile = safeChild(sourceDirectory, expectedFile.name)
const sourceFileInfo = await lstat(sourceFile)
if (
@@ -745,7 +919,7 @@ export class SpeechModelManager {
}
if (
sourceFileInfo.size !== expectedFile.size ||
(await hashFile(sourceFile, signal)).sha256 !==
(await hashModelFile(sourceFile, signal)).sha256 !==
expectedFile.sha256
) {
throw new Error(`本地模型文件校验失败:${expectedFile.name}`)
@@ -760,7 +934,7 @@ export class SpeechModelManager {
): Promise<void> {
const entries = await readdir(directory, { withFileTypes: true })
for (const entry of entries) {
ensureNotAborted(signal)
ensureModelOperationNotAborted(signal)
counter.visited += 1
if (counter.visited > 4_096) {
throw new Error('本地模型目录包含过多条目')
@@ -830,8 +1004,8 @@ export class SpeechModelManager {
): Promise<InstalledSpeechModel> {
const files = []
for (const file of entry.files) {
ensureNotAborted(signal)
const metadata = await hashFile(
ensureModelOperationNotAborted(signal)
const metadata = await hashModelFile(
safeChild(stagingDirectory, file.name),
signal
)
@@ -899,11 +1073,7 @@ export class SpeechModelManager {
)
return value.selectedModelId
} catch (error) {
if (
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
if (isMissingFileError(error)) {
return null
}
return null
@@ -913,24 +1083,45 @@ export class SpeechModelManager {
private async writeSelection(modelId: string | null): Promise<void> {
await this.ensureRoot()
const target = safeChild(this.rootDirectory, SELECTION_FILE_NAME)
const partialName =
`${SELECTION_FILE_NAME}.${randomUUID()}${MODEL_PARTIAL_SUFFIX}`
const partial = safeChild(
this.rootDirectory,
`${SELECTION_FILE_NAME}.${randomUUID()}${PARTIAL_SUFFIX}`
)
await writeFile(
partial,
`${JSON.stringify(
selectionSchema.parse({ selectedModelId: modelId })
)}\n`,
{ encoding: 'utf8', flag: 'wx' }
partialName
)
this.activeSelectionPartialNames.add(partialName)
try {
await rename(partial, target)
await this.selectionFileOperations.writeFile(
partial,
`${JSON.stringify(
selectionSchema.parse({ selectedModelId: modelId })
)}\n`,
{ encoding: 'utf8', flag: 'wx' }
)
await this.selectionFileOperations.rename(partial, target)
} catch (error) {
await rm(partial, { force: true })
throw error
} finally {
this.activeSelectionPartialNames.delete(partialName)
}
}
private cleanupStaleArtifacts(): Promise<void> {
return cleanupStaleModelInstallArtifacts({
rootDirectory: this.rootDirectory,
isModelId: (value) => speechModelIdSchema.safeParse(value).success,
activeModelIds: new Set(this.operations.keys()),
partialFileNames: new Set(
this.catalog.flatMap((entry) =>
entry.files.map((file) => file.name)
)
),
cleanSelectionPartials: true,
activeSelectionPartialNames: this.activeSelectionPartialNames,
escapeMessage: '模型路径超出受管目录'
})
}
}
export function createSpeechModelManager(
+193 -3
View File
@@ -1,5 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import { runStartupPrerequisites } from './startup-prerequisites'
import {
createStartupFailureDiagnostic,
formatStartupFailureMessage,
runStartupPrerequisites,
StartupPrerequisiteError
} from './startup-prerequisites'
function deferred<T = void>(): {
promise: Promise<T>
@@ -83,7 +88,11 @@ describe('runStartupPrerequisites', () => {
expect(rejected).not.toHaveBeenCalled()
configuredRuntime.resolve({ id: 'unused' })
await expect(result).rejects.toBe(assistantError)
await expect(result).rejects.toMatchObject({
name: 'StartupPrerequisiteError',
stage: 'assistant-database',
cause: assistantError
})
expect(rejected).toHaveBeenCalledOnce()
})
@@ -108,7 +117,188 @@ describe('runStartupPrerequisites', () => {
deepSeekHome.resolve()
knowledgeAndGateway.resolve()
await expect(result).rejects.toBe(runtimeError)
await expect(result).rejects.toMatchObject({
name: 'StartupPrerequisiteError',
stage: 'runtime',
cause: runtimeError
})
expect(rejected).toHaveBeenCalledOnce()
})
it('collects simultaneous async failures in deterministic stage order', async () => {
const runtimeHomeError = new TypeError('runtime home failed')
const knowledgeError = new RangeError('knowledge failed')
const runtimeError = new SyntaxError('runtime failed')
const deepSeekHome = deferred()
const knowledgeAndGateway = deferred()
const configuredRuntime = deferred<{ id: string }>()
const result = runStartupPrerequisites({
prepareDeepSeekHome: () => deepSeekHome.promise,
initializeKnowledgeAndGateway: () =>
knowledgeAndGateway.promise,
hydrateConfiguredRuntime: () => configuredRuntime.promise,
initializeAssistant: () => undefined
})
configuredRuntime.reject(runtimeError)
knowledgeAndGateway.reject(knowledgeError)
deepSeekHome.reject(runtimeHomeError)
await expect(result).rejects.toMatchObject({
name: 'StartupPrerequisiteError',
stage: 'runtime-home',
stages: ['runtime-home', 'knowledge', 'runtime'],
cause: runtimeHomeError
})
})
it('preserves assistant initialization as the primary failure', async () => {
const assistantError = new Error('assistant failed')
const runtimeHomeError = new Error('runtime home failed')
await expect(
runStartupPrerequisites({
prepareDeepSeekHome: () => Promise.reject(runtimeHomeError),
initializeKnowledgeAndGateway: () =>
Promise.reject(new Error('knowledge failed')),
hydrateConfiguredRuntime: () =>
Promise.reject(new Error('runtime failed')),
initializeAssistant: () => {
throw assistantError
}
})
).rejects.toMatchObject({
stage: 'assistant-database',
stages: [
'assistant-database',
'runtime-home',
'knowledge',
'runtime'
],
cause: assistantError
})
})
it.each([
['runtime-home', 'prepareDeepSeekHome'],
['knowledge', 'initializeKnowledgeAndGateway'],
['runtime', 'hydrateConfiguredRuntime']
] as const)(
'identifies a failed %s startup branch',
async (stage, operation) => {
const failure = new Error(`${stage} failed`)
const dependencies = {
prepareDeepSeekHome: () => Promise.resolve(),
initializeKnowledgeAndGateway: () => Promise.resolve(),
hydrateConfiguredRuntime: () => Promise.resolve({ id: 'configured' }),
initializeAssistant: () => undefined
}
dependencies[operation] = () => Promise.reject(failure) as never
const result = runStartupPrerequisites(dependencies)
await expect(result).rejects.toEqual(
expect.objectContaining<Partial<StartupPrerequisiteError>>({
name: 'StartupPrerequisiteError',
stage,
cause: failure
})
)
}
)
it.each([
['runtime-home', 'prepareDeepSeekHome'],
['knowledge', 'initializeKnowledgeAndGateway'],
['runtime', 'hydrateConfiguredRuntime']
] as const)(
'observes a synchronous throw from the %s promise dependency',
async (stage, operation) => {
const failure = new Error(`${stage} synchronous failure`)
const dependencies = {
prepareDeepSeekHome: () => Promise.resolve(),
initializeKnowledgeAndGateway: () => Promise.resolve(),
hydrateConfiguredRuntime: () =>
Promise.resolve({ id: 'configured' }),
initializeAssistant: () => undefined
}
dependencies[operation] = (() => {
throw failure
}) as never
await expect(
runStartupPrerequisites(dependencies)
).rejects.toMatchObject({
name: 'StartupPrerequisiteError',
stage,
stages: [stage],
cause: failure
})
}
)
})
describe('startup failure reporting', () => {
it('keeps secret-bearing causes out of user-facing formatting', () => {
const secret = 'provider-key=sk-secret-value'
const failure = new Error(secret)
const error = new StartupPrerequisiteError(
'runtime',
failure,
['runtime']
)
const message = formatStartupFailureMessage(error)
expect(message).toContain('阶段:runtime')
expect(message).not.toContain(secret)
expect(Object.isFrozen(error.stages)).toBe(true)
})
it('formats all prerequisite stages and logs only bounded metadata', async () => {
const secret = 'provider-key=sk-secret-value'
let startupError: unknown
try {
await runStartupPrerequisites({
prepareDeepSeekHome: () =>
Promise.reject(new TypeError(secret)),
initializeKnowledgeAndGateway: () =>
Promise.reject(new Error('another secret')),
hydrateConfiguredRuntime: () =>
Promise.resolve({ id: 'configured' }),
initializeAssistant: () => undefined
})
} catch (error) {
startupError = error
}
const message = formatStartupFailureMessage(startupError)
const diagnostic = createStartupFailureDiagnostic(startupError)
expect(message).toContain('阶段:runtime-home, knowledge')
expect(message).not.toContain(secret)
expect(diagnostic).toEqual({
stages: ['runtime-home', 'knowledge'],
errorName: 'StartupPrerequisiteError',
causeName: 'TypeError'
})
expect(JSON.stringify(diagnostic)).not.toContain(secret)
expect(Object.isFrozen(diagnostic)).toBe(true)
expect(Object.isFrozen(diagnostic.stages)).toBe(true)
})
it('maps generic startup errors to the closed application stage', () => {
const secret = 'provider-key=sk-secret-value'
const error = new Error(secret)
expect(formatStartupFailureMessage(error)).toContain(
'阶段:application'
)
expect(createStartupFailureDiagnostic(error)).toEqual({
stages: ['application'],
errorName: 'Error'
})
expect(formatStartupFailureMessage(error)).not.toContain(secret)
})
})
+131 -5
View File
@@ -5,6 +5,101 @@ export type StartupPrerequisiteDependencies<ConfiguredRuntime> = {
initializeAssistant: () => void
}
export type StartupPrerequisiteStage =
| 'runtime-home'
| 'knowledge'
| 'runtime'
| 'assistant-database'
export type StartupFailureStage =
| StartupPrerequisiteStage
| 'application'
export type StartupFailureDiagnostic = Readonly<{
stages: readonly StartupFailureStage[]
errorName: string
causeName?: string
}>
export class StartupPrerequisiteError extends Error {
readonly stage: StartupPrerequisiteStage
readonly stages: readonly StartupPrerequisiteStage[]
constructor(
stage: StartupPrerequisiteStage,
cause: unknown,
stages: readonly StartupPrerequisiteStage[] = [stage]
) {
super(`Startup prerequisite failed: ${stage}`, { cause })
this.name = 'StartupPrerequisiteError'
this.stage = stage
this.stages = Object.freeze([...stages])
}
}
const applicationFailureStages = Object.freeze([
'application'
] satisfies StartupFailureStage[])
const safeErrorNames = new Set([
'AbortError',
'AggregateError',
'Error',
'EvalError',
'RangeError',
'ReferenceError',
'SyntaxError',
'TimeoutError',
'TypeError',
'URIError'
])
function safeErrorName(error: unknown): string {
if (!(error instanceof Error)) {
return 'NonError'
}
let name: unknown
try {
name = error.name
} catch {
return 'Error'
}
return typeof name === 'string' && safeErrorNames.has(name)
? name
: 'Error'
}
export function getStartupFailureStages(
error: unknown
): readonly StartupFailureStage[] {
return error instanceof StartupPrerequisiteError
? error.stages
: applicationFailureStages
}
export function formatStartupFailureMessage(error: unknown): string {
const stages = getStartupFailureStages(error)
return `启动初始化未完成(阶段:${stages.join(', ')})。请重启应用;若问题持续,请记录阶段标识,并在备份数据后排查应用数据或 Runtime 配置。`
}
export function createStartupFailureDiagnostic(
error: unknown
): StartupFailureDiagnostic {
const stages = Object.freeze([...getStartupFailureStages(error)])
if (error instanceof StartupPrerequisiteError) {
return Object.freeze({
stages,
errorName: 'StartupPrerequisiteError',
causeName: safeErrorName(error.cause)
})
}
return Object.freeze({
stages,
errorName: safeErrorName(error)
})
}
function startObserved<T>(operation: () => Promise<T>): Promise<T> {
let started: Promise<T>
try {
@@ -45,17 +140,48 @@ export async function runStartupPrerequisites<ConfiguredRuntime>(
configuredRuntimeReady
] as const)
const failures: Array<
Readonly<{
stage: StartupPrerequisiteStage
cause: unknown
}>
> = []
if (assistantInitializationFailed) {
throw assistantInitializationError
failures.push({
stage: 'assistant-database',
cause: assistantInitializationError
})
}
if (deepSeekHome.status === 'rejected') {
throw deepSeekHome.reason
failures.push({
stage: 'runtime-home',
cause: deepSeekHome.reason
})
}
if (knowledgeAndGateway.status === 'rejected') {
throw knowledgeAndGateway.reason
failures.push({
stage: 'knowledge',
cause: knowledgeAndGateway.reason
})
}
if (configuredRuntime.status === 'rejected') {
throw configuredRuntime.reason
failures.push({
stage: 'runtime',
cause: configuredRuntime.reason
})
}
return configuredRuntime.value
const primaryFailure = failures[0]
if (primaryFailure) {
throw new StartupPrerequisiteError(
primaryFailure.stage,
primaryFailure.cause,
failures.map(({ stage }) => stage)
)
}
if (configuredRuntime.status === 'fulfilled') {
return configuredRuntime.value
}
throw new Error('Unreachable startup prerequisite state')
}
+21
View File
@@ -89,6 +89,7 @@ import type {
import type {
DocumentOcrAssets,
DocumentOcrFailure,
DocumentOcrModelProgressSnapshot,
DocumentOcrRequest,
DocumentOcrResult,
DocumentParsingDiagnostic,
@@ -125,6 +126,11 @@ import type {
RuntimeExtensionAction,
RuntimeExtensionMarketplaceSnapshot
} from '../shared/runtime-extension-contracts'
import type {
GlobalShortcutSettings,
GlobalShortcutSettingsSnapshot,
GlobalShortcutSettingsUpdateResult
} from '../shared/shortcut'
const desktopApi: DesktopApi = {
app: {
@@ -388,6 +394,17 @@ const desktopApi: DesktopApi = {
ipcRenderer.removeListener(ipcChannels.versionCheckResult, handler)
}
},
shortcuts: {
getSettings: () =>
ipcRenderer.invoke(
ipcChannels.shortcutSettingsGet
) as Promise<GlobalShortcutSettingsSnapshot>,
updateSettings: (input: GlobalShortcutSettings) =>
ipcRenderer.invoke(
ipcChannels.shortcutSettingsUpdate,
input
) as Promise<GlobalShortcutSettingsUpdateResult>
},
releaseNotes: {
getPending: () =>
ipcRenderer.invoke(
@@ -475,6 +492,10 @@ const desktopApi: DesktopApi = {
ipcRenderer.invoke(
ipcChannels.documentParsingGet
) as Promise<DocumentParsingSnapshot>,
getOcrModelProgress: () =>
ipcRenderer.invoke(
ipcChannels.documentOcrModelsProgress
) as Promise<DocumentOcrModelProgressSnapshot>,
update: (input: DocumentParsingSettings) =>
ipcRenderer.invoke(
ipcChannels.documentParsingUpdate,
+20
View File
@@ -63,6 +63,15 @@ describe('sandboxed preload', () => {
)
})
it('exposes a narrow OCR operation progress snapshot', () => {
const source = readFileSync(
join(process.cwd(), 'src', 'preload', 'index.ts'),
'utf8'
)
expect(source).toContain('getOcrModelProgress:')
expect(source).toContain('ipcChannels.documentOcrModelsProgress')
})
it('exposes a removable attachment parsing progress listener', () => {
const source = readFileSync(
join(process.cwd(), 'src', 'preload', 'index.ts'),
@@ -85,6 +94,17 @@ describe('sandboxed preload', () => {
expect(source).toContain('ipcChannels.releaseNotesAcknowledge')
})
it('exposes only get and validated-update shortcut settings methods', () => {
const source = readFileSync(
join(process.cwd(), 'src', 'preload', 'index.ts'),
'utf8'
)
expect(source).toContain('shortcuts: {')
expect(source).toContain('ipcChannels.shortcutSettingsGet')
expect(source).toContain('ipcChannels.shortcutSettingsUpdate')
expect(source).not.toContain('globalShortcut.')
})
it('exposes bounded knowledge task actions', () => {
const source = readFileSync(
join(process.cwd(), 'src', 'preload', 'index.ts'),
+137
View File
@@ -7,6 +7,10 @@ import {
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TokenUsageSummary } from '../../shared/assistant-contracts'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName
} from '../../shared/assistant-contracts'
import { ActivityPanel } from './ActivityPanel'
import {
MAX_ACTIVITY_RECORDS,
@@ -361,6 +365,139 @@ describe('ActivityPanel', () => {
expect(screen.getByText('范围不可用')).toBeInTheDocument()
})
it('localizes current project IDs while preserving unknown snapshots', async () => {
await i18n.changeLanguage('en-US')
const currentProjectId =
'00000000-0000-4000-8000-000000000101'
const currentRecord: ActivityRecord = {
...makeRecord(1),
scope: {
kind: 'project',
projectId: currentProjectId,
projectName: builtInDefaultProjectSeedName
}
}
const deletedRecord: ActivityRecord = {
...makeRecord(2),
scope: {
kind: 'project',
projectId: 'deleted-project',
projectName: 'Deleted project snapshot'
}
}
const usage = makeTokenUsage()
usage.records = [
{
...usage.records[0]!,
projectId: currentProjectId,
projectName: builtInDefaultProjectSeedName
},
{
...usage.records[0]!,
requestId: 'request-deleted',
projectId: 'deleted-project',
projectName: 'Deleted project snapshot'
}
]
render(
<ActivityPanel
onClear={vi.fn()}
onOpenConversation={vi.fn()}
projects={[
{
id: currentProjectId,
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'
}
]}
records={[currentRecord, deletedRecord]}
tokenUsage={usage}
/>
)
expect(screen.getByText('Project: Default project')).toBeInTheDocument()
expect(
screen.getByText('Project: Deleted project snapshot')
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('tab', { name: 'Usage analytics' })
)
expect(screen.getByText('Default project')).toBeInTheDocument()
expect(
screen.getByText('Deleted project snapshot')
).toBeInTheDocument()
})
it('preserves ordinary activity and token snapshots after a project rename', async () => {
await i18n.changeLanguage('en-US')
const projectId = 'ordinary-project'
const usage = makeTokenUsage()
usage.records = [
{
...usage.records[0]!,
projectId,
projectName: 'Original project snapshot'
}
]
render(
<ActivityPanel
onClear={vi.fn()}
onOpenConversation={vi.fn()}
projects={[
{
id: projectId,
name: 'Renamed current project',
description: '',
rootPath: 'C:\\Renamed',
defaultWorkMode: 'ask',
kind: 'user',
status: 'active',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-02T00:00:00.000Z'
}
]}
records={[
{
...makeRecord(1),
scope: {
kind: 'project',
projectId,
projectName: 'Original project snapshot'
}
}
]}
tokenUsage={usage}
/>
)
expect(
screen.getByText('Project: Original project snapshot')
).toBeInTheDocument()
expect(
screen.queryByText('Project: Renamed current project')
).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('tab', { name: 'Usage analytics' })
)
expect(
screen.getByText('Original project snapshot')
).toBeInTheDocument()
expect(
screen.queryByText('Renamed current project')
).not.toBeInTheDocument()
})
it('uses shared page tabs and explicit global scope', () => {
render(
<ActivityPanel
+66 -9
View File
@@ -1,7 +1,11 @@
import { Activity, Trash2 } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { TokenUsageSummary } from '../../shared/assistant-contracts'
import type {
AssistantProject,
TokenUsageSummary
} from '../../shared/assistant-contracts'
import { isUntouchedBuiltInDefaultProject } from '../../shared/assistant-contracts'
import {
MAX_ACTIVITY_RECORDS,
type ActivityRecord
@@ -20,6 +24,7 @@ import {
SegmentedControl,
type WorkspaceScope
} from './WorkspacePrimitives'
import { getProjectDisplayText } from './project-display'
type ActivityFilter = 'all' | 'active' | 'failed'
type ActivityView = 'tasks' | 'timeline' | 'usage'
@@ -31,6 +36,7 @@ type ActivityActorKind =
| 'approval'
export type ActivityPanelProps = {
projects?: readonly AssistantProject[]
records: readonly ActivityRecord[]
tokenUsage: TokenUsageSummary
onClear: () => void
@@ -234,12 +240,14 @@ function groupActivityRecordsByProject(
}
export function ActivityPanel({
projects = [],
records,
tokenUsage,
onClear,
onOpenConversation
}: ActivityPanelProps): React.JSX.Element {
const { t, i18n } = useTranslation('activity')
const { t: tWorkspace } = useTranslation('workspace')
const [activeView, setActiveView] =
useState<ActivityView>('tasks')
const [filter, setFilter] = useState<ActivityFilter>('all')
@@ -329,17 +337,66 @@ export function ActivityPanel({
}
]
const projectDisplayNames = useMemo(
() =>
new Map(
projects.flatMap((project) => {
const displayName = getProjectDisplayText(
project,
tWorkspace
).name
return isUntouchedBuiltInDefaultProject(project) &&
displayName !== project.name
? [[project.id, displayName] as const]
: []
})
),
[projects, tWorkspace]
)
const displayRecords = useMemo(
() =>
records.map((record) => {
if (record.scope.kind !== 'project') {
return record
}
const projectName = projectDisplayNames.get(
record.scope.projectId
)
return projectName
? {
...record,
scope: {
...record.scope,
projectName
}
}
: record
}),
[projectDisplayNames, records]
)
const displayTokenUsage = useMemo(
() => ({
...tokenUsage,
records: tokenUsage.records.map((record) => {
const projectName = record.projectId
? projectDisplayNames.get(record.projectId)
: undefined
return projectName ? { ...record, projectName } : record
})
}),
[projectDisplayNames, tokenUsage]
)
const visibleRecords = useMemo(
() => records.slice(0, MAX_ACTIVITY_RECORDS),
[records]
() => displayRecords.slice(0, MAX_ACTIVITY_RECORDS),
[displayRecords]
)
const filteredRecords = useMemo(
() => visibleRecords.filter((record) => matchesFilter(record, filter)),
[filter, visibleRecords]
)
const projectGroups = useMemo(
() => groupActivityRecordsByProject(filteredRecords, records),
[filteredRecords, records]
() => groupActivityRecordsByProject(filteredRecords, displayRecords),
[displayRecords, filteredRecords]
)
const timelineBounds = useMemo(() => {
const timestamps = filteredRecords.map((record) => record.createdAt)
@@ -366,8 +423,8 @@ export function ActivityPanel({
(record) => record.id === selectedTimelineRecordId
)
const conversationTitles = useMemo(
() => getConversationTitles(records),
[records]
() => getConversationTitles(displayRecords),
[displayRecords]
)
const activeCount = visibleRecords.filter(isActive).length
const failedCount = visibleRecords.filter(isFailed).length
@@ -376,8 +433,8 @@ export function ActivityPanel({
[tokenUsage]
)
const tokenRows = useMemo(
() => groupTokenUsage(tokenUsage, tokenGroup),
[tokenGroup, tokenUsage]
() => groupTokenUsage(displayTokenUsage, tokenGroup),
[displayTokenUsage, tokenGroup]
)
const tokenGroupLabel =
tokenGroups.find((item) => item.value === tokenGroup)?.columnLabel ??
+452 -20
View File
@@ -16,12 +16,17 @@ import type {
DesktopApi
} from '../../shared/contracts'
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
import type {
AssistantProject,
AssistantSchedule,
AssistantTask,
ConversationSnapshot
} from '../../shared/assistant-contracts'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName
} from '../../shared/assistant-contracts'
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
const speechRecognitionMocks = vi.hoisted(() => ({
@@ -48,6 +53,14 @@ const lazyRouteMocks = vi.hoisted(() => {
}
})
const routeModuleLoads = vi.hoisted(() => ({
activity: 0,
heartbeat: 0,
knowledge: 0,
magicNotes: 0,
settings: 0
}))
vi.mock('./speech-recognition', async (importOriginal) => ({
...(await importOriginal<
typeof import('./speech-recognition')
@@ -57,9 +70,30 @@ vi.mock('./speech-recognition', async (importOriginal) => ({
vi.mock('./KnowledgeWorkspace', async (importOriginal) => {
await lazyRouteMocks.waitForKnowledgeRoute()
routeModuleLoads.knowledge += 1
return importOriginal<typeof import('./KnowledgeWorkspace')>()
})
vi.mock('./HeartbeatCenter', async (importOriginal) => {
routeModuleLoads.heartbeat += 1
return importOriginal<typeof import('./HeartbeatCenter')>()
})
vi.mock('./MagicNotesWorkspace', async (importOriginal) => {
routeModuleLoads.magicNotes += 1
return importOriginal<typeof import('./MagicNotesWorkspace')>()
})
vi.mock('./SettingsPanel', async (importOriginal) => {
routeModuleLoads.settings += 1
return importOriginal<typeof import('./SettingsPanel')>()
})
vi.mock('./ActivityPanel', async (importOriginal) => {
routeModuleLoads.activity += 1
return importOriginal<typeof import('./ActivityPanel')>()
})
import App from './App'
import { loadActivityRecords } from './activity-store'
import { changeUiLocale } from './i18n'
@@ -89,11 +123,12 @@ const modelProfileId = '00000000-0000-4000-8000-000000000001'
const projectId = '00000000-0000-4000-8000-000000000101'
const project = {
id: projectId,
name: '默认项目',
description: '测试项目',
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: 'C:\\Users\\test',
defaultWorkMode: 'ask' as const,
kind: 'user' as const,
builtInDefault: true,
status: 'active' as const,
createdAt: '2026-07-31T00:00:00.000Z',
updatedAt: '2026-07-31T00:00:00.000Z'
@@ -981,9 +1016,22 @@ describe('App', () => {
expect(screen.getByText('GOODBUDDY WORKSPACE')).toBeInTheDocument()
expect(
screen.getByRole('heading', {
level: 1,
name: 'Conversation'
})
).toBeInTheDocument()
expect(
screen.getByRole('heading', {
level: 2,
name: 'What would you like to accomplish today?'
})
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Current project' })
).toHaveTextContent('Default project')
expect(screen.getByText('Project: Default project')).toHaveClass(
'scope-badge'
)
expect(
screen.getByText(/Hi, I’m GoodBuddy/u)
).toBeInTheDocument()
@@ -999,6 +1047,140 @@ describe('App', () => {
}
})
it('localizes untouched project labels but keeps settings and destructive values raw', async () => {
const secondProject = {
...project,
id: '00000000-0000-4000-8000-000000000102',
name: 'Second project',
description: 'Another workspace',
rootPath: 'C:\\Second'
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
secondProject
])
vi.mocked(api.tasks.list).mockResolvedValueOnce([
{
id: '00000000-0000-4000-8000-000000000901',
projectId,
title: 'Seed project task',
instructions: 'Verify the project label',
origin: 'schedule',
status: 'queued',
workMode: 'ask',
createdAt: '2026-07-31T01:00:00.000Z'
}
])
await changeUiLocale('en-US')
try {
render(<App />)
const projectTrigger = await screen.findByRole('button', {
name: 'Current project'
})
expect(projectTrigger).toHaveTextContent('Default project')
fireEvent.click(projectTrigger)
expect(
within(
screen.getByRole('menu', { name: 'Current project' })
).getByText('Default project', { selector: 'b' })
).toBeInTheDocument()
fireEvent.click(projectTrigger)
fireEvent.click(
screen.getByLabelText('Toggle assistant workspace')
)
fireEvent.click(
await screen.findByRole('tab', { name: 'Task center' })
)
const assistantSidebar = screen.getByLabelText(
'Assistant workspace'
)
expect(
within(assistantSidebar).getByText('Project: Default project')
).toBeInTheDocument()
fireEvent.click(
within(assistantSidebar).getByRole('button', {
name: 'New custom task'
})
)
const taskDialog = screen.getByRole('dialog', {
name: 'New custom task'
})
expect(
within(taskDialog).getByText('Default project')
).toBeInTheDocument()
fireEvent.click(
within(taskDialog).getByRole('button', {
name: 'Close new custom task'
})
)
fireEvent.click(
within(assistantSidebar).getByRole('button', {
name: 'Close assistant workspace'
})
)
fireEvent.click(screen.getByLabelText('Project settings'))
const settingsDialog = screen.getByRole('dialog', {
name: 'Project settings'
})
expect(
within(settingsDialog).getByLabelText('Name')
).toHaveValue(builtInDefaultProjectSeedName)
expect(
within(settingsDialog).getByLabelText('Description')
).toHaveValue(builtInDefaultProjectSeedDescription)
fireEvent.click(
within(settingsDialog).getByRole('button', {
name: 'Delete project'
})
)
const confirmation = within(settingsDialog).getByLabelText(
`Enter “${builtInDefaultProjectSeedName}” to confirm deletion`
)
const deleteButton = within(settingsDialog).getByRole('button', {
name: 'Permanently delete project'
})
fireEvent.change(confirmation, {
target: { value: 'Default project' }
})
expect(deleteButton).toBeDisabled()
fireEvent.change(confirmation, {
target: { value: builtInDefaultProjectSeedName }
})
expect(deleteButton).toBeEnabled()
fireEvent.click(
within(settingsDialog).getByRole('button', {
name: 'Cancel deletion'
})
)
fireEvent.click(
within(settingsDialog).getByRole('button', {
name: 'Save project'
})
)
await waitFor(() =>
expect(api.projects.update).toHaveBeenCalledWith(
projectId,
expect.objectContaining({
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription
})
)
)
expect(
screen.getByRole('button', { name: 'Current project' })
).toHaveTextContent('Default project')
expect(api.projects.delete).not.toHaveBeenCalled()
} finally {
cleanup()
await changeUiLocale('zh-CN')
}
})
it('renders localized workspace branding in Chinese', async () => {
render(<App />)
@@ -1347,13 +1529,41 @@ describe('App', () => {
.toBeInTheDocument()
})
it('schedules lazy workspace routes for idle preloading', () => {
it('idle-preloads only the small Heartbeat route at startup', async () => {
render(<App />)
expect(window.requestIdleCallback).toHaveBeenCalledWith(
expect.any(Function),
{ timeout: 2000 }
)
const idleCallback = vi.mocked(window.requestIdleCallback)
.mock.calls[0]?.[0]
await act(async () => {
idleCallback?.({
didTimeout: false,
timeRemaining: () => 50
})
})
await waitFor(() => expect(routeModuleLoads.heartbeat).toBe(1))
expect(routeModuleLoads).toMatchObject({
activity: 0,
knowledge: 0,
magicNotes: 0,
settings: 0
})
})
it('preloads a heavy route once across repeated pointer and focus intent', async () => {
render(<App />)
const activity = await screen.findByRole('button', {
name: '运行记录'
})
fireEvent.pointerEnter(activity)
fireEvent.focus(activity)
fireEvent.pointerEnter(activity)
await waitFor(() => expect(routeModuleLoads.activity).toBe(1))
})
it('waits for project bootstrap before project-scoped startup loads', async () => {
@@ -1440,6 +1650,33 @@ describe('App', () => {
expect(route).not.toHaveAttribute('hidden')
})
it('enforces the workspace KeepAlive cap during rapid visits', async () => {
let now = 2_000_000
vi.spyOn(Date, 'now').mockImplementation(() => ++now)
render(<App />)
fireEvent.click(
await screen.findByRole('button', { name: '知识库' })
)
await screen.findByRole('heading', { name: '知识库' })
fireEvent.click(screen.getByRole('button', { name: '智能心跳' }))
await screen.findByRole('heading', { name: '智能心跳' })
fireEvent.click(screen.getByRole('button', { name: '运行记录' }))
await screen.findByRole('heading', { name: '运行记录' })
fireEvent.click(screen.getByRole('button', { name: '对话' }))
fireEvent.click(
screen.getByRole('button', { name: /本地工作区/u })
)
await screen.findByRole('heading', { name: '设置中心' })
expect(
document.querySelectorAll('.workspace-route-cache')
).toHaveLength(4)
expect(
document.querySelector('[data-route="knowledge"]')
).not.toBeInTheDocument()
})
it('preserves title, message, and project filtering with deferred search', async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
@@ -2403,14 +2640,9 @@ describe('App', () => {
const cancel = screen.getByRole('button', {
name: '取消删除对话 新对话'
})
const confirm = screen.getByRole('button', {
name: '确认永久删除对话 新对话'
})
expect(cancel).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Tab' })
expect(confirm).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true })
expect(fireEvent.keyDown(dialog, { key: 'Tab' })).toBe(true)
expect(cancel).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() =>
@@ -3309,6 +3541,136 @@ describe('App', () => {
).toBeInTheDocument()
})
it('guards sidebar navigation away from dirty Settings drafts', async () => {
render(<App />)
fireEvent.click(await screen.findByText('本地工作区'))
await screen.findByRole('heading', { name: '设置中心' })
fireEvent.change(await screen.findByLabelText('默认工作区目录'), {
target: { value: 'C:\\Unsaved from App' }
})
fireEvent.click(
screen.getByRole('button', { name: '知识库' })
)
expect(
screen.getByRole('heading', { name: '设置中心' })
).toBeVisible()
expect(screen.getByRole('alert')).toHaveTextContent(
'当前设置有未保存更改'
)
fireEvent.click(
screen.getByRole('button', { name: '放弃更改并关闭' })
)
expect(
await screen.findByRole('heading', { name: '知识库' })
).toBeVisible()
})
it('updates and removes the composer shortcut hint immediately after saving Settings', async () => {
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
}
let shortcutSnapshot: GlobalShortcutSettingsSnapshot = {
settings: {
enabled: true,
accelerator: 'CommandOrControl+Shift+Space'
},
defaultSettings: {
enabled: true,
accelerator: 'CommandOrControl+Shift+Space'
},
platform: 'win32',
displayAccelerator: 'Ctrl+Shift+Space',
registered: true,
registeredAccelerator: 'CommandOrControl+Shift+Space',
status: 'registered'
}
api.updates = {
getSettings: vi.fn(async () => ({ ...applicationSettings })),
updateSettings: vi.fn(async (input) => {
applicationSettings = { ...applicationSettings, ...input }
return { ...applicationSettings }
}),
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {})
}
api.shortcuts = {
getSettings: vi.fn(async () => shortcutSnapshot),
updateSettings: vi.fn(async (input) => {
shortcutSnapshot = {
...shortcutSnapshot,
settings: input,
displayAccelerator:
input.accelerator === 'Control+Alt+K'
? 'Ctrl+Alt+K'
: 'Ctrl+Shift+Space',
registered: input.enabled,
registeredAccelerator: input.enabled
? input.accelerator
: undefined,
status: input.enabled ? 'registered' : 'disabled'
}
return { ok: true as const, snapshot: shortcutSnapshot }
})
}
try {
render(<App />)
expect(
await screen.findByText('Ctrl+Shift+Space')
).toBeInTheDocument()
fireEvent.click(await screen.findByText('本地工作区'))
await screen.findByRole('heading', { name: '设置中心' })
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const shortcutInput = await screen.findByLabelText('快捷键')
fireEvent.change(shortcutInput, {
target: { value: 'Control+Alt+K' }
})
fireEvent.click(
screen.getByRole('button', { name: '保存快捷键' })
)
await waitFor(() =>
expect(api.shortcuts?.updateSettings).toHaveBeenCalledWith({
enabled: true,
accelerator: 'Control+Alt+K'
})
)
fireEvent.click(screen.getByRole('button', { name: '对话' }))
expect(await screen.findByText('Ctrl+Alt+K')).toBeInTheDocument()
fireEvent.click(await screen.findByText('本地工作区'))
await screen.findByRole('heading', { name: '设置中心' })
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const shortcutSwitch = await screen.findByRole('switch', {
name: '启用全局快捷键'
})
fireEvent.click(shortcutSwitch)
fireEvent.click(
screen.getByRole('button', { name: '保存快捷键' })
)
await waitFor(() =>
expect(api.shortcuts?.updateSettings).toHaveBeenCalledWith({
enabled: false,
accelerator: 'Control+Alt+K'
})
)
fireEvent.click(screen.getByRole('button', { name: '对话' }))
await screen.findByLabelText('向 GoodBuddy 提问')
expect(
screen.queryByText('Ctrl+Alt+K')
).not.toBeInTheDocument()
} finally {
delete api.shortcuts
delete api.updates
}
})
it('restores persisted context usage and compression state after restart', async () => {
const settings = await api.settings.getRuntime()
const profile = settings.modelProfiles[0]!
@@ -3486,11 +3848,52 @@ describe('App', () => {
evidence: []
})
render(<App />)
await screen.findByRole('button', {
const knowledgeScopeTrigger = await screen.findByRole('button', {
name: '选择知识库,本次已启用 1 个'
})
expect(knowledgeScopeTrigger).toHaveAttribute(
'aria-haspopup',
'dialog'
)
fireEvent.click(knowledgeScopeTrigger)
expect(knowledgeScopeTrigger).toHaveAttribute(
'aria-expanded',
'true'
)
expect(
screen.getByRole('dialog', {
name: '本次对话检索范围'
})
).toBeInTheDocument()
await waitFor(() =>
expect(
screen.getByRole('checkbox', { name: /产品知识/u })
).toHaveFocus()
)
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(knowledgeScopeTrigger).toHaveAttribute(
'aria-expanded',
'false'
)
expect(knowledgeScopeTrigger).toHaveFocus()
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
fireEvent.click(knowledgeScopeTrigger)
const scopeCheckbox = screen.getByRole('checkbox', {
name: /产品知识/u
})
await waitFor(() => expect(scopeCheckbox).toHaveFocus())
const composerInput = screen.getByLabelText('向 GoodBuddy 提问')
fireEvent.focusOut(scopeCheckbox, {
relatedTarget: composerInput
})
composerInput.focus()
expect(knowledgeScopeTrigger).toHaveAttribute(
'aria-expanded',
'false'
)
expect(composerInput).toHaveFocus()
fireEvent.change(composerInput, {
target: { value: '发布流程是什么?' }
})
fireEvent.click(await screen.findByLabelText('发送'))
@@ -3896,25 +4299,35 @@ describe('App', () => {
expect(
within(userArticle).getByRole('img', { name: '页面截图.png' })
).toHaveAttribute('src', imageAttachment.contentUrl)
fireEvent.click(
within(userArticle).getByRole('button', {
name: '查看图片 页面截图.png'
})
)
const viewerTrigger = within(userArticle).getByRole('button', {
name: '查看图片 页面截图.png'
})
fireEvent.click(viewerTrigger)
const imageDialog = await screen.findByRole('dialog', {
name: '页面截图.png'
})
expect(
within(imageDialog).getByRole('img', { name: '页面截图.png' })
).toHaveAttribute('src', imageAttachment.contentUrl)
fireEvent.click(
const closeViewer = within(imageDialog).getByRole('button', {
name: '关闭图片查看器'
})
expect(closeViewer).toHaveFocus()
expect(document.querySelector('main')?.inert).toBe(true)
fireEvent.keyDown(closeViewer, { key: 'Tab' })
expect(
within(imageDialog).getByRole('button', {
name: '关闭图片查看器'
name: '下载图片'
})
).toHaveFocus()
fireEvent.keyDown(imageDialog, { key: 'Escape' })
await waitFor(() =>
expect(viewerTrigger).toHaveFocus()
)
expect(
screen.queryByRole('dialog', { name: '页面截图.png' })
).not.toBeInTheDocument()
expect(document.querySelector('main')?.inert).toBe(false)
fireEvent.click(
within(userArticle).getByRole('button', {
name: '下载图片 页面截图.png'
@@ -4402,6 +4815,7 @@ describe('App', () => {
await waitFor(() =>
expect(api.workspace.getChanges).toHaveBeenCalledWith(projectId)
)
fireEvent.click(screen.getByLabelText('关闭助手工作栏'))
selectProjectOption(secondProject.name)
await waitFor(() =>
expect(api.workspace.getChanges).toHaveBeenCalledWith(
@@ -4417,6 +4831,7 @@ describe('App', () => {
files: [{ path: 'second.md', status: '??' }],
truncated: false
})
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
expect(await screen.findByText('second.md')).toBeInTheDocument()
resolveFirst?.({
rootPath: project.rootPath,
@@ -7296,8 +7711,19 @@ describe('App', () => {
const sidebar = screen.getByLabelText('助手工作栏')
expect(sidebar).not.toHaveClass('assistant-sidebar--open')
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
const assistantTrigger =
screen.getByLabelText('切换助手工作栏')
fireEvent.click(assistantTrigger)
expect(sidebar).toHaveClass('assistant-sidebar--open')
expect(sidebar).toHaveAttribute('role', 'dialog')
expect(sidebar).toHaveAttribute('aria-modal', 'true')
const main = document.querySelector('main')
expect(main).toHaveAttribute('inert')
await waitFor(() =>
expect(
screen.getByRole('tab', { name: '任务中心' })
).toHaveFocus()
)
expect(
screen.getByRole('tab', { name: '任务中心' })
@@ -7328,6 +7754,12 @@ describe('App', () => {
).not.toBeInTheDocument()
fireEvent.click(screen.getByLabelText('关闭助手工作栏'))
expect(sidebar).not.toHaveClass('assistant-sidebar--open')
expect(main).not.toHaveAttribute('inert')
await waitFor(() => expect(assistantTrigger).toHaveFocus())
fireEvent.click(assistantTrigger)
fireEvent.keyDown(sidebar, { key: 'Escape' })
expect(sidebar).not.toHaveClass('assistant-sidebar--open')
})
it('keeps completed chat replies out of the results sidebar', async () => {
@@ -7757,7 +8189,7 @@ describe('App', () => {
)
fireEvent.click(
screen.getByRole('button', { name: '关闭侧栏' })
within(sidebar).getByRole('button', { name: '知识库' })
)
await waitFor(() => expect(toggle).toHaveFocus())
expect(sidebar).toHaveClass('sidebar--closed')
+325 -44
View File
@@ -127,7 +127,6 @@ import {
normalizeInteractiveWorkMode,
projectChannelLabels
} from '../../shared/assistant-contracts'
import { ActivityPanel } from './ActivityPanel'
import {
ChatTimeline,
type ImageViewerItem,
@@ -171,6 +170,8 @@ import { ConversationInputQueue } from './ConversationInputQueue'
import { OverflowMarquee } from './OverflowMarquee'
import { findTaskSchedule } from './TaskScheduleActions'
import type { SettingsCategoryId } from './settings-categories'
import type { SettingsLeaveRequester } from './SettingsPanel'
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
import goodbuddyDarkIcon from './assets/goodbuddy-dark.png'
import goodbuddyLightIcon from './assets/goodbuddy-light.png'
import {
@@ -202,10 +203,13 @@ import {
import { formatMediumDateTime } from './locale-formatters'
import { formatCompactTokens } from './token-format'
import {
filterKeepAliveEntries,
pruneKeepAliveEntries,
touchKeepAliveEntry,
touchAndPruneKeepAliveEntries,
type KeepAliveCacheEntry
} from './keep-alive-cache'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { getProjectDisplayText } from './project-display'
const knowledgeWorkspaceRoute = createPreloadableComponent(
() => import('./KnowledgeWorkspace'),
@@ -223,17 +227,19 @@ const settingsPanelRoute = createPreloadableComponent(
() => import('./SettingsPanel'),
(module) => module.SettingsPanel
)
const activityPanelRoute = createPreloadableComponent(
() => import('./ActivityPanel'),
(module) => module.ActivityPanel
)
const idleRouteModuleLoaders = [
knowledgeWorkspaceRoute.preload,
heartbeatCenterRoute.preload,
magicNotesWorkspaceRoute.preload,
settingsPanelRoute.preload
heartbeatCenterRoute.preload
] as const
const KnowledgeWorkspace = knowledgeWorkspaceRoute.Component
const HeartbeatCenter = heartbeatCenterRoute.Component
const MagicNotesWorkspace = magicNotesWorkspaceRoute.Component
const SettingsPanel = settingsPanelRoute.Component
const ActivityPanel = activityPanelRoute.Component
const messageRenderBatchSize = 80
const conversationPersistenceIntervalMs = 500
@@ -501,6 +507,24 @@ type WorkspaceView =
| 'activity'
| 'settings'
const intentRoutePreloaders: Partial<
Record<WorkspaceView, () => Promise<unknown>>
> = {
'magic-notes': magicNotesWorkspaceRoute.preload,
knowledge: knowledgeWorkspaceRoute.preload,
activity: activityPanelRoute.preload,
settings: settingsPanelRoute.preload
}
function preloadWorkspaceRouteOnIntent(view: WorkspaceView): void {
const preload = intentRoutePreloaders[view]
if (preload) {
void preload().catch(() => {
// Click and programmatic navigation retain their local retry boundary.
})
}
}
const emptyTokenUsage: TokenUsageSummary = {
totals: {
callCount: 0,
@@ -743,6 +767,7 @@ function ChatHistoryPane({
visibleMessageCount: number
}): React.JSX.Element {
const { t } = useTranslation('app')
const headingId = `chat-heading-${conversation.id}`
const scrollRef = useRef<HTMLElement>(null)
const pinnedToBottomRef = useRef(
scrollSnapshot?.pinnedToBottom ?? true
@@ -948,8 +973,12 @@ function ChatHistoryPane({
hidden={!active}
inert={!active}
>
<h1 className="sr-only" id={headingId}>
{t('chat.heading')}
</h1>
{taskStrip}
<section
aria-labelledby={headingId}
className="chat"
id={active ? 'chat-message-list' : undefined}
onScroll={updateScrollPosition}
@@ -961,7 +990,7 @@ function ChatHistoryPane({
<Sparkles size={18} />
</div>
<p className="eyebrow">{t('chat.welcome.eyebrow')}</p>
<h1>{t('chat.welcome.title')}</h1>
<h2>{t('chat.welcome.title')}</h2>
<p className="welcome__description">
{t('chat.welcome.description')}
</p>
@@ -2112,12 +2141,17 @@ function App(): React.JSX.Element {
const [assistantSidebarOpen, setAssistantSidebarOpen] = useState(
() => window.innerWidth >= 1280
)
const [assistantSidebarOverlay, setAssistantSidebarOverlay] =
useState(() => window.innerWidth < 1280)
const [assistantSidebarTab, setAssistantSidebarTab] =
useState<AssistantSidebarTab>('tasks')
const [browserStates, setBrowserStates] = useState<
Record<string, BrowserLiveState>
>({})
const [view, setViewState] = useState<WorkspaceView>('chat')
const settingsLeaveRequesterRef = useRef<
SettingsLeaveRequester | undefined
>(undefined)
const [cachedWorkspaceViews, setCachedWorkspaceViews] = useState<
KeepAliveCacheEntry<WorkspaceView>[]
>(() => [{ key: 'chat', lastVisitedAt: Date.now() }])
@@ -2128,17 +2162,83 @@ function App(): React.JSX.Element {
? [{ key: activeId, lastVisitedAt: Date.now() }]
: []
)
const commitView = useCallback(
(next: WorkspaceView): void => {
const now = Date.now()
const runningConversationIds = new Set(
[...activeRuns.current.values()].map((run) => run.conversationId)
)
preparingConversations.current.forEach((conversationId) =>
runningConversationIds.add(conversationId)
)
const protectedWorkspaceViews = new Set<WorkspaceView>()
if (runningConversationIds.size > 0) {
protectedWorkspaceViews.add('chat')
protectedWorkspaceViews.add('activity')
}
if (knowledgeOperationCountRef.current > 0) {
protectedWorkspaceViews.add('knowledge')
protectedWorkspaceViews.add('activity')
}
if (
assistantTasksRef.current.some(
(task) =>
task.status === 'queued' ||
task.status === 'running' ||
task.status === 'waiting_approval'
)
) {
protectedWorkspaceViews.add('activity')
}
viewRef.current = next
setCachedWorkspaceViews((current) =>
touchAndPruneKeepAliveEntries(current, next, now, {
expiresAfterMs: keepAliveExpirationMs,
maximumEntries: maximumCachedWorkspaceViews,
protectedKeys: protectedWorkspaceViews,
recentEntries: recentCachedWorkspaceViews
})
)
setViewState(next)
},
[]
)
const setView = useCallback(
(update: SetStateAction<WorkspaceView>): void => {
const next =
typeof update === 'function'
? update(viewRef.current)
: update
viewRef.current = next
setCachedWorkspaceViews((current) =>
touchKeepAliveEntry(current, next, Date.now())
if (viewRef.current === 'settings' && next !== 'settings') {
const requestLeave = settingsLeaveRequesterRef.current
if (requestLeave) {
requestLeave(() => commitView(next))
return
}
}
commitView(next)
},
[commitView]
)
const registerSettingsLeaveRequester = useCallback(
(requester: SettingsLeaveRequester | undefined): void => {
settingsLeaveRequesterRef.current = requester
},
[]
)
const handleShortcutSettingsChanged = useCallback(
(snapshot: GlobalShortcutSettingsSnapshot): void => {
setAppInfo((current) =>
current
? {
...current,
shortcut: snapshot.registered
? snapshot.displayAccelerator
: '',
shortcutStatus: snapshot.status
}
: current
)
setViewState(next)
},
[]
)
@@ -2150,8 +2250,20 @@ function App(): React.JSX.Element {
: update
activeConversationIdRef.current = next
if (next) {
const now = Date.now()
const runningConversationIds = new Set(
[...activeRuns.current.values()].map((run) => run.conversationId)
)
preparingConversations.current.forEach((conversationId) =>
runningConversationIds.add(conversationId)
)
setCachedConversationViews((current) =>
touchKeepAliveEntry(current, next, Date.now())
touchAndPruneKeepAliveEntries(current, next, now, {
expiresAfterMs: keepAliveExpirationMs,
maximumEntries: maximumCachedConversations,
protectedKeys: runningConversationIds,
recentEntries: recentCachedConversations
})
)
}
setActiveIdState(next)
@@ -2234,6 +2346,14 @@ function App(): React.JSX.Element {
const imageViewerTriggerRef = useRef<HTMLElement | undefined>(
undefined
)
const imageViewerDialogRef = useRef<HTMLElement>(null)
const imageViewerCloseRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!imageViewerItem) {
return
}
return activateModalFocus(() => imageViewerCloseRef.current)
}, [imageViewerItem])
useEffect(
() =>
window.goodbuddy.context.onFileSelectionProgress((progress) => {
@@ -2255,6 +2375,7 @@ function App(): React.JSX.Element {
const [knowledgeLoading, setKnowledgeLoading] = useState(true)
const [knowledgeLoadError, setKnowledgeLoadError] = useState<string>()
const [knowledgeOperationCount, setKnowledgeOperationCount] = useState(0)
const knowledgeOperationCountRef = useRef(knowledgeOperationCount)
const knowledgeLoadRequestRef = useRef(0)
const failedKnowledgeLibraryIdRef = useRef<string | undefined>(
undefined
@@ -2263,6 +2384,8 @@ function App(): React.JSX.Element {
string[]
>([])
const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false)
const knowledgeScopeTriggerRef = useRef<HTMLButtonElement>(null)
const knowledgeScopePopoverRef = useRef<HTMLDivElement>(null)
const [activityRecords, setActivityRecords] = useState<ActivityRecord[]>(
loadActivityRecords
)
@@ -2314,6 +2437,7 @@ function App(): React.JSX.Element {
>({})
const sidebarRef = useRef<HTMLElement>(null)
const sidebarToggleRef = useRef<HTMLButtonElement>(null)
const assistantSidebarToggleRef = useRef<HTMLButtonElement>(null)
const conversationActionTriggerRefs = useRef(
new Map<string, HTMLButtonElement>()
)
@@ -2339,6 +2463,15 @@ function App(): React.JSX.Element {
setSidebarOpen(false)
requestAnimationFrame(() => sidebarToggleRef.current?.focus())
}, [])
const navigateFromSidebar = useCallback(
(nextView: WorkspaceView): void => {
setView(nextView)
if (narrowWindow) {
closeNarrowSidebar()
}
},
[closeNarrowSidebar, narrowWindow, setView]
)
useEffect(() => {
if (!conversationStoreReady) {
@@ -2399,7 +2532,9 @@ function App(): React.JSX.Element {
}
setCachedConversationViews((current) =>
pruneKeepAliveEntries(
current.filter((entry) => conversationIds.has(entry.key)),
filterKeepAliveEntries(current, (entry) =>
conversationIds.has(entry.key)
),
{
currentKey: activeId,
expiresAfterMs: keepAliveExpirationMs,
@@ -2429,6 +2564,7 @@ function App(): React.JSX.Element {
const collapseSidebarAtNarrowWidth = (): void => {
const narrow = window.innerWidth < 900
setNarrowWindow(narrow)
setAssistantSidebarOverlay(window.innerWidth < 1280)
if (narrow) {
setSidebarOpen(false)
}
@@ -2462,6 +2598,41 @@ function App(): React.JSX.Element {
}
}, [closeNarrowSidebar, narrowWindow, sidebarOpen])
useEffect(() => {
if (!knowledgeScopeOpen) {
return
}
const focusFrame = requestAnimationFrame(() => {
knowledgeScopePopoverRef.current
?.querySelector<HTMLInputElement>('input')
?.focus()
})
const isScopeTarget = (target: EventTarget | null): boolean =>
target instanceof Node &&
(knowledgeScopePopoverRef.current?.contains(target) === true ||
knowledgeScopeTriggerRef.current?.contains(target) === true)
const closeOnOutsidePointer = (event: PointerEvent): void => {
if (!isScopeTarget(event.target)) {
setKnowledgeScopeOpen(false)
}
}
const closeOnEscape = (event: KeyboardEvent): void => {
if (event.key !== 'Escape') {
return
}
event.preventDefault()
setKnowledgeScopeOpen(false)
knowledgeScopeTriggerRef.current?.focus()
}
document.addEventListener('pointerdown', closeOnOutsidePointer)
document.addEventListener('keydown', closeOnEscape)
return () => {
cancelAnimationFrame(focusFrame)
document.removeEventListener('pointerdown', closeOnOutsidePointer)
document.removeEventListener('keydown', closeOnEscape)
}
}, [knowledgeScopeOpen])
useLayoutEffect(() => {
conversationsRef.current = conversations
}, [conversations])
@@ -3094,6 +3265,9 @@ function App(): React.JSX.Element {
() => projects.find((project) => project.id === activeProjectId),
[activeProjectId, projects]
)
const activeProjectDisplayName = activeProject
? getProjectDisplayText(activeProject, tWorkspace).name
: undefined
const filteredConversations = useMemo(() => {
const query = deferredSearchQuery.trim().toLocaleLowerCase()
const candidates = query
@@ -3157,8 +3331,13 @@ function App(): React.JSX.Element {
)
const projectNames = useMemo(
() =>
new Map(projects.map((project) => [project.id, project.name])),
[projects]
new Map(
projects.map((project) => [
project.id,
getProjectDisplayText(project, tWorkspace).name
])
),
[projects, tWorkspace]
)
const pendingSidebarApprovals = useMemo<PendingSidebarApproval[]>(
() =>
@@ -5680,11 +5859,10 @@ function App(): React.JSX.Element {
}, [])
const closeImageViewer = (): void => {
const trigger = imageViewerTriggerRef.current
setImageViewerItem(undefined)
requestAnimationFrame(() => {
imageViewerTriggerRef.current?.focus()
imageViewerTriggerRef.current = undefined
})
imageViewerTriggerRef.current = undefined
requestAnimationFrame(() => trigger?.focus())
}
const openCitationContext = useCallback(async (
@@ -6665,7 +6843,11 @@ function App(): React.JSX.Element {
const runKnowledgeSourceAction = async <T,>(
action: () => Promise<T>
): Promise<T> => {
setKnowledgeOperationCount((count) => count + 1)
setKnowledgeOperationCount((count) => {
const next = count + 1
knowledgeOperationCountRef.current = next
return next
})
try {
const result = await action()
await refreshSelectedKnowledge()
@@ -6674,7 +6856,11 @@ function App(): React.JSX.Element {
await refreshSelectedKnowledge().catch(() => undefined)
throw error
} finally {
setKnowledgeOperationCount((count) => Math.max(0, count - 1))
setKnowledgeOperationCount((count) => {
const next = Math.max(0, count - 1)
knowledgeOperationCountRef.current = next
return next
})
}
}
@@ -7079,16 +7265,28 @@ function App(): React.JSX.Element {
runtimeSettings
])
const assistantOverlayOpen =
assistantSidebarOverlay &&
assistantSidebarOpen &&
view === 'chat'
const mainSidebarOpen = narrowWindow && sidebarOpen
const backgroundIsolated = mainSidebarOpen || assistantOverlayOpen
return (
<div className="app-shell">
<aside
aria-label={
narrowWindow && sidebarOpen ? t('sidebar.label') : undefined
}
aria-hidden={!sidebarOpen}
aria-hidden={!sidebarOpen || assistantOverlayOpen}
aria-modal={narrowWindow && sidebarOpen ? 'true' : undefined}
className={sidebarOpen ? 'sidebar' : 'sidebar sidebar--closed'}
inert={!sidebarOpen}
inert={!sidebarOpen || assistantOverlayOpen}
onKeyDown={(event) => {
if (mainSidebarOpen) {
trapTabFocus(event, sidebarRef.current)
}
}}
ref={sidebarRef}
role={narrowWindow && sidebarOpen ? 'dialog' : undefined}
>
@@ -7152,7 +7350,7 @@ function App(): React.JSX.Element {
className={
view === 'chat' ? 'nav-item nav-item--active' : 'nav-item'
}
onClick={() => setView('chat')}
onClick={() => navigateFromSidebar('chat')}
type="button"
>
<MessageSquare aria-hidden="true" size={17} />
@@ -7166,7 +7364,11 @@ function App(): React.JSX.Element {
? 'nav-item nav-item--active'
: 'nav-item'
}
onClick={() => setView('magic-notes')}
onFocus={() => preloadWorkspaceRouteOnIntent('magic-notes')}
onClick={() => navigateFromSidebar('magic-notes')}
onPointerEnter={() =>
preloadWorkspaceRouteOnIntent('magic-notes')
}
type="button"
>
<Sparkles aria-hidden="true" size={17} />
@@ -7180,7 +7382,11 @@ function App(): React.JSX.Element {
? 'nav-item nav-item--active'
: 'nav-item'
}
onClick={() => setView('knowledge')}
onFocus={() => preloadWorkspaceRouteOnIntent('knowledge')}
onClick={() => navigateFromSidebar('knowledge')}
onPointerEnter={() =>
preloadWorkspaceRouteOnIntent('knowledge')
}
type="button"
>
<Library aria-hidden="true" size={17} />
@@ -7193,7 +7399,7 @@ function App(): React.JSX.Element {
? 'nav-item nav-item--active'
: 'nav-item'
}
onClick={() => setView('heartbeat')}
onClick={() => navigateFromSidebar('heartbeat')}
type="button"
>
<HeartPulse aria-hidden="true" size={17} />
@@ -7216,7 +7422,11 @@ function App(): React.JSX.Element {
? 'nav-item nav-item--active'
: 'nav-item'
}
onClick={() => setView('activity')}
onFocus={() => preloadWorkspaceRouteOnIntent('activity')}
onClick={() => navigateFromSidebar('activity')}
onPointerEnter={() =>
preloadWorkspaceRouteOnIntent('activity')
}
type="button"
>
<TerminalSquare aria-hidden="true" size={17} />
@@ -7292,6 +7502,9 @@ function App(): React.JSX.Element {
return next
})
setView('chat')
if (narrowWindow) {
closeNarrowSidebar()
}
}}
>
<span className="conversation-item__primary">
@@ -7517,7 +7730,12 @@ function App(): React.JSX.Element {
? 'conversation-task-child conversation-task-child--active'
: 'conversation-task-child'
}
onClick={() => openAssistantTask(task)}
onClick={() => {
openAssistantTask(task)
if (narrowWindow) {
closeNarrowSidebar()
}
}}
type="button"
>
<span
@@ -7549,6 +7767,9 @@ function App(): React.JSX.Element {
)
setActiveId(conversation.id)
setView('chat')
if (narrowWindow) {
closeNarrowSidebar()
}
}}
type="button"
>
@@ -7577,7 +7798,11 @@ function App(): React.JSX.Element {
<button
className="user-card"
type="button"
onClick={() => setView('settings')}
onFocus={() => preloadWorkspaceRouteOnIntent('settings')}
onClick={() => navigateFromSidebar('settings')}
onPointerEnter={() =>
preloadWorkspaceRouteOnIntent('settings')
}
>
<span className="avatar">GB</span>
<span className="user-card__copy">
@@ -7602,9 +7827,9 @@ function App(): React.JSX.Element {
)}
<main
aria-hidden={narrowWindow && sidebarOpen ? 'true' : undefined}
aria-hidden={backgroundIsolated ? 'true' : undefined}
className="workspace"
inert={narrowWindow && sidebarOpen}
inert={backgroundIsolated}
>
<header className="topbar">
<button
@@ -7648,7 +7873,8 @@ function App(): React.JSX.Element {
activeProject
? {
kind: 'project',
projectName: activeProject.name
projectName:
activeProjectDisplayName ?? activeProject.name
}
: {
kind: 'unavailable',
@@ -7689,6 +7915,7 @@ function App(): React.JSX.Element {
onClick={() =>
setAssistantSidebarOpen((current) => !current)
}
ref={assistantSidebarToggleRef}
type="button"
>
<PanelRightOpen size={18} />
@@ -8065,8 +8292,20 @@ function App(): React.JSX.Element {
</button>
</div>
{knowledgeSnapshot.libraries.length > 0 && (
<div className="knowledge-scope">
<div
className="knowledge-scope"
onBlurCapture={(event) => {
if (
!(event.relatedTarget instanceof Node) ||
!event.currentTarget.contains(event.relatedTarget)
) {
setKnowledgeScopeOpen(false)
}
}}
>
<button
aria-controls="knowledge-scope-popover"
aria-haspopup="dialog"
aria-label={t('composer.knowledge.select', {
count: enabledKnowledgeLibraryIds.length
})}
@@ -8074,6 +8313,7 @@ function App(): React.JSX.Element {
onClick={() =>
setKnowledgeScopeOpen((current) => !current)
}
ref={knowledgeScopeTriggerRef}
title={t('composer.knowledge.title')}
type="button"
>
@@ -8084,7 +8324,13 @@ function App(): React.JSX.Element {
</span>
</button>
{knowledgeScopeOpen && (
<div className="knowledge-scope__popover">
<div
aria-label={t('composer.knowledge.scope')}
className="knowledge-scope__popover"
id="knowledge-scope-popover"
ref={knowledgeScopePopoverRef}
role="dialog"
>
<strong>{t('composer.knowledge.scope')}</strong>
{knowledgeSnapshot.libraries.map((library) => (
<label key={library.id}>
@@ -9132,7 +9378,7 @@ function App(): React.JSX.Element {
onClose={() => {
setSettingsInitialCategory(undefined)
setSettingsInitialChannel(undefined)
setView('chat')
commitView('chat')
}}
onExpertsChanged={(experts) => {
setAssistantExperts(experts)
@@ -9151,9 +9397,11 @@ function App(): React.JSX.Element {
setMagicNotesEnabled(enabled)
}}
onNotify={notify}
onLeaveRequestReady={registerSettingsLeaveRequester}
onSaved={(settings) => {
setRuntimeSettings(settings)
}}
onShortcutSettingsChanged={handleShortcutSettingsChanged}
onUpdateProject={updateProject}
open={view === 'settings'}
presentation="page"
@@ -9170,12 +9418,29 @@ function App(): React.JSX.Element {
route="activity"
>
<PageShell variant="dashboard">
<ActivityPanel
onClear={() => setActivityRecords([])}
onOpenConversation={openActivityConversation}
records={activityRecords}
tokenUsage={tokenUsage}
/>
<RouteErrorBoundary
key="activity"
fallback={
<RouteLoadError
message={t('route.loadFailed')}
reloadLabel={t('route.reload')}
/>
}
>
<Suspense
fallback={
<RouteLoadingStatus label={t('route.loading')} />
}
>
<ActivityPanel
onClear={() => setActivityRecords([])}
onOpenConversation={openActivityConversation}
projects={projects}
records={activityRecords}
tokenUsage={tokenUsage}
/>
</Suspense>
</RouteErrorBoundary>
</PageShell>
</KeepAliveRoute>
)}
@@ -9233,9 +9498,13 @@ function App(): React.JSX.Element {
className="image-viewer-dialog"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
closeImageViewer()
return
}
trapTabFocus(event, imageViewerDialogRef.current)
}}
ref={imageViewerDialogRef}
role="dialog"
>
<header className="image-viewer-dialog__header">
@@ -9253,9 +9522,9 @@ function App(): React.JSX.Element {
</button>
<button
aria-label={t('chat.images.closeViewer')}
autoFocus
className="icon-button"
onClick={closeImageViewer}
ref={imageViewerCloseRef}
type="button"
>
<X size={16} />
@@ -9283,7 +9552,9 @@ function App(): React.JSX.Element {
onClose={() => setCustomTaskDialog(undefined)}
onCreate={createCustomTask}
projectId={activeProject.id}
projectName={activeProject.name}
projectName={
activeProjectDisplayName ?? activeProject.name
}
runtimeLabel={activeRuntimeLabel}
supportsToolExecution={Boolean(
runtime?.supportsToolExecution
@@ -9293,6 +9564,14 @@ function App(): React.JSX.Element {
}
/>
)}
{assistantOverlayOpen && (
<button
aria-label={tWorkspace('sidebar.dismissOverlay')}
className="assistant-sidebar-backdrop"
onClick={() => setAssistantSidebarOpen(false)}
type="button"
/>
)}
<RightAssistantSidebar
approvals={pendingSidebarApprovals}
artifacts={sidebarArtifacts}
@@ -9386,6 +9665,8 @@ function App(): React.JSX.Element {
open={
assistantSidebarOpen && view === 'chat'
}
overlay={assistantSidebarOverlay}
restoreFocusRef={assistantSidebarToggleRef}
tab={assistantSidebarTab}
workspaceChanges={workspaceChanges}
workspaceProjectId={activeProjectId || undefined}
@@ -123,6 +123,7 @@ const snapshot: DocumentParsingSnapshot = {
}
const getSnapshot = vi.fn(async () => snapshot)
const getOcrModelProgress = vi.fn(async () => ({ operations: [] }))
const update = vi.fn(async (input: DocumentParsingSettings) => ({
...snapshot,
settings: input
@@ -181,6 +182,7 @@ describe('DocumentParsingSettingsSection', () => {
value: {
documentParsing: {
getSnapshot,
getOcrModelProgress,
update,
test,
installOcrModel,
@@ -319,6 +321,51 @@ describe('DocumentParsingSettingsSection', () => {
)
})
it('waits for each OCR progress request before scheduling the next', async () => {
let resolveProgress:
| ((value: { operations: [] }) => void)
| undefined
let resolveInstall:
| ((value: DocumentParsingSnapshot) => void)
| undefined
getOcrModelProgress.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveProgress = resolve
})
)
installOcrModel.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveInstall = resolve
})
)
render(<DocumentParsingSettingsSection />)
const installButton = await screen.findByRole('button', {
name: '下载 PP-OCRv6 Tiny'
})
vi.useFakeTimers()
try {
fireEvent.click(installButton)
await vi.advanceTimersByTimeAsync(300)
expect(getOcrModelProgress).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1_200)
expect(getOcrModelProgress).toHaveBeenCalledTimes(1)
resolveProgress?.({ operations: [] })
await vi.advanceTimersByTimeAsync(299)
expect(getOcrModelProgress).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(getOcrModelProgress).toHaveBeenCalledTimes(2)
resolveInstall?.(snapshot)
await vi.runAllTimersAsync()
} finally {
vi.useRealTimers()
}
})
it('downloads and selects an uninstalled model in one action', async () => {
const installedMedium = {
...snapshot,
@@ -463,17 +510,35 @@ describe('DocumentParsingSettingsSection', () => {
render(<DocumentParsingSettingsSection />)
await screen.findByText('PP-OCRv6 Tiny')
fireEvent.click(
screen.getByRole('button', {
name: '测试聊天与成果模式'
})
)
const trigger = screen.getByRole('button', {
name: '测试聊天与成果模式'
})
trigger.focus()
fireEvent.click(trigger)
const dialog = await screen.findByRole('dialog', {
name: '解析测试结果'
})
expect(dialog).toHaveTextContent('扫描件识别正文')
const close = screen.getByRole('button', {
name: '关闭结果'
})
expect(close).toHaveFocus()
const backgroundSection = trigger.closest<HTMLElement>(
'.settings-section'
)
expect(backgroundSection?.inert).toBe(true)
expect(
await screen.findByRole('dialog', {
name: '解析测试结果'
})
).toHaveTextContent('扫描件识别正文')
fireEvent.keyDown(dialog, { key: 'Tab' })
).toBe(false)
expect(close).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
expect(trigger).toHaveFocus()
expect(backgroundSection?.inert).toBe(false)
expect(test).toHaveBeenCalledWith('chat-attachment')
expect(update).not.toHaveBeenCalled()
})
@@ -29,6 +29,7 @@ import type {
DocumentParsingTestPurpose
} from '../../shared/document-parsing-contracts'
import type { AppNotificationInput } from './notifications'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import {
SettingsCategoryHeader,
SettingsWarningList
@@ -37,6 +38,7 @@ import {
type DocumentParsingSettingsSectionProps = {
onNotify?: (notification: AppNotificationInput) => void
onOpenModelDownloadSourceSettings?: () => void
onDirtyChange?: (dirty: boolean) => void
}
function errorMessage(reason: unknown, fallback: string): string {
@@ -117,9 +119,10 @@ function DiagnosticDialog({
onClose: () => void
}): React.JSX.Element {
const { t } = useTranslation('settings')
const dialogRef = useRef<HTMLElement>(null)
const closeRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
closeRef.current?.focus()
return activateModalFocus(() => closeRef.current)
}, [])
return (
<div
@@ -136,9 +139,13 @@ function DiagnosticDialog({
className="document-parsing-diagnostic"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
trapTabFocus(event, dialogRef.current)
}}
ref={dialogRef}
role="dialog"
>
<header>
@@ -209,7 +216,8 @@ function DiagnosticDialog({
export function DocumentParsingSettingsSection({
onNotify,
onOpenModelDownloadSourceSettings
onOpenModelDownloadSourceSettings,
onDirtyChange
}: DocumentParsingSettingsSectionProps): React.JSX.Element {
const { t } = useTranslation('settings')
const [snapshot, setSnapshot] = useState<DocumentParsingSnapshot>()
@@ -223,6 +231,14 @@ export function DocumentParsingSettingsSection({
const [diagnostic, setDiagnostic] =
useState<DocumentParsingDiagnostic>()
const mountedRef = useRef(false)
const settingsDirty =
snapshot !== undefined &&
draft !== undefined &&
JSON.stringify(draft) !== JSON.stringify(snapshot.settings)
useEffect(() => {
onDirtyChange?.(settingsDirty)
}, [onDirtyChange, settingsDirty])
const refresh = useCallback(async (): Promise<void> => {
const api = window.goodbuddy.documentParsing
@@ -235,6 +251,27 @@ export function DocumentParsingSettingsSection({
}
}, [t])
const refreshProgress = useCallback(async (): Promise<void> => {
const api = window.goodbuddy.documentParsing
if (!api) {
return
}
const progress = await api.getOcrModelProgress()
if (mountedRef.current) {
setSnapshot((current) =>
current
? {
...current,
ocrModels: {
...current.ocrModels,
operations: progress.operations
}
}
: current
)
}
}, [])
useEffect(() => {
const api = window.goodbuddy.documentParsing
let active = true
@@ -274,11 +311,27 @@ export function DocumentParsingSettingsSection({
if (!shouldPoll) {
return
}
const timer = window.setInterval(() => {
void refresh().catch(() => undefined)
}, 300)
return () => window.clearInterval(timer)
}, [refresh, shouldPoll])
let active = true
let timer: number | undefined
const poll = async (): Promise<void> => {
try {
await refreshProgress()
} catch {
// The final full refresh reports actionable operation errors.
} finally {
if (active) {
timer = window.setTimeout(() => void poll(), 300)
}
}
}
timer = window.setTimeout(() => void poll(), 300)
return () => {
active = false
if (timer !== undefined) {
window.clearTimeout(timer)
}
}
}, [refreshProgress, shouldPoll])
const updateDraft = <Key extends keyof DocumentParsingSettings>(
key: Key,
@@ -450,8 +503,6 @@ export function DocumentParsingSettingsSection({
modelDownloadAvailability?.available === true
const pendingModelSelection =
draft.localOcrModelId !== snapshot.settings.localOcrModelId
const settingsDirty =
JSON.stringify(draft) !== JSON.stringify(snapshot.settings)
const selectedModelReady = installedModel !== undefined
const invalidPendingModel =
pendingModelSelection && !selectedModelReady
+114 -3
View File
@@ -15,6 +15,10 @@ import type {
AssistantMemory,
AssistantTask
} from '../../shared/assistant-contracts'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName
} from '../../shared/assistant-contracts'
import { HeartbeatCenter, type HeartbeatCenterProps } from './HeartbeatCenter'
import i18n from './i18n'
@@ -110,11 +114,12 @@ function createProps(
projects: [
{
id: '00000000-0000-4000-8000-000000000101',
name: '默认项目',
description: '',
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: 'C:\\Workspace',
defaultWorkMode: 'ask',
kind: 'user',
builtInDefault: true,
status: 'active',
createdAt: '2026-07-31T01:00:00.000Z',
updatedAt: '2026-07-31T01:00:00.000Z'
@@ -155,6 +160,12 @@ describe('HeartbeatCenter', () => {
screen.getByRole('button', { name: 'Run heartbeat now' })
).toBeInTheDocument()
expect(screen.getByText(entry.summary)).toBeInTheDocument()
expect(screen.getByText('Project: Default project')).toHaveClass(
'scope-badge'
)
expect(
screen.getByText(/Every day at 09:00 · Default project/u)
).toBeInTheDocument()
const englishDate = new Intl.DateTimeFormat('en-US', {
month: '2-digit',
day: '2-digit',
@@ -171,6 +182,14 @@ describe('HeartbeatCenter', () => {
expect(
screen.getByRole('button', { name: /Handle in conversation/ })
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('tab', { name: 'Heartbeat plans' })
)
fireEvent.click(
screen.getByRole('button', { name: 'Selected projects' })
)
expect(screen.getByLabelText('Default project')).toBeInTheDocument()
})
it('shows heartbeat health, growth dimensions, and the latest report', () => {
@@ -179,7 +198,7 @@ describe('HeartbeatCenter', () => {
expect(
screen.getByRole('heading', { level: 1, name: '智能心跳' })
).toBeInTheDocument()
expect(screen.getByText('全局')).toHaveClass(
expect(screen.getByText('项目:默认项目')).toHaveClass(
'scope-badge'
)
expect(screen.getByText(/每天 09:00 · 默认项目/u)).toBeInTheDocument()
@@ -199,6 +218,98 @@ describe('HeartbeatCenter', () => {
expect(within(dimensions).getByText('2')).toBeInTheDocument()
})
it('distinguishes multi-project-only scope from global and project scope', async () => {
const secondProject = {
...createProps().projects[0]!,
id: '00000000-0000-4000-8000-000000000102',
name: '第二项目',
rootPath: 'C:\\Second'
}
const projectConfig: AssistantHeartbeatConfig = {
...config,
scope: {
kind: 'projects',
projectIds: [createProps().projects[0]!.id, secondProject.id]
}
}
const { rerender } = render(
<HeartbeatCenter
{...createProps({
configs: [projectConfig],
projects: [...createProps().projects, secondProject]
})}
/>
)
expect(screen.getByLabelText('2 个项目')).toHaveTextContent(
'2 个项目'
)
expect(screen.queryByText(/全局/u)).not.toBeInTheDocument()
rerender(
<HeartbeatCenter
{...createProps({
configs: [
projectConfig,
{
...config,
id: 'heartbeat-global',
scope: { kind: 'global' }
}
],
projects: [...createProps().projects, secondProject]
})}
/>
)
expect(screen.getByLabelText('项目 + 全局')).toBeInTheDocument()
await i18n.changeLanguage('en-US')
rerender(
<HeartbeatCenter
{...createProps({
configs: [projectConfig],
projects: [...createProps().projects, secondProject]
})}
/>
)
expect(screen.getByLabelText('2 projects')).toBeInTheDocument()
})
it('uses level-two headings for direct tab panel sections', () => {
render(<HeartbeatCenter {...createProps()} />)
expect(
screen.getByRole('heading', { level: 2, name: '当前状态' })
).toBeInTheDocument()
expect(
screen.getByRole('heading', { level: 2, name: '成长趋势' })
).toBeInTheDocument()
expect(
screen.getByRole('heading', { level: 2, name: '本次心跳' })
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: /待处理建议/u }))
expect(
screen.getByRole('heading', { level: 2, name: '待确认记忆' })
).toBeInTheDocument()
expect(
screen.getByRole('heading', { level: 2, name: '行动建议' })
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '心跳轨迹' }))
expect(
screen.getByRole('heading', { level: 2, name: '成长轨迹' })
).toBeInTheDocument()
expect(
screen.getByRole('heading', { level: 2, name: '运行记录' })
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '心跳计划' }))
expect(
screen.getByRole('heading', { level: 2, name: '智能心跳' })
).toBeInTheDocument()
})
it('turns heartbeat findings into explicit user actions', async () => {
const onSetMemoryStatus = vi.fn(async () => {})
const onSetTaskStatus = vi.fn(async () => {})
+71 -21
View File
@@ -24,10 +24,12 @@ import type {
HeartbeatUpdateInput
} from '../../shared/assistant-contracts'
import { HeartbeatSettings } from './HeartbeatSettings'
import { getProjectDisplayText } from './project-display'
import {
EmptyState,
PageHeader,
PageTabs
PageTabs,
type WorkspaceScope
} from './WorkspacePrimitives'
type HeartbeatCenterTab =
@@ -101,6 +103,7 @@ export function HeartbeatCenter({
onRetryLoad
}: HeartbeatCenterProps): React.JSX.Element {
const { t, i18n } = useTranslation('heartbeat')
const { t: tWorkspace } = useTranslation('workspace')
const [tab, setTab] = useState<HeartbeatCenterTab>('overview')
const [pendingAction, setPendingAction] = useState<string>()
const [error, setError] = useState<string>()
@@ -194,11 +197,14 @@ export function HeartbeatCenter({
if (config.scope.kind === 'global') {
return t('center.scope.global')
}
const projectNames = config.scope.projectIds.map(
(projectId) =>
projects.find((project) => project.id === projectId)?.name ??
t('settings.scope.unavailableProject')
)
const projectNames = config.scope.projectIds.map((projectId) => {
const project = projects.find(
(candidate) => candidate.id === projectId
)
return project
? getProjectDisplayText(project, tWorkspace).name
: t('settings.scope.unavailableProject')
})
return projectNames.join(t('settings.scope.nameSeparator'))
}
@@ -276,6 +282,50 @@ export function HeartbeatCenter({
)
const hasHeartbeatData =
configs.length > 0 || runs.length > 0 || entries.length > 0
const heartbeatScope = useMemo<WorkspaceScope>(() => {
if (
configs.length === 0 ||
configs.every((config) => config.scope.kind === 'global')
) {
return { kind: 'global' }
}
const includesGlobal = configs.some(
(config) => config.scope.kind === 'global'
)
const projectIds = [
...new Set(
configs.flatMap((config) =>
config.scope.kind === 'projects'
? config.scope.projectIds
: []
)
)
]
if (!includesGlobal && projectIds.length === 1) {
const project = projects.find(
(candidate) => candidate.id === projectIds[0]
)
return project
? {
kind: 'project',
projectName: getProjectDisplayText(
project,
tWorkspace
).name
}
: {
kind: 'unavailable',
explanation: t('settings.scope.unavailableProject')
}
}
if (!includesGlobal && projectIds.length > 1) {
return {
kind: 'projects',
projectCount: projectIds.length
}
}
return { kind: 'mixed' }
}, [configs, projects, t, tWorkspace])
const initialLoadBlocked =
!hasHeartbeatData && (loading || loadError !== undefined)
@@ -368,7 +418,7 @@ export function HeartbeatCenter({
eyebrow={t('center.eyebrow')}
headingId="heartbeat-center-title"
icon={<HeartPulse size={22} />}
scope={{ kind: 'global' }}
scope={heartbeatScope}
title={t('center.title')}
/>
@@ -445,9 +495,9 @@ export function HeartbeatCenter({
<p className="eyebrow">
{t('center.currentStatus.eyebrow')}
</p>
<h3 id="heartbeat-status-title">
<h2 id="heartbeat-status-title">
{t('center.currentStatus.title')}
</h3>
</h2>
</div>
<span
className={
@@ -666,9 +716,9 @@ export function HeartbeatCenter({
<p className="eyebrow">
{t('center.trend.eyebrow')}
</p>
<h3 id="heartbeat-trend-title">
<h2 id="heartbeat-trend-title">
{t('center.trend.title')}
</h3>
</h2>
</div>
</div>
{recentTrend.length === 0 ? (
@@ -760,9 +810,9 @@ export function HeartbeatCenter({
<p className="eyebrow">
{t('center.latest.eyebrow')}
</p>
<h3 id="latest-heartbeat-title">
<h2 id="latest-heartbeat-title">
{t('center.latest.title')}
</h3>
</h2>
</div>
{latestEntry && (
<time dateTime={latestEntry.createdAt}>
@@ -829,9 +879,9 @@ export function HeartbeatCenter({
<p className="eyebrow">
{t('center.suggestions.memoryEyebrow')}
</p>
<h3 id="heartbeat-memory-title">
<h2 id="heartbeat-memory-title">
{t('center.suggestions.memoryTitle')}
</h3>
</h2>
</div>
<span>
{t('center.suggestions.memoryCount', {
@@ -943,9 +993,9 @@ export function HeartbeatCenter({
<p className="eyebrow">
{t('center.suggestions.taskEyebrow')}
</p>
<h3 id="heartbeat-task-title">
<h2 id="heartbeat-task-title">
{t('center.suggestions.taskTitle')}
</h3>
</h2>
</div>
<span>
{t('center.suggestions.taskCount', {
@@ -1065,10 +1115,10 @@ export function HeartbeatCenter({
<p className="eyebrow">
{t('center.history.timelineEyebrow')}
</p>
<h3 id="heartbeat-reports-title">
<h2 id="heartbeat-reports-title">
<History aria-hidden="true" size={16} />
{t('center.history.timelineTitle')}
</h3>
</h2>
</div>
<span>
{t('center.history.reportCount', {
@@ -1168,9 +1218,9 @@ export function HeartbeatCenter({
<p className="eyebrow">
{t('center.history.auditEyebrow')}
</p>
<h3 id="heartbeat-runs-title">
<h2 id="heartbeat-runs-title">
{t('center.history.auditTitle')}
</h3>
</h2>
</div>
<span>
{t('center.history.runCount', {
+13 -8
View File
@@ -12,6 +12,7 @@ import {
DestructiveConfirmActions,
SegmentedControl
} from './WorkspacePrimitives'
import { getProjectDisplayText } from './project-display'
type HeartbeatSettingsProps = {
heartbeats: AssistantHeartbeatConfig[]
@@ -38,6 +39,7 @@ export function HeartbeatSettings({
onRunNow
}: HeartbeatSettingsProps): React.JSX.Element {
const { t, i18n } = useTranslation('heartbeat')
const { t: tWorkspace } = useTranslation('workspace')
const [editingId, setEditingId] = useState<string>()
const [name, setName] = useState(t('settings.defaultName'))
const [time, setTime] = useState('09:00')
@@ -162,11 +164,12 @@ export function HeartbeatSettings({
if (heartbeat.scope.kind === 'global') {
return t('settings.scope.global')
}
const names = heartbeat.scope.projectIds.map(
(projectId) =>
projectById.get(projectId)?.name ??
t('settings.scope.unavailableProject')
)
const names = heartbeat.scope.projectIds.map((projectId) => {
const project = projectById.get(projectId)
return project
? getProjectDisplayText(project, tWorkspace).name
: t('settings.scope.unavailableProject')
})
return t('settings.scope.selectedProjectsSummary', {
count: names.length,
names: names.join(t('settings.scope.nameSeparator'))
@@ -176,10 +179,10 @@ export function HeartbeatSettings({
return (
<div className="heartbeat-settings">
<div className="heartbeat-settings__intro">
<h3>
<h2>
<HeartPulse size={15} />
{t('settings.title')}
</h3>
</h2>
<p>{t('settings.description')}</p>
</div>
<div className="heartbeat-settings__editor">
@@ -250,7 +253,9 @@ export function HeartbeatSettings({
}
type="checkbox"
/>
<span>{project.name}</span>
<span>
{getProjectDisplayText(project, tWorkspace).name}
</span>
{project.status !== 'active' && (
<small>{t('settings.scope.archived')}</small>
)}
+1 -1
View File
@@ -30,7 +30,7 @@ type ChartKnowledgeGraphRelation = Omit<
evidenceIds?: readonly string[]
}
type KnowledgeGraphChartProps = {
export type KnowledgeGraphChartProps = {
nodes: readonly ChartKnowledgeGraphNode[]
relations: readonly ChartKnowledgeGraphRelation[]
selectedNodeId?: string
+257 -16
View File
@@ -7,8 +7,11 @@ import {
waitFor,
within
} from '@testing-library/react'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
KnowledgeGraphChartLoader,
KnowledgeWorkspace,
type KnowledgeWorkspaceProps
} from './KnowledgeWorkspace'
@@ -17,6 +20,17 @@ import {
defaultKnowledgeOntologySettings
} from '../../shared/knowledge-ontology'
const knowledgeWorkspaceSource = readFileSync(
join(
process.cwd(),
'src',
'renderer',
'src',
'KnowledgeWorkspace.tsx'
),
'utf8'
)
const g6Mock = vi.hoisted(() => {
const handlers = new Map<string, (event: unknown) => void>()
const graph = {
@@ -460,11 +474,13 @@ describe('KnowledgeWorkspace', () => {
expect(screen.getByText('第二份文档内容')).toBeInTheDocument()
})
it('switches to the graph and opens entity details', () => {
it('switches to the graph and opens entity details', async () => {
render(<KnowledgeWorkspace {...createProps()} />)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
expect(screen.getByLabelText('实体关系图')).toBeInTheDocument()
expect(
await screen.findByLabelText('实体关系图')
).toBeInTheDocument()
expect(
screen.getByRole('tab', { name: /拓扑/u })
).toHaveAttribute('aria-selected', 'true')
@@ -1217,6 +1233,9 @@ describe('KnowledgeWorkspace', () => {
render(<KnowledgeWorkspace {...createProps()} />)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
await waitFor(() =>
expect(g6Mock.graph.setOptions).toHaveBeenCalled()
)
expect(
screen.getByRole('option', { name: 'GoodBuddy · 概念 (CONCEPT)' })
).toBeInTheDocument()
@@ -1265,9 +1284,7 @@ describe('KnowledgeWorkspace', () => {
const workspace = screen.getByLabelText('知识工作区')
expect(workspace).toHaveClass('knowledge-workspace')
expect(workspace).toHaveStyle({
background: 'var(--surface-canvas)'
})
expect(workspace).not.toHaveAttribute('style')
expect(workspace.querySelector('aside')).toHaveClass(
'knowledge-workspace__sidebar'
)
@@ -1276,9 +1293,7 @@ describe('KnowledgeWorkspace', () => {
name: '知识库详情'
})
expect(detailRegion).toHaveClass('knowledge-workspace__main')
expect(detailRegion).toHaveStyle({
background: 'var(--surface-raised)'
})
expect(detailRegion).not.toHaveAttribute('style')
expect(screen.getByText('全局')).toHaveClass('scope-badge')
const mobileBack = screen.getByRole('button', {
name: '返回知识库列表'
@@ -1286,11 +1301,14 @@ describe('KnowledgeWorkspace', () => {
expect(mobileBack).toHaveClass('knowledge-workspace__mobile-back')
fireEvent.click(mobileBack)
expect(workspace).toHaveClass('knowledge-workspace--mobile-list')
fireEvent.click(
screen.getByRole('button', {
name: /^产品知识 1 个文档/u
})
const selectedLibraryButton = screen.getByRole('button', {
name: /^产品知识 1 个文档/u
})
expect(selectedLibraryButton).toHaveClass(
'knowledge-workspace__library-button--selected'
)
expect(selectedLibraryButton).not.toHaveAttribute('style')
fireEvent.click(selectedLibraryButton)
expect(workspace).not.toHaveClass('knowledge-workspace--mobile-list')
expect(screen.getByRole('tablist', { name: '知识库视图' })).toHaveClass(
'page-tabs'
@@ -1324,13 +1342,19 @@ describe('KnowledgeWorkspace', () => {
target: { value: 'entity-1' }
})
expect(screen.getByLabelText('知识图谱画布').parentElement).toHaveClass(
'knowledge-graph--with-details'
'knowledge-graph'
)
expect(screen.getByLabelText('知识图谱画布').parentElement)
.not.toHaveClass('knowledge-graph--with-details')
expect(screen.getByLabelText('实体详情')).toHaveClass(
'knowledge-graph__detail'
)
})
it('keeps Knowledge workspace presentation in semantic classes', () => {
expect(knowledgeWorkspaceSource).not.toMatch(/\bstyle\s*=/u)
})
it('manages the G6 graph, zoom, selection, movement, and cleanup', async () => {
const onMoveNode = vi.fn()
const { rerender, unmount } = render(
@@ -1338,7 +1362,7 @@ describe('KnowledgeWorkspace', () => {
)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
const graph = screen.getByLabelText('实体关系图')
const graph = await screen.findByLabelText('实体关系图')
expect(graph).toHaveClass('knowledge-graph__chart')
expect(g6Mock.Graph).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1491,6 +1515,7 @@ describe('KnowledgeWorkspace', () => {
it('preserves the G6 instance and refreshes theme colors', async () => {
render(<KnowledgeWorkspace {...createProps()} />)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
await screen.findByLabelText('实体关系图')
g6Mock.graph.getZoom.mockReturnValueOnce(1.3)
act(() => {
@@ -1523,7 +1548,7 @@ describe('KnowledgeWorkspace', () => {
delete document.documentElement.dataset.theme
})
it('sizes dense nodes by degree and labels key entities', () => {
it('sizes dense nodes by degree and labels key entities', async () => {
const graphNodes = Array.from({ length: 30 }, (_, index) => ({
id: `entity-${index}`,
label: `实体 ${index}`,
@@ -1553,6 +1578,7 @@ describe('KnowledgeWorkspace', () => {
/>
)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
await screen.findByLabelText('实体关系图')
expect(g6Mock.graph.setOptions).toHaveBeenLastCalledWith(
expect.objectContaining({
@@ -1598,6 +1624,50 @@ describe('KnowledgeWorkspace', () => {
)
})
it('shows a graph-local loading state and retries a failed chart chunk', async () => {
let attempts = 0
const loadModule = vi.fn(async () => {
attempts += 1
if (attempts <= 2) {
throw new Error('chunk unavailable')
}
return {
KnowledgeGraphChart: () => (
<div aria-label="已加载的图谱测试组件" />
)
} as unknown as typeof import('./KnowledgeGraphChart')
})
const props = createProps()
render(
<KnowledgeGraphChartLoader
fitViewRequest={0}
loadModule={loadModule}
nodes={props.graphNodes}
onMoveNode={props.onMoveNode}
onSelectNode={vi.fn()}
onZoomChange={vi.fn()}
relations={props.graphRelations}
zoom={1}
/>
)
expect(
screen.getByRole('status')
).toHaveTextContent('正在加载知识图谱…')
expect(
await screen.findByRole('alert')
).toHaveTextContent('知识图谱未能加载,请重试。')
expect(loadModule).toHaveBeenCalledTimes(2)
fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(
await screen.findByLabelText('已加载的图谱测试组件')
).toBeInTheDocument()
expect(loadModule).toHaveBeenCalledTimes(3)
})
it('creates relationships, merges entities, and opens graph evidence', async () => {
const onCreateRelation = vi.fn()
const onMergeEntities = vi.fn()
@@ -1644,7 +1714,118 @@ describe('KnowledgeWorkspace', () => {
target: { value: 'entity-2' }
})
fireEvent.click(screen.getByRole('button', { name: '合并到目标实体' }))
expect(onMergeEntities).toHaveBeenCalledWith('entity-1', 'entity-2')
expect(
screen.getByRole('alertdialog', {
name: '将“GoodBuddy”合并到“Electron”?'
})
).toHaveAccessibleDescription(
'“GoodBuddy”的关系、别名和证据将并入“Electron”,随后删除源实体。此操作无法恢复。'
)
expect(onMergeEntities).not.toHaveBeenCalled()
fireEvent.click(
screen.getByRole('button', { name: '合并实体' })
)
await waitFor(() =>
expect(onMergeEntities).toHaveBeenCalledWith(
'entity-1',
'entity-2'
)
)
await waitFor(() =>
expect(screen.getByLabelText('选择图谱实体')).toHaveFocus()
)
})
it('confirms graph entity and relation deletion with concrete impact', async () => {
const onDeleteEntity = vi.fn(async () => {})
const onDeleteRelation = vi.fn(async () => {})
render(
<KnowledgeWorkspace
{...createProps({ onDeleteEntity, onDeleteRelation })}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
fireEvent.change(screen.getByLabelText('选择图谱实体'), {
target: { value: 'entity-1' }
})
fireEvent.click(
screen.getByRole('button', { name: '删除实体 GoodBuddy' })
)
expect(
screen.getByRole('alertdialog', {
name: '删除实体“GoodBuddy”?'
})
).toHaveAccessibleDescription(
'将永久删除实体“GoodBuddy”及其 1 条关联关系;相关证据引用也会从图谱中移除,且无法恢复。'
)
expect(onDeleteEntity).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '取消' }))
fireEvent.click(
screen.getByRole('button', { name: '删除关系 使用' })
)
expect(
screen.getByRole('alertdialog', {
name: '删除关系“使用 (USES)”?'
})
).toHaveAccessibleDescription(
'将永久删除“GoodBuddy”到“Electron”的“使用 (USES)”关系及其图谱证据引用,且无法恢复。两个实体本身会保留。'
)
fireEvent.click(
screen.getByRole('button', { name: '删除关系' })
)
await waitFor(() =>
expect(onDeleteRelation).toHaveBeenCalledWith('relation-1')
)
await waitFor(() =>
expect(screen.getByLabelText('选择图谱实体')).toHaveFocus()
)
fireEvent.click(
screen.getByRole('button', { name: '删除实体 GoodBuddy' })
)
fireEvent.click(
screen.getByRole('button', { name: '删除实体' })
)
await waitFor(() =>
expect(onDeleteEntity).toHaveBeenCalledWith('entity-1')
)
await waitFor(() =>
expect(screen.getByLabelText('选择图谱实体')).toHaveFocus()
)
})
it('keeps a failed graph confirmation open and focused', async () => {
const onDeleteEntity = vi.fn(async () => {
throw new Error('删除实体失败')
})
render(
<KnowledgeWorkspace
{...createProps({ onDeleteEntity })}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
fireEvent.change(screen.getByLabelText('选择图谱实体'), {
target: { value: 'entity-1' }
})
fireEvent.click(
screen.getByRole('button', { name: '删除实体 GoodBuddy' })
)
const confirm = screen.getByRole('button', {
name: '删除实体'
})
confirm.focus()
fireEvent.click(confirm)
expect(await screen.findByText('删除实体失败')).toBeInTheDocument()
expect(
screen.getByRole('alertdialog', {
name: '删除实体“GoodBuddy”?'
})
).toBeInTheDocument()
expect(confirm).toHaveFocus()
})
it('renders an explicit empty graph state', () => {
@@ -1660,6 +1841,25 @@ describe('KnowledgeWorkspace', () => {
).toBeInTheDocument()
})
it('keeps one primary creation action in the empty library state', () => {
render(
<KnowledgeWorkspace
{...createProps({
libraries: [],
selectedLibraryId: undefined
})}
/>
)
expect(screen.getByText('建立第一个知识库')).toBeInTheDocument()
expect(
screen.getAllByRole('button', { name: '新建知识库' })
).toHaveLength(1)
expect(
screen.queryByRole('button', { name: '创建知识库' })
).not.toBeInTheDocument()
})
it('keeps loading distinct from the first-library empty state', () => {
render(
<KnowledgeWorkspace
@@ -1718,6 +1918,37 @@ describe('KnowledgeWorkspace', () => {
).toHaveAttribute('aria-current', 'page')
})
it('isolates and traps focus in the library edit dialog', async () => {
render(<KnowledgeWorkspace {...createProps()} />)
const trigger = screen.getByRole('button', { name: '编辑' })
trigger.focus()
fireEvent.click(trigger)
const dialog = screen.getByRole('dialog', {
name: '编辑知识库'
})
const nameInput = screen.getByLabelText('名称')
expect(nameInput).toHaveFocus()
expect(
document.querySelector<HTMLElement>(
'.knowledge-workspace__main'
)?.inert
).toBe(true)
fireEvent.keyDown(nameInput, { key: 'Tab', shiftKey: true })
expect(
screen.getByRole('button', { name: '保存修改' })
).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() => expect(trigger).toHaveFocus())
expect(
document.querySelector<HTMLElement>(
'.knowledge-workspace__main'
)?.inert
).toBe(false)
})
it('confirms that deleting a managed library removes managed copies', async () => {
const onDeleteLibrary = vi.fn()
render(
@@ -1734,8 +1965,18 @@ describe('KnowledgeWorkspace', () => {
name: '删除知识库确认'
})
expect(screen.getByRole('button', { name: '取消' })).toHaveFocus()
expect(
document.querySelector<HTMLElement>(
'.knowledge-workspace__main'
)?.inert
).toBe(true)
fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() => expect(trigger).toHaveFocus())
expect(
document.querySelector<HTMLElement>(
'.knowledge-workspace__main'
)?.inert
).toBe(false)
fireEvent.click(trigger)
expect(
screen.getByText(
File diff suppressed because it is too large Load Diff
@@ -151,8 +151,10 @@ const summaryFromDetail = (
const list = vi.fn<() => Promise<MagicNotesSnapshot>>()
const get = vi.fn<(noteId: string) => Promise<MagicNoteDetail>>()
const listTodos = vi.fn<() => Promise<MagicTodosSnapshot>>()
const create = vi.fn<DesktopApi['magicNotes']['create']>()
const remove = vi.fn<DesktopApi['magicNotes']['remove']>()
const createEntry = vi.fn<DesktopApi['magicNotes']['createEntry']>()
const updateEntry = vi.fn<DesktopApi['magicNotes']['updateEntry']>()
const analyze = vi.fn<DesktopApi['magicNotes']['analyze']>()
const updateTodo = vi.fn<DesktopApi['magicNotes']['updateTodo']>()
const analyzeTodo = vi.fn<DesktopApi['magicNotes']['analyzeTodo']>()
@@ -189,6 +191,7 @@ beforeEach(() => {
list.mockResolvedValue({ notes: [detail] })
get.mockResolvedValue(detail)
listTodos.mockResolvedValue({ todos: [noteTodo, manualTodo] })
create.mockResolvedValue(alternateDetail(thirdNoteId, '新笔记'))
remove.mockResolvedValue()
const createdDetail: MagicNoteDetail = {
...detail,
@@ -213,6 +216,19 @@ beforeEach(() => {
]
}
createEntry.mockResolvedValue(createdDetail)
updateEntry.mockResolvedValue({
...detail,
revision: detail.revision + 1,
entries: detail.entries.map((entry) => ({
...entry,
content: {
version: 1,
ops: [{ insert: '新的句子\n' }]
},
plainText: '新的句子',
revision: entry.revision + 1
}))
})
updateTodo.mockImplementation(async (input) => ({
...noteTodo,
completed: input.completed,
@@ -267,8 +283,10 @@ beforeEach(() => {
list,
get,
listTodos,
create,
remove,
createEntry,
updateEntry,
analyze,
updateTodo,
analyzeTodo,
@@ -586,6 +604,298 @@ describe('MagicNotesWorkspace', () => {
)
})
it('keeps a non-empty composer draft until note switching is confirmed', async () => {
const second = alternateDetail(secondNoteId, '第二篇笔记')
list.mockResolvedValue({
notes: [summaryFromDetail(detail), summaryFromDetail(second)]
})
get.mockImplementation((requestedId) =>
Promise.resolve(requestedId === second.id ? second : detail)
)
render(<MagicNotesWorkspace onNotify={onNotify} />)
await screen.findByText('记录正文')
fireEvent.click(screen.getByText('模拟输入并回车'))
const callsBeforeSwitch = get.mock.calls.length
fireEvent.click(screen.getByText(second.title).closest('button')!)
const confirmation = screen.getByRole('alertdialog', {
name: '放弃当前未保存草稿?'
})
const continueEditing = screen.getByRole('button', {
name: '继续编辑'
})
const discardAndSwitch = screen.getByRole('button', {
name: '放弃草稿并切换'
})
expect(confirmation).toHaveAccessibleDescription(
'切换后,当前记录草稿中的文字和附件将被丢弃。'
)
expect(continueEditing).toHaveFocus()
expect(get).toHaveBeenCalledTimes(callsBeforeSwitch)
expect(
screen.getByRole('button', { name: /发布笔记/ })
).toHaveAttribute('aria-pressed', 'true')
discardAndSwitch.focus()
fireEvent.keyDown(discardAndSwitch, { key: 'Tab' })
expect(continueEditing).toHaveFocus()
fireEvent.keyDown(continueEditing, { key: 'Tab', shiftKey: true })
expect(discardAndSwitch).toHaveFocus()
fireEvent.keyDown(confirmation, { key: 'Escape' })
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
await waitFor(() =>
expect(screen.getByTestId('magic-note-editor')).toHaveFocus()
)
fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
await waitFor(() =>
expect(createEntry).toHaveBeenCalledWith({
noteId,
content: {
version: 1,
ops: [{ insert: '新的句子\n' }]
}
})
)
fireEvent.click(screen.getByText('模拟输入并回车'))
fireEvent.click(screen.getByText(second.title).closest('button')!)
fireEvent.click(
screen.getByRole('button', { name: '放弃草稿并切换' })
)
expect(await screen.findByDisplayValue(second.title)).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /第二篇笔记/ })
).toHaveAttribute('aria-pressed', 'true')
})
it('guards dirty existing-entry edits and keeps the first pending target', async () => {
const second = alternateDetail(secondNoteId, '第二篇笔记')
const third = alternateDetail(thirdNoteId, '第三篇笔记')
list.mockResolvedValue({
notes: [
summaryFromDetail(detail),
summaryFromDetail(second),
summaryFromDetail(third)
]
})
get.mockImplementation((requestedId) =>
Promise.resolve(
requestedId === second.id
? second
: requestedId === third.id
? third
: detail
)
)
const { container } = render(
<MagicNotesWorkspace onNotify={onNotify} />
)
await screen.findByText('记录正文')
fireEvent.click(screen.getByRole('button', { name: '编辑' }))
const editEditor = container.querySelector<HTMLButtonElement>(
'.magic-note-entry__editor [data-testid="magic-note-editor"]'
)!
fireEvent.click(editEditor)
const secondButton = screen.getByText(second.title).closest('button')!
const thirdButton = screen.getByText(third.title).closest('button')!
fireEvent.click(secondButton)
const dialog = screen.getByRole('alertdialog', {
name: '放弃当前未保存草稿?'
})
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(
container.querySelector<HTMLElement>('.magic-notes-list-pane')?.inert
).toBe(true)
fireEvent.click(thirdButton)
fireEvent.keyDown(dialog, { key: 'Escape' })
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
expect(
container.querySelector<HTMLElement>('.magic-notes-list-pane')?.inert
).toBe(false)
await waitFor(() => expect(editEditor).toHaveFocus())
fireEvent.click(secondButton)
fireEvent.click(
screen.getByRole('button', { name: '放弃草稿并切换' })
)
expect(await screen.findByDisplayValue(second.title)).toBeInTheDocument()
await waitFor(() => expect(secondButton).toHaveFocus())
expect(get).not.toHaveBeenCalledWith(third.id)
})
it('guards keyboard tab selection and switches only after discard', async () => {
render(<MagicNotesWorkspace onNotify={onNotify} />)
await screen.findByText('记录正文')
fireEvent.click(screen.getByText('模拟输入并回车'))
const notesTab = screen.getByRole('tab', { name: '笔记' })
const todosTab = screen.getByRole('tab', { name: '待办' })
notesTab.focus()
fireEvent.keyDown(notesTab, { key: 'ArrowRight' })
expect(todosTab).toHaveAttribute('aria-selected', 'false')
expect(notesTab).toHaveAttribute('aria-selected', 'true')
expect(
screen.getByRole('alertdialog', {
name: '放弃当前未保存草稿?'
})
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '放弃草稿并切换' })
)
expect(todosTab).toHaveAttribute('aria-selected', 'true')
expect(screen.getByText('准备演示')).toBeInTheDocument()
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
it('does not create and select another note before the draft is discarded', async () => {
const { container } = render(
<MagicNotesWorkspace onNotify={onNotify} />
)
await screen.findByText('记录正文')
fireEvent.click(screen.getByText('模拟输入并回车'))
fireEvent.click(screen.getByRole('button', { name: '新建笔记' }))
fireEvent.change(
container.querySelector<HTMLInputElement>(
'.magic-notes-create input'
)!,
{
target: { value: '新笔记' }
}
)
fireEvent.click(screen.getByRole('button', { name: '创建笔记' }))
expect(create).not.toHaveBeenCalled()
expect(
screen.getByRole('button', { name: /发布笔记/ })
).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(
screen.getByRole('button', { name: '放弃草稿并切换' })
)
await waitFor(() =>
expect(create).toHaveBeenCalledWith({ title: '新笔记' })
)
expect(await screen.findByDisplayValue('新笔记')).toBeInTheDocument()
})
it('preserves the composer draft across save errors and unrelated rerenders', async () => {
createEntry.mockRejectedValueOnce(new Error('暂时无法保存'))
render(<MagicNotesWorkspace onNotify={onNotify} />)
await screen.findByText('记录正文')
fireEvent.click(screen.getByText('模拟输入并回车'))
await i18n.changeLanguage('en-US')
fireEvent.click(screen.getByRole('button', { name: 'Save entry' }))
await waitFor(() =>
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
tone: 'error',
message: '暂时无法保存'
})
)
)
fireEvent.click(screen.getByRole('button', { name: 'Save entry' }))
await waitFor(() => expect(createEntry).toHaveBeenCalledTimes(2))
expect(createEntry).toHaveBeenNthCalledWith(1, {
noteId,
content: {
version: 1,
ops: [{ insert: '新的句子\n' }]
}
})
expect(createEntry).toHaveBeenNthCalledWith(2, {
noteId,
content: {
version: 1,
ops: [{ insert: '新的句子\n' }]
}
})
await i18n.changeLanguage('zh-CN')
})
it('finalizes a created entry before a to-do refresh failure', async () => {
render(<MagicNotesWorkspace onNotify={onNotify} />)
await screen.findByText('记录正文')
listTodos.mockRejectedValueOnce(new Error('待办刷新失败'))
fireEvent.click(screen.getByText('模拟输入并回车'))
fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
await waitFor(() => expect(createEntry).toHaveBeenCalledOnce())
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
tone: 'success',
message: '记录已保存'
})
)
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
tone: 'error',
message: '待办刷新失败'
})
)
fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
expect(createEntry).toHaveBeenCalledOnce()
expect(screen.getByText('请先输入记录内容')).toBeInTheDocument()
})
it('finalizes an edited entry before a to-do refresh failure', async () => {
const { container } = render(
<MagicNotesWorkspace onNotify={onNotify} />
)
await screen.findByText('记录正文')
listTodos.mockRejectedValueOnce(new Error('待办刷新失败'))
fireEvent.click(screen.getByRole('button', { name: '编辑' }))
fireEvent.click(
container.querySelector<HTMLButtonElement>(
'.magic-note-entry__editor [data-testid="magic-note-editor"]'
)!
)
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => expect(updateEntry).toHaveBeenCalledOnce())
expect(updateEntry).toHaveBeenCalledWith({
entryId,
content: {
version: 1,
ops: [{ insert: '新的句子\n' }]
},
expectedRevision: detail.entries[0]!.revision
})
expect(
screen.queryByRole('button', { name: '保存修改' })
).not.toBeInTheDocument()
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
tone: 'success',
message: '记录已更新,原 AI 评论已清除'
})
)
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
tone: 'error',
message: '待办刷新失败'
})
)
})
it('does not override a note selected while retrying a refresh', async () => {
const second = alternateDetail(secondNoteId, '第二篇笔记')
list.mockResolvedValue({
+350 -59
View File
@@ -19,6 +19,7 @@ import {
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState
@@ -40,6 +41,7 @@ import type { MagicNoteCommentMode } from '../../shared/application-settings-con
import { MagicNoteContent } from './MagicNoteContent'
import { MagicNoteEditor } from './MagicNoteEditor'
import { MarkdownRenderer } from './MarkdownRenderer'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import type { AppNotificationInput } from './notifications'
import {
EmptyState,
@@ -61,6 +63,12 @@ type ValidationTarget =
| 'note-title'
| 'new-entry'
| 'edit-entry'
type DraftSwitchTarget =
| { kind: 'library-view'; value: LibraryView }
| { kind: 'create-note'; title: string }
| { kind: 'edit-entry'; entry: MagicNoteEntry }
| { kind: 'note'; noteId: string; entryId?: string }
| { kind: 'todo'; todoId: string }
const defaultAiPaneWidth = 280
const minimumAiPaneWidth = 240
@@ -117,6 +125,13 @@ function hasContent(content?: MagicNoteRichContent): boolean {
)
}
function richContentEqual(
left: MagicNoteRichContent | undefined,
right: MagicNoteRichContent | undefined
): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}
function errorMessage(error: unknown, fallback: string): string {
if (typeof error === 'string') {
return error
@@ -174,12 +189,14 @@ function AiComment({
function TodoListItem({
disabled,
id,
onSelect,
onToggle,
selected,
todo
}: {
disabled: boolean
id: string
onSelect: () => void
onToggle: () => void
selected: boolean
@@ -214,6 +231,7 @@ function TodoListItem({
<button
aria-pressed={selected}
className="magic-todo-list-item__content"
id={id}
onClick={onSelect}
type="button"
>
@@ -351,6 +369,8 @@ export function MagicNotesWorkspace({
target: ValidationTarget
message: string
}>()
const [pendingDraftSwitch, setPendingDraftSwitch] =
useState<DraftSwitchTarget>()
const detailRequestRef = useRef(0)
const requestedNoteIdRef = useRef('')
const refreshRequestRef = useRef(0)
@@ -374,6 +394,12 @@ export function MagicNotesWorkspace({
const magicNotesLayoutRef = useRef<HTMLDivElement>(null)
const liveAiPaneWidthRef = useRef(defaultAiPaneWidth)
const aiResizePointerIdRef = useRef<number | undefined>(undefined)
const composerRef = useRef<HTMLDivElement>(null)
const continueEditingRef = useRef<HTMLButtonElement>(null)
const discardDraftRef = useRef<HTMLButtonElement>(null)
const discardDraftDialogRef = useRef<HTMLDivElement>(null)
const discardDraftTitleId = useId()
const discardDraftDescriptionId = useId()
const runDraftAnalysisRef = useRef<
(content: MagicNoteRichContent) => Promise<void>
>(async () => undefined)
@@ -716,6 +742,8 @@ export function MagicNotesWorkspace({
window.clearTimeout(draftAnalysisTimerRef.current)
draftAnalysisTimerRef.current = undefined
}
setEditingEntry(undefined)
editingContentRef.current = undefined
setSelectedNoteId(noteId)
applyDetail(nextDetail)
}
@@ -734,6 +762,226 @@ export function MagicNotesWorkspace({
[applyDetail]
)
const discardComposerDraft = useCallback((): void => {
composerContentRef.current = undefined
draftAnalysisArmedRef.current = false
draftAnalysisQueuedRef.current = false
draftAnalysisContextRef.current += 1
setDraftAnalyses([])
if (draftAnalysisTimerRef.current !== undefined) {
window.clearTimeout(draftAnalysisTimerRef.current)
draftAnalysisTimerRef.current = undefined
}
setComposerKey((current) => current + 1)
}, [])
const discardEditingDraft = useCallback((): void => {
setEditingEntry(undefined)
editingContentRef.current = undefined
clearValidation('edit-entry')
}, [clearValidation])
const hasDirtyEditingDraft = useCallback(
(): boolean =>
Boolean(
editingEntry &&
!richContentEqual(
editingContentRef.current,
editingEntry.content
)
),
[editingEntry]
)
const createNote = useCallback(
async (title: string, discardDraft: boolean): Promise<void> => {
const operation = 'create-note'
if (!beginBusy(operation)) {
return
}
try {
const created = await window.goodbuddy.magicNotes.create({
title
})
if (discardDraft) {
discardComposerDraft()
discardEditingDraft()
}
applyDetail(created)
requestedNoteIdRef.current = created.id
setSelectedNoteId(created.id)
setNewTitle('')
setCreating(false)
notifySuccess(t('notifications.noteCreated'))
} catch (createError) {
notifyError(createError)
} finally {
endBusy(operation)
}
},
[
applyDetail,
beginBusy,
discardComposerDraft,
discardEditingDraft,
endBusy,
notifyError,
notifySuccess,
t
]
)
const focusSwitchTarget = useCallback(
(target: DraftSwitchTarget): void => {
requestAnimationFrame(() => {
const focusTarget =
target.kind === 'library-view'
? document.getElementById(`magic-library-tab-${target.value}`)
: target.kind === 'note'
? document.getElementById(
`magic-note-select-${target.noteId}`
)
: target.kind === 'todo'
? document.getElementById(
`magic-todo-select-${target.todoId}`
)
: target.kind === 'edit-entry'
? document
.getElementById(
`magic-note-entry-${target.entry.id}`
)
?.querySelector<HTMLElement>(
'.ql-editor, [data-testid="magic-note-editor"]'
)
: composerRef.current?.querySelector<HTMLElement>(
'.ql-editor, [data-testid="magic-note-editor"]'
)
focusTarget?.focus()
})
},
[]
)
const performDraftSwitch = useCallback(
(target: DraftSwitchTarget): void => {
setPendingDraftSwitch(undefined)
setValidation(undefined)
if (target.kind === 'library-view') {
if (target.value === 'todos') {
discardComposerDraft()
discardEditingDraft()
}
setLibraryView(target.value)
setCreating(false)
setSearch('')
focusSwitchTarget(target)
return
}
if (target.kind === 'todo') {
setSelectedTodoId(target.todoId)
focusSwitchTarget(target)
return
}
if (target.kind === 'edit-entry') {
discardEditingDraft()
setDeletingEntryId('')
setEditingEntry(target.entry)
editingContentRef.current = target.entry.content
focusSwitchTarget(target)
return
}
if (target.kind === 'create-note') {
void createNote(target.title, true)
.then(() => focusSwitchTarget(target))
return
}
setDeletingNote(false)
setLibraryView('notes')
void loadDetail(target.noteId).then(() => {
focusSwitchTarget(target)
if (!target.entryId) {
return
}
requestAnimationFrame(() =>
document
.getElementById(`magic-note-entry-${target.entryId}`)
?.scrollIntoView({ block: 'center' })
)
})
},
[
createNote,
discardComposerDraft,
discardEditingDraft,
focusSwitchTarget,
loadDetail
]
)
const requestDraftSwitch = useCallback(
(target: DraftSwitchTarget): void => {
if (pendingDraftSwitch) {
return
}
const changesContext =
target.kind === 'library-view'
? target.value !== libraryView
: target.kind === 'note'
? target.noteId !== selectedNoteId ||
target.entryId !== undefined
: target.kind === 'todo'
? target.todoId !== selectedTodoId
: target.kind === 'edit-entry'
? target.entry.id !== editingEntry?.id
: true
if (!changesContext) {
return
}
const wouldClearComposer = target.kind !== 'edit-entry'
const wouldClearEditing = target.kind !== 'todo'
if (
(wouldClearComposer && hasContent(composerContentRef.current)) ||
(wouldClearEditing && hasDirtyEditingDraft())
) {
setPendingDraftSwitch(target)
return
}
performDraftSwitch(target)
},
[
editingEntry?.id,
hasDirtyEditingDraft,
libraryView,
pendingDraftSwitch,
performDraftSwitch,
selectedNoteId,
selectedTodoId
]
)
const continueEditing = useCallback((): void => {
setPendingDraftSwitch(undefined)
requestAnimationFrame(() => {
const editor = editingEntry
? document
.getElementById(`magic-note-entry-${editingEntry.id}`)
?.querySelector<HTMLElement>(
'.ql-editor, [data-testid="magic-note-editor"]'
)
: composerRef.current?.querySelector<HTMLElement>(
'.ql-editor, [data-testid="magic-note-editor"]'
)
editor?.focus()
})
}, [editingEntry])
useEffect(() => {
if (!pendingDraftSwitch) {
return
}
return activateModalFocus(() => continueEditingRef.current)
}, [pendingDraftSwitch])
const refreshNotes = useCallback(
async (preferredId?: string): Promise<void> => {
const requestId = ++refreshRequestRef.current
@@ -884,7 +1132,7 @@ export function MagicNotesWorkspace({
const aiPaneWidthLimits = getAiPaneWidthLimits(magicNotesLayoutWidth)
const canResizeAiPane = aiPaneOpen && magicNotesLayoutWidth > 800
const createNote = async (): Promise<void> => {
const submitCreateNote = async (): Promise<void> => {
const title = newTitle.trim()
if (!title) {
setValidation({
@@ -894,25 +1142,10 @@ export function MagicNotesWorkspace({
return
}
clearValidation('create-note')
const operation = 'create-note'
if (!beginBusy(operation)) {
return
}
try {
const created = await window.goodbuddy.magicNotes.create({
title
})
applyDetail(created)
requestedNoteIdRef.current = created.id
setSelectedNoteId(created.id)
setNewTitle('')
setCreating(false)
notifySuccess(t('notifications.noteCreated'))
} catch (createError) {
notifyError(createError)
} finally {
endBusy(operation)
}
requestDraftSwitch({
kind: 'create-note',
title
})
}
const analyzeTodo = async (todoId: string): Promise<void> => {
@@ -1028,8 +1261,8 @@ export function MagicNotesWorkspace({
content: composerContent
})
applyDetail(updated)
await reloadTodos()
composerContentRef.current = undefined
setPendingDraftSwitch(undefined)
draftAnalysisArmedRef.current = false
draftAnalysisQueuedRef.current = false
draftAnalysisContextRef.current += 1
@@ -1040,6 +1273,11 @@ export function MagicNotesWorkspace({
}
setComposerKey((current) => current + 1)
notifySuccess(t('notifications.entrySaved'))
try {
await reloadTodos()
} catch (refreshTodosError) {
notifyError(refreshTodosError)
}
const createdEntry = updated.entries.find(
(entry) => !existingEntryIds.has(entry.id)
)
@@ -1097,10 +1335,14 @@ export function MagicNotesWorkspace({
expectedRevision: editingEntry.revision
})
applyDetail(updated)
await reloadTodos()
setEditingEntry(undefined)
editingContentRef.current = undefined
notifySuccess(t('notifications.entryUpdated'))
try {
await reloadTodos()
} catch (refreshTodosError) {
notifyError(refreshTodosError)
}
if (commentMode === 'after-save-auto') {
const options = await createAnalysisOptions()
setLiveAnalysis({
@@ -1268,12 +1510,12 @@ export function MagicNotesWorkspace({
<PageTabs
ariaLabel={t('page.contentLabel')}
idPrefix="magic-library"
onChange={(value) => {
setLibraryView(value)
setCreating(false)
setValidation(undefined)
setSearch('')
}}
onChange={(value) =>
requestDraftSwitch({
kind: 'library-view',
value
})
}
tabs={libraryTabs}
value={libraryView}
variant="segmented"
@@ -1303,7 +1545,7 @@ export function MagicNotesWorkspace({
className="magic-notes-create"
onSubmit={(event) => {
event.preventDefault()
void createNote()
void submitCreateNote()
}}
>
<label>
@@ -1380,6 +1622,7 @@ export function MagicNotesWorkspace({
visibleNotes.map((note) => (
<button
key={note.id}
id={`magic-note-select-${note.id}`}
aria-pressed={selectedNoteId === note.id}
className={`magic-note-list-item ${
selectedNoteId === note.id
@@ -1387,13 +1630,12 @@ export function MagicNotesWorkspace({
: ''
}`}
type="button"
onClick={() => {
setValidation(undefined)
setDeletingNote(false)
setEditingEntry(undefined)
editingContentRef.current = undefined
void loadDetail(note.id)
}}
onClick={() =>
requestDraftSwitch({
kind: 'note',
noteId: note.id
})
}
>
<span className="magic-note-list-item__title">
{note.pinned && (
@@ -1487,11 +1729,14 @@ export function MagicNotesWorkspace({
{directory.todos.map((todo) => (
<TodoListItem
disabled={busy === `update-todo-${todo.id}`}
id={`magic-todo-select-${todo.id}`}
key={todo.id}
onSelect={() => {
setValidation(undefined)
setSelectedTodoId(todo.id)
}}
onSelect={() =>
requestDraftSwitch({
kind: 'todo',
todoId: todo.id
})
}
onToggle={() =>
void updateTodoCompletion(todo)
}
@@ -1538,7 +1783,12 @@ export function MagicNotesWorkspace({
</span>
<button
className="secondary-button"
onClick={() => void loadDetail(detailLoadError.noteId)}
onClick={() =>
requestDraftSwitch({
kind: 'note',
noteId: detailLoadError.noteId
})
}
type="button"
>
{t('actions.retry')}
@@ -1670,7 +1920,7 @@ export function MagicNotesWorkspace({
</div>
)}
<div className="magic-note-composer">
<div className="magic-note-composer" ref={composerRef}>
<MagicNoteEditor
key={`${detail.id}-${composerKey}`}
ariaDescribedBy={
@@ -1703,6 +1953,52 @@ export function MagicNotesWorkspace({
}
}}
/>
{pendingDraftSwitch && (
<div
aria-describedby={discardDraftDescriptionId}
aria-labelledby={discardDraftTitleId}
aria-modal="true"
className="magic-note-draft-confirmation"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
continueEditing()
return
}
trapTabFocus(event, discardDraftDialogRef.current)
}}
ref={discardDraftDialogRef}
role="alertdialog"
tabIndex={-1}
>
<strong id={discardDraftTitleId}>
{t('confirmations.discardDraftTitle')}
</strong>
<span id={discardDraftDescriptionId}>
{t('confirmations.discardDraftDescription')}
</span>
<div>
<button
className="secondary-button"
onClick={continueEditing}
ref={continueEditingRef}
type="button"
>
{t('actions.continueEditing')}
</button>
<button
className="danger-button"
onClick={() =>
performDraftSwitch(pendingDraftSwitch)
}
ref={discardDraftRef}
type="button"
>
{t('actions.discardAndSwitch')}
</button>
</div>
</div>
)}
{validation?.target === 'new-entry' && (
<p
className="magic-notes-field-error"
@@ -1764,12 +2060,12 @@ export function MagicNotesWorkspace({
<button
className="secondary-button"
type="button"
onClick={() => {
clearValidation('edit-entry')
setDeletingEntryId('')
setEditingEntry(entry)
editingContentRef.current = entry.content
}}
onClick={() =>
requestDraftSwitch({
kind: 'edit-entry',
entry
})
}
>
{t('actions.edit')}
</button>
@@ -1975,18 +2271,13 @@ export function MagicNotesWorkspace({
</p>
<button
className="secondary-button"
onClick={() => {
setLibraryView('notes')
void loadDetail(selectedTodo.noteId).then(() => {
requestAnimationFrame(() =>
document
.getElementById(
`magic-note-entry-${selectedTodo.entryId}`
)
?.scrollIntoView({ block: 'center' })
)
onClick={() =>
requestDraftSwitch({
kind: 'note',
noteId: selectedTodo.noteId,
entryId: selectedTodo.entryId
})
}}
}
type="button"
>
<BookOpen size={14} />
@@ -241,6 +241,8 @@ flowchart LR
)
expect(mermaidMock.initialize).toHaveBeenCalledWith(
expect.objectContaining({
fontFamily:
'"Inter Variable", "Segoe UI Variable", "SF Pro Text", "PingFang SC", "Microsoft YaHei UI", "Noto Sans SC Variable", "Noto Sans CJK SC", "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif',
htmlLabels: false,
maxEdges: 300,
maxTextSize: 20_000,
+1 -1
View File
@@ -192,7 +192,7 @@ function mermaidConfig(darkTheme: boolean): MermaidConfig {
deterministicIds: true,
dompurifyConfig: sharedSanitizerConfig,
fontFamily:
'"Inter Variable", "Noto Sans SC Variable", "Segoe UI Variable", sans-serif',
'"Inter Variable", "Segoe UI Variable", "SF Pro Text", "PingFang SC", "Microsoft YaHei UI", "Noto Sans SC Variable", "Noto Sans CJK SC", "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif',
htmlLabels: false,
logLevel: 'fatal',
maxEdges: MAX_MERMAID_EDGES,
@@ -1,3 +1,4 @@
import { RotateCcw, Save } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
@@ -6,6 +7,12 @@ import type {
ModelDownloadSource
} from '../../shared/application-settings-contracts'
import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts'
import {
canonicalizeShortcutAccelerator,
type GlobalShortcutSettings,
type GlobalShortcutSettingsSnapshot,
type GlobalShortcutUpdateErrorCode
} from '../../shared/shortcut'
import type { AppNotificationInput } from './notifications'
import {
PageTabs,
@@ -19,18 +26,47 @@ import {
type PlatformFeaturesSettingsSectionProps = {
onMagicNotesEnabledChange: (enabled: boolean) => void
onNotify?: (notification: AppNotificationInput) => void
onDirtyChange?: (dirty: boolean) => void
onShortcutSettingsChanged?: (
snapshot: GlobalShortcutSettingsSnapshot
) => void
}
type PlatformFeaturesTab = 'general' | 'magic-notes'
const shortcutErrorTranslationKeys: Record<
GlobalShortcutUpdateErrorCode,
| 'platformFeatures.shortcut.errors.conflict'
| 'platformFeatures.shortcut.errors.registrationFailed'
| 'platformFeatures.shortcut.errors.saveFailed'
> = {
conflict: 'platformFeatures.shortcut.errors.conflict',
'registration-failed':
'platformFeatures.shortcut.errors.registrationFailed',
'save-failed': 'platformFeatures.shortcut.errors.saveFailed'
}
export function PlatformFeaturesSettingsSection({
onMagicNotesEnabledChange,
onNotify
onNotify,
onDirtyChange,
onShortcutSettingsChanged
}: PlatformFeaturesSettingsSectionProps): React.JSX.Element {
const { t } = useTranslation('settingsSections')
const [activeSection, setActiveSection] =
useState<PlatformFeaturesTab>('general')
const [settings, setSettings] = useState<ApplicationSettings>()
const [shortcutSnapshot, setShortcutSnapshot] =
useState<GlobalShortcutSettingsSnapshot>()
const [shortcutDraft, setShortcutDraft] =
useState<GlobalShortcutSettings>()
const [shortcutSaving, setShortcutSaving] = useState(false)
const [shortcutError, setShortcutError] = useState<string | undefined>(
() =>
window.goodbuddy.shortcuts
? undefined
: t('platformFeatures.shortcut.errors.serviceUnavailable')
)
const [saving, setSaving] = useState(false)
const [sourceError, setSourceError] = useState<string>()
const [error, setError] = useState<string | undefined>(() =>
@@ -64,6 +100,139 @@ export function PlatformFeaturesSettingsSection({
}
}, [t])
useEffect(() => {
const shortcuts = window.goodbuddy.shortcuts
let active = true
if (!shortcuts) {
return () => {
active = false
}
}
void shortcuts
.getSettings()
.then((snapshot) => {
if (active) {
setShortcutSnapshot(snapshot)
setShortcutDraft(snapshot.settings)
}
})
.catch(() => {
if (active) {
setShortcutError(
t('platformFeatures.shortcut.errors.readFailed')
)
}
})
return () => {
active = false
}
}, [t])
const shortcutDirty =
shortcutSnapshot !== undefined &&
shortcutDraft !== undefined &&
(shortcutSnapshot.settings.enabled !== shortcutDraft.enabled ||
shortcutSnapshot.settings.accelerator !==
shortcutDraft.accelerator)
useEffect(() => {
onDirtyChange?.(shortcutDirty)
}, [onDirtyChange, shortcutDirty])
const saveShortcut = async (): Promise<void> => {
const shortcuts = window.goodbuddy.shortcuts
if (!shortcuts || !shortcutDraft) {
return
}
let input: GlobalShortcutSettings
try {
input = {
...shortcutDraft,
accelerator: canonicalizeShortcutAccelerator(
shortcutDraft.accelerator
)
}
} catch {
setShortcutError(
t('platformFeatures.shortcut.errors.invalidAccelerator')
)
return
}
setShortcutSaving(true)
setShortcutError(undefined)
try {
setShortcutDraft(input)
const result = await shortcuts.updateSettings(input)
setShortcutSnapshot(result.snapshot)
if (!result.ok) {
setShortcutError(t(shortcutErrorTranslationKeys[result.error]))
return
}
setShortcutDraft(result.snapshot.settings)
onShortcutSettingsChanged?.(result.snapshot)
onNotify?.({
tone: 'success',
message: t('platformFeatures.shortcut.saved'),
dedupeKey: 'global-shortcut-saved'
})
} catch {
setShortcutError(
t('platformFeatures.shortcut.errors.saveFailed')
)
} finally {
setShortcutSaving(false)
}
}
const recordShortcut = (
event: React.KeyboardEvent<HTMLInputElement>
): void => {
if (
['Control', 'Shift', 'Alt', 'Meta'].includes(event.key) ||
event.key === 'Escape' ||
event.key === 'Tab'
) {
return
}
if (
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
) {
return
}
event.preventDefault()
const isMac = shortcutSnapshot?.platform === 'darwin'
const parts = [
event.ctrlKey
? isMac
? 'Control'
: 'CommandOrControl'
: undefined,
event.metaKey
? isMac
? 'Command'
: 'Super'
: undefined,
event.altKey ? 'Alt' : undefined,
event.shiftKey ? 'Shift' : undefined,
event.key === ' ' ? 'Space' : event.key
].filter((part): part is string => Boolean(part))
try {
const accelerator = canonicalizeShortcutAccelerator(
parts.join('+')
)
setShortcutDraft((current) =>
current ? { ...current, accelerator } : current
)
setShortcutError(undefined)
} catch {
setShortcutError(
t('platformFeatures.shortcut.errors.invalidAccelerator')
)
}
}
const changeModelDownloadSource = async (
modelDownloadSource: ModelDownloadSource
): Promise<void> => {
@@ -198,6 +367,112 @@ export function PlatformFeaturesSettingsSection({
id="platform-features-panel-general"
role="tabpanel"
>
<article className="capability-card">
<div className="capability-card__header">
<div>
<strong>{t('platformFeatures.shortcut.title')}</strong>
<small>
{t('platformFeatures.shortcut.description')}
</small>
</div>
</div>
{shortcutDraft && shortcutSnapshot ? (
<>
<label className="toggle-row">
<input
checked={shortcutDraft.enabled}
disabled={shortcutSaving}
onChange={(event) =>
setShortcutDraft({
...shortcutDraft,
enabled: event.target.checked
})
}
role="switch"
type="checkbox"
/>
<span>{t('platformFeatures.shortcut.enabled')}</span>
</label>
<label className="field">
<span>{t('platformFeatures.shortcut.accelerator')}</span>
<input
aria-label={t(
'platformFeatures.shortcut.accelerator'
)}
aria-describedby="global-shortcut-recorder-help"
disabled={!shortcutDraft.enabled || shortcutSaving}
onChange={(event) =>
setShortcutDraft({
...shortcutDraft,
accelerator: event.target.value
})
}
onKeyDown={recordShortcut}
value={shortcutDraft.accelerator}
/>
<small id="global-shortcut-recorder-help">
{t('platformFeatures.shortcut.recorderHelp')}
</small>
</label>
<div className="update-settings__actions">
<button
className="secondary-button"
disabled={shortcutSaving}
onClick={() => {
setShortcutDraft({
...shortcutSnapshot.defaultSettings
})
setShortcutError(undefined)
}}
type="button"
>
<RotateCcw aria-hidden="true" size={13} />
{t('platformFeatures.shortcut.reset')}
</button>
<button
className="primary-button"
disabled={!shortcutDirty || shortcutSaving}
onClick={() => void saveShortcut()}
type="button"
>
<Save aria-hidden="true" size={13} />
{shortcutSaving
? t('platformFeatures.shortcut.saving')
: t('platformFeatures.shortcut.save')}
</button>
</div>
<p
className={
shortcutError
? 'settings-warning'
: 'settings-notice'
}
role={shortcutError ? 'alert' : 'status'}
>
{shortcutError ??
t(
`platformFeatures.shortcut.status.${shortcutSnapshot.status}`,
{
shortcut:
shortcutSnapshot.displayAccelerator
}
)}
</p>
</>
) : (
<p
className={
shortcutError
? 'settings-warning'
: 'settings-notice'
}
role={shortcutError ? 'alert' : 'status'}
>
{shortcutError ??
t('platformFeatures.shortcut.loading')}
</p>
)}
</article>
{settings ? (
<article className="capability-card">
<div className="capability-card__header">
+14 -3
View File
@@ -32,6 +32,7 @@ import {
channelProjectDraft,
ChannelProjectSettingsFields
} from './ChannelProjectSettingsFields'
import { getProjectDisplayText } from './project-display'
type ProjectSwitcherProps = {
projects: AssistantProject[]
@@ -88,6 +89,9 @@ export function ProjectSwitcher({
const activeProject = projects.find(
(project) => project.id === activeProjectId
)
const activeProjectDisplay = activeProject
? getProjectDisplayText(activeProject, t)
: undefined
const userProjects = projects.filter(
(project) => project.kind === 'user'
)
@@ -270,10 +274,13 @@ export function ProjectSwitcher({
}
}}
ref={projectPickerButtonRef}
title={activeProject?.name}
title={activeProjectDisplay?.name}
type="button"
>
<span>{activeProject?.name ?? t('projectSwitcher.selector.empty')}</span>
<span>
{activeProjectDisplay?.name ??
t('projectSwitcher.selector.empty')}
</span>
<ChevronDown aria-hidden="true" size={14} />
</button>
{projectMenuOpen && (
@@ -340,6 +347,10 @@ export function ProjectSwitcher({
{group.projects.map((project) => {
const selected = project.id === activeProjectId
const ProjectIcon = group.icon
const projectDisplay = getProjectDisplayText(
project,
t
)
const detail =
project.kind === 'channel'
? t('projectSwitcher.selector.remoteDetail', {
@@ -372,7 +383,7 @@ export function ProjectSwitcher({
>
<ProjectIcon aria-hidden="true" size={16} />
<span>
<b>{project.name}</b>
<b>{projectDisplay.name}</b>
<small>{detail}</small>
</span>
{selected && (
@@ -1,4 +1,10 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import {
cleanup,
fireEvent,
render,
screen,
waitFor
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
RightAssistantSidebar,
@@ -18,11 +24,17 @@ beforeEach(() => {
function renderSidebar({
tab = 'context',
artifacts = [],
onLoadArtifact = vi.fn(async () => undefined)
onLoadArtifact = vi.fn(async () => undefined),
onClose = vi.fn(),
overlay = false,
restoreFocusRef
}: {
tab?: AssistantSidebarTab
artifacts?: SidebarArtifact[]
onLoadArtifact?: (artifactId: string) => Promise<void>
onClose?: () => void
overlay?: boolean
restoreFocusRef?: { current: HTMLElement | null }
} = {}): HTMLElement {
render(
<RightAssistantSidebar
@@ -35,7 +47,7 @@ function renderSidebar({
tasks={[]}
conversationTitles={new Map()}
projectNames={new Map()}
onClose={vi.fn()}
onClose={onClose}
onCreateCustomTask={vi.fn()}
onImportArtifacts={vi.fn(async () => undefined)}
onListWorkspaceDirectory={vi.fn(async (path: string) => ({
@@ -57,11 +69,13 @@ function renderSidebar({
onStopBrowser={vi.fn(async () => undefined)}
onTabChange={vi.fn()}
open
overlay={overlay}
restoreFocusRef={restoreFocusRef}
tab={tab}
/>
)
return screen.getByRole('complementary', {
return screen.getByRole(overlay ? 'dialog' : 'complementary', {
name: '助手工作栏'
})
}
@@ -166,6 +180,27 @@ describe('RightAssistantSidebar resizing', () => {
).not.toBeInTheDocument()
})
it('treats overlay mode as a focus-trapped dismissible dialog', async () => {
const onClose = vi.fn()
const trigger = document.createElement('button')
document.body.append(trigger)
const sidebar = renderSidebar({
onClose,
overlay: true,
restoreFocusRef: { current: trigger }
})
expect(sidebar).toHaveAttribute('aria-modal', 'true')
await waitFor(() =>
expect(
screen.getByRole('tab', { name: '上下文' })
).toHaveFocus()
)
fireEvent.keyDown(sidebar, { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
trigger.remove()
})
it('keeps the product Task index in the task center', () => {
renderSidebar({ tab: 'tasks' })
@@ -33,6 +33,7 @@ import type {
KnowledgeLibrary
} from '../../shared/contracts'
import { WorkspaceFilesPanel } from './WorkspaceFilesPanel'
import { trapTabFocus } from './dialog-focus'
import { SegmentedControl } from './WorkspacePrimitives'
import {
findTaskSchedule,
@@ -79,6 +80,8 @@ type RightAssistantSidebarProps = {
workspaceChanges?: WorkspaceChanges
workspaceProjectId?: string
browserState?: BrowserLiveState
overlay?: boolean
restoreFocusRef?: { current: HTMLElement | null }
onClose: () => void
onInteractBrowser: () => Promise<void>
onStopBrowser: () => Promise<void>
@@ -156,6 +159,8 @@ export function RightAssistantSidebar({
workspaceChanges,
workspaceProjectId,
browserState,
overlay = false,
restoreFocusRef,
onClose,
onInteractBrowser,
onStopBrowser,
@@ -197,6 +202,8 @@ export function RightAssistantSidebar({
const [sidebarWidth, setSidebarWidth] = useState(defaultSidebarWidth)
const [isResizing, setIsResizing] = useState(false)
const sidebarRef = useRef<HTMLElement>(null)
const wasOpen = useRef(false)
const wasOverlayOpen = useRef(false)
const liveSidebarWidth = useRef(defaultSidebarWidth)
const resizePointerId = useRef<number | undefined>(undefined)
const [selectedArtifactId, setSelectedArtifactId] = useState<string>()
@@ -287,6 +294,24 @@ export function RightAssistantSidebar({
return () => window.removeEventListener('resize', handleViewportResize)
}, [])
useEffect(() => {
if (open && overlay && !wasOverlayOpen.current) {
const focusFrame = requestAnimationFrame(() => {
document
.getElementById(`assistant-sidebar-tab-${tab}`)
?.focus()
})
wasOpen.current = open
wasOverlayOpen.current = true
return () => cancelAnimationFrame(focusFrame)
}
if (!open && wasOpen.current) {
requestAnimationFrame(() => restoreFocusRef?.current?.focus())
}
wasOpen.current = open
wasOverlayOpen.current = open && overlay
}, [open, overlay, restoreFocusRef, tab])
const resizeFromClientX = (
clientX: number,
commit: boolean
@@ -430,12 +455,25 @@ export function RightAssistantSidebar({
ref={sidebarRef}
aria-label={t('sidebar.ariaLabel')}
aria-hidden={!open}
aria-modal={open && overlay ? 'true' : undefined}
className={
open
? `assistant-sidebar assistant-sidebar--open${isResizing && canResize ? ' assistant-sidebar--resizing' : ''}`
: 'assistant-sidebar'
}
inert={!open}
onKeyDown={(event) => {
if (!open || !overlay) {
return
}
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
trapTabFocus(event, sidebarRef.current)
}}
role={open && overlay ? 'dialog' : undefined}
style={
{
'--assistant-sidebar-width': `${sidebarWidth}px`
@@ -33,6 +33,7 @@ type RuntimeCustomizationSectionProps = {
export type RuntimeCustomizationSectionHandle = {
save: () => Promise<boolean>
discard: () => void
}
type RuntimeCustomizationError = {
@@ -534,9 +535,7 @@ export const RuntimeCustomizationSection = forwardRef<
}
}, [settings, settingsDirty, t])
useImperativeHandle(ref, () => ({ save }), [save])
const discardChanges = (): void => {
const discardChanges = useCallback((): void => {
if (!persistedSettings) {
return
}
@@ -547,7 +546,13 @@ export const RuntimeCustomizationSection = forwardRef<
''
)
setError(undefined)
}
}, [persistedSettings])
useImperativeHandle(
ref,
() => ({ save, discard: discardChanges }),
[discardChanges, save]
)
const addPreset = (): void => {
if (
+286 -1
View File
@@ -20,6 +20,9 @@ import type {
CapabilitySnapshot
} from '../../shared/capability-contracts'
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
import type {
GlobalShortcutSettingsSnapshot
} from '../../shared/shortcut'
import type {
EmbeddingDiagnosticResult,
EmbeddingSettingsSnapshot
@@ -27,7 +30,10 @@ import type {
import type { SpeechModelSnapshot } from '../../shared/speech-model-contracts'
import { builtinMcpServers } from '../../shared/builtin-mcp-servers'
import { builtinModelToolGroups } from '../../shared/builtin-model-tools'
import { SettingsPanel } from './SettingsPanel'
import {
SettingsPanel,
type SettingsLeaveRequester
} from './SettingsPanel'
import {
RuntimeCustomizationSection,
type RuntimeCustomizationSectionHandle
@@ -413,6 +419,37 @@ const updateApplicationSettings = vi.fn<
}
return { ...applicationSettings }
})
let shortcutSettingsSnapshot: GlobalShortcutSettingsSnapshot = {
settings: {
enabled: true,
accelerator: 'CommandOrControl+Shift+Space'
},
defaultSettings: {
enabled: true,
accelerator: 'CommandOrControl+Shift+Space'
},
platform: 'win32',
displayAccelerator: 'Ctrl+Shift+Space',
registered: true,
registeredAccelerator: 'CommandOrControl+Shift+Space',
status: 'registered'
}
const getShortcutSettings = vi.fn(async () => shortcutSettingsSnapshot)
const updateShortcutSettings = vi.fn<
NonNullable<DesktopApi['shortcuts']>['updateSettings']
>(async (input) => {
shortcutSettingsSnapshot = {
...shortcutSettingsSnapshot,
settings: input,
displayAccelerator: input.accelerator,
registered: input.enabled,
registeredAccelerator: input.enabled
? input.accelerator
: undefined,
status: input.enabled ? 'registered' : 'disabled'
}
return { ok: true, snapshot: shortcutSettingsSnapshot }
})
const speechCatalog: SpeechModelSnapshot['catalog'] = [
{
id: 'sensevoice-small-int8',
@@ -592,6 +629,37 @@ describe('SettingsPanel runtime files', () => {
magicNoteCommentFormat: 'combined'
}
speechModelSnapshot = createSpeechModelSnapshot()
shortcutSettingsSnapshot = {
settings: {
enabled: true,
accelerator: 'CommandOrControl+Shift+Space'
},
defaultSettings: {
enabled: true,
accelerator: 'CommandOrControl+Shift+Space'
},
platform: 'win32',
displayAccelerator: 'Ctrl+Shift+Space',
registered: true,
registeredAccelerator: 'CommandOrControl+Shift+Space',
status: 'registered'
}
getShortcutSettings.mockImplementation(
async () => shortcutSettingsSnapshot
)
updateShortcutSettings.mockImplementation(async (input) => {
shortcutSettingsSnapshot = {
...shortcutSettingsSnapshot,
settings: input,
displayAccelerator: input.accelerator,
registered: input.enabled,
registeredAccelerator: input.enabled
? input.accelerator
: undefined,
status: input.enabled ? 'registered' : 'disabled'
}
return { ok: true, snapshot: shortcutSettingsSnapshot }
})
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
@@ -668,6 +736,10 @@ describe('SettingsPanel runtime files', () => {
check: vi.fn(),
openReleasePage: vi.fn(),
onResult: vi.fn(() => () => {})
},
shortcuts: {
getSettings: getShortcutSettings,
updateSettings: updateShortcutSettings
}
} as unknown as DesktopApi
})
@@ -1167,6 +1239,12 @@ describe('SettingsPanel runtime files', () => {
expect(
screen.queryByText('当前选择:ModelScope')
).not.toBeInTheDocument()
expect(
await screen.findByText('全局快捷唤起')
).toBeInTheDocument()
expect(screen.getByLabelText('快捷键')).toHaveValue(
'CommandOrControl+Shift+Space'
)
})
it('keeps the confirmed model download source when saving fails', async () => {
@@ -1495,6 +1573,213 @@ describe('SettingsPanel runtime files', () => {
)
})
it('records and saves the global shortcut in General settings', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
expect(
screen.getByRole('tab', { name: '平台功能' })
).toHaveAttribute('aria-selected', 'true')
await waitFor(() =>
expect(getShortcutSettings).toHaveBeenCalled()
)
await waitFor(() =>
expect(
screen.queryByText('正在读取快捷键状态…')
).not.toBeInTheDocument()
)
expect(
await screen.findByText('全局快捷唤起')
).toBeInTheDocument()
expect(
screen.queryByText('读取快捷键设置失败,请重试')
).not.toBeInTheDocument()
const accelerator = await screen.findByLabelText('快捷键')
expect(accelerator).toHaveValue(
'CommandOrControl+Shift+Space'
)
fireEvent.keyDown(accelerator, {
key: 'k',
ctrlKey: true,
altKey: true
})
expect(accelerator).toHaveValue('CommandOrControl+Alt+K')
const platformTab = screen.getByRole('tab', {
name: '平台功能'
})
fireEvent.keyDown(platformTab, { key: 'ArrowRight' })
expect(platformTab).toHaveAttribute('aria-selected', 'true')
fireEvent.click(
screen.getByRole('button', { name: '保存快捷键' })
)
await waitFor(() =>
expect(updateShortcutSettings).toHaveBeenCalledWith({
enabled: true,
accelerator: 'CommandOrControl+Alt+K'
})
)
expect(
await screen.findByText('已注册:CommandOrControl+Alt+K')
).toBeInTheDocument()
})
it('records physical Control separately from Command on macOS', async () => {
shortcutSettingsSnapshot = {
...shortcutSettingsSnapshot,
platform: 'darwin',
displayAccelerator: 'Command+Shift+Space'
}
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const accelerator = await screen.findByLabelText('快捷键')
fireEvent.keyDown(accelerator, {
key: 'k',
ctrlKey: true
})
expect(accelerator).toHaveValue('Control+K')
fireEvent.keyDown(accelerator, {
key: 'k',
ctrlKey: true,
metaKey: true
})
expect(accelerator).toHaveValue('Control+Command+K')
})
it('preserves runtime drafts across navigation and protects them on close', async () => {
const onClose = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={onClose}
onSaved={vi.fn()}
/>
)
const workspace = await screen.findByLabelText('默认工作区目录')
fireEvent.change(workspace, {
target: { value: 'C:\\Unsaved workspace' }
})
const runtimeTab = screen.getByRole('tab', {
name: 'Agent Runtime'
})
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
expect(
screen.getByRole('tab', { name: '外观' })
).toHaveAttribute('aria-selected', 'true')
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }))
expect(onClose).not.toHaveBeenCalled()
expect(
screen.getByRole('alert')
).toHaveTextContent('当前设置有未保存更改')
fireEvent.click(
screen.getByRole('button', { name: '继续编辑' })
)
fireEvent.click(runtimeTab)
expect(await screen.findByLabelText('默认工作区目录')).toHaveValue(
'C:\\Unsaved workspace'
)
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }))
expect(
screen.getByRole('button', { name: '放弃更改并关闭' })
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '放弃更改并关闭' })
)
expect(onClose).toHaveBeenCalledOnce()
})
it('routes external leave requests through the existing dirty confirmation', async () => {
let requestLeave: SettingsLeaveRequester | undefined
const proceed = vi.fn()
const onClose = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
onLeaveRequestReady={(requester) => {
requestLeave = requester
}}
open
onClearLocalData={vi.fn(async () => {})}
onClose={onClose}
onSaved={vi.fn()}
/>
)
fireEvent.change(await screen.findByLabelText('默认工作区目录'), {
target: { value: 'C:\\Pending external navigation' }
})
act(() => requestLeave?.(proceed))
expect(proceed).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByRole('alert')).toHaveTextContent(
'当前设置有未保存更改'
)
fireEvent.click(
screen.getByRole('button', { name: '放弃更改并关闭' })
)
expect(proceed).toHaveBeenCalledOnce()
expect(onClose).not.toHaveBeenCalled()
})
it('keeps shortcut input after a registration conflict', async () => {
updateShortcutSettings.mockResolvedValueOnce({
ok: false,
error: 'conflict',
snapshot: shortcutSettingsSnapshot
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
await waitFor(() =>
expect(getShortcutSettings).toHaveBeenCalled()
)
const accelerator = await screen.findByLabelText('快捷键')
fireEvent.change(accelerator, {
target: { value: 'Control+Alt+K' }
})
fireEvent.click(
screen.getByRole('button', { name: '保存快捷键' })
)
expect(
await screen.findByText(/已被其他应用占用/u)
).toBeInTheDocument()
expect(accelerator).toHaveValue('Control+Alt+K')
})
it('keeps a speech model draft when saving the selection fails', async () => {
selectSpeechModel.mockRejectedValueOnce(
new Error('语音模型切换失败')
+301 -58
View File
@@ -9,7 +9,13 @@ import {
Trash2,
X
} from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import type {
AssistantExpert,
@@ -63,6 +69,8 @@ import type {
EmbeddingConfigurationSummary
} from '../../shared/embedding-contracts'
import { useUiLocale } from './i18n/UiLocaleProvider'
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
import { findPreferredCompatibleModelProfile } from './model-profile-selection'
type ModelType = 'llm' | 'embedding' | 'rerank' | 'speech'
type AgentRuntimeType =
@@ -156,6 +164,8 @@ function normalizeContextCompressionTokenDrafts(
}
}
export type SettingsLeaveRequester = (proceed: () => void) => void
type SettingsPanelProps = {
open: boolean
presentation?: 'modal' | 'page'
@@ -175,6 +185,12 @@ type SettingsPanelProps = {
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
magicNotesEnabled?: boolean
onMagicNotesEnabledChange?: (enabled: boolean) => void
onShortcutSettingsChanged?: (
snapshot: GlobalShortcutSettingsSnapshot
) => void
onLeaveRequestReady?: (
requester: SettingsLeaveRequester | undefined
) => void
}
function settingsErrorMessage(reason: unknown, fallback: string): string {
@@ -525,7 +541,9 @@ export function SettingsPanel({
appearanceTheme = 'system',
onAppearanceThemeChange = () => {},
magicNotesEnabled = false,
onMagicNotesEnabledChange = () => {}
onMagicNotesEnabledChange = () => {},
onShortcutSettingsChanged = () => {},
onLeaveRequestReady = () => {}
}: SettingsPanelProps): React.JSX.Element | null {
const { i18n, t } = useTranslation('settings')
const {
@@ -647,6 +665,15 @@ export function SettingsPanel({
useState<AgentRuntimeType>('opencode')
const [runtimeCustomizationDirty, setRuntimeCustomizationDirty] =
useState(false)
const [platformFeaturesDirty, setPlatformFeaturesDirty] =
useState(false)
const [documentParsingDirty, setDocumentParsingDirty] =
useState(false)
const [pendingLeave, setPendingLeave] = useState<
| { kind: 'close' }
| { kind: 'navigate'; category: SettingsCategoryId }
| { kind: 'external'; proceed: () => void }
>()
const runtimeCustomizationRef =
useRef<RuntimeCustomizationSectionHandle>(null)
const handleRuntimeCustomizationDirtyChange = useCallback(
@@ -725,6 +752,141 @@ export function SettingsPanel({
activeTab === 'mcp' ||
activeTab === 'about'
const savedConfiguredSettings = settings
? configuredRuntimeSettings(settings)
: undefined
const runtimeDraftDirty =
settings !== undefined &&
JSON.stringify({
provider,
modelProfiles,
defaultModelProfileId,
opencodeModelSource,
continueModelSource,
deepseekHarnessModelSource,
opencodeBaseUrl,
opencodeBinaryPath,
opencodeConfigPath,
continueBinaryPath,
continueConfigPath,
continueMode,
knowledgeEmbeddingEnabled,
knowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel,
knowledgeEmbeddingApiKey,
clearKnowledgeEmbeddingApiKey,
knowledgeRerankEnabled,
knowledgeRerankEndpoint,
knowledgeRerankModel,
knowledgeRerankApiKey,
clearKnowledgeRerankApiKey,
workspacePath,
toolApproval,
subagentSmartRoutingEnabled,
contextCompression,
contextCompressionTokenInput
}) !==
JSON.stringify({
provider: settings.provider,
modelProfiles: toModelProfileDrafts(settings),
defaultModelProfileId: settings.defaultModelProfileId,
opencodeModelSource:
savedConfiguredSettings?.opencodeModelSource,
continueModelSource:
savedConfiguredSettings?.continueModelSource,
deepseekHarnessModelSource:
savedConfiguredSettings?.deepseekHarnessModelSource ?? {
kind: 'platform'
},
opencodeBaseUrl: savedConfiguredSettings?.opencodeBaseUrl,
opencodeBinaryPath:
savedConfiguredSettings?.opencodeBinaryPath,
opencodeConfigPath:
savedConfiguredSettings?.opencodeConfigPath,
continueBinaryPath:
savedConfiguredSettings?.continueBinaryPath,
continueConfigPath:
savedConfiguredSettings?.continueConfigPath,
continueMode: settings.continueMode,
knowledgeEmbeddingEnabled:
settings.knowledgeEmbeddingEnabled,
knowledgeEmbeddingBaseUrl:
settings.knowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel: settings.knowledgeEmbeddingModel,
knowledgeEmbeddingApiKey: '',
clearKnowledgeEmbeddingApiKey: false,
knowledgeRerankEnabled:
settings.knowledgeRerankEnabled ??
defaultRuntimeSettings.knowledgeRerankEnabled,
knowledgeRerankEndpoint:
settings.knowledgeRerankEndpoint ??
defaultRuntimeSettings.knowledgeRerankEndpoint,
knowledgeRerankModel:
settings.knowledgeRerankModel ??
defaultRuntimeSettings.knowledgeRerankModel,
knowledgeRerankApiKey: '',
clearKnowledgeRerankApiKey: false,
workspacePath: savedConfiguredSettings?.workspacePath,
toolApproval:
settings.toolApproval === 'policy' ? 'policy' : 'always',
subagentSmartRoutingEnabled:
settings.subagentSmartRoutingEnabled,
contextCompression:
settings.contextCompression ??
defaultContextCompressionSettings,
contextCompressionTokenInput: contextCompressionTokenDrafts(
settings.contextCompression ??
defaultContextCompressionSettings
)
})
const hasUnsavedDrafts =
runtimeDraftDirty ||
speechModelSelectionDirty ||
runtimeCustomizationDirty ||
platformFeaturesDirty ||
documentParsingDirty
const navigationWouldLoseDraft =
runtimeCustomizationDirty ||
platformFeaturesDirty ||
documentParsingDirty
const unsavedCloseMessage =
runtimeCustomizationDirty &&
!runtimeDraftDirty &&
!speechModelSelectionDirty &&
!platformFeaturesDirty &&
!documentParsingDirty
? t('runtime.customization.unsavedClose')
: t('unsaved.close')
useEffect(() => {
if (!open || !hasUnsavedDrafts) {
return
}
const preventUnload = (event: BeforeUnloadEvent): void => {
event.preventDefault()
event.returnValue = ''
}
window.addEventListener('beforeunload', preventUnload)
return () =>
window.removeEventListener('beforeunload', preventUnload)
}, [hasUnsavedDrafts, open])
const requestTabChange = (
category: SettingsCategoryId
): boolean => {
if (category === activeTab) {
return true
}
if (navigationWouldLoseDraft) {
setPendingLeave({ kind: 'navigate', category })
return false
}
setPendingLeave(undefined)
setError(undefined)
setActiveTab(category)
return true
}
const handleTabKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>,
tab: SettingsCategoryId
@@ -747,8 +909,9 @@ export function SettingsPanel({
}
event.preventDefault()
const nextTab = settingsTabs[nextIndex]!
setError(undefined)
setActiveTab(nextTab)
if (!requestTabChange(nextTab)) {
return
}
event.currentTarget.parentElement
?.querySelector<HTMLButtonElement>(
`#settings-tab-${nextTab}`
@@ -770,6 +933,9 @@ export function SettingsPanel({
setSpeechModelDraftId(undefined)
setPersistedSpeechModelId(undefined)
setSpeechModelSelectionDirty(false)
setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false)
setPendingLeave(undefined)
setAgentRuntimeType('opencode')
hydrateSettings(value)
})
@@ -831,10 +997,6 @@ export function SettingsPanel({
}
}, [i18n, open])
if (!open) {
return null
}
const normalizedContextCompression =
normalizeContextCompressionTokenDrafts(
contextCompression,
@@ -852,11 +1014,14 @@ export function SettingsPanel({
}
const close = (): void => {
if (runtimeCustomizationDirty) {
setActiveTab('runtime')
setError(t('runtime.customization.unsavedClose'))
if (hasUnsavedDrafts) {
setPendingLeave({ kind: 'close' })
return
}
closeImmediately()
}
const resetSensitiveDrafts = (): void => {
setModelProfiles((profiles) =>
profiles.map((profile) => ({
...profile,
@@ -871,10 +1036,66 @@ export function SettingsPanel({
setSpeechModelDraftId(undefined)
setPersistedSpeechModelId(undefined)
setSpeechModelSelectionDirty(false)
setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false)
setPendingLeave(undefined)
setError(undefined)
}
const closeImmediately = (): void => {
resetSensitiveDrafts()
onClose()
}
const leaveSettings = (proceed: () => void): void => {
resetSensitiveDrafts()
proceed()
}
const discardAndLeave = (): void => {
if (settings) {
hydrateSettings(settings)
}
setSpeechModelDraftId(persistedSpeechModelId)
setSpeechModelSelectionDirty(false)
runtimeCustomizationRef.current?.discard()
setRuntimeCustomizationDirty(false)
setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false)
const leave = pendingLeave
setPendingLeave(undefined)
setError(undefined)
if (leave?.kind === 'navigate') {
setActiveTab(leave.category)
return
}
if (leave?.kind === 'external') {
setActiveTab('runtime')
leaveSettings(leave.proceed)
return
}
if (leave?.kind === 'close') {
closeImmediately()
}
}
useLayoutEffect(() => {
const requestLeave: SettingsLeaveRequester = (proceed) => {
if (hasUnsavedDrafts) {
setPendingLeave({ kind: 'external', proceed })
return
}
resetSensitiveDrafts()
proceed()
}
onLeaveRequestReady(requestLeave)
return () => onLeaveRequestReady(undefined)
}, [hasUnsavedDrafts, onLeaveRequestReady])
if (!open) {
return null
}
const save = async (
notifySuccess = true
): Promise<RuntimeSettings | undefined> => {
@@ -996,6 +1217,7 @@ export function SettingsPanel({
selectedSpeechModelId = speechSnapshot.selectedModelId
}
hydrateSettings(value, true)
setPendingLeave(undefined)
if (speechModelSelectionDirty) {
setSpeechModelDraftId(selectedSpeechModelId)
setPersistedSpeechModelId(selectedSpeechModelId)
@@ -1215,15 +1437,12 @@ export function SettingsPanel({
(profile) => profile.id === id
)
const remaining = modelProfiles.filter((profile) => profile.id !== id)
const compatibleFallback =
remaining.find(
(profile) =>
profile.id === defaultModelProfileId &&
isAgentRuntimeModelProtocol(profile.protocol)
) ??
remaining.find((profile) =>
const compatibleFallback = findPreferredCompatibleModelProfile(
remaining,
defaultModelProfileId,
(profile) =>
isAgentRuntimeModelProtocol(profile.protocol)
)
)
const runtimeFallback: RuntimeModelSource = compatibleFallback
? { kind: 'profile', profileId: compatibleFallback.id }
: { kind: 'platform' }
@@ -1255,13 +1474,10 @@ export function SettingsPanel({
deepseekHarnessModelSource.kind === 'profile' &&
deepseekHarnessModelSource.profileId === id
) {
const harnessFallback = remaining.find(
(profile) =>
profile.id === defaultModelProfileId &&
profile.protocol === 'openai-chat-completions'
) ?? remaining.find(
(profile) =>
profile.protocol === 'openai-chat-completions'
const harnessFallback = findPreferredCompatibleModelProfile(
remaining,
defaultModelProfileId,
(profile) => profile.protocol === 'openai-chat-completions'
)
setDeepseekHarnessModelSource(
harnessFallback
@@ -1284,13 +1500,12 @@ export function SettingsPanel({
profile: ModelProfileDraft
): void => {
const previousDefaultProfileId = defaultModelProfileId
const compatibleProfile = isAgentRuntimeModelProtocol(
profile.protocol
const compatibleProfile = findPreferredCompatibleModelProfile(
modelProfiles,
profile.id,
(candidate) =>
isAgentRuntimeModelProtocol(candidate.protocol)
)
? profile
: modelProfiles.find((candidate) =>
isAgentRuntimeModelProtocol(candidate.protocol)
)
const nextRuntimeSource: RuntimeModelSource = compatibleProfile
? { kind: 'profile', profileId: compatibleProfile.id }
: { kind: 'platform' }
@@ -1341,21 +1556,18 @@ export function SettingsPanel({
(profile) => profile.id === selectedModelProfileId
) ?? modelProfiles[0]
const defaultTextModelProfile =
modelProfiles.find(
findPreferredCompatibleModelProfile(
modelProfiles,
defaultModelProfileId,
(profile) =>
profile.id === defaultModelProfileId &&
isAgentRuntimeModelProtocol(profile.protocol)
) ??
modelProfiles.find((profile) =>
isAgentRuntimeModelProtocol(profile.protocol)
)
const defaultDeepseekHarnessModelProfile =
modelProfiles.find(
(profile) =>
profile.id === defaultModelProfileId &&
isDeepseekHarnessCompatible(profile)
) ??
modelProfiles.find(isDeepseekHarnessCompatible)
findPreferredCompatibleModelProfile(
modelProfiles,
defaultModelProfileId,
isDeepseekHarnessCompatible
)
const activeRuntimeModelSource =
agentRuntimeType === 'opencode'
? opencodeModelSource
@@ -1440,8 +1652,7 @@ export function SettingsPanel({
id={`settings-tab-${category.id}`}
key={category.id}
onClick={() => {
setError(undefined)
setActiveTab(category.id)
requestTabChange(category.id)
}}
onKeyDown={(event) =>
handleTabKeyDown(event, category.id)
@@ -1469,6 +1680,39 @@ export function SettingsPanel({
ref={settingsBodyRef}
role="tabpanel"
>
{pendingLeave && (
<div
className="settings-warning"
role="alert"
>
<p>
{pendingLeave.kind !== 'navigate'
? unsavedCloseMessage
: t('unsaved.navigation')}
</p>
<div className="update-settings__actions">
<button
className="secondary-button"
onClick={() => {
setPendingLeave(undefined)
setError(undefined)
}}
type="button"
>
{t('unsaved.keepEditing')}
</button>
<button
className="danger-button"
onClick={discardAndLeave}
type="button"
>
{pendingLeave.kind !== 'navigate'
? t('unsaved.discardAndClose')
: t('unsaved.discardAndNavigate')}
</button>
</div>
</div>
)}
{!categoryRendersOwnHeader && (
<SettingsCategoryHeader
actions={
@@ -1609,8 +1853,10 @@ export function SettingsPanel({
)}
{activeTab === 'platform-features' && (
<PlatformFeaturesSettingsSection
onDirtyChange={setPlatformFeaturesDirty}
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
onNotify={onNotify}
onShortcutSettingsChanged={onShortcutSettingsChanged}
/>
)}
{activeTab === 'runtime' && (
@@ -2423,17 +2669,13 @@ export function SettingsPanel({
.value as ModelProfileDraft['protocol']
updateModelProfile(profile.id, { protocol })
const compatibleFallback =
modelProfiles.find(
findPreferredCompatibleModelProfile(
modelProfiles.filter(
(candidate) =>
candidate.id !== profile.id
),
defaultModelProfileId,
(candidate) =>
candidate.id !== profile.id &&
candidate.id === defaultModelProfileId &&
isAgentRuntimeModelProtocol(
candidate.protocol
)
) ??
modelProfiles.find(
(candidate) =>
candidate.id !== profile.id &&
isAgentRuntimeModelProtocol(
candidate.protocol
)
@@ -2926,7 +3168,7 @@ export function SettingsPanel({
<SpeechModelSettingsSection
onNotify={onNotify}
onOpenModelDownloadSourceSettings={() =>
setActiveTab('platform-features')
requestTabChange('platform-features')
}
onSelectedModelIdChange={(modelId, changed) => {
setSpeechModelDraftId(modelId)
@@ -3074,7 +3316,7 @@ export function SettingsPanel({
className="secondary-button"
onClick={() => {
setModelType('llm')
setActiveTab('model')
requestTabChange('model')
}}
type="button"
>
@@ -3124,9 +3366,10 @@ export function SettingsPanel({
{activeTab === 'document-parsing' && (
<DocumentParsingSettingsSection
onDirtyChange={setDocumentParsingDirty}
onNotify={onNotify}
onOpenModelDownloadSourceSettings={() =>
setActiveTab('platform-features')
requestTabChange('platform-features')
}
/>
)}
+124 -7
View File
@@ -15,7 +15,8 @@ import {
PageHeader,
PageShell,
PageTabs,
SegmentedControl
SegmentedControl,
ScopeBadge
} from './WorkspacePrimitives'
const stylesheet = readFileSync(
@@ -92,6 +93,10 @@ describe('WorkspacePrimitives', () => {
)
expect(stylesheet).toMatch(/--font-body:\s*13px/u)
expect(stylesheet).toMatch(/--font-caption:\s*11px/u)
expect(stylesheet).toMatch(
/--text-disabled:\s*#[\da-f]{6};/iu
)
expect(stylesheet).toMatch(/--z-dialog:\s*130;/u)
expect(stylesheet).toMatch(/font-synthesis:\s*style/u)
expect(stylesheet).toContain(
'"Inter Variable", "Segoe UI Variable", "SF Pro Text", "PingFang SC"'
@@ -119,6 +124,76 @@ describe('WorkspacePrimitives', () => {
)
})
it('exposes an unavailable scope explanation to assistive technology', () => {
render(
<ScopeBadge
scope={{
kind: 'unavailable',
explanation: '项目已归档,请选择其他项目。'
}}
/>
)
expect(
screen.getByText('范围不可用')
).toHaveAccessibleDescription('项目已归档,请选择其他项目。')
})
it('labels multi-project scope distinctly from mixed global scope', () => {
const { rerender } = render(
<ScopeBadge
scope={{
kind: 'projects',
projectCount: 2
}}
/>
)
expect(screen.getByLabelText('2 个项目')).toHaveTextContent(
'2 个项目'
)
expect(screen.queryByText(/全局/u)).not.toBeInTheDocument()
rerender(<ScopeBadge scope={{ kind: 'mixed' }} />)
expect(screen.getByLabelText('项目 + 全局')).toBeInTheDocument()
})
it('keeps forced-color and narrow assistant overrides authoritative', () => {
const forcedColorsStart = stylesheet.indexOf(
'@media (forced-colors: active)'
)
const forcedColorsEnd = stylesheet.indexOf(
'@media (prefers-reduced-motion: reduce)',
forcedColorsStart
)
const forcedColors = stylesheet.slice(
forcedColorsStart,
forcedColorsEnd
)
expect(forcedColors).toContain(':root :is(')
expect(forcedColors).not.toContain(':where(')
expect(forcedColors).toMatch(
/html:root\s+:is\(\s*\.assistant-sidebar__tab--active,\s*\.page-tabs__tab--active\s*\),\s*html:root\[data-theme='dark'\]\s+:is\(\s*\.assistant-sidebar__tab--active,\s*\.page-tabs__tab--active\s*\)\s*\{[^}]*border:\s*2px solid Highlight;[^}]*background:\s*Highlight;[^}]*color:\s*HighlightText;[^}]*forced-color-adjust:\s*none;/u
)
expect(forcedColors).toMatch(
/\.task-status-dot--failed,[\s\S]*?forced-color-adjust:\s*none;/u
)
expect(forcedColors).toMatch(
/\.heartbeat-center__meter\s*\{[^}]*forced-color-adjust:\s*none;/u
)
const narrowAssistant = stylesheet.slice(
stylesheet.indexOf('@media (max-width: 719px)'),
stylesheet.indexOf(
'@media (max-width: 720px)',
stylesheet.indexOf('@media (max-width: 719px)')
)
)
expect(narrowAssistant).toMatch(
/\.assistant-sidebar--open\s*\{[^}]*width:\s*calc\(100vw - 16px\);[^}]*flex-basis:\s*calc\(100vw - 16px\);/u
)
})
it('floats Runtime context compaction without shifting the composer', () => {
expect(stylesheet).toMatch(
/\.composer-wrap\s*\{[^}]*var\(--space-2\);[^}]*background:\s*var\(--surface-raised\);/u
@@ -159,6 +234,48 @@ describe('WorkspacePrimitives', () => {
)
})
it('keeps project forms and disabled actions visually consistent', () => {
expect(stylesheet).toMatch(
/\.project-create-card label > span\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.project-create-card input,\s*\.project-create-card textarea,\s*\.project-create-card select\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/\.project-create-card label > small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.project-create-card__error\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.field > span\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.field input,\s*\.field textarea,\s*\.field select\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/\.field small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.primary-button:disabled\s*\{[^}]*cursor:\s*not-allowed;/u
)
expect(stylesheet).toMatch(
/\.secondary-button:disabled\s*\{[^}]*cursor:\s*not-allowed;/u
)
expect(stylesheet).toMatch(
/\.danger-button:disabled\s*\{[^}]*cursor:\s*not-allowed;/u
)
expect(stylesheet).toContain(
'.primary-button:hover:not(:disabled)'
)
expect(stylesheet).toContain(
'.danger-button:hover:not(:disabled)'
)
expect(stylesheet).not.toMatch(
/\.primary-button:disabled\s*\{[^}]*cursor:\s*wait;/u
)
})
it('separates Runtime-specific controls from the main composer toolbar', () => {
expect(stylesheet).toMatch(
/\.composer__toolbar--with-runtime-controls\s*\{[^}]*border-radius:\s*0;/u
@@ -434,13 +551,13 @@ describe('WorkspacePrimitives', () => {
const confirmButton = screen.getByRole('button', {
name: '永久删除对象'
})
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(dialog).not.toHaveAttribute('aria-modal')
expect(dialog).toHaveAccessibleDescription('删除此对象?')
expect(cancelButton).toHaveFocus()
fireEvent.keyDown(cancelButton, { key: 'Tab', shiftKey: true })
expect(confirmButton).toHaveFocus()
fireEvent.keyDown(confirmButton, { key: 'Tab' })
expect(
fireEvent.keyDown(cancelButton, { key: 'Tab', shiftKey: true })
).toBe(true)
expect(cancelButton).toHaveFocus()
fireEvent.keyDown(cancelButton, { key: 'Escape' })
@@ -462,7 +579,7 @@ describe('WorkspacePrimitives', () => {
expect(screen.getByRole('button', { name: '删除' })).toHaveFocus()
})
it('keeps focus on the dialog while destructive actions are disabled', () => {
it('keeps focus on the inline confirmation while actions are disabled', () => {
const onCancel = vi.fn()
const { rerender } = render(
<DestructiveConfirmActions
@@ -491,7 +608,7 @@ describe('WorkspacePrimitives', () => {
name: '正在删除'
})
expect(dialog).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Tab' })
expect(fireEvent.keyDown(dialog, { key: 'Tab' })).toBe(true)
expect(dialog).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Escape' })
expect(onCancel).not.toHaveBeenCalled()
+19 -34
View File
@@ -17,6 +17,7 @@ export type WorkspaceScope =
| { kind: 'global' }
| { kind: 'all-projects' }
| { kind: 'project'; projectName: string }
| { kind: 'projects'; projectCount: number }
| { kind: 'mixed'; projectName?: string }
| { kind: 'unavailable'; explanation: string }
@@ -75,6 +76,7 @@ export function ScopeBadge({
scope: WorkspaceScope
}): React.JSX.Element {
const { t } = useTranslation('workspace')
const explanationId = useId()
const content =
scope.kind === 'global'
? {
@@ -93,7 +95,14 @@ export function ScopeBadge({
projectName: scope.projectName
})
}
: scope.kind === 'mixed'
: scope.kind === 'projects'
? {
icon: <Layers3 size={12} />,
label: t('primitives.scope.projects', {
count: scope.projectCount
})
}
: scope.kind === 'mixed'
? {
icon: <Layers3 size={12} />,
label: scope.projectName
@@ -109,11 +118,20 @@ export function ScopeBadge({
return (
<span
aria-label={content.label}
aria-describedby={
scope.kind === 'unavailable' ? explanationId : undefined
}
className="scope-badge"
title={scope.kind === 'unavailable' ? scope.explanation : undefined}
>
<span aria-hidden="true">{content.icon}</span>
{content.label}
{scope.kind === 'unavailable' && (
<span className="sr-only" id={explanationId}>
{scope.explanation}
</span>
)}
</span>
)
}
@@ -363,7 +381,6 @@ export function DestructiveConfirmActions({
}): React.JSX.Element {
const { t } = useTranslation('workspace')
const cancelRef = useRef<HTMLButtonElement>(null)
const confirmRef = useRef<HTMLButtonElement>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const wasConfirming = useRef(confirming)
@@ -406,43 +423,12 @@ export function DestructiveConfirmActions({
aria-describedby={descriptionId}
aria-labelledby={titleId}
aria-live="assertive"
aria-modal="true"
className="danger-confirm"
onKeyDown={(event) => {
if (event.key === 'Escape' && !disabled) {
event.preventDefault()
onCancel()
return
}
if (event.key !== 'Tab') {
return
}
if (disabled) {
event.preventDefault()
dialogRef.current?.focus()
return
}
const cancelButton = cancelRef.current
const confirmButton = confirmRef.current
if (
!cancelButton ||
!confirmButton ||
cancelButton.disabled ||
confirmButton.disabled
) {
return
}
event.preventDefault()
const nextButton = event.shiftKey
? document.activeElement === cancelButton
? confirmButton
: cancelButton
: document.activeElement === confirmButton
? cancelButton
: confirmButton
nextButton.focus()
}}
ref={dialogRef}
role="alertdialog"
@@ -472,7 +458,6 @@ export function DestructiveConfirmActions({
className="danger-button"
disabled={disabled}
onClick={onConfirm}
ref={confirmRef}
type="button"
>
{confirmLabel}
+41 -7
View File
@@ -42,15 +42,49 @@ export function activateModalFocus(
document.activeElement instanceof HTMLElement
? document.activeElement
: null
const appShell = document.querySelector<HTMLElement>('.app-shell')
const wasInert = appShell?.inert ?? false
if (appShell) {
appShell.inert = true
const focusTarget = initialFocus()
const modalRoot = focusTarget?.closest<HTMLElement>('[aria-modal="true"]')
const isolatedElements: Array<{
element: HTMLElement
wasInert: boolean
}> = []
const isolate = (element: HTMLElement): void => {
if (isolatedElements.some((entry) => entry.element === element)) {
return
}
isolatedElements.push({
element,
wasInert: element.inert ?? false
})
element.inert = true
}
initialFocus()?.focus()
return () => {
if (modalRoot) {
const appShell = document.querySelector<HTMLElement>('.app-shell')
if (appShell && !appShell.contains(modalRoot)) {
isolate(appShell)
}
let activeBranch: HTMLElement = modalRoot
while (activeBranch.parentElement) {
for (const sibling of activeBranch.parentElement.children) {
if (sibling !== activeBranch && sibling instanceof HTMLElement) {
isolate(sibling)
}
}
activeBranch = activeBranch.parentElement
if (activeBranch === document.body) {
break
}
}
} else {
const appShell = document.querySelector<HTMLElement>('.app-shell')
if (appShell) {
appShell.inert = wasInert
isolate(appShell)
}
}
focusTarget?.focus()
return () => {
for (const { element, wasInert } of isolatedElements) {
element.inert = wasInert
}
restoreFocus?.focus()
}
@@ -198,6 +198,7 @@ export const app = {
}
},
chat: {
heading: 'Conversation',
user: 'You',
taskResult: 'Task result: {{title}}',
welcome: {
@@ -437,6 +437,10 @@ export const knowledge = {
interactionHint:
'Drag nodes to arrange them. Drag the canvas to pan, and use the wheel to zoom.',
empty: 'This library does not have any generated entity relations yet.',
chunk: {
loading: 'Loading knowledge graph…',
loadFailed: 'The knowledge graph could not load. Try again.'
},
topologyAriaLabel: 'Graph topology',
visibleRelations: {
title: 'Visible relations',
@@ -447,6 +451,7 @@ export const knowledge = {
},
addEntityPanelAriaLabel: 'Add entity panel',
entityDetailsAriaLabel: 'Entity details',
deleteEntityAriaLabel: 'Delete entity {{name}}',
closeEntityDetailsAriaLabel: 'Close entity details',
noEntityDescription: 'This entity has no additional description.',
aliases: 'Aliases: {{aliases}}',
@@ -459,6 +464,27 @@ export const knowledge = {
targetPlaceholder: 'Select the entity to keep',
actionAriaLabel: 'Merge into target entity'
},
confirm: {
processing: 'Working…',
deleteEntity: {
title: 'Delete entity “{{name}}”?',
description:
'This permanently deletes “{{name}}”, its {{count}} connected relations, and their graph evidence references. It cannot be undone.',
action: 'Delete entity'
},
deleteRelation: {
title: 'Delete relation “{{type}}”?',
description:
'This permanently deletes the “{{type}}” relation from “{{source}}” to “{{target}}” and its graph evidence references. Both entities remain. It cannot be undone.',
action: 'Delete relation'
},
merge: {
title: 'Merge “{{source}}” into “{{target}}”?',
description:
'Relations, aliases, and evidence from “{{source}}” are moved into “{{target}}”, then the source entity is deleted. This cannot be undone.',
action: 'Merge entities'
}
},
evidence: {
title: 'Evidence ({{count}})',
empty: 'This entity and its relations have no linked evidence.'
@@ -31,7 +31,9 @@ export const magicNotes = {
hideAiComments: 'Hide AI comments',
showAiComments: 'Show AI comments',
pinNote: 'Pin note',
unpinNote: 'Unpin note'
unpinNote: 'Unpin note',
continueEditing: 'Continue editing',
discardAndSwitch: 'Discard and switch'
},
status: {
loading: 'Loading',
@@ -116,7 +118,10 @@ export const magicNotes = {
},
confirmations: {
deleteNote: 'Delete “{{title}}” and all of its entries?',
deleteEntry: 'Delete this entry? This action cannot be undone.'
deleteEntry: 'Delete this entry? This action cannot be undone.',
discardDraftTitle: 'Discard this unsaved draft?',
discardDraftDescription:
'Switching will discard the text and attachments in the current entry draft.'
},
comments: {
paneLabel: 'AI comments',
@@ -109,6 +109,15 @@ export const settings = {
clearing: 'Clearing…',
clearLocalData: 'Clear local data'
},
unsaved: {
close:
'These settings have unsaved changes. Save them first, or confirm that you want to discard them before closing Settings.',
navigation:
'These settings have unsaved changes. Save them first, or confirm that you want to discard them before changing categories.',
keepEditing: 'Keep editing',
discardAndClose: 'Discard changes and close',
discardAndNavigate: 'Discard changes and continue'
},
errors: {
readSettings: 'Could not load settings',
detectRuntimes: 'Could not detect Agent Runtimes',
@@ -239,6 +239,41 @@ export const settingsSections = {
general: 'General',
magicNotes: 'Magic Notes'
},
shortcut: {
title: 'Global quick access',
description:
'Show or hide GoodBuddy while another application is active.',
enabled: 'Enable global shortcut',
accelerator: 'Shortcut',
recorderHelp:
'Focus this field and press a key combination, or enter an Electron accelerator such as CommandOrControl+Shift+Space.',
reset: 'Reset to default',
save: 'Save shortcut',
saving: 'Saving…',
saved: 'Global shortcut updated',
loading: 'Loading shortcut status…',
status: {
registered: 'Registered: {{shortcut}}',
disabled: 'The global shortcut is disabled.',
conflict:
'Another application is using this shortcut. Record a different combination and save it.',
failed:
'The system could not register this shortcut. Record another combination or check system shortcut settings.'
},
errors: {
serviceUnavailable:
'Shortcut settings are not available in this version',
readFailed: 'Could not load shortcut settings. Try again.',
invalidAccelerator:
'Enter a valid key combination that includes a modifier.',
conflict:
'Another application is using this shortcut. Record a different combination and try again.',
registrationFailed:
'The system could not register this shortcut. Choose another combination or check system shortcut settings.',
saveFailed:
'The shortcut could not be saved. The previous working shortcut is still active. Try again.'
}
},
modelDownloadSource: {
cardTitle: 'Local models',
cardDescription:
@@ -2,6 +2,10 @@ import type { TranslationShape } from '../../resource-types'
import type { workspace as chineseWorkspace } from '../zh-CN/workspace'
export const workspace = {
builtInDefaultProject: {
name: 'Default project',
description: 'GoodBuddy default workspace'
},
projectSwitcher: {
workModes: {
ask: 'Ask · Read-only',
@@ -70,6 +74,7 @@ export const workspace = {
resizeValue: '{{width}} pixels',
title: 'Workspace',
close: 'Close assistant workspace',
dismissOverlay: 'Dismiss assistant workspace overlay',
categoriesAriaLabel: 'Workspace categories',
tabs: {
tasks: {
@@ -291,8 +296,9 @@ export const workspace = {
global: 'Global',
allProjects: 'All projects',
project: 'Project: {{projectName}}',
projects: '{{count}} projects',
mixedProject: 'Project: {{projectName}} + Global',
mixedCurrent: 'Current project + Global',
mixedCurrent: 'Projects + Global',
unavailable: 'Scope unavailable'
},
destructive: {
@@ -189,6 +189,7 @@ export const app = {
}
},
chat: {
heading: '对话内容',
user: '用户',
taskResult: '任务结果:{{title}}',
welcome: {
@@ -404,6 +404,10 @@ export const knowledge = {
fitView: '显示全部',
interactionHint: '拖动节点调整位置;拖动画布平移,滚轮缩放。',
empty: '当前知识库尚未生成实体关系。',
chunk: {
loading: '正在加载知识图谱…',
loadFailed: '知识图谱未能加载,请重试。'
},
topologyAriaLabel: '图谱拓扑',
visibleRelations: {
title: '可见关系',
@@ -414,6 +418,7 @@ export const knowledge = {
},
addEntityPanelAriaLabel: '新增实体面板',
entityDetailsAriaLabel: '实体详情',
deleteEntityAriaLabel: '删除实体 {{name}}',
closeEntityDetailsAriaLabel: '关闭实体详情',
noEntityDescription: '该实体没有附加描述。',
aliases: '别名:{{aliases}}',
@@ -426,6 +431,27 @@ export const knowledge = {
targetPlaceholder: '选择保留的实体',
actionAriaLabel: '合并到目标实体'
},
confirm: {
processing: '处理中…',
deleteEntity: {
title: '删除实体“{{name}}”?',
description:
'将永久删除实体“{{name}}”及其 {{count}} 条关联关系;相关证据引用也会从图谱中移除,且无法恢复。',
action: '删除实体'
},
deleteRelation: {
title: '删除关系“{{type}}”?',
description:
'将永久删除“{{source}}”到“{{target}}”的“{{type}}”关系及其图谱证据引用,且无法恢复。两个实体本身会保留。',
action: '删除关系'
},
merge: {
title: '将“{{source}}”合并到“{{target}}”?',
description:
'“{{source}}”的关系、别名和证据将并入“{{target}}”,随后删除源实体。此操作无法恢复。',
action: '合并实体'
}
},
evidence: {
title: '证据 ({{count}})',
empty: '此实体和相关关系没有关联证据。'
@@ -27,7 +27,9 @@ export const magicNotes = {
hideAiComments: '隐藏 AI 评论',
showAiComments: '显示 AI 评论',
pinNote: '置顶笔记',
unpinNote: '取消置顶'
unpinNote: '取消置顶',
continueEditing: '继续编辑',
discardAndSwitch: '放弃草稿并切换'
},
status: {
loading: '正在加载',
@@ -108,7 +110,9 @@ export const magicNotes = {
},
confirmations: {
deleteNote: '删除“{{title}}”及其中全部记录?',
deleteEntry: '删除这条记录?此操作不可撤销。'
deleteEntry: '删除这条记录?此操作不可撤销。',
discardDraftTitle: '放弃当前未保存草稿?',
discardDraftDescription: '切换后,当前记录草稿中的文字和附件将被丢弃。'
},
comments: {
paneLabel: 'AI 评论',
@@ -96,6 +96,13 @@ export const settings = {
clearing: '正在清除…',
clearLocalData: '清除本地数据'
},
unsaved: {
close: '当前设置有未保存更改。请先保存,或确认放弃更改后再关闭设置中心。',
navigation: '当前设置有未保存更改。请先保存,或确认放弃更改后再切换分类。',
keepEditing: '继续编辑',
discardAndClose: '放弃更改并关闭',
discardAndNavigate: '放弃更改并切换'
},
errors: {
readSettings: '读取设置失败',
detectRuntimes: 'Runtime 自动检测失败',
@@ -221,6 +221,33 @@ export const settingsSections = {
general: '通用设置',
magicNotes: '魔法笔记'
},
shortcut: {
title: '全局快捷唤起',
description: '在其他应用前台时,也可以显示或隐藏 GoodBuddy。',
enabled: '启用全局快捷键',
accelerator: '快捷键',
recorderHelp:
'聚焦输入框后直接按下组合键,或填写 Electron accelerator(例如 CommandOrControl+Shift+Space)。',
reset: '恢复默认',
save: '保存快捷键',
saving: '保存中…',
saved: '全局快捷键已更新',
loading: '正在读取快捷键状态…',
status: {
registered: '已注册:{{shortcut}}',
disabled: '全局快捷键已停用。',
conflict: '当前快捷键被其他应用占用,请录制其他组合键后保存。',
failed: '系统未能注册当前快捷键,请录制其他组合键或检查系统快捷键设置。'
},
errors: {
serviceUnavailable: '当前版本未提供快捷键设置服务',
readFailed: '读取快捷键设置失败,请重试',
invalidAccelerator: '快捷键无效,请按下包含修饰键的组合键后重试',
conflict: '该快捷键已被其他应用占用,请录制其他组合键后重试',
registrationFailed: '系统未能注册该快捷键,请更换组合键或检查系统快捷键设置',
saveFailed: '快捷键未能保存,原有可用快捷键已保留,请重试'
}
},
modelDownloadSource: {
cardTitle: '本地模型',
cardDescription: '管理 GoodBuddy 托管本地模型的获取方式',
@@ -1,4 +1,8 @@
export const workspace = {
builtInDefaultProject: {
name: '默认项目',
description: 'GoodBuddy 默认工作区'
},
projectSwitcher: {
workModes: {
ask: 'Ask · 只读问答',
@@ -67,6 +71,7 @@ export const workspace = {
resizeValue: '{{width}} 像素',
title: '工作栏',
close: '关闭助手工作栏',
dismissOverlay: '关闭助手工作栏背景',
categoriesAriaLabel: '工作栏分类',
tabs: {
tasks: {
@@ -277,8 +282,9 @@ export const workspace = {
global: '全局',
allProjects: '全部项目',
project: '项目:{{projectName}}',
projects: '{{count}} 个项目',
mixedProject: '项目:{{projectName}} + 全局',
mixedCurrent: '当前项目 + 全局',
mixedCurrent: '项目 + 全局',
unavailable: '范围不可用'
},
destructive: {
+104 -3
View File
@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest'
import {
filterKeepAliveEntries,
pruneKeepAliveEntries,
touchKeepAliveEntry
touchAndPruneKeepAliveEntries,
touchKeepAliveEntry,
type KeepAliveCacheEntry
} from './keep-alive-cache'
describe('keep-alive cache', () => {
@@ -21,6 +24,44 @@ describe('keep-alive cache', () => {
])
})
it('preserves reference equality for no-op touches and filtering', () => {
const entries = [
{ key: 'knowledge', lastVisitedAt: 20 },
{ key: 'chat', lastVisitedAt: 10 }
]
expect(touchKeepAliveEntry(entries, 'chat', 10)).toBe(entries)
expect(
filterKeepAliveEntries(entries, () => true)
).toBe(entries)
expect(
touchAndPruneKeepAliveEntries(entries, 'knowledge', 20, {
expiresAfterMs: 100,
maximumEntries: 2,
protectedKeys: new Set<string>(),
recentEntries: 1
})
).toBe(entries)
})
it('preserves reference equality for a no-op sweep', () => {
const entries = [
{ key: 'knowledge', lastVisitedAt: 20 },
{ key: 'chat', lastVisitedAt: 30 }
]
expect(
pruneKeepAliveEntries(entries, {
currentKey: 'chat',
expiresAfterMs: 100,
maximumEntries: 2,
now: 40,
protectedKeys: new Set(['knowledge']),
recentEntries: 1
})
).toBe(entries)
})
it('keeps recent and protected entries while expiring inactive ones', () => {
expect(
pruneKeepAliveEntries(
@@ -65,12 +106,72 @@ describe('keep-alive cache', () => {
).toEqual([
'conversation-7',
'conversation-6',
'conversation-0',
'conversation-5',
'conversation-4',
'conversation-0'
'conversation-4'
])
})
it('applies the documented priority when protected entries exceed capacity', () => {
const entries = [
{ key: 'newest', lastVisitedAt: 60 },
{ key: 'recent', lastVisitedAt: 50 },
{ key: 'protected-newest', lastVisitedAt: 40 },
{ key: 'protected-older', lastVisitedAt: 30 },
{ key: 'current', lastVisitedAt: 10 }
]
expect(
pruneKeepAliveEntries(entries, {
currentKey: 'current',
expiresAfterMs: 1_000,
maximumEntries: 4,
now: 100,
protectedKeys: new Set([
'protected-newest',
'protected-older',
'current'
]),
recentEntries: 2
}).map((entry) => entry.key)
).toEqual([
'current',
'newest',
'recent',
'protected-newest'
])
})
it.each([
['conversation', 12, 5, 20],
['workspace', 4, 3, 8]
])(
'enforces the %s cap on every rapid touch',
(_kind, maximumEntries, recentEntries, visitCount) => {
const entries = Array.from(
{ length: visitCount },
(_, index) => `entry-${index}`
).reduce<KeepAliveCacheEntry<string>[]>(
(current, key, index) =>
touchAndPruneKeepAliveEntries(current, key, index + 1, {
expiresAfterMs: 60 * 60 * 1_000,
maximumEntries,
protectedKeys: new Set<string>(),
recentEntries
}),
[]
)
expect(entries).toHaveLength(maximumEntries)
expect(entries.map((entry) => entry.key)).toEqual(
Array.from(
{ length: maximumEntries },
(_, index) => `entry-${visitCount - index - 1}`
)
)
}
)
it('expires an unprotected entry after one hour', () => {
const entries = [{ key: 'knowledge', lastVisitedAt: 1_000 }]
const options = {
+130 -39
View File
@@ -3,14 +3,55 @@ export type KeepAliveCacheEntry<Key extends string> = {
lastVisitedAt: number
}
export type KeepAlivePruneOptions<Key extends string> = {
currentKey?: Key
expiresAfterMs: number
maximumEntries: number
now: number
protectedKeys?: ReadonlySet<Key>
recentEntries: number
}
export function filterKeepAliveEntries<Key extends string>(
entries: KeepAliveCacheEntry<Key>[],
predicate: (entry: KeepAliveCacheEntry<Key>) => boolean
): KeepAliveCacheEntry<Key>[]
export function filterKeepAliveEntries<Key extends string>(
entries: readonly KeepAliveCacheEntry<Key>[],
predicate: (entry: KeepAliveCacheEntry<Key>) => boolean
): readonly KeepAliveCacheEntry<Key>[]
export function filterKeepAliveEntries<Key extends string>(
entries: readonly KeepAliveCacheEntry<Key>[],
predicate: (entry: KeepAliveCacheEntry<Key>) => boolean
): readonly KeepAliveCacheEntry<Key>[] {
const filtered = entries.filter(predicate)
return filtered.length === entries.length ? entries : filtered
}
export function touchKeepAliveEntry<Key extends string>(
entries: KeepAliveCacheEntry<Key>[],
key: Key,
visitedAt: number
): KeepAliveCacheEntry<Key>[]
export function touchKeepAliveEntry<Key extends string>(
entries: readonly KeepAliveCacheEntry<Key>[],
key: Key,
visitedAt: number
): KeepAliveCacheEntry<Key>[] {
const existing = entries.find((entry) => entry.key === key)
if (existing?.lastVisitedAt === visitedAt) {
return [...entries]
): readonly KeepAliveCacheEntry<Key>[]
export function touchKeepAliveEntry<Key extends string>(
entries: readonly KeepAliveCacheEntry<Key>[],
key: Key,
visitedAt: number
): readonly KeepAliveCacheEntry<Key>[] {
const existingIndex = entries.findIndex((entry) => entry.key === key)
if (
existingIndex >= 0 &&
entries[existingIndex]?.lastVisitedAt === visitedAt &&
entries.findIndex(
(entry, index) => index !== existingIndex && entry.key === key
) < 0
) {
return entries
}
return [
...entries.filter((entry) => entry.key !== key),
@@ -18,6 +59,51 @@ export function touchKeepAliveEntry<Key extends string>(
]
}
export function touchAndPruneKeepAliveEntries<Key extends string>(
entries: KeepAliveCacheEntry<Key>[],
key: Key,
visitedAt: number,
options: Omit<KeepAlivePruneOptions<Key>, 'currentKey' | 'now'>
): KeepAliveCacheEntry<Key>[]
export function touchAndPruneKeepAliveEntries<Key extends string>(
entries: readonly KeepAliveCacheEntry<Key>[],
key: Key,
visitedAt: number,
options: Omit<KeepAlivePruneOptions<Key>, 'currentKey' | 'now'>
): readonly KeepAliveCacheEntry<Key>[]
export function touchAndPruneKeepAliveEntries<Key extends string>(
entries: readonly KeepAliveCacheEntry<Key>[],
key: Key,
visitedAt: number,
options: Omit<KeepAlivePruneOptions<Key>, 'currentKey' | 'now'>
): readonly KeepAliveCacheEntry<Key>[] {
return pruneKeepAliveEntries(
touchKeepAliveEntry(entries, key, visitedAt),
{
...options,
currentKey: key,
now: visitedAt
}
)
}
export function pruneKeepAliveEntries<Key extends string>(
entries: KeepAliveCacheEntry<Key>[],
options: KeepAlivePruneOptions<Key>
): KeepAliveCacheEntry<Key>[]
export function pruneKeepAliveEntries<Key extends string>(
entries: readonly KeepAliveCacheEntry<Key>[],
options: KeepAlivePruneOptions<Key>
): readonly KeepAliveCacheEntry<Key>[]
/**
* Prunes pane cache membership without modifying the pane data itself.
*
* Capacity is absolute. Candidates are selected in this exact order:
* the current pane, the globally newest `recentEntries`, the newest
* protected panes, then all other unexpired panes by recency. Overlapping
* categories do not consume capacity twice. Equal timestamps retain their
* input order.
*/
export function pruneKeepAliveEntries<Key extends string>(
entries: readonly KeepAliveCacheEntry<Key>[],
{
@@ -27,43 +113,48 @@ export function pruneKeepAliveEntries<Key extends string>(
now,
protectedKeys,
recentEntries
}: {
currentKey?: Key
expiresAfterMs: number
maximumEntries: number
now: number
protectedKeys?: ReadonlySet<Key>
recentEntries: number
}: KeepAlivePruneOptions<Key>
): readonly KeepAliveCacheEntry<Key>[] {
const newestFirst = entries
.map((entry, index) => ({ entry, index }))
.sort(
(left, right) =>
right.entry.lastVisitedAt - left.entry.lastVisitedAt ||
left.index - right.index
)
.map(({ entry }) => entry)
const selected: KeepAliveCacheEntry<Key>[] = []
const selectedKeys = new Set<Key>()
const capacity = Math.max(0, maximumEntries)
const add = (entry: KeepAliveCacheEntry<Key> | undefined): void => {
if (
entry &&
selected.length < capacity &&
!selectedKeys.has(entry.key)
) {
selected.push(entry)
selectedKeys.add(entry.key)
}
}
): KeepAliveCacheEntry<Key>[] {
const newestFirst = [...entries].sort(
(left, right) => right.lastVisitedAt - left.lastVisitedAt
)
const alwaysKeep = new Set(
newestFirst.slice(0, recentEntries).map((entry) => entry.key)
)
if (currentKey) {
alwaysKeep.add(currentKey)
add(newestFirst.find((entry) => entry.key === currentKey))
}
protectedKeys?.forEach((key) => alwaysKeep.add(key))
newestFirst
.slice(0, Math.max(0, recentEntries))
.forEach(add)
newestFirst
.filter((entry) => protectedKeys?.has(entry.key))
.forEach(add)
newestFirst
.filter(
(entry) => now - entry.lastVisitedAt < expiresAfterMs
)
.forEach(add)
const retained = newestFirst.filter(
(entry) =>
alwaysKeep.has(entry.key) ||
now - entry.lastVisitedAt < expiresAfterMs
)
if (retained.length <= maximumEntries) {
return retained
}
const removableOldestFirst = retained
.filter((entry) => !alwaysKeep.has(entry.key))
.sort((left, right) => left.lastVisitedAt - right.lastVisitedAt)
const removeCount = retained.length - maximumEntries
const removedKeys = new Set(
removableOldestFirst
.slice(0, removeCount)
.map((entry) => entry.key)
)
return retained.filter((entry) => !removedKeys.has(entry.key))
const unchanged =
selected.length === entries.length &&
selectedKeys.size === entries.length &&
entries.every((entry) => selectedKeys.has(entry.key))
return unchanged ? entries : selected
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { findPreferredCompatibleModelProfile } from './model-profile-selection'
const profiles = [
{ id: 'image', compatible: false },
{ id: 'first-text', compatible: true },
{ id: 'preferred-text', compatible: true }
]
describe('findPreferredCompatibleModelProfile', () => {
it.each([
['preferred compatible profile', 'preferred-text', 'preferred-text'],
[
'first compatible fallback for an incompatible preference',
'image',
'first-text'
],
[
'first compatible fallback for a missing preference',
'missing',
'first-text'
],
['first compatible fallback without a preference', undefined, 'first-text']
] as const)(
'selects the %s',
(_case, preferredProfileId, expectedId) => {
expect(
findPreferredCompatibleModelProfile(
profiles,
preferredProfileId,
(profile) => profile.compatible
)?.id
).toBe(expectedId)
}
)
it('returns undefined when no profile is compatible', () => {
expect(
findPreferredCompatibleModelProfile(
profiles,
'preferred-text',
() => false
)
).toBeUndefined()
})
})
@@ -0,0 +1,14 @@
export function findPreferredCompatibleModelProfile<
Profile extends { id: string }
>(
profiles: readonly Profile[],
preferredProfileId: string | undefined,
isCompatible: (profile: Profile) => boolean
): Profile | undefined {
return (
profiles.find(
(profile) =>
profile.id === preferredProfileId && isCompatible(profile)
) ?? profiles.find(isCompatible)
)
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import type { AssistantProject } from '../../shared/assistant-contracts'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName
} from '../../shared/assistant-contracts'
import { getProjectDisplayText } from './project-display'
const project: 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('getProjectDisplayText', () => {
it('localizes the untouched seed without changing the project', () => {
expect(
getProjectDisplayText(project, (key) => ({
'builtInDefaultProject.name': 'Default project',
'builtInDefaultProject.description':
'GoodBuddy default workspace'
})[key])
).toEqual({
name: 'Default project',
description: 'GoodBuddy default workspace'
})
expect(project.name).toBe(builtInDefaultProjectSeedName)
expect(project.description).toBe(
builtInDefaultProjectSeedDescription
)
})
it('keeps independently created identical project text raw', () => {
const independentProject = {
...project,
builtInDefault: false
}
expect(
getProjectDisplayText(independentProject, () => 'localized')
).toEqual({
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription
})
})
it('keeps localization across unrelated project setting changes', () => {
expect(
getProjectDisplayText(
{
...project,
rootPath: 'D:\\Moved',
defaultWorkMode: 'execute',
runtimeSelection: { provider: 'continue' },
updatedAt: '2026-08-01T00:00:01.000Z'
},
(key) =>
key === 'builtInDefaultProject.name'
? 'Default project'
: 'GoodBuddy default workspace'
)
).toEqual({
name: 'Default project',
description: 'GoodBuddy default workspace'
})
})
})
+28
View File
@@ -0,0 +1,28 @@
import type { AssistantProject } from '../../shared/assistant-contracts'
import { isUntouchedBuiltInDefaultProject } from '../../shared/assistant-contracts'
export type ProjectDisplayText = {
name: string
description: string
}
type ProjectDisplayTranslation = (
key:
| 'builtInDefaultProject.name'
| 'builtInDefaultProject.description'
) => string
export function getProjectDisplayText(
project: AssistantProject,
translate: ProjectDisplayTranslation
): ProjectDisplayText {
return isUntouchedBuiltInDefaultProject(project)
? {
name: translate('builtInDefaultProject.name'),
description: translate('builtInDefaultProject.description')
}
: {
name: project.name,
description: project.description
}
}
File diff suppressed because it is too large Load Diff
+74
View File
@@ -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)
})
})
+15
View File
@@ -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
+15
View File
@@ -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>
+9
View File
@@ -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
>
+4
View File
@@ -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',
+44 -1
View File
@@ -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)
}
)
})
+175
View File
@@ -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