feat: add app management, local inference controls, and task statistics

This commit is contained in:
mesalogo
2026-09-18 08:20:45 +08:00
parent 6dd6297414
commit bbae893c45
103 changed files with 7962 additions and 1490 deletions
+88 -16
View File
@@ -1,7 +1,7 @@
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ApplicationSettingsStore,
applicationSettingsSchema,
@@ -44,6 +44,55 @@ afterEach(async () => {
})
describe('ApplicationSettingsStore', () => {
it('keeps the confirmed snapshot when an atomic save fails', async () => {
const { filePath, store } = await createStore()
const confirmed = await store.get()
const changed = vi.fn()
store.onChanged(changed)
await mkdir(filePath)
await expect(store.update({ localInferenceEnabled: false })).rejects.toThrow()
expect(await store.get()).toEqual(confirmed)
expect(changed).not.toHaveBeenCalled()
})
it('defaults missing released Notes fields to enabled without resetting unrelated values', async () => {
const { filePath, store } = await createStore()
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version: 11, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false }
for (const key of ['applicationNavigation', 'localInferenceEnabled', 'magicNotesEnabled', 'magicNotesShowIncompleteTodoCount']) delete legacy[key]
await writeFile(filePath, JSON.stringify(legacy), 'utf8')
expect(await store.get()).toEqual({ ...defaultApplicationSettings, checkUpdatesOnStartup: false })
expect(await createApplicationSettingsStore(filePath).get()).toEqual(await store.get())
})
it('migrates released settings without replacing explicit false values or comment choices', async () => {
const { filePath, store } = await createStore()
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version: 11, lastSeenReleaseNotesVersion: null, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' }
for (const key of ['applicationNavigation', 'localInferenceEnabled']) delete legacy[key]
await writeFile(filePath, JSON.stringify(legacy), 'utf8')
const migrated = await store.get()
expect(migrated).toMatchObject({ magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, localInferenceEnabled: true, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' })
await store.update({ localInferenceEnabled: false })
await store.update({ applicationNavigation: { ...migrated.applicationNavigation, order: [...migrated.applicationNavigation.order].reverse(), pinned: { ...migrated.applicationNavigation.pinned, 'local-inference': false } } })
const reloaded = await createApplicationSettingsStore(filePath).get()
expect(reloaded).toMatchObject({ magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, localInferenceEnabled: false, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' })
expect(reloaded.applicationNavigation.order).toEqual([...migrated.applicationNavigation.order].reverse())
expect(reloaded.applicationNavigation.pinned['local-inference']).toBe(false)
})
it('rejects incomplete, duplicate, unknown order and pin values without applying defaults to patches', () => {
const navigation = defaultApplicationSettings.applicationNavigation
for (const invalid of [
{ ...navigation, order: navigation.order.slice(1) },
{ ...navigation, order: ['magic-notes', 'magic-notes'] },
{ ...navigation, order: ['unknown', 'local-inference'] },
{ ...navigation, order: ['knowledge', 'local-inference'] },
{ ...navigation, order: ['heartbeat', 'local-inference'] },
{ ...navigation, pinned: { ...navigation.pinned, heartbeat: true } },
{ ...navigation, pinned: { ...navigation.pinned, knowledge: true } },
{ ...navigation, pinned: { ...navigation.pinned, extra: true } },
{ ...navigation, pinned: { knowledge: true } }
]) expect(applicationSettingsUpdateSchema.safeParse({ applicationNavigation: invalid }).success).toBe(false)
expect(applicationSettingsUpdateSchema.parse({ checkUpdatesOnStartup: false })).toEqual({ checkUpdatesOnStartup: false })
})
it('returns defaults without creating a settings file', async () => {
const { directory, store } = await createStore()
@@ -61,6 +110,7 @@ describe('ApplicationSettingsStore', () => {
magicNotesShowIncompleteTodoCount: false
})
).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -73,6 +123,7 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'combined'
})
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -85,7 +136,8 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 11,
...defaultApplicationSettings,
version: 12,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -119,7 +171,7 @@ describe('ApplicationSettingsStore', () => {
remoteProjectsEnabled: true
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 11,
version: 12,
lastSeenReleaseNotesVersion: null,
...defaultApplicationSettings,
remoteProjectsEnabled: true
@@ -138,6 +190,7 @@ describe('ApplicationSettingsStore', () => {
await expect(
createApplicationSettingsStore(filePath).get()
).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -152,7 +205,7 @@ describe('ApplicationSettingsStore', () => {
})
it.each([1, 2])(
'loads version %s settings missing the field with Magic Notes disabled',
'loads version %s settings missing the field with Magic Notes enabled',
async (version) => {
const { filePath, store } = await createStore()
await writeFile(
@@ -165,13 +218,14 @@ describe('ApplicationSettingsStore', () => {
)
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
@@ -192,6 +246,7 @@ describe('ApplicationSettingsStore', () => {
)
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -219,6 +274,7 @@ describe('ApplicationSettingsStore', () => {
)
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -252,7 +308,8 @@ describe('ApplicationSettingsStore', () => {
createApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion()
).resolves.toBe('0.8.18')
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 11,
...defaultApplicationSettings,
version: 12,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -283,6 +340,7 @@ describe('ApplicationSettingsStore', () => {
)
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -311,6 +369,7 @@ describe('ApplicationSettingsStore', () => {
await writeFile(filePath, JSON.stringify(versionSix), 'utf8')
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'mirror',
modelDownloadSource: 'modelscope',
@@ -323,8 +382,9 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'structured'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...defaultApplicationSettings,
...versionSix,
version: 11,
version: 12,
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
@@ -334,8 +394,9 @@ describe('ApplicationSettingsStore', () => {
await store.update({ modelDownloadSource: 'hugging-face' })
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...defaultApplicationSettings,
...versionSix,
version: 11,
version: 12,
modelDownloadSource: 'hugging-face',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
@@ -359,6 +420,7 @@ describe('ApplicationSettingsStore', () => {
await writeFile(filePath, JSON.stringify(versionSeven), 'utf8')
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -371,8 +433,9 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...defaultApplicationSettings,
...versionSeven,
version: 11,
version: 12,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -398,6 +461,7 @@ describe('ApplicationSettingsStore', () => {
const migratedSettings = await store.get()
expect(migratedSettings).toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'mirror',
modelDownloadSource: 'hugging-face',
@@ -411,8 +475,9 @@ describe('ApplicationSettingsStore', () => {
})
await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBe('0.8.18')
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...defaultApplicationSettings,
...versionEight,
version: 11,
version: 12,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false
@@ -420,8 +485,9 @@ describe('ApplicationSettingsStore', () => {
await store.update({ checkUpdatesOnStartup: true })
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...defaultApplicationSettings,
...versionEight,
version: 11,
version: 12,
checkUpdatesOnStartup: true,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
@@ -454,6 +520,7 @@ describe('ApplicationSettingsStore', () => {
})
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'mirror',
modelDownloadSource: 'hugging-face',
@@ -476,8 +543,9 @@ describe('ApplicationSettingsStore', () => {
const persisted = JSON.parse(await readFile(filePath, 'utf8'))
expect(persisted).toEqual({
...defaultApplicationSettings,
...versionNine,
version: 11,
version: 12,
conversationHtmlRenderingEnabled: true,
localToolEnvironment: {
node: {
@@ -538,6 +606,7 @@ describe('ApplicationSettingsStore', () => {
await expect(
store.update({ checkUpdatesOnStartup: false })
).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -596,7 +665,7 @@ describe('ApplicationSettingsStore', () => {
await expect(store.get()).resolves.toEqual(defaultApplicationSettings)
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionTenSettings,
version: 11,
version: 12,
conversationHtmlRenderingEnabled: true
})
})
@@ -606,7 +675,7 @@ describe('ApplicationSettingsStore', () => {
JSON.stringify({
version: 3,
checkUpdatesOnStartup: false,
magicNotesEnabled: false
magicNotesEnabled: 'false'
}),
JSON.stringify({
version: 2,
@@ -685,6 +754,7 @@ describe('ApplicationSettingsStore', () => {
])
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -697,7 +767,8 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 11,
...defaultApplicationSettings,
version: 12,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
@@ -727,6 +798,7 @@ describe('ApplicationSettingsStore', () => {
magicNotesEnabled: true
})
).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
+30 -4
View File
@@ -4,6 +4,7 @@ import { z } from 'zod'
import {
applicationSettingsSchema,
applicationSettingsUpdateSchema,
defaultApplicationNavigation,
type ApplicationSettings
} from '../shared/application-settings-contracts'
import {
@@ -27,7 +28,7 @@ export {
} from '../shared/application-settings-contracts'
export type { ApplicationSettings } from '../shared/application-settings-contracts'
const CURRENT_SETTINGS_VERSION = 11
const CURRENT_SETTINGS_VERSION = 12
const legacyStoredApplicationSettingsSchema = z
.object({
@@ -148,6 +149,8 @@ const storedApplicationSettingsSchema = applicationSettingsSchema
})
.strict()
const versionElevenStoredApplicationSettingsSchema = storedApplicationSettingsSchema.extend({ version: z.literal(11) })
type StoredApplicationSettings = z.infer<typeof storedApplicationSettingsSchema>
export type LegacyLocalToolEnvironmentPaths = {
@@ -234,7 +237,9 @@ export const defaultApplicationSettings: ApplicationSettings = {
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
applicationNavigation: defaultApplicationNavigation,
localInferenceEnabled: true,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
@@ -245,18 +250,26 @@ export class ApplicationSettingsStore {
private settingsLoad?: Promise<StoredApplicationSettings>
private warnings: SettingsWarning[] = []
private updateQueue: Promise<void> = Promise.resolve()
private readonly listeners = new Set<(settings: ApplicationSettings) => void>()
constructor(
private readonly filePath: string,
private readonly legacyLocalToolEnvironmentResolver: LegacyLocalToolEnvironmentResolver = resolveLegacyLocalToolEnvironmentPaths
) {}
onChanged(listener: (settings: ApplicationSettings) => void): () => void {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
private async migrateLegacy(
settings: Omit<
StoredApplicationSettings,
| 'conversationHtmlRenderingEnabled'
| 'localToolEnvironment'
| 'version'
| 'applicationNavigation'
| 'localInferenceEnabled'
> & {
conversationHtmlRenderingEnabled?: boolean
version: number
@@ -271,6 +284,7 @@ export class ApplicationSettingsStore {
? { source: 'custom', executablePath }
: { source: 'managed' }
const next: StoredApplicationSettings = {
...defaultApplicationSettings,
...settings,
conversationHtmlRenderingEnabled:
settings.conversationHtmlRenderingEnabled ?? true,
@@ -328,6 +342,12 @@ export class ApplicationSettingsStore {
)
const result = storedApplicationSettingsSchema.safeParse(parsed)
if (!result.success) {
const versionElevenResult = versionElevenStoredApplicationSettingsSchema.safeParse(parsed)
if (versionElevenResult.success) {
const next: StoredApplicationSettings = { ...versionElevenResult.data, version: CURRENT_SETTINGS_VERSION }
await this.persist(next)
return next
}
const versionTenResult =
versionTenStoredApplicationSettingsSchema.safeParse(parsed)
if (versionTenResult.success) {
@@ -437,7 +457,7 @@ export class ApplicationSettingsStore {
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined',
@@ -482,6 +502,8 @@ export class ApplicationSettingsStore {
conversationHtmlRenderingEnabled:
stored.conversationHtmlRenderingEnabled,
remoteProjectsEnabled: stored.remoteProjectsEnabled,
applicationNavigation: stored.applicationNavigation,
localInferenceEnabled: stored.localInferenceEnabled,
magicNotesEnabled: stored.magicNotesEnabled,
magicNotesShowIncompleteTodoCount:
stored.magicNotesShowIncompleteTodoCount,
@@ -511,7 +533,7 @@ export class ApplicationSettingsStore {
}
await this.persist(next)
this.warnings = []
return {
const settings: ApplicationSettings = {
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
updateSource: next.updateSource,
modelDownloadSource: next.modelDownloadSource,
@@ -519,12 +541,16 @@ export class ApplicationSettingsStore {
conversationHtmlRenderingEnabled:
next.conversationHtmlRenderingEnabled,
remoteProjectsEnabled: next.remoteProjectsEnabled,
applicationNavigation: next.applicationNavigation,
localInferenceEnabled: next.localInferenceEnabled,
magicNotesEnabled: next.magicNotesEnabled,
magicNotesShowIncompleteTodoCount:
next.magicNotesShowIncompleteTodoCount,
magicNoteCommentMode: next.magicNoteCommentMode,
magicNoteCommentFormat: next.magicNoteCommentFormat
}
for (const listener of this.listeners) listener(settings)
return settings
})
this.updateQueue = operation.then(
() => undefined,
+265 -3
View File
@@ -6,6 +6,7 @@ import {
builtInDefaultProjectSeedName,
activityHistorySnapshotSchema,
assistantIdSchema,
executionStatsInputSchema,
conversationAnsweredQuestionSchema,
conversationMessageSchema,
conversationSnapshotSchema,
@@ -26,6 +27,8 @@ import type {
AssistantProject,
AssistantSchedule,
AssistantTask,
ExecutionStats,
ExecutionStatsInput,
ActivityHistorySnapshot,
ConversationQueueItem,
ConversationAnsweredQuestion,
@@ -4317,6 +4320,230 @@ export class AssistantDatabase {
return rows.map(toTask)
}
getExecutionStats(
input: ExecutionStatsInput,
activeRequestIds: ReadonlySet<string> = new Set()
): ExecutionStats {
const scope = executionStatsInputSchema.parse(input)
const result: ExecutionStats = {
durationMs: 0, requestCount: 0, incompleteRequestCount: 0,
activeRequestCount: 0, asOf: Date.now(), taskDurations: []
}
const database = this.requireDatabase()
const scopeId = 'conversationId' in scope ? scope.conversationId : scope.projectId
const scopeColumn = 'conversationId' in scope ? 'conversation_id' : 'project_id'
const durations = new Map<string, ExecutionStats['taskDurations'][number]>()
const observed = new Set<string>()
const coverage = new Map<string, number>()
const bucket = (conversationId: string | null, owner: string | null): string =>
JSON.stringify([conversationId, owner])
const add = (id: string | null, durationMs: number, incomplete: number): void => {
if (!id || !('projectId' in scope)) return
const entry = durations.get(id) ?? { id, durationMs: 0, incompleteRequestCount: 0 }
entry.durationMs += durationMs
entry.incompleteRequestCount += incomplete
durations.set(id, entry)
}
// Materialize task-level evidence once. Stream/tool payloads are never parsed;
// task_events_task_idx supplies request membership and boundary seeks.
const rows = database.prepare(`
WITH scoped AS MATERIALIZED (
SELECT t.id, t.conversation_id, t.visible, t.started_at, t.remote_recoverable,
COALESCE(parent.id, retained.id) AS schedule_task_id,
space.kind = 'ssh' AS ssh_project,
EXISTS (SELECT 1 FROM messages m
WHERE m.conversation_id = t.conversation_id AND m.request_id = t.id) AS linked,
EXISTS (SELECT 1 FROM task_events proof WHERE proof.task_id = t.id
AND proof.kind IN ('text', 'reasoning', 'tool', 'done', 'error',
'subagent', 'generated-image', 'question')) AS reply_event,
EXISTS (SELECT 1 FROM task_events remote WHERE remote.task_id = t.id
AND remote.remote_operation_id IS NOT NULL) AS remote_event,
(SELECT tail.created_at FROM task_events tail WHERE tail.task_id = t.id
ORDER BY tail.id DESC LIMIT 1) AS last_at
FROM tasks t
LEFT JOIN schedule_runs sr ON sr.id = t.id
LEFT JOIN tasks parent ON parent.schedule_id = sr.schedule_id
LEFT JOIN tasks retained ON retained.id = t.parent_task_id
AND retained.origin = 'schedule' AND retained.visible = 1
LEFT JOIN project_execution_spaces space ON space.project_id = t.project_id
WHERE t.${scopeColumn} = ?
AND t.schedule_id IS NULL AND t.origin != 'subagent'
AND NOT (t.origin = 'schedule' AND t.visible = 1)
)
SELECT scoped.*, e.kind, e.created_at,
CASE WHEN e.kind = 'status'
THEN json_extract(e.payload_json, '$.status') END AS event_status,
CASE WHEN e.kind = 'status' THEN
json_extract(e.payload_json, '$.requestId') = scoped.id
AND json_extract(e.payload_json, '$.type') = 'status'
AND json_type(e.payload_json, '$.message') = 'text'
END AS runtime_status,
CASE WHEN e.kind = 'status' THEN (
SELECT previous.created_at FROM task_events previous
WHERE previous.task_id = scoped.id AND previous.id < e.id
ORDER BY previous.id DESC LIMIT 1
) END AS previous_at
FROM scoped LEFT JOIN task_events e ON e.task_id = scoped.id
AND e.kind IN ('status', 'done', 'error')
ORDER BY scoped.id, e.id
`).iterate(scopeId)
let task: {
id: string; proven: boolean; remote: boolean; incomplete: boolean
start: number | undefined; last: number | undefined; duration: number
conversationId: string | null; owner: string | null
lastEvidence: number
} | undefined
const finish = (): void => {
if (!task?.proven) return
result.requestCount++
observed.add(task.id)
const key = bucket(task.conversationId, task.owner)
coverage.set(key, (coverage.get(key) ?? 0) + 1)
if (activeRequestIds.has(task.id)) result.activeRequestCount++
if (task.remote) {
task.duration = 0
task.incomplete = true
} else if (task.start !== undefined) {
if (Number.isFinite(task.lastEvidence) && task.lastEvidence >= task.start && task.lastEvidence <= result.asOf) {
task.last = Math.max(task.last ?? task.start, task.lastEvidence)
}
const live = activeRequestIds.has(task.id) && task.start <= result.asOf
task.duration += Math.max(0, (live ? result.asOf : task.last ?? task.start) - task.start)
task.incomplete ||= !live
}
result.durationMs += task.duration
if (task.incomplete) result.incompleteRequestCount++
add(task.owner ?? task.id, task.duration, Number(task.incomplete))
}
for (const raw of rows) {
const row = raw as unknown as {
id: string; started_at: string | null; remote_recoverable: number
linked: number; kind: string | null; created_at: string | null
reply_event: number; remote_event: number; last_at: string | null; previous_at: string | null
event_status: string | null; runtime_status: number | null; ssh_project: number | null
conversation_id: string | null; visible: number; schedule_task_id: string | null
}
if (task?.id !== row.id) {
finish()
const start = Date.parse(row.started_at ?? '')
task = {
id: row.id, proven: row.linked === 1 || row.reply_event === 1 || row.schedule_task_id !== null || activeRequestIds.has(row.id),
remote: row.remote_recoverable === 1 || row.remote_event === 1 || row.ssh_project === 1,
lastEvidence: Date.parse(row.last_at ?? ''),
conversationId: row.conversation_id,
owner: row.schedule_task_id ?? (row.visible === 1 ? row.id : null),
incomplete: !Number.isFinite(start),
start: Number.isFinite(start) ? start : undefined,
last: Number.isFinite(start) ? start : undefined, duration: 0
}
}
const current = task!
current.proven ||= row.runtime_status === 1
if (row.kind === null) continue
const status = row.event_status
// Recovery writes interrupted at restart, not when execution actually stopped.
if (status === 'interrupted') {
if (current.start !== undefined) {
const previous = Date.parse(row.previous_at ?? '')
if (Number.isFinite(previous) && previous >= current.start && previous <= result.asOf) {
current.last = Math.max(current.last ?? current.start, previous)
}
current.duration += Math.max(0, (current.last ?? current.start) - current.start)
}
current.start = undefined
current.last = undefined
current.incomplete = true
continue
}
const time = Date.parse(row.created_at ?? '')
if (!Number.isFinite(time) || time > result.asOf || (current.last !== undefined && time < current.last)) {
current.incomplete = true
continue
}
if (status === 'running' && current.start === undefined) current.start = time
const closes = row.kind === 'done' || row.kind === 'error' ||
['completed', 'failed', 'cancelled', 'paused', 'queued', 'idle'].includes(status ?? '')
if (closes && current.start !== undefined) {
current.duration += time - current.start
current.start = undefined
}
current.last = time
}
finish()
// A schedule run has an independent request ID. Its stable task is never a clock.
for (const row of database.prepare(`
SELECT t.id, t.conversation_id, t.origin, t.schedule_id,
sr.id AS run_id, sr.status AS run_status
FROM tasks t LEFT JOIN schedule_runs sr ON sr.schedule_id = t.schedule_id
WHERE t.${scopeColumn} = ? AND t.visible = 1 AND t.origin != 'subagent'
ORDER BY t.id, sr.id
`).iterate(scopeId)) {
const id = row.id as string
add(id, 0, 0)
const key = bucket(row.conversation_id as string | null, id)
// A removed plan without retained execution links cannot establish a zero.
if (row.origin === 'schedule' && row.schedule_id === null && !coverage.has(key)) {
result.requestCount++
result.incompleteRequestCount++
add(id, 0, 1)
coverage.set(key, 1)
}
if (row.run_id && ['completed', 'failed', 'running'].includes(row.run_status as string) && !observed.has(row.run_id as string)) {
observed.add(row.run_id as string)
result.requestCount++
result.incompleteRequestCount++
add(id, 0, 1)
coverage.set(key, (coverage.get(key) ?? 0) + 1)
}
}
// Local snapshots lack request IDs. Compare top-level assistant rows per
// conversation/task, never nested subagent blocks or child-task messages.
const messages = database.prepare(`
SELECT m.conversation_id, m.request_id,
COALESCE(parent.id, retained.id, json_extract(m.metadata_json, '$.task.id'),
CASE WHEN mt.visible = 1 THEN mt.id END) AS owner,
COUNT(*) AS count
FROM messages m JOIN conversations c ON c.id = m.conversation_id
LEFT JOIN tasks mt ON mt.id = m.request_id
LEFT JOIN schedule_runs sr ON sr.id = m.request_id
LEFT JOIN tasks parent ON parent.schedule_id = sr.schedule_id
LEFT JOIN tasks retained ON retained.id = mt.parent_task_id
AND retained.origin = 'schedule' AND retained.visible = 1
WHERE ${'conversationId' in scope ? 'm.conversation_id' : 'c.project_id'} = ?
AND m.role = 'assistant'
AND NOT (c.channel IS NULL AND m.sequence = 0 AND m.request_id IS NULL
AND json_extract(m.metadata_json, '$.task.id') IS NULL
AND m.content IN (?, ?))
AND COALESCE(mt.origin, '') != 'subagent'
AND NOT EXISTS (SELECT 1 FROM tasks child WHERE child.origin = 'subagent'
AND child.id = json_extract(m.metadata_json, '$.task.id'))
GROUP BY m.conversation_id, owner, m.request_id
ORDER BY m.request_id IS NULL
`).all(
scopeId,
// Persisted greetings have no marker. Match only shipped default copy,
// not arbitrary assistant-only/imported history or localized prefixes.
'你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件,或使用知识库整理和检索信息。需要我操作文件或调用工具时,请选择合适的 Agent Runtime 和工作模式。',
'Hi, I’m GoodBuddy. Ask me a question, add local files, or use your knowledge base to organize and retrieve information. When you want me to operate on files or use tools, choose the appropriate Agent Runtime and work mode.'
)
for (const row of messages) {
const owner = row.owner as string | null
const key = bucket(row.conversation_id as string, owner)
const available = coverage.get(key) ?? 0
const linked = row.request_id as string | null
const count = linked ? 1 : row.count as number
const covered = linked ? Number(observed.has(linked)) : Math.min(count, available)
coverage.set(key, Math.max(0, available - covered))
const missing = count - covered
result.requestCount += missing
result.incompleteRequestCount += missing
add(owner, 0, missing)
}
result.taskDurations = [...durations.values()]
return result
}
getActivityHistory(): ActivityHistorySnapshot {
const row = this.requireDatabase()
.prepare(
@@ -4424,13 +4651,18 @@ export class AssistantDatabase {
work_mode, progress, created_at, started_at, visible,
remote_recoverable, current_user_message_id,
current_assistant_message_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, NULL, ?, ?, ?,
VALUES (?, ?, ?, COALESCE((
SELECT parent.id FROM schedule_runs sr
JOIN tasks parent ON parent.schedule_id = sr.schedule_id
WHERE sr.id = ?
), ?), ?, ?, ?, ?, ?, ?, 0, ?, NULL, ?, ?, ?,
?, ?, ?)`
)
.run(
taskId,
input.projectId ?? null,
input.conversationId ?? null,
taskId,
input.parentTaskId ?? null,
input.expertId ?? null,
input.routingMode ?? null,
@@ -6486,7 +6718,7 @@ export class AssistantDatabase {
(id, project_id, task_template_json, timezone, recurrence_json,
next_run_at, missed_run_policy, enabled, last_run_at,
created_at, updated_at)
VALUES (?, ?, ?, 'UTC', ?, ?, 'run_once', 1, NULL, ?, ?)`
VALUES (?, ?, ?, 'UTC', ?, ?, 'run_once', ?, ?, ?, ?)`
)
.run(
scheduleId,
@@ -6497,7 +6729,11 @@ export class AssistantDatabase {
workMode: parsed.workMode
}),
JSON.stringify({ type: parsed.recurrence }),
parsed.nextRunAt,
parsed.runImmediately ? now : parsed.nextRunAt,
parsed.runImmediately ? 0 : 1,
// Immediate creation consumes the once trigger even if its queue item
// is cancelled. Further execution must use an explicit manual run.
parsed.runImmediately ? now : null,
now,
now
)
@@ -6521,6 +6757,22 @@ export class AssistantDatabase {
parsed.workMode,
now
)
if (parsed.runImmediately) {
// Commit the task and its only automatic occurrence together. The timer
// must never see an enabled immediate schedule between create and queue.
const runId = randomUUID()
database.prepare(
`INSERT INTO schedule_runs
(id, schedule_id, scheduled_for, task_id, status)
VALUES (?, ?, ?, ?, 'pending')`
).run(runId, scheduleId, now, taskId)
database.prepare(
`INSERT INTO conversation_queue_items
(id, conversation_id, source, label, payload_json,
schedule_run_id, schedule_id, task_id, status, created_at)
VALUES (?, ?, 'schedule', ?, '{}', ?, ?, ?, 'pending', ?)`
).run(runId, conversationId, parsed.title, runId, scheduleId, taskId, now)
}
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
@@ -6605,6 +6857,16 @@ export class AssistantDatabase {
if (activeRun) {
throw new Error('定时任务正在运行,完成后才能删除计划')
}
// Preserve older requests' proven run ownership before the cascade removes it.
database.prepare(`
UPDATE tasks AS request SET parent_task_id = (
SELECT parent.id FROM schedule_runs sr
JOIN tasks parent ON parent.schedule_id = sr.schedule_id
WHERE sr.id = request.id AND sr.schedule_id = ?
)
WHERE request.parent_task_id IS NULL
AND request.id IN (SELECT id FROM schedule_runs WHERE schedule_id = ?)
`).run(scheduleId, scheduleId)
database
.prepare(
`UPDATE tasks
@@ -0,0 +1,390 @@
import { randomUUID } from 'node:crypto'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { performance } from 'node:perf_hooks'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AssistantDatabase } from './assistant-database'
describe('execution duration evidence', () => {
let directory: string
let database: AssistantDatabase
let projectId: string
let conversationId: string
const epoch = Date.parse('2026-09-17T00:00:00Z')
const at = (seconds: number): void => { vi.setSystemTime(epoch + seconds * 1000) }
const request = (overrides: Partial<Parameters<AssistantDatabase['createTask']>[0]> = {}): string => {
const id = randomUUID()
database.createTask({ id, projectId, conversationId, title: 'Reply', instructions: 'Reply', workMode: 'ask', visible: false, ...overrides })
return id
}
const text = (id: string): void => database.appendTaskEvent(id, 'text', { requestId: id, type: 'text', delta: 'Reply' })
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'goodbuddy-execution-stats-'))
database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
database.initialize(directory)
projectId = database.listProjects()[0]!.id
conversationId = randomUUID()
vi.useFakeTimers({ toFake: ['Date'] })
at(0)
})
afterEach(async () => {
database.close()
vi.useRealTimers()
await rm(directory, { recursive: true, force: true })
})
it('sums hidden replies without round idle, counts approval/tool time, and scopes projects', () => {
const first = request()
text(first)
at(2)
database.updateTaskStatus(first, 'waiting_approval')
at(8)
database.updateTaskStatus(first, 'running')
at(10)
database.updateTaskStatus(first, 'completed')
at(100)
const second = request()
text(second)
at(105)
database.updateTaskStatus(second, 'failed')
const anotherConversation = request({ conversationId: randomUUID() })
text(anotherConversation)
at(108)
database.updateTaskStatus(anotherConversation, 'cancelled')
const unrelated = request({ projectId: undefined, conversationId: randomUUID() })
text(unrelated)
at(120)
database.updateTaskStatus(unrelated, 'completed')
expect(database.listTasks()).toEqual([])
expect(database.getExecutionStats({ conversationId })).toEqual({
durationMs: 15000, requestCount: 2, incompleteRequestCount: 0,
activeRequestCount: 0, asOf: epoch + 120000, taskDurations: []
})
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 18000, requestCount: 3 })
expect(database.getExecutionStats({ conversationId: randomUUID() })).toMatchObject({ durationMs: 0, requestCount: 0 })
})
it('excludes stable schedules, nested experts, and status-only maintenance but includes scheduled reply requests', () => {
const schedule = database.createSchedule({ projectId, title: 'Schedule', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
database.updateTaskStatus(schedule.taskId, 'running')
text(schedule.taskId)
const child = request({ origin: 'subagent', parentTaskId: schedule.taskId })
text(child)
const compact = request({ title: 'Compressed context' })
const actual = request({ conversationId: schedule.conversationId, parentTaskId: schedule.taskId })
text(actual)
at(10)
for (const id of [schedule.taskId, child, compact, actual]) database.updateTaskStatus(id, 'completed')
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 10000, requestCount: 1, incompleteRequestCount: 0 })
})
it('uses message request identity when a reply has no runtime events and does not use message timestamps', () => {
const id = request()
database.saveLocalConversations([{
header: { id: conversationId, projectId, title: 'Chat', updatedAt: epoch },
messages: [{ id: randomUUID(), role: 'assistant', content: '', state: 'error', createdAt: epoch - 86400000 }]
}])
// Only persisted request links are evidence; local snapshot saves do not supply them.
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
raw.prepare('UPDATE messages SET request_id = ? WHERE conversation_id = ?').run(id, conversationId)
raw.close()
at(3)
database.updateTaskStatus(id, 'failed')
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 3000, requestCount: 1, incompleteRequestCount: 0 })
})
it('keeps only evidenced time for open and restart-interrupted replies', () => {
const id = request()
at(5)
text(id)
at(100)
expect(database.getExecutionStats({ conversationId }, new Set([id]))).toMatchObject({ durationMs: 100000, requestCount: 1, incompleteRequestCount: 0, activeRequestCount: 1 })
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, incompleteRequestCount: 1, activeRequestCount: 0 })
database.close()
at(86400)
database.initialize(directory)
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1, activeRequestCount: 0 })
// A later resumed segment cannot include the shutdown/restart gap.
at(86500)
database.updateTaskStatus(id, 'running')
at(86510)
database.updateTaskStatus(id, 'completed')
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 15000, incompleteRequestCount: 1 })
})
it('segments repeated runs and pauses without using the final completed_at span', () => {
const id = request()
text(id)
at(5)
database.updateTaskStatus(id, 'paused')
at(100)
database.updateTaskStatus(id, 'running')
at(107)
database.updateTaskStatus(id, 'completed')
at(200)
database.updateTaskStatus(id, 'running')
at(203)
database.updateTaskStatus(id, 'completed')
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 15000, requestCount: 1, incompleteRequestCount: 0 })
})
it('does not substitute remote replay receipt time or unknown starts for actual execution time', () => {
const remote = request()
at(86400)
database.appendRemoteTaskEventOnce({ taskId: remote, bindingId: randomUUID(), operationId: randomUUID(), semanticSequence: '1', eventIndex: 0, kind: 'done', payload: { requestId: remote, type: 'done' } })
database.updateTaskStatus(remote, 'completed')
const unknown = request()
text(unknown)
at(86410)
database.updateTaskStatus(unknown, 'completed')
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
raw.prepare('UPDATE tasks SET started_at = NULL WHERE id = ?').run(unknown)
raw.close()
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 0, requestCount: 2, incompleteRequestCount: 2 })
})
it('includes all retained requests beyond list pagination', () => {
for (let index = 0; index < 105; index++) {
at(index * 10)
const id = request()
text(id)
at(index * 10 + 1)
database.updateTaskStatus(id, 'completed')
}
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 105000, requestCount: 105, incompleteRequestCount: 0 })
})
it('groups real schedule request IDs into stable cards across runs without parent lifecycle time', () => {
const schedule = database.createSchedule({ projectId, title: 'Schedule', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
const idle = database.createSchedule({ projectId, conversationId: schedule.conversationId, title: 'Idle', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch + 86400000).toISOString() })
for (const start of [100, 1000]) {
at(start)
const queued = database.queueScheduleNow(schedule.id)
database.claimConversationQueueItem(schedule.conversationId)
database.completeConversationUserQueueItem(queued.id)
// Production agentRun uses scheduleRunId as requestId, without parentTaskId.
const id = queued.scheduleRunId!
request({ id, conversationId: schedule.conversationId })
database.updateTaskStatus(schedule.taskId, 'running')
text(id)
at(start + 10)
database.updateTaskStatus(id, 'completed')
database.completeTaskScheduleRun(id)
}
const live = database.queueScheduleNow(schedule.id)
database.claimConversationQueueItem(schedule.conversationId)
request({ id: live.scheduleRunId!, conversationId: schedule.conversationId })
text(live.scheduleRunId!)
at(1025)
const stats = database.getExecutionStats({ projectId }, new Set([live.scheduleRunId!]))
expect(stats).toMatchObject({ durationMs: 35000, requestCount: 3, incompleteRequestCount: 0, activeRequestCount: 1 })
expect(stats.taskDurations).toEqual(expect.arrayContaining([
{ id: schedule.taskId, durationMs: 35000, incompleteRequestCount: 0 },
{ id: idle.taskId, durationMs: 0, incompleteRequestCount: 0 }
]))
expect(stats.taskDurations.some(item => item.id === live.scheduleRunId)).toBe(false)
})
it.each([false, true])('retains schedule execution totals after plan deletion (repair old link: %s)', (oldLink) => {
const schedule = database.createSchedule({ projectId, title: 'Removable plan', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
at(100)
const run = database.queueScheduleNow(schedule.id)
database.claimConversationQueueItem(schedule.conversationId)
const task = database.createTask({ id: run.scheduleRunId!, projectId, conversationId: schedule.conversationId, title: 'Run', instructions: 'Reply', workMode: 'ask', visible: false })
expect(task.parentTaskId).toBe(schedule.taskId)
text(task.id)
at(110)
database.updateTaskStatus(task.id, 'completed')
database.completeTaskScheduleRun(task.id)
if (oldLink) {
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
raw.prepare('UPDATE tasks SET parent_task_id = NULL WHERE id = ?').run(task.id)
raw.close()
}
const before = database.getExecutionStats({ projectId })
at(10000)
database.removeSchedule(schedule.id)
expect(database.getExecutionStats({ projectId })).toEqual({ ...before, asOf: epoch + 10000000 })
expect(before.taskDurations).toEqual([{ id: schedule.taskId, durationMs: 10000, incompleteRequestCount: 0 }])
database.close()
database.initialize(directory)
expect(database.getExecutionStats({ projectId }).taskDurations).toEqual(before.taskDurations)
})
it('does not claim a complete zero for an already removed plan with no retained execution relation', () => {
const schedule = database.createSchedule({ projectId, title: 'Old removed plan', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
database.removeSchedule(schedule.id)
expect(database.getExecutionStats({ projectId }).taskDurations).toEqual([{ id: schedule.taskId, durationMs: 0, incompleteRequestCount: 1 }])
})
it('recognizes runtime status-only replies but not maintenance statuses at restart', () => {
const id = request()
const maintenance = request({ title: 'Maintenance' })
at(5)
database.appendTaskEvent(id, 'status', { requestId: id, type: 'status', message: 'Processing request' })
database.updateTaskStatus(maintenance, 'running')
database.close()
at(10000)
database.initialize(directory)
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
})
it('marks SSH channel timing unknown before the first remote provenance and keeps that result on replay', () => {
const project = database.createSshProject({
project: { name: 'SSH project', description: '', rootPath: '/srv/workspace', defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' } },
executionSpace: { kind: 'ssh', hostId: randomUUID(), remoteRootPath: '/srv/workspace' },
assertCurrent: () => {}
})
const conversation = database.getOrCreateRemoteConversation({ projectId: project.id, channel: 'wecom', accountId: 'account', externalConversationId: 'external', conversationType: 'direct', title: 'SSH channel', accountDisplay: 'Account' })
const id = request({ projectId: project.id, conversationId: conversation.id, origin: 'delegation' })
at(5)
database.appendTaskEvent(id, 'status', { requestId: id, type: 'status', message: 'Remote Agent is processing' })
const expected = { durationMs: 0, requestCount: 1, incompleteRequestCount: 1, activeRequestCount: 1 }
expect(database.getExecutionStats({ projectId: project.id }, new Set([id]))).toMatchObject(expected)
at(100)
database.appendRemoteTaskEventOnce({ taskId: id, bindingId: randomUUID(), operationId: randomUUID(), semanticSequence: '1', eventIndex: 0, kind: 'text', payload: { requestId: id, type: 'text', delta: 'Reply' } })
expect(database.getExecutionStats({ projectId: project.id }, new Set([id]))).toMatchObject(expected)
database.updateTaskStatus(id, 'completed')
expect(database.getExecutionStats({ conversationId: conversation.id })).toMatchObject({ ...expected, activeRequestCount: 0 })
})
it('marks retained assistant replies without request history incomplete and ignores nested subagent content', () => {
const known = request()
text(known)
at(2)
database.updateTaskStatus(known, 'completed')
const child = request({ origin: 'subagent', parentTaskId: known })
text(child)
database.updateTaskStatus(child, 'completed')
const childMessage = randomUUID()
database.saveLocalConversations([{
header: { id: conversationId, projectId, title: 'Retained', updatedAt: epoch },
messages: [
{ id: randomUUID(), role: 'assistant', content: 'Known reply', state: 'complete', createdAt: epoch, subagents: [] },
{ id: randomUUID(), role: 'assistant', content: 'Older reply without a task', state: 'complete', createdAt: epoch },
{ id: childMessage, role: 'assistant', content: 'Child reply', state: 'complete', createdAt: epoch }
]
}])
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
raw.prepare('UPDATE messages SET request_id = ? WHERE id = ?').run(child, childMessage)
raw.close()
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 2000, requestCount: 2, incompleteRequestCount: 1 })
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 2000, requestCount: 2, incompleteRequestCount: 1 })
})
it('flags missing schedule execution once even when its assistant message is retained', () => {
const schedule = database.createSchedule({ projectId, title: 'Lost run', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
const queued = database.queueScheduleNow(schedule.id)
database.claimConversationQueueItem(schedule.conversationId)
database.completeScheduleRun(queued.scheduleRunId!, 'completed')
database.saveLocalConversations([{
header: { id: schedule.conversationId, projectId, title: 'Lost run', updatedAt: epoch },
messages: [{ id: randomUUID(), role: 'assistant', content: 'Retained result', state: 'complete', createdAt: epoch, task: { id: schedule.taskId, title: 'Lost run' } }]
}])
expect(database.getExecutionStats({ projectId })).toMatchObject({
durationMs: 0, requestCount: 1, incompleteRequestCount: 1,
taskDurations: [{ id: schedule.taskId, durationMs: 0, incompleteRequestCount: 1 }]
})
})
it('cannot make a remote or missing-start request complete just because it has a live lease', () => {
const remote = request()
database.appendRemoteTaskEventOnce({ taskId: remote, bindingId: randomUUID(), operationId: randomUUID(), semanticSequence: '1', eventIndex: 0, kind: 'text', payload: { requestId: remote, type: 'text', delta: 'Remote' } })
const unknown = request()
text(unknown)
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
raw.prepare('UPDATE tasks SET started_at = NULL WHERE id = ?').run(unknown)
raw.close()
at(100)
expect(database.getExecutionStats({ conversationId }, new Set([remote, unknown]))).toMatchObject({ durationMs: 0, incompleteRequestCount: 2, activeRequestCount: 2 })
})
it('counts confirmed live replies before their first event without counting maintenance leases', () => {
const id = request()
request({ title: 'Context compression' })
at(10)
expect(database.getExecutionStats({ conversationId }, new Set([id]))).toMatchObject({ durationMs: 10000, requestCount: 1, incompleteRequestCount: 0, activeRequestCount: 1 })
})
it('reports wholly missing reply history and task attribution without using message timestamps', () => {
const schedule = database.createSchedule({ projectId, title: 'Old task', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
database.saveLocalConversations([{
header: { id: schedule.conversationId, projectId, title: 'Old conversation', updatedAt: epoch },
messages: [{ id: randomUUID(), role: 'assistant', content: 'Old reply', state: 'complete', createdAt: epoch - 100000, task: { id: schedule.taskId, title: 'Old task' } }]
}])
expect(database.getExecutionStats({ conversationId: schedule.conversationId })).toMatchObject({ durationMs: 0, requestCount: 1, incompleteRequestCount: 1 })
expect(database.getExecutionStats({ projectId }).taskDurations).toEqual([{ id: schedule.taskId, durationMs: 0, incompleteRequestCount: 1 }])
})
it('does not let request coverage in another conversation conceal missing replies', () => {
const id = request()
text(id)
at(1)
database.updateTaskStatus(id, 'completed')
database.saveLocalConversations([{
header: { id: randomUUID(), projectId, title: 'Other conversation', updatedAt: epoch },
messages: [{ id: randomUUID(), role: 'assistant', content: 'Missing history', state: 'complete', createdAt: epoch }]
}])
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 1000, requestCount: 2, incompleteRequestCount: 1 })
})
it.each([
['zh-CN', '你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件,或使用知识库整理和检索信息。需要我操作文件或调用工具时,请选择合适的 Agent Runtime 和工作模式。'],
['en-US', 'Hi, I’m GoodBuddy. Ask me a question, add local files, or use your knowledge base to organize and retrieve information. When you want me to operate on files or use tools, choose the appropriate Agent Runtime and work mode.']
])('excludes the persisted default greeting but retains unknown reply history (%s)', async (locale, greeting) => {
expect(await readFile(join(process.cwd(), 'src/renderer/src/i18n/locales', locale, 'app.ts'), 'utf8')).toContain(greeting)
const header = { id: conversationId, projectId, title: 'New conversation', updatedAt: epoch }
const welcome = { id: randomUUID(), role: 'assistant' as const, content: greeting, state: 'complete' as const, createdAt: epoch }
database.saveLocalConversations([{ header, messages: [welcome] }])
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 0, requestCount: 0, incompleteRequestCount: 0 })
database.saveLocalConversations([{ header, messages: [welcome,
{ id: randomUUID(), role: 'user', content: 'Question', state: 'complete', createdAt: epoch + 1 },
{ id: randomUUID(), role: 'assistant', content: greeting, state: 'complete', createdAt: epoch + 2 }
] }])
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 0, requestCount: 1, incompleteRequestCount: 1 })
})
it('seeks interruption boundaries without parsing large tool/delta payloads on repeated scope queries', () => {
const id = request()
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
const prepare = vi.spyOn(DatabaseSync.prototype, 'prepare')
try {
const insert = raw.prepare('INSERT INTO task_events (task_id, kind, payload_json, created_at) VALUES (?, ?, ?, ?)')
const payload = JSON.stringify({ requestId: id, output: 'x'.repeat(32768) })
raw.exec('BEGIN')
for (let index = 0; index < 3000; index++) {
insert.run(id, index % 2 ? 'text' : 'tool', payload, new Date(epoch + 5000).toISOString())
}
raw.exec('COMMIT')
at(100)
database.updateTaskStatus(id, 'interrupted')
const baseline = raw.prepare("SELECT SUM(length(json_extract(payload_json, '$.requestId'))) FROM task_events WHERE task_id = ?")
const baselineStart = performance.now()
baseline.get(id)
baseline.get(id)
const baselineMs = performance.now() - baselineStart
// One visible-panel tick reads both scopes; repeat to exercise warm polling.
const start = performance.now()
for (let iteration = 0; iteration < 5; iteration++) {
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
}
const pairMs = (performance.now() - start) / 5
const query = prepare.mock.calls.find(([sql]) => sql.includes('WITH scoped AS MATERIALIZED'))![0]
const plan = raw.prepare(`EXPLAIN QUERY PLAN ${query}`).all(projectId).map(row => row.detail as string)
for (const alias of ['proof', 'remote', 'tail', 'previous', 'e']) {
expect(plan.some(detail => detail.includes(`SEARCH ${alias} USING INDEX task_events_task_idx`))).toBe(true)
}
// Malformed bodies make accidental JSON extraction fail deterministically.
raw.prepare("UPDATE task_events SET payload_json = '{unparsed' WHERE task_id = ? AND kind IN ('text', 'tool')").run(id)
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
console.info(`execution stats: 3000 events, ${Math.round(payload.length * 3000 / 1024 / 1024)} MiB payload; old JSON extraction pair ${baselineMs.toFixed(1)} ms; scalar scope pair ${pairMs.toFixed(1)} ms`)
} finally {
prepare.mockRestore()
raw.close()
}
})
})
@@ -76,7 +76,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
database.close()
const raw = new DatabaseSync(path)
raw.exec('PRAGMA user_version = 2')
raw.exec('ALTER TABLE conversations DROP COLUMN pinned; PRAGMA user_version = 2')
raw.close()
const migrated = new AssistantDatabase(path)
@@ -128,7 +128,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
'DELETE FROM heartbeat_config_projects WHERE config_id = ?'
)
.run(config.id)
raw.exec('PRAGMA user_version = 20')
raw.exec('ALTER TABLE conversations DROP COLUMN pinned; PRAGMA user_version = 20')
raw.close()
const migrated = new AssistantDatabase(path)
@@ -0,0 +1,138 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { describe, expect, it } from 'vitest'
import { AssistantDatabase } from './assistant-database'
const input = {
title: 'Immediate task',
prompt: 'Run exactly once',
recurrence: 'once' as const,
nextRunAt: '2020-01-01T00:00:00.000Z',
runImmediately: true
}
describe('immediate schedule creation', () => {
it('commits one real queue occurrence and never rematerializes it on scheduler ticks', () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
try {
const schedule = database.createSchedule(input)
expect(schedule.enabled).toBe(false)
expect(schedule).not.toHaveProperty('runImmediately')
const [item] = database.listConversationQueueItems(schedule.conversationId)
expect(item).toMatchObject({ scheduleId: schedule.id, taskId: schedule.taskId })
expect(database.listTasks().find(task => task.id === schedule.taskId)?.status).toBe('queued')
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
const claimed = database.claimConversationQueueItem(schedule.conversationId)
expect(claimed).toMatchObject({ source: 'schedule', runId: item!.id })
expect(database.claimConversationQueueItem(schedule.conversationId)).toBeUndefined()
database.completeScheduleRun(item!.id, 'completed')
expect(database.listTasks().find(task => task.id === schedule.taskId)?.status).toBe('completed')
expect(database.listSchedules()[0]).toMatchObject({ enabled: false, lastRunAt: expect.any(String) })
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
expect(database.listConversationQueueItems()).toEqual([])
} finally {
database.close()
}
})
it('rolls back conversation, task and schedule when queue insertion fails, so retry creates only one', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-schedule-create-'))
const path = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(path)
database.initialize(process.cwd())
const sql = new DatabaseSync(path)
try {
sql.exec(`CREATE TRIGGER reject_queue BEFORE INSERT ON conversation_queue_items
BEGIN SELECT RAISE(ABORT, 'queue unavailable'); END;`)
expect(() => database.createSchedule(input)).toThrow('queue unavailable')
expect(database.listSchedules()).toEqual([])
expect(database.listTasks()).toEqual([])
expect(database.listConversations()).toEqual([])
expect(sql.prepare('SELECT COUNT(*) AS count FROM schedule_runs').get()?.count).toBe(0)
sql.exec('DROP TRIGGER reject_queue')
database.createSchedule(input)
expect(database.listSchedules()).toHaveLength(1)
expect(database.listTasks()).toHaveLength(1)
expect(database.listConversations()).toHaveLength(1)
expect(database.listConversationQueueItems()).toHaveLength(1)
} finally {
sql.close()
database.close()
await rm(directory, { recursive: true, force: true })
}
})
it('consumes the immediate trigger even when cancelled and requires an explicit new run', () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
try {
const schedule = database.createSchedule(input)
expect(schedule.lastRunAt).toBe(schedule.nextRunAt)
const [item] = database.listConversationQueueItems(schedule.conversationId)
database.cancelConversationQueueItem(item!.id)
expect(database.listSchedules()[0]).toMatchObject({
enabled: false, lastRunAt: schedule.lastRunAt
})
expect(database.getTask(schedule.taskId).status).toBe('cancelled')
expect(() => database.setScheduleEnabled(schedule.id, true)).toThrow(
'已执行的一次性计划不能恢复自动运行'
)
expect(database.getTask(schedule.taskId).status).toBe('cancelled')
const tomorrow = new Date(Date.parse(schedule.nextRunAt) + 86_400_000)
expect(database.queueDueSchedules(tomorrow)).toEqual([])
const retry = database.queueScheduleNow(schedule.id)
expect(retry.id).not.toBe(item!.id)
expect(database.listSchedules()).toHaveLength(1)
expect(database.listConversationQueueItems()).toHaveLength(1)
expect(database.getTask(schedule.taskId).status).toBe('queued')
expect(database.claimConversationQueueItem(schedule.conversationId)).toMatchObject({
runId: retry.id
})
database.completeScheduleRun(retry.id, 'completed')
expect(database.getTask(schedule.taskId).status).toBe('completed')
expect(database.queueDueSchedules(tomorrow)).toEqual([])
} finally {
database.close()
}
})
it.each(['once', 'daily', 'weekly'] as const)('preserves pause and manual-run cancellation for a future %s schedule', (recurrence) => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
try {
const nextRunAt = new Date(Date.now() + 86_400_000).toISOString()
const schedule = database.createSchedule({
...input, runImmediately: false, recurrence, nextRunAt
})
database.setScheduleEnabled(schedule.id, false)
const manual = database.queueScheduleNow(schedule.id)
database.cancelConversationQueueItem(manual.id)
expect(database.listSchedules()[0]).toMatchObject({
enabled: false, lastRunAt: undefined, nextRunAt
})
expect(database.queueDueSchedules(new Date(nextRunAt))).toEqual([])
database.setScheduleEnabled(schedule.id, true)
expect(database.queueDueSchedules(new Date(nextRunAt))).toHaveLength(1)
} finally {
database.close()
}
})
it('keeps scheduled creation enabled and rejects recurring immediate requests', () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
try {
expect(() => database.createSchedule({ ...input, recurrence: 'daily' })).toThrow('立即执行只支持单次任务')
const schedule = database.createSchedule({ ...input, runImmediately: false })
expect(schedule.enabled).toBe(true)
expect(database.listConversationQueueItems()).toEqual([])
expect(database.queueDueSchedules()).toHaveLength(1)
} finally {
database.close()
}
})
})
+3 -2
View File
@@ -1,4 +1,5 @@
import type { BrowserWindow } from 'electron'
import { localInferenceService } from './local-inference-service'
import { ipcChannels } from '../shared/ipc-channels'
import {
documentOcrFailureSchema,
@@ -59,7 +60,7 @@ export class DocumentOcrBroker {
1_000
)
)
return new Promise<DocumentOcrResult>((resolve, reject) => {
return localInferenceService.run('ocr', '文档解析', () => new Promise<DocumentOcrResult>((resolve, reject) => {
const onAbort = (): void =>
this.cancelRequest(request.requestId, 'OCR 解析已取消')
signal?.addEventListener('abort', onAbort, { once: true })
@@ -78,7 +79,7 @@ export class DocumentOcrBroker {
return
}
this.dispatchNext()
})
}), { model: request.modelId, cancelUnavailableReason: '现有 OCR 取消会终止共享 Worker;管理页不将服务释放用作单任务取消。' })
}
respond(input: unknown): void {
+17
View File
@@ -96,6 +96,23 @@ afterEach(async () => {
})
describe('GoodBuddyConfigService', () => {
it('publishes persisted application changes from config apply through the settings event', async () => {
const { application, service, workspace } = await createHarness()
const changed = vi.fn()
const unsubscribe = application.onChanged(changed)
const current = await application.get()
const applicationNavigation = { ...current.applicationNavigation, order: [...current.applicationNavigation.order].reverse() }
const plan = await service.plan('settings-sync', workspace, {
operations: [{ operation: 'application.update', updates: { applicationNavigation } }],
})
expect(changed).not.toHaveBeenCalled()
await service.apply('settings-sync', { planId: plan.planId }, new AbortController().signal, async () => true)
expect(changed).toHaveBeenCalledExactlyOnceWith(await application.get())
expect(changed.mock.calls[0]![0].applicationNavigation).toEqual(applicationNavigation)
unsubscribe()
await application.update({ localInferenceEnabled: false })
expect(changed).toHaveBeenCalledOnce()
})
it('returns only sanitized configuration and publishes common examples', async () => {
const { service, capabilities } = await createHarness()
await capabilities.saveMcpServer(undefined, {
+67 -53
View File
@@ -1,3 +1,5 @@
import { localInferenceService } from './local-inference-service'
import { createEmbeddingUtilityTransport } from './knowledge/embedding-utility-transport'
import {
app,
BrowserWindow,
@@ -45,10 +47,7 @@ import { OpenAIEmbeddingClient } from './knowledge/openai-embedding-client'
import { embeddingProviderFingerprint } from './knowledge/embedding-provider-key'
import type { EmbeddingProvider } from './knowledge/types'
import { EmbeddingModelManager } from './knowledge/embedding-model-manager'
import {
EmbeddingInferenceBroker,
type EmbeddingInferenceTransport
} from './knowledge/embedding-inference-broker'
import { EmbeddingInferenceBroker } from './knowledge/embedding-inference-broker'
import { CohereRerankClient } from './knowledge/cohere-rerank-client'
import { RuntimeSettingsStore } from './runtime-settings-store'
import type { ResolvedRuntimeSettings } from './runtime-settings-store'
@@ -220,6 +219,7 @@ let documentOcrBroker: DocumentOcrBroker | undefined
let documentOcrModelManager: DocumentOcrModelManager | undefined
let embeddingModelManager: EmbeddingModelManager | undefined
const embeddingBrokers = new Set<EmbeddingInferenceBroker>()
const sharedEmbeddingBrokers = new Map<string, { broker: EmbeddingInferenceBroker; users: number; unregister: () => void }>()
let stopRuntimeReconfiguration: (() => Promise<void>) | undefined
let dshExtensionInstaller: DshNpmExtensionInstaller | undefined
let remoteAgentServices: RemoteAgentServices | undefined
@@ -234,47 +234,6 @@ type ManagedEmbeddingProvider = EmbeddingProvider & {
dispose?: () => void | Promise<void>
}
function createEmbeddingUtilityTransport(
modulePath: string,
modelDirectory: string
): EmbeddingInferenceTransport {
const child = utilityProcess.fork(
modulePath,
[modelDirectory],
{
serviceName: 'GoodBuddy Embedding Inference',
stdio: 'pipe',
allowLoadingUnsignedLibraries: false
}
)
return {
postMessage: (message) => child.postMessage(message),
onMessage: (listener) => {
child.on('message', listener)
return () => child.removeListener('message', listener)
},
onClose: (listener) => {
let closed = false
const notify = (): void => {
if (closed) {
return
}
closed = true
listener()
}
child.on('exit', notify)
child.on('error', notify)
return () => {
child.removeListener('exit', notify)
child.removeListener('error', notify)
}
},
close: () => {
child.kill()
}
}
}
async function createEmbeddingProvider(
settings: ResolvedRuntimeSettings,
manager: EmbeddingModelManager,
@@ -320,14 +279,38 @@ async function createEmbeddingProvider(
if (!tokenizerDigest || !modelDigest) {
throw new Error('内置向量模型安装清单不完整')
}
const broker = new EmbeddingInferenceBroker({
createTransport: () =>
createEmbeddingUtilityTransport(
join(mainModuleDirectory, 'embedding-inference-bootstrap.js'),
modelDirectory
)
})
embeddingBrokers.add(broker)
let shared = sharedEmbeddingBrokers.get(modelDirectory)
if (!shared) {
const broker = new EmbeddingInferenceBroker({
createTransport: () =>
createEmbeddingUtilityTransport(
join(mainModuleDirectory, 'embedding-inference-bootstrap.js'),
modelDirectory
)
})
embeddingBrokers.add(broker)
const unregister = localInferenceService.register('embedding', {
snapshot: () => ({
id: 'embedding', name: '向量生成', engine: 'Granite / ONNX',
ownership: 'managed-process', state: broker.getManagementState(), model: model.id,
error: broker.getManagementError(),
resources: broker.getResources(),
detail: 'GoodBuddy 托管进程,由知识检索与索引共享。进程启动后模型在首个请求时加载;资源指标为整个向量服务进程占用。停止会中断使用方任务,启动不重放任务。',
actions: broker.getManagementState() === 'stopped' || broker.getManagementState() === 'idle' || broker.getManagementState() === 'error'
? ['start'] : broker.getManagementState() === 'running' ? ['stop', 'restart'] : []
}),
act: async (action) => {
if (action !== 'start') await broker.stop()
if (action !== 'stop') await broker.start()
}
})
shared = { broker, users: 0, unregister }
sharedEmbeddingBrokers.set(modelDirectory, shared)
}
shared.users += 1
const sharedBroker = shared
const broker = shared.broker
let disposed = false
return {
provider: 'builtin',
model: model.id,
@@ -354,6 +337,12 @@ async function createEmbeddingProvider(
embedDocuments: (texts, signal) =>
broker.embed(texts, 'document', signal),
dispose: async () => {
if (disposed) return
disposed = true
sharedBroker.users -= 1
if (sharedBroker.users > 0) return
sharedEmbeddingBrokers.delete(modelDirectory)
sharedBroker.unregister()
try {
await broker.shutdown()
} finally {
@@ -803,6 +792,19 @@ if (hasSingleInstanceLock) {
documentOcrModelManager,
documentOcrBroker
)
localInferenceService.register('ocr', {
snapshot: async () => {
const snapshot = await documentParsingService.snapshot()
const model = snapshot.status.localOcr
return {
id: 'ocr', name: '文字识别', engine: 'PaddleOCR / ONNX Web', ownership: 'renderer-worker',
resources: { scope: 'unavailable', reason: 'Web Worker 与界面共享渲染进程,无法独立统计 CPU / 内存' },
state: model.available ? 'unknown' : 'unavailable', model: model.id,
detail: `${model.detail}。模型由 Renderer Worker 持有,文档解析按需加载,空闲 60 秒自动释放;仅允许无活动任务时手动释放。`,
actions: []
}
}
})
const versionChecker = new VersionChecker({
fetch: globalThis.fetch,
currentVersion: app.getVersion(),
@@ -818,6 +820,18 @@ if (hasSingleInstanceLock) {
const speechTranscriptionService = new SpeechTranscriptionService(
speechModelManager
)
localInferenceService.register('asr', {
snapshot: async () => {
const model = await speechModelManager.getSelectedRuntimeModel()
return {
id: 'asr', name: '语音识别', engine: 'sherpa-onnx', ownership: 'request-worker',
resources: { scope: 'unavailable', reason: '识别线程与主进程共享资源,无法独立统计 CPU / 内存' },
state: model ? 'unknown' : 'unavailable', model: model?.id,
detail: model ? '每个识别请求独立加载模型,完成后释放;支持取消单次识别,没有常驻服务可启停。' : '请在设置中安装并选择本地语音模型。',
actions: []
}
}
})
browserService = new BrowserService({ parentWindow: mainWindow })
const bundledRuntimePaths = resolveBundledRuntimePaths({
appPath: app.getAppPath(),
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import type { ProcessMetric } from 'electron'
import { createInferenceProcessSampler } from './inference-process-resources'
describe('inference process resources', () => {
it('measures only the utility PID, warms CPU and converts KiB to bytes', () => {
let time = 0
const metric: ProcessMetric = { pid: 42, type: 'Utility', creationTime: 1, cpu: { cumulativeCPUUsage: 2, percentCPUUsage: 0, idleWakeupsPerSecond: 0 }, memory: { workingSetSize: 2048, peakWorkingSetSize: 2048 } }
const sample = createInferenceProcessSampler(() => 42, () => [{ ...metric, pid: 99 }, metric], () => time)
expect(sample()).toMatchObject({ scope: 'service-process', pid: 42, workingSetBytes: 2097152, cpuUnavailableReason: '正在建立采样基线' })
time = 5100
metric.cpu.cumulativeCPUUsage = 9.65
expect(sample()).toMatchObject({ cpuPercent: 150 })
time = 21000
expect(sample()).toMatchObject({ cpuUnavailableReason: '正在建立采样基线' })
time = 26000
metric.creationTime = 2
expect(sample()).not.toHaveProperty('cpuPercent')
time = 31000
metric.cpu.cumulativeCPUUsage = 0
expect(sample()).not.toHaveProperty('cpuPercent')
})
it('clears baselines on exit, missing metrics and failure; preserves partial readings', () => {
let pid: number | undefined = 42
let time = 0
let fail = false
const metric: ProcessMetric = { pid: 42, type: 'Utility', creationTime: 1, cpu: { percentCPUUsage: 0, idleWakeupsPerSecond: 0 }, memory: { workingSetSize: 1024, peakWorkingSetSize: 1024 } }
const sample = createInferenceProcessSampler(() => pid, () => { if (fail) throw new Error('sampling'); return [metric] }, () => time += 2000)
expect(sample()).toMatchObject({ workingSetBytes: 1048576, cpuUnavailableReason: '系统未提供进程 CPU 时间' })
metric.cpu.cumulativeCPUUsage = 1
metric.memory.workingSetSize = NaN
expect(sample()).toMatchObject({ memoryUnavailableReason: '系统未提供工作集内存' })
fail = true
expect(sample()).toEqual({ scope: 'unavailable', reason: '进程资源采样失败' })
fail = false
expect(sample()).not.toHaveProperty('cpuPercent')
pid = undefined
expect(sample()).toEqual({ scope: 'unavailable', reason: '服务进程未运行' })
pid = 43
expect(sample()).toEqual({ scope: 'unavailable', reason: '等待进程资源数据' })
metric.pid = 43
expect(sample()).not.toHaveProperty('cpuPercent')
metric.type = 'Tab'
expect(sample()).toMatchObject({ scope: 'unavailable' })
})
})
+48
View File
@@ -0,0 +1,48 @@
import type { ProcessMetric } from 'electron'
import type { InferenceResources } from '../shared/local-inference-contracts'
// One sampler per transport: a replacement process never inherits the old baseline.
export function createInferenceProcessSampler(
getPid: () => number | undefined,
getMetrics: () => ProcessMetric[],
now: () => number = () => performance.now()
): () => InferenceResources {
let previous: { pid: number; creationTime: number; time: number; cpu: number } | undefined
return () => {
const pid = getPid()
let metric: ProcessMetric | undefined
try {
if (pid !== undefined) metric = getMetrics().find((entry) => entry.pid === pid && entry.type === 'Utility')
} catch {
previous = undefined
return { scope: 'unavailable', reason: '进程资源采样失败' }
}
if (!metric) {
previous = undefined
return { scope: 'unavailable', reason: pid === undefined ? '服务进程未运行' : '等待进程资源数据' }
}
const time = now()
const cpu = metric.cpu.cumulativeCPUUsage
const resources: InferenceResources = {
scope: 'service-process', pid: metric.pid, sampledAt: Date.now(),
cpuUnavailableReason: '正在建立采样基线'
}
// Electron reports working set in KiB. Do not replace absent readings with zero.
const memory = metric.memory?.workingSetSize
if (Number.isFinite(memory) && memory >= 0) resources.workingSetBytes = memory * 1024
else resources.memoryUnavailableReason = '系统未提供工作集内存'
if (cpu !== undefined && Number.isFinite(cpu) && cpu >= 0) {
if (previous && previous.pid === metric.pid && previous.creationTime === metric.creationTime &&
time > previous.time && time - previous.time <= 15000 && cpu >= previous.cpu) {
// Single-core capacity is 100%; multithreaded inference may exceed 100%.
resources.cpuPercent = (cpu - previous.cpu) * 100_000 / (time - previous.time)
resources.cpuUnavailableReason = undefined
}
previous = { pid: metric.pid, creationTime: metric.creationTime, time, cpu }
} else {
previous = undefined
resources.cpuUnavailableReason = '系统未提供进程 CPU 时间'
}
return resources
}
}
+158 -5
View File
@@ -12,6 +12,7 @@ import { join } from 'node:path'
import { ipcChannels } from '../shared/ipc-channels'
import type {
AssistantProject,
AssistantSchedule,
ConversationSnapshot,
ConversationQueueItem
} from '../shared/assistant-contracts'
@@ -21,7 +22,8 @@ import {
runtimeSettingsInputSchema,
type AgentEvent,
type BrowserLiveState,
type BrowserTabId
type BrowserTabId,
type ConversationQueueDispatch
} from '../shared/contracts'
import type {
SshHostAgentConnectionStatus
@@ -1056,6 +1058,8 @@ describe('registerIpcHandlers update source routing', () => {
const versionChecker = {
check: vi.fn(async () => result)
}
const unsubscribe = vi.fn()
const onChanged = vi.fn<(listener: (settings: unknown) => void) => () => void>(() => unsubscribe)
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
@@ -1076,10 +1080,14 @@ describe('registerIpcHandlers update source routing', () => {
undefined,
undefined,
undefined,
{ get: getApplicationSettings } as never,
{ get: getApplicationSettings, onChanged } as never,
versionChecker as never
)
const changedSettings = { updateSource: 'github' }
onChanged.mock.calls[0]![0](changedSettings)
expect(webContents.send).toHaveBeenCalledWith(ipcChannels.applicationSettingsChanged, changedSettings)
await expect(
electronMocks.handlers.get(ipcChannels.versionCheck)?.(event)
).resolves.toEqual(result)
@@ -1111,6 +1119,7 @@ describe('registerIpcHandlers update source routing', () => {
)
await dispose()
expect(unsubscribe).toHaveBeenCalledOnce()
})
})
@@ -1177,6 +1186,7 @@ describe('registerIpcHandlers model download source routing', () => {
snapshot: vi.fn(async () => ocrSnapshot)
}
const applicationSettingsStore = {
onChanged: vi.fn(),
get: vi.fn(async () => ({
modelDownloadSource: 'hugging-face'
}))
@@ -1781,7 +1791,8 @@ describe('registerIpcHandlers SSH hosts', () => {
undefined, // subagentService
undefined, // channelSettingsStore
{
get: vi.fn(async () => ({ remoteProjectsEnabled: true }))
get: vi.fn(async () => ({ remoteProjectsEnabled: true })),
onChanged: vi.fn()
} as never, // applicationSettingsStore
undefined, // versionChecker
undefined, // speechModelManager
@@ -2212,6 +2223,7 @@ describe('registerIpcHandlers SSH hosts', () => {
updatedAt: '2026-08-01T00:00:00.000Z'
}
const applicationSettingsStore = {
onChanged: vi.fn(),
get: vi.fn(async () => ({ remoteProjectsEnabled: false }))
}
const sshHostService = {
@@ -4235,6 +4247,7 @@ describe('registerIpcHandlers workspace files', () => {
undefined,
undefined,
{
onChanged: vi.fn(),
get: vi.fn(async () => {
throw new Error('settings unavailable')
})
@@ -4323,6 +4336,7 @@ describe('registerIpcHandlers workspace files', () => {
undefined,
undefined,
{
onChanged: vi.fn(),
get: vi.fn(async () => ({ remoteProjectsEnabled: true }))
} as never
)
@@ -4363,6 +4377,42 @@ describe('registerIpcHandlers token usage', () => {
vi.clearAllMocks()
})
it('validates execution statistics scope and sender before reading evidence', async () => {
const summary = { durationMs: 5000, requestCount: 2, incompleteRequestCount: 1, activeRequestCount: 0, asOf: 10000, taskDurations: [{ id: 'task', durationMs: 5000, incompleteRequestCount: 1 }] }
const assistantDatabase = {
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
getExecutionStats: vi.fn(() => summary)
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'), send: vi.fn()
}
const window = { webContents, isDestroyed: vi.fn(() => false), on: vi.fn(), removeListener: vi.fn() }
const dispose = registerIpcHandlers(
window as never, { capability: 'text' } as never,
'CommandOrControl+Shift+Space', {} as never, {} as never,
{ clear: vi.fn() } as never, {} as never, assistantDatabase as never,
{ clear: vi.fn() } as never, {} as never, vi.fn(async () => {})
)
try {
const handler = electronMocks.handlers.get(ipcChannels.tasksExecutionStats)!
const event = { sender: webContents, senderFrame: webContents.mainFrame }
const id = '00000000-0000-4000-8000-000000000301'
for (const scope of [{ conversationId: id }, { projectId: id }]) {
expect(handler(event, scope)).toBe(summary)
expect(assistantDatabase.getExecutionStats).toHaveBeenLastCalledWith(scope, new Set())
}
expect(() => handler(event, {})).toThrow()
expect(() => handler(event, { projectId: id, conversationId: id })).toThrow()
expect(() => handler({ sender: {}, senderFrame: webContents.mainFrame }, { projectId: id })).toThrow()
expect(assistantDatabase.getExecutionStats).toHaveBeenCalledTimes(2)
} finally {
await dispose()
}
})
it('returns the database token summary to a trusted renderer', async () => {
const summary = {
totals: {
@@ -5273,7 +5323,8 @@ describe('registerIpcHandlers agent terminal state', () => {
subagentService as never,
undefined,
{
get: getApplicationSettings
get: getApplicationSettings,
onChanged: vi.fn()
} as never,
undefined,
undefined,
@@ -5516,7 +5567,7 @@ describe('registerIpcHandlers agent terminal state', () => {
title: '每周汇总',
prompt: '汇总本周进展',
recurrence: 'weekly' as const,
nextRunAt: '2026-08-21T09:00:00.000Z'
nextRunAt: new Date(Date.now() + 60_000).toISOString()
}
await electronMocks.handlers.get(
@@ -5532,6 +5583,68 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose()
})
it.each(['completed', 'failed'] as const)('creates an immediate task into the production queue and dispatches exactly one ordinary run (%s)', async (status) => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const conversationId = crypto.randomUUID()
database.saveLocalConversations([{
header: { id: conversationId, title: 'Immediate', updatedAt: Date.now(), workMode: 'ask' },
messages: []
}])
const run = vi.fn(async function* (request: AgentExecutionRequest) {
if (status === 'failed') {
yield { type: 'error' as const, requestId: request.requestId, message: 'Runtime failed' }
return
}
yield { type: 'text' as const, requestId: request.requestId, delta: 'Once' }
yield { type: 'done' as const, requestId: request.requestId }
})
const harness = createHarness(
{ runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run },
undefined, 'always', undefined, false, undefined, undefined, undefined,
false, undefined, undefined, undefined, undefined, database
)
const event = trustedEvent(harness.webContents)
try {
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId)
const create = electronMocks.handlers.get(ipcChannels.schedulesCreate)!
const input = {
conversationId, title: 'Immediate', prompt: 'Run once', recurrence: 'once',
nextRunAt: '2020-01-01T00:00:00.000Z'
}
expect(() => create(event, input)).toThrow('首次运行时间必须晚于当前时间')
expect(database.listSchedules()).toEqual([])
const schedule = await create(event, { ...input, runImmediately: true }) as AssistantSchedule
expect(schedule.enabled).toBe(false)
const dispatches = () => harness.webContents.send.mock.calls.filter(
([channel]) => channel === ipcChannels.conversationQueueDispatch
)
expect(dispatches()).toHaveLength(1)
const dispatch = dispatches()[0]![1] as ConversationQueueDispatch
expect(dispatch).toMatchObject({ scheduled: true, item: { taskId: schedule.taskId } })
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
await harness.handler!(event, {
requestId: dispatch.item.id,
conversationId,
queueItemId: dispatch.item.id,
prompt: input.prompt,
workMode: 'ask',
currentUserMessageId: crypto.randomUUID(),
currentAssistantMessageId: crypto.randomUUID()
})
await vi.waitFor(() => expect(database.getTask(schedule.taskId).status).toBe(status))
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId)
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
expect(dispatches()).toHaveLength(1)
expect(run).toHaveBeenCalledOnce()
expect(database.listConversationQueueItems()).toEqual([])
expect(database.listSchedules()).toHaveLength(1)
} finally {
await harness.dispose()
database.close()
}
})
it('dispatches scheduled messages through ordinary agent runs with current history and tools', async () => {
const fullOutput = `${'x'.repeat(1_000_001)} scheduled tail`
const taskId = '00000000-0000-4000-8000-000000000701'
@@ -7814,6 +7927,46 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose()
})
it('retains user-enabled knowledge scopes when other applications are disabled', async () => {
const libraryId = '11111111-1111-4111-8111-111111111111'
let finish: (() => void) | undefined
const held = new Promise<void>(resolve => { finish = resolve })
const run = vi.fn(async function* (request: { requestId: string; knowledgeLibraryIds: string[] }) {
if (request.requestId.endsWith('21')) await held
yield { requestId: request.requestId, type: 'done' }
})
const retrieveMany = vi.fn(async () => [])
const gateway = { grant: vi.fn(() => 'capability'), getAvailableToolNames: vi.fn(() => ['knowledge_search']), drainReferences: vi.fn(() => []), revoke: vi.fn() }
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: true, supportsScopedDataTools: true, run }, undefined, 'always', undefined, false, undefined, {
database: { listKnowledgeBases: vi.fn(() => [{ id: libraryId, name: 'Knowledge' }]) }, retrieveMany
}, gateway)
const request = { prompt: 'use saved knowledge', workMode: 'ask', knowledgeLibraryIds: [libraryId], knowledgeRetrievalMode: 'always' }
try {
await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000021', conversationId: 'in-flight' })
await vi.waitFor(() => expect(run).toHaveBeenCalledOnce())
expect(retrieveMany).toHaveBeenCalledOnce()
harness.getApplicationSettings.mockResolvedValue({ localInferenceEnabled: false, magicNotesEnabled: false })
await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000022', conversationId: 'disabled' })
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2))
expect(run.mock.calls[0]![0].knowledgeLibraryIds).toEqual([libraryId])
expect(run.mock.calls[1]![0].knowledgeLibraryIds).toEqual([libraryId])
expect(retrieveMany).toHaveBeenCalledTimes(2)
expect(gateway.grant).toHaveBeenCalledTimes(2)
expect(harness.assistantDatabase.updateTaskStatus).not.toHaveBeenCalledWith('00000000-0000-4000-8000-000000000021', 'cancelled')
finish!()
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith('00000000-0000-4000-8000-000000000021', 'completed'))
harness.getApplicationSettings.mockResolvedValue({ localInferenceEnabled: true, magicNotesEnabled: false })
await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000023', conversationId: 'restored' })
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(3))
expect(run.mock.calls[2]![0].knowledgeLibraryIds).toEqual([libraryId])
expect(retrieveMany).toHaveBeenCalledTimes(3)
expect(request.knowledgeLibraryIds).toEqual([libraryId])
} finally {
finish!()
await harness.dispose()
}
})
it('grants read-only Magic Notes tools in Ask and write tools in Execute', async () => {
const runtime = {
runtimeId: 'model',
+62 -5
View File
@@ -1,5 +1,7 @@
import { externalKnowledgeInstanceInputSchema, externalKnowledgeInstanceEnabledInputSchema, externalKnowledgeInstanceSaveInputSchema, externalKnowledgeCatalogListInputSchema, externalKnowledgeCatalogGetInputSchema, externalKnowledgeBindingSaveInputSchema, externalKnowledgeBindingUpdateInputSchema, externalKnowledgeBindingTestInputSchema } from '../shared/external-knowledge-contracts'
import { knowledgeReferenceKey, toKnowledgeReference } from '../shared/knowledge-reference'
import { localInferenceService } from './local-inference-service'
import { inferenceActionSchema, inferenceCancelSchema } from '../shared/local-inference-contracts'
import {
app,
BrowserWindow,
@@ -200,6 +202,7 @@ import {
} from '../shared/magic-notes-contracts'
import {
assistantIdSchema,
executionStatsInputSchema,
activityHistorySnapshotSchema,
conversationBranchInputSchema,
conversationSnapshotsSchema,
@@ -1345,6 +1348,7 @@ export function registerIpcHandlers(
): () => Promise<void> {
type ActiveRequestLease = {
controller: AbortController
isReply: boolean
conversationId: string
detachOnApplicationExit: boolean
teamMode?: boolean
@@ -1375,10 +1379,12 @@ export function registerIpcHandlers(
const leaseActiveRequest = (
requestId: string,
conversationId: string,
controller: AbortController
controller: AbortController,
isReply = true
): ActiveRequestLease => {
const lease: ActiveRequestLease = {
controller,
isReply,
conversationId,
detachOnApplicationExit: false,
release: (): void => {
@@ -1580,6 +1586,11 @@ export function registerIpcHandlers(
}
window.on('maximize', notifyMaximizedChanged)
window.on('unmaximize', notifyMaximizedChanged)
const removeApplicationSettingsListener = applicationSettingsStore?.onChanged((settings) => {
if (!window.isDestroyed()) {
window.webContents.send(ipcChannels.applicationSettingsChanged, settings)
}
})
const removeBrowserStateListener = browserControl?.onState((state) => {
if (!window.isDestroyed() && state.ownerWindowId === window.webContents.id) {
window.webContents.send(ipcChannels.browserState, state)
@@ -5117,7 +5128,8 @@ export function registerIpcHandlers(
const activeRequestLease = leaseActiveRequest(
request.requestId,
request.conversationId,
controller
controller,
false
)
assistantDatabase.createTask({
id: request.requestId,
@@ -6480,6 +6492,29 @@ export function registerIpcHandlers(
}
)
registerHandler(ipcChannels.localInferenceGet, async (event) => {
assertTrustedSender(event, window)
const snapshot = await localInferenceService.snapshot()
const settings = await settingsStore.getPublicSettings()
return {
...snapshot,
externalConnections: settings.embeddingConnections?.filter((connection) => connection.kind !== 'builtin')
.map((connection) => ({ id: connection.id, name: connection.name, model: connection.modelName })) ?? []
}
})
registerHandler(ipcChannels.localInferenceOpenSettings, (event) => {
assertTrustedSender(event, window)
window.webContents.send(ipcChannels.settingsOpen)
})
registerHandler(ipcChannels.localInferenceAct, (event, input: unknown) => {
assertTrustedSender(event, window)
return trackExecution(localInferenceService.act(inferenceActionSchema.parse(input)))
})
registerHandler(ipcChannels.localInferenceCancel, (event, input: unknown) => {
assertTrustedSender(event, window)
localInferenceService.cancel(inferenceCancelSchema.parse(input).taskId)
})
registerHandler(ipcChannels.speechModelsGet, (event) => {
assertTrustedSender(event, window)
if (!speechModelManager) {
@@ -7220,6 +7255,13 @@ export function registerIpcHandlers(
assertTrustedSender(event, window)
return assistantDatabase.listTasks()
})
registerHandler(ipcChannels.tasksExecutionStats, (event, input: unknown) => {
assertTrustedSender(event, window)
return assistantDatabase.getExecutionStats(
executionStatsInputSchema.parse(input),
new Set([...activeRequests].filter(([, lease]) => lease.isReply && !lease.controller.signal.aborted).map(([id]) => id))
)
})
registerHandler(ipcChannels.tasksSetStatus, (event, input: unknown) => {
assertTrustedSender(event, window)
const parsed = taskStatusRequestSchema.parse(input)
@@ -7398,9 +7440,23 @@ export function registerIpcHandlers(
registerHandler(ipcChannels.schedulesCreate, (event, input: unknown) => {
assertTrustedSender(event, window)
return assistantDatabase.createSchedule(
scheduleCreateSchema.parse(input)
)
const value = scheduleCreateSchema.parse(input)
if (value.runImmediately) {
if (executionPaused || shuttingDown) {
throw new Error('本地数据维护期间暂不接受新任务')
}
} else if (new Date(value.nextRunAt).getTime() <= Date.now()) {
throw new Error('首次运行时间必须晚于当前时间。')
}
const schedule = assistantDatabase.createSchedule(value)
if (value.runImmediately) {
publishConversationQueueChange(schedule.conversationId)
if (!isConversationExecuting(schedule.conversationId)) {
readyConversationQueues.add(schedule.conversationId)
void pumpConversationQueue(schedule.conversationId)
}
}
return schedule
})
registerHandler(
@@ -8737,6 +8793,7 @@ export function registerIpcHandlers(
return async () => {
shuttingDown = true
removeApplicationSettingsListener?.()
removeLocalToolEnvironmentProgressListener?.()
await localToolEnvironmentService?.dispose()
removeBrowserStateListener?.()
@@ -1,5 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import { localInferenceService } from '../local-inference-service'
import { EmbeddingInferenceWorker } from './embedding-inference-worker'
import {
EMBEDDING_INFERENCE_MAX_IN_FLIGHT,
EMBEDDING_INFERENCE_PROTOCOL,
EMBEDDING_INFERENCE_PROTOCOL_VERSION,
type EmbeddingInferenceRequest
@@ -62,6 +65,255 @@ function embedRequest(transport: TestTransport): Extract<
}
describe('EmbeddingInferenceBroker', () => {
it('drops exited process resources and uses the replacement transport after restart', async () => {
const first = new TestTransport()
const second = new TestTransport()
const resourceTransport = (transport: TestTransport, pid: number): EmbeddingInferenceTransport => Object.assign(transport, {
getResources: () => ({ scope: 'service-process' as const, pid, sampledAt: 1, workingSetBytes: 1024 })
})
const factory = vi.fn().mockReturnValueOnce(resourceTransport(first, 42)).mockReturnValueOnce(resourceTransport(second, 43))
const broker = new EmbeddingInferenceBroker({ createTransport: factory })
expect(broker.getResources()).toMatchObject({ scope: 'unavailable' })
await broker.start()
expect(broker.getResources()).toMatchObject({ pid: 42 })
first.crash()
expect(broker.getResources()).toMatchObject({ scope: 'unavailable' })
await broker.start()
expect(broker.getResources()).toMatchObject({ pid: 43 })
await broker.stop()
expect(broker.getResources()).toMatchObject({ scope: 'unavailable' })
})
it('keeps source-cancelled computation in stop impact until the worker returns', async () => {
const transport = new TestTransport()
let finish!: (vectors: number[][]) => void
let workerSignal!: AbortSignal
const engine = {
tokenizer: { tokenize: vi.fn() },
onnx: { run: vi.fn() },
embed: vi.fn(({ signal }: { signal: AbortSignal }) => {
workerSignal = signal
return new Promise<number[][]>((resolve) => { finish = resolve })
})
}
const worker = new EmbeddingInferenceWorker({
modelDirectory: process.cwd(), engine,
port: {
postMessage: (message) => transport.receive(message),
onMessage: (listener) => {
const spy = vi.spyOn(transport, 'postMessage').mockImplementation((message) => {
transport.sent.push(message)
listener(message)
})
return () => spy.mockRestore()
}
}
})
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
const act = vi.fn(async () => undefined)
const unregister = localInferenceService.register('embedding', {
snapshot: () => ({ id: 'embedding', name: 'Embedding', engine: 'ONNX', detail: '', ownership: 'managed-process', state: 'running', actions: ['stop', 'restart'] }),
act
})
try {
const controller = new AbortController()
const pending = broker.embed(['delayed computation'], 'query', controller.signal)
const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
await vi.waitFor(() => expect(engine.embed).toHaveBeenCalledOnce())
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
controller.abort()
await rejection
expect(workerSignal.aborted).toBe(true)
const activeTask = (await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)!
expect(activeTask.state).toBe('cancelling')
expect(activeTask.finishedAt).toBeUndefined()
await expect(localInferenceService.act({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: [] })).rejects.toThrow('任务已变化')
await expect(localInferenceService.act({ serviceId: 'embedding', action: 'restart', confirmedTaskIds: [] })).rejects.toThrow('任务已变化')
localInferenceService.cancel(task.id)
expect(transport.sent.filter((message) => message.type === 'cancel')).toHaveLength(1)
expect(act).not.toHaveBeenCalled()
expect(transport.close).not.toHaveBeenCalled()
finish([[1, 2]])
await vi.waitFor(async () => {
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)).toMatchObject({ state: 'cancelled', finishedAt: expect.any(Number) })
})
await localInferenceService.act({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: [] })
expect(act).toHaveBeenCalledOnce()
} finally {
unregister()
finish?.([[1, 2]])
await worker.shutdown()
await broker.stop()
}
})
it.each([
['abort', 'acknowledgement'], ['abort', 'late result'], ['abort', 'process exit'],
['timeout', 'acknowledgement'], ['timeout', 'late result'], ['timeout', 'process exit']
] as const)('keeps execution cancelling after %s until %s', async (cause, completion) => {
vi.useFakeTimers()
const transport = new TestTransport()
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport, timeoutMs: 100 })
try {
const controller = new AbortController()
const pending = broker.embed(['cancelled work'], 'query', controller.signal)
const rejection = expect(pending).rejects.toMatchObject({ name: cause === 'abort' ? 'AbortError' : 'TimeoutError' })
await vi.advanceTimersByTimeAsync(0)
const request = embedRequest(transport)
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
if (cause === 'abort') controller.abort()
else await vi.advanceTimersByTimeAsync(100)
await rejection
await vi.advanceTimersByTimeAsync(10_000)
const activeTask = (await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)!
expect(activeTask.state).toBe('cancelling')
expect(activeTask.finishedAt).toBeUndefined()
expect(transport.close).not.toHaveBeenCalled()
expect(transport.sent.filter((message) => message.type === 'cancel')).toHaveLength(1)
if (completion === 'process exit') transport.crash()
else transport.receive(response(completion === 'late result'
? { type: 'result', requestId: request.requestId, vectors: [[1, 2]] }
: { type: 'error', requestId: request.requestId, code: 'CANCELLED', message: 'cancelled' }))
await vi.advanceTimersByTimeAsync(0)
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)).toMatchObject({
state: cause === 'abort' ? 'cancelled' : 'failed',
error: cause === 'abort' ? 'Embedding inference was cancelled' : 'Embedding inference timed out',
finishedAt: expect.any(Number)
})
} finally {
await broker.stop()
vi.useRealTimers()
}
})
it('keeps rejected work active through an unconfirmed stop until process exit', async () => {
const transport = new TestTransport()
transport.close.mockRejectedValue(new Error('exit not confirmed'))
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
const pending = broker.embed(['still computing'], 'query')
const rejection = expect(pending).rejects.toThrow('停止')
await vi.waitFor(() => expect(transport.sent).toHaveLength(1))
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
await expect(broker.stop()).rejects.toThrow('exit not confirmed')
await rejection
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)?.finishedAt).toBeUndefined()
transport.crash()
await vi.waitFor(async () => {
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)).toMatchObject({ state: 'failed', finishedAt: expect.any(Number) })
})
})
it('keeps cancelled requests within the in-flight bound until acknowledgement', async () => {
const transport = new TestTransport()
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
const controller = new AbortController()
try {
const callers = Array.from({ length: EMBEDDING_INFERENCE_MAX_IN_FLIGHT }, () =>
expect(broker.embed(['pending'], 'query', controller.signal)).rejects.toMatchObject({ name: 'AbortError' }))
await vi.waitFor(() => expect(transport.sent).toHaveLength(EMBEDDING_INFERENCE_MAX_IN_FLIGHT))
controller.abort()
await Promise.all(callers)
await expect(broker.embed(['over capacity'], 'query')).rejects.toMatchObject({ code: 'OVERLOADED' })
for (const request of transport.sent.filter((message) => message.type === 'embed')) {
transport.receive(response({ type: 'error', requestId: request.requestId, code: 'CANCELLED', message: 'cancelled' }))
}
const next = broker.embed(['capacity released'], 'query')
await vi.waitFor(() => expect(transport.sent.at(-1)?.type).toBe('embed'))
const request = transport.sent.at(-1)!
if (request.type !== 'embed') throw new Error('No next embed request was sent')
transport.receive(response({ type: 'result', requestId: request.requestId, vectors: [[1, 2]] }))
await expect(next).resolves.toEqual([[1, 2]])
} finally {
await broker.stop()
}
})
it('keeps execution active after shutdown grace and close timeout until process exit', async () => {
vi.useFakeTimers()
const transport = new TestTransport()
transport.close.mockImplementation(() => new Promise<void>((_resolve, reject) => {
setTimeout(() => reject(new Error('exit not confirmed')), 5000)
}))
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
try {
const controller = new AbortController()
const rejection = expect(broker.embed(['still computing'], 'query', controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
await vi.advanceTimersByTimeAsync(0)
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
controller.abort()
await rejection
const shutdown = broker.shutdown()
await vi.advanceTimersByTimeAsync(1000)
await shutdown
expect(transport.close).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(5000)
expect(broker.getManagementError()).toBe('exit not confirmed')
const activeTask = (await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)!
expect(activeTask.state).toBe('cancelling')
expect(activeTask.finishedAt).toBeUndefined()
transport.crash()
await vi.advanceTimersByTimeAsync(0)
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)).toMatchObject({ state: 'cancelled', finishedAt: expect.any(Number) })
} finally {
transport.crash()
vi.useRealTimers()
}
})
it('retains a live transport and reports a failed stop instead of claiming stopped', async () => {
const transport: EmbeddingInferenceTransport = {
postMessage: vi.fn(), onMessage: () => () => undefined, onClose: () => () => undefined,
close: vi.fn(async () => { throw new Error('exit not confirmed') })
}
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
await broker.start()
await expect(broker.stop()).rejects.toThrow('exit not confirmed')
expect(broker.getManagementState()).toBe('running')
expect(broker.getManagementError()).toBe('exit not confirmed')
})
it('reports startup failures and supports an explicit retry', async () => {
const factory = vi.fn().mockRejectedValueOnce(new Error('spawn failed')).mockResolvedValue(new TestTransport())
const broker = new EmbeddingInferenceBroker({ createTransport: factory })
await expect(broker.start()).rejects.toThrow('START_FAILURE')
expect(broker.getManagementState()).toBe('error')
await broker.start()
expect(broker.getManagementState()).toBe('running')
expect(broker.getManagementError()).toBeUndefined()
await broker.stop()
})
it('management cancellation waits for worker acknowledgement without closing shared transport', async () => {
const transport = new TestTransport()
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
const pending = broker.embed(['cancel this'], 'query')
const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
await vi.waitFor(() => expect(transport.sent).toHaveLength(1))
const request = embedRequest(transport)
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
localInferenceService.cancel(task.id)
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)?.state).toBe('cancelling')
expect(transport.close).not.toHaveBeenCalled()
transport.receive(response({ type: 'error', requestId: request.requestId, code: 'CANCELLED', message: 'cancelled' }))
await rejection
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)?.state).toBe('cancelled')
await broker.stop()
})
it('stops shared execution, rejects work until explicitly started, and never replays', async () => {
const transports: TestTransport[] = []
const broker = new EmbeddingInferenceBroker({ createTransport: () => { const transport = new TestTransport(); transports.push(transport); return transport } })
const pending = broker.embed(['interrupted'], 'document')
const rejection = expect(pending).rejects.toThrow('停止')
await vi.waitFor(() => expect(transports[0]?.sent).toHaveLength(1))
await broker.stop()
await rejection
expect(transports[0]!.close).toHaveBeenCalledOnce()
await expect(broker.embed(['blocked'], 'query')).rejects.toMatchObject({ code: 'CLOSED' })
await broker.start()
expect(transports).toHaveLength(2)
expect(transports[1]!.sent).toEqual([])
await broker.stop()
})
it('uses UUID requests with roles and validates bounded results', async () => {
const transport = new TestTransport()
const broker = new EmbeddingInferenceBroker({
+141 -39
View File
@@ -1,4 +1,6 @@
import { randomUUID } from 'node:crypto'
import { localInferenceService } from '../local-inference-service'
import type { InferenceResources } from '../../shared/local-inference-contracts'
import {
EMBEDDING_INFERENCE_MAX_IN_FLIGHT,
embeddingInferenceProtocolBase,
@@ -15,13 +17,13 @@ const MAX_TIMEOUT_MS = 300_000
const DEFAULT_MAX_RESTARTS = 2
const MAX_RESTARTS = 10
const SHUTDOWN_GRACE_MS = 1_000
const MAX_RETIRED_REQUESTS = 256
export interface EmbeddingInferenceTransport {
getResources?(): InferenceResources
postMessage(message: EmbeddingInferenceRequest): void
onMessage(listener: (message: unknown) => void): () => void
onClose(listener: () => void): () => void
close(): void
close(): void | Promise<void>
}
export type EmbeddingInferenceTransportFactory = () =>
@@ -42,6 +44,8 @@ type PendingRequest = {
readonly expectedCount: number
readonly resolve: (vectors: number[][]) => void
readonly reject: (error: Error) => void
readonly rejectCaller: (error: Error) => void
callerError?: Error
readonly timeout: ReturnType<typeof setTimeout>
removeAbortListener?: () => void
}
@@ -62,9 +66,10 @@ export class EmbeddingInferenceBrokerError extends Error {
| 'START_FAILURE'
| 'ENGINE_FAILURE'
| 'INVALID_RESULT'
| 'OVERLOADED'
| 'OVERLOADED',
detail?: string
) {
super(`Embedding inference failed (${code})`)
super(`Embedding inference failed (${code})${detail ? `: ${detail}` : ''}`)
this.name = 'EmbeddingInferenceBrokerError'
}
}
@@ -100,13 +105,65 @@ export class EmbeddingInferenceBroker {
private readonly timeoutMs: number
private readonly maxRestarts: number
private readonly pending = new Map<string, PendingRequest>()
private readonly retiredRequestIds = new Set<string>()
private active?: ActiveTransport
private starting?: Promise<ActiveTransport>
private restarts = 0
private everStarted = false
private closed = false
private shutdownComplete?: () => void
private paused = false
private stopping = false
private managementError?: string
getManagementState(): 'stopped' | 'stopping' | 'starting' | 'running' | 'idle' | 'error' {
if (this.stopping) return 'stopping'
if (this.paused || this.closed) return 'stopped'
if (this.starting) return 'starting'
if (this.active) return 'running'
return this.managementError ? 'error' : 'idle'
}
getManagementError(): string | undefined { return this.managementError }
getResources(): InferenceResources {
return this.active?.transport.getResources?.() ?? {
scope: 'unavailable', reason: this.active ? '当前执行端未提供进程指标' : '服务进程未运行'
}
}
async start(): Promise<void> {
if (this.closed) throw new EmbeddingInferenceBrokerError('CLOSED')
this.paused = false
this.restarts = 0
try {
await this.ensureTransport()
this.managementError = undefined
} catch (error) {
this.managementError = String(error)
throw error
}
}
async stop(): Promise<void> {
this.paused = true
this.stopping = true
const error = new Error('向量执行服务已由用户停止;任务不会自动重放')
this.rejectCallers(error)
try {
if (this.starting) await this.starting.catch(() => undefined)
const active = this.active
if (!active) return
await active.transport.close()
this.disposeTransport(active.transport, false)
this.rejectAll(error)
} catch (error) {
this.paused = false
this.managementError = error instanceof Error ? error.message : String(error)
throw error
} finally {
this.stopping = false
}
}
constructor(options: EmbeddingInferenceBrokerOptions) {
if (typeof options.createTransport !== 'function') {
@@ -131,12 +188,29 @@ export class EmbeddingInferenceBroker {
texts: readonly string[],
role: EmbeddingInferenceRole,
signal?: AbortSignal
): Promise<number[][]> {
const controller = new AbortController()
return new Promise<number[][]>((resolve, reject) => {
// Track worker execution separately from prompt caller cancellation/timeout.
void localInferenceService.run('embedding', role === 'query' ? '向量请求 · 查询' : '向量请求 · 文档',
(markCancelling) => this.embedRequest(texts, role, signal, controller.signal, (error) => {
markCancelling()
reject(error)
}),
{ cancel: () => controller.abort() }).then(resolve, reject)
})
}
private async embedRequest(
texts: readonly string[], role: EmbeddingInferenceRole, signal: AbortSignal | undefined,
managementSignal: AbortSignal,
rejectCaller: (error: Error) => void
): Promise<number[][]> {
const normalizedTexts = validateEmbeddingInferenceTexts(texts)
if (role !== 'query' && role !== 'document') {
throw new TypeError('role must be query or document')
}
if (this.closed) {
if (this.closed || this.paused) {
throw new EmbeddingInferenceBrokerError('CLOSED')
}
if (signal?.aborted) {
@@ -147,7 +221,7 @@ export class EmbeddingInferenceBroker {
}
const active = await this.ensureTransport()
if (this.closed) {
if (this.closed || this.paused) {
throw new EmbeddingInferenceBrokerError('CLOSED')
}
if (signal?.aborted) {
@@ -166,6 +240,7 @@ export class EmbeddingInferenceBroker {
expectedCount: normalizedTexts.length,
resolve,
reject,
rejectCaller,
timeout
}
if (signal) {
@@ -178,6 +253,21 @@ export class EmbeddingInferenceBroker {
}
this.pending.set(requestId, pending)
const requestCancellation = (): void => {
// Management cancellation waits for the worker's result or CANCELLED response.
try {
active.transport.postMessage({ ...embeddingInferenceProtocolBase(), type: 'cancel', requestId })
} catch {
this.failTransport(active.transport, new EmbeddingInferenceBrokerError('PROCESS_CLOSED'))
}
}
const removeSourceAbort = pending.removeAbortListener
pending.removeAbortListener = () => {
removeSourceAbort?.()
managementSignal.removeEventListener('abort', requestCancellation)
}
managementSignal.addEventListener('abort', requestCancellation, { once: true })
try {
active.transport.postMessage({
...embeddingInferenceProtocolBase(),
@@ -186,6 +276,7 @@ export class EmbeddingInferenceBroker {
role,
texts: normalizedTexts
})
if (managementSignal.aborted) requestCancellation()
} catch {
this.failTransport(
active.transport,
@@ -200,10 +291,7 @@ export class EmbeddingInferenceBroker {
return
}
this.closed = true
for (const requestId of this.pending.keys()) {
this.retireRequestId(requestId)
}
this.rejectAll(new EmbeddingInferenceBrokerError('CLOSED'))
this.rejectCallers(new EmbeddingInferenceBrokerError('CLOSED'))
let active = this.active
if (!active && this.starting) {
@@ -261,7 +349,7 @@ export class EmbeddingInferenceBroker {
.then(() => this.createTransport())
.then((transport) => {
if (this.closed) {
transport.close()
void Promise.resolve(transport.close()).catch(() => undefined)
throw new EmbeddingInferenceBrokerError('CLOSED')
}
const active: ActiveTransport = {
@@ -272,7 +360,8 @@ export class EmbeddingInferenceBroker {
removeCloseListener: transport.onClose(() =>
this.failTransport(
transport,
new EmbeddingInferenceBrokerError('PROCESS_CLOSED')
new EmbeddingInferenceBrokerError('PROCESS_CLOSED'),
true
)
)
}
@@ -280,6 +369,7 @@ export class EmbeddingInferenceBroker {
return active
})
.catch((error: unknown) => {
this.managementError = error instanceof Error ? error.message : String(error)
if (error instanceof EmbeddingInferenceBrokerError) {
throw error
}
@@ -317,6 +407,7 @@ export class EmbeddingInferenceBroker {
)
return
}
this.rejectAll(new EmbeddingInferenceBrokerError('CLOSED'))
this.shutdownComplete()
return
}
@@ -330,9 +421,6 @@ export class EmbeddingInferenceBroker {
const pending = this.pending.get(message.requestId)
if (!pending) {
if (this.retiredRequestIds.delete(message.requestId)) {
return
}
this.failTransport(
transport,
new EmbeddingInferenceBrokerError('PROTOCOL_VIOLATION')
@@ -345,7 +433,7 @@ export class EmbeddingInferenceBroker {
message.requestId,
message.code === 'CANCELLED'
? abortError()
: new EmbeddingInferenceBrokerError(message.code)
: new EmbeddingInferenceBrokerError(message.code, message.message)
)
return
}
@@ -365,11 +453,12 @@ export class EmbeddingInferenceBroker {
private cancelPending(requestId: string, error: Error): void {
const pending = this.pending.get(requestId)
if (!pending) {
if (!pending || pending.callerError) {
return
}
this.retireRequestId(requestId)
this.finishPending(requestId, error)
pending.callerError = error
clearTimeout(pending.timeout)
pending.rejectCaller(error)
try {
this.active?.transport.postMessage({
...embeddingInferenceProtocolBase(),
@@ -398,6 +487,7 @@ export class EmbeddingInferenceBroker {
this.pending.delete(requestId)
clearTimeout(pending.timeout)
pending.removeAbortListener?.()
error = pending.callerError ?? error
if (error) {
pending.reject(error)
} else {
@@ -405,26 +495,23 @@ export class EmbeddingInferenceBroker {
}
}
private retireRequestId(requestId: string): void {
this.retiredRequestIds.add(requestId)
if (this.retiredRequestIds.size > MAX_RETIRED_REQUESTS) {
const oldest = this.retiredRequestIds.values().next().value
if (typeof oldest === 'string') {
this.retiredRequestIds.delete(oldest)
}
}
}
private failTransport(
transport: EmbeddingInferenceTransport,
error: Error
error: Error,
closed = false
): void {
if (this.active?.transport !== transport) {
return
}
this.rejectCallers(error)
if (closed) {
this.disposeTransport(transport, false)
this.rejectAll(error)
this.shutdownComplete?.()
return
}
this.managementError = error.message
this.disposeTransport(transport, true)
this.rejectAll(error)
this.shutdownComplete?.()
}
private disposeTransport(
@@ -435,15 +522,30 @@ export class EmbeddingInferenceBroker {
if (!active || active.transport !== transport) {
return
}
if (close) {
try {
void Promise.resolve(transport.close()).then(() => {
if (this.active !== active) return
this.disposeTransport(transport, false)
this.rejectAll(new EmbeddingInferenceBrokerError('PROCESS_CLOSED'))
}).catch((error: unknown) => {
this.managementError = error instanceof Error ? error.message : String(error)
})
} catch (error) {
this.managementError = error instanceof Error ? error.message : String(error)
}
return
}
this.active = undefined
active.removeMessageListener()
active.removeCloseListener()
if (close) {
try {
transport.close()
} catch {
// The process is already unavailable.
}
}
private rejectCallers(error: Error): void {
for (const pending of this.pending.values()) {
pending.callerError ??= error
clearTimeout(pending.timeout)
pending.rejectCaller(pending.callerError)
}
}
@@ -206,13 +206,13 @@ export class EmbeddingInferenceWorker {
requestId: message.requestId,
vectors
})
} catch {
} catch (error) {
this.postError(
message.requestId,
controller.signal.aborted ? 'CANCELLED' : 'ENGINE_FAILURE',
controller.signal.aborted
? 'Embedding inference was cancelled'
: 'Embedding engine failed'
: error instanceof Error ? error.message : 'Embedding engine failed'
)
}
}
@@ -0,0 +1,61 @@
import { app, utilityProcess } from 'electron'
import { createInferenceProcessSampler } from '../inference-process-resources'
import type { EmbeddingInferenceTransport } from './embedding-inference-broker'
export function createEmbeddingUtilityTransport(
modulePath: string,
modelDirectory: string
): Promise<EmbeddingInferenceTransport> {
return new Promise((resolve, reject) => {
const child = utilityProcess.fork(modulePath, [modelDirectory], {
serviceName: 'GoodBuddy Embedding Inference',
stdio: 'pipe',
allowLoadingUnsignedLibraries: false
})
const startupError = (): void => { reject(new Error('向量进程启动失败')) }
child.once('error', startupError)
child.once('exit', startupError)
child.once('spawn', () => {
child.removeListener('error', startupError)
child.removeListener('exit', startupError)
resolve({
getResources: createInferenceProcessSampler(() => child.pid, () => app.getAppMetrics()),
postMessage: (message) => child.postMessage(message),
onMessage: (listener) => {
child.on('message', listener)
return () => { child.removeListener('message', listener) }
},
onClose: (listener) => {
let closed = false
const notify = (): void => {
if (closed) return
closed = true
listener()
}
child.on('exit', notify)
child.on('error', notify)
return () => {
child.removeListener('exit', notify)
child.removeListener('error', notify)
}
},
close: () => new Promise<void>((resolveClose, rejectClose) => {
const finish = (): void => {
clearTimeout(timeout)
resolveClose()
}
const timeout = setTimeout(() => {
child.removeListener('exit', finish)
rejectClose(new Error('向量进程尚未确认退出,请刷新状态后重试'))
}, 5000)
child.once('exit', finish)
if (!child.kill()) {
clearTimeout(timeout)
child.removeListener('exit', finish)
rejectClose(new Error('无法停止向量进程,请刷新状态后重试'))
}
})
})
})
})
}
@@ -1,7 +1,7 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { Tokenizer } from '@huggingface/tokenizers'
import * as ort from 'onnxruntime-web/wasm'
import * as ort from 'onnxruntime-web'
import type {
EmbeddingInferenceEngine,
EmbeddingOnnxRuntime,
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest'
import { LocalInferenceService } from './local-inference-service'
import type { InferenceService } from '../shared/local-inference-contracts'
const service: InferenceService = { id: 'embedding', name: 'Embedding', engine: 'ONNX', ownership: 'managed-process', state: 'running', detail: '', actions: ['stop', 'restart'] }
describe('local inference management', () => {
it('does not infer service readiness from an active ASR request', async () => {
const registry = new LocalInferenceService()
registry.register('asr', { snapshot: () => ({ ...service, id: 'asr', state: 'unknown', actions: [] }) })
let finish!: () => void
const work = registry.run('asr', 'speech', () => new Promise<void>((resolve) => { finish = resolve }))
expect((await registry.snapshot()).services.find((entry) => entry.id === 'asr')?.state).toBe('unknown')
finish()
await work
})
it('requires confirmation of every current task and blocks new work during stop', async () => {
const registry = new LocalInferenceService()
let finish!: () => void
const work = registry.run('embedding', 'index', () => new Promise<void>((resolve) => { finish = resolve }))
const act = vi.fn(async () => {
await expect(registry.run('embedding', 'new query', async () => [])).rejects.toThrow('切换')
finish()
})
registry.register('embedding', { snapshot: () => service, act })
await expect(registry.act({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: [] })).rejects.toThrow('重新确认')
expect(act).not.toHaveBeenCalled()
const task = (await registry.snapshot()).tasks[0]!
await registry.act({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: [task.id] })
await work
expect(act).toHaveBeenCalledOnce()
})
it('waits for cancellation acknowledgement and preserves natural completion', async () => {
const registry = new LocalInferenceService()
let finish!: () => void
const cancel = vi.fn()
const work = registry.run('asr', 'speech', () => new Promise<void>((resolve) => { finish = resolve }), { cancel })
const id = (await registry.snapshot()).tasks[0]!.id
registry.cancel(id)
registry.cancel(id)
expect(cancel).toHaveBeenCalledOnce()
expect((await registry.snapshot()).tasks[0]!.state).toBe('cancelling')
finish()
await work
expect((await registry.snapshot()).tasks[0]!.state).toBe('completed')
})
it('records real failures, bounds history, and isolates unavailable engines', async () => {
const registry = new LocalInferenceService()
registry.register('asr', { snapshot: () => { throw new Error('missing model') } })
for (let index = 0; index < 103; index++) await registry.run('embedding', 'query', async () => [])
await expect(registry.run('ocr', 'document', async () => { throw new Error('OCR failed') })).rejects.toThrow('OCR failed')
const snapshot = await registry.snapshot()
expect(snapshot.tasks).toHaveLength(100)
expect(snapshot.tasks[0]).toMatchObject({ state: 'failed', error: 'OCR failed' })
expect(snapshot.services.find((entry) => entry.id === 'asr')).toMatchObject({ state: 'error', error: 'Error: missing model' })
expect(snapshot.services.find((entry) => entry.id === 'tts')).toMatchObject({ state: 'unavailable', actions: [] })
})
})
+109
View File
@@ -0,0 +1,109 @@
import { randomUUID } from 'node:crypto'
import {
inferenceActionSchema,
type InferenceAction,
type InferenceService,
type InferenceServiceId,
type InferenceTask,
type LocalInferenceSnapshot
} from '../shared/local-inference-contracts'
type Adapter = {
snapshot: () => InferenceService | Promise<InferenceService>
act?: (action: InferenceAction['action']) => Promise<void>
}
const inventory: InferenceService[] = [
{ id: 'asr', name: '语音识别', engine: 'sherpa-onnx', ownership: 'request-worker', state: 'unavailable', detail: '语音服务尚未初始化。', actions: [] },
{ id: 'ocr', name: '文字识别', engine: 'PaddleOCR / ONNX Web', ownership: 'renderer-worker', state: 'unknown', detail: '模型由文档解析按需加载,空闲 60 秒自动释放。', actions: [] },
{ id: 'embedding', name: '向量生成', engine: 'Granite / ONNX', ownership: 'managed-process', state: 'unavailable', detail: '内置向量提供方尚未初始化;请在设置中安装内置模型并启用内置向量连接。外部向量端点由外部管理,本页不控制其进程。', actions: [] },
{ id: 'tts', name: '语音合成', engine: '未接入', ownership: 'unavailable', state: 'unavailable', detail: '当前版本没有语音合成执行服务。', actions: [] }
]
export class LocalInferenceService {
private readonly adapters = new Map<InferenceServiceId, Adapter>()
private readonly tasks = new Map<string, InferenceTask>()
private readonly cancellations = new Map<string, () => void>()
private readonly operations = new Set<InferenceServiceId>()
register(id: InferenceServiceId, adapter: Adapter): () => void {
this.adapters.set(id, adapter)
return () => { if (this.adapters.get(id) === adapter) this.adapters.delete(id) }
}
async snapshot(): Promise<LocalInferenceSnapshot> {
const services = await Promise.all(inventory.map(async (fallback) => {
try {
return await this.adapters.get(fallback.id)?.snapshot() ?? {
...fallback, resources: { scope: 'unavailable' as const, reason: fallback.id === 'tts' ? '尚未接入执行服务' : '服务尚未初始化' }
}
} catch (error) {
return { ...fallback, state: 'error' as const, error: String(error), actions: [] }
}
}))
return { services, tasks: [...this.tasks.values()].map((task) => ({ ...task })).reverse() }
}
async run<T>(
serviceId: InferenceServiceId,
source: string,
work: (markCancelling: () => void) => Promise<T>,
options: { cancel?: () => void; model?: string; cancelUnavailableReason?: string } = {}
): Promise<T> {
if (this.operations.has(serviceId)) throw new Error('服务正在切换状态,请稍后重试')
const id = randomUUID()
const task: InferenceTask = {
id, serviceId, source, model: options.model, state: 'running', startedAt: Date.now(),
cancelUnavailableReason: options.cancel ? undefined : options.cancelUnavailableReason ?? '该引擎未提供独立任务取消接口。'
}
this.tasks.set(id, task)
if (options.cancel) this.cancellations.set(id, options.cancel)
try {
const result = await work(() => {
if (task.finishedAt === undefined) task.state = 'cancelling'
})
task.state = 'completed'
return result
} catch (error) {
task.state = error instanceof Error && error.name === 'AbortError' ? 'cancelled' : 'failed'
task.error = error instanceof Error ? error.message : String(error)
throw error
} finally {
task.finishedAt = Date.now()
this.cancellations.delete(id)
const finished = [...this.tasks.values()].filter((entry) => entry.finishedAt !== undefined)
for (const entry of finished.slice(0, Math.max(0, finished.length - 100))) this.tasks.delete(entry.id)
}
}
cancel(taskId: string): void {
const task = this.tasks.get(taskId)
if (!task || task.finishedAt !== undefined) return
if (task.state === 'cancelling') return
const cancel = this.cancellations.get(taskId)
if (!cancel) throw new Error(task.cancelUnavailableReason)
cancel()
task.state = 'cancelling'
}
async act(input: InferenceAction): Promise<void> {
const request = inferenceActionSchema.parse(input)
const adapter = this.adapters.get(request.serviceId)
if (!adapter?.act) throw new Error('该服务不支持手动进程控制')
if (this.operations.has(request.serviceId)) throw new Error('服务操作正在进行')
this.operations.add(request.serviceId)
try {
const state = await adapter.snapshot()
if (!state.actions.includes(request.action)) throw new Error('当前状态不支持此操作')
const active = [...this.tasks.values()].filter((task) => task.serviceId === request.serviceId && task.finishedAt === undefined)
if (request.action !== 'start' && active.some((task) => !request.confirmedTaskIds.includes(task.id))) {
throw new Error('使用此服务的任务已变化,请刷新并重新确认影响')
}
await adapter.act(request.action)
} finally {
this.operations.delete(request.serviceId)
}
}
}
export const localInferenceService = new LocalInferenceService()
+20 -16
View File
@@ -1,6 +1,7 @@
import { createRequire } from 'node:module'
import { join } from 'node:path'
import { Worker } from 'node:worker_threads'
import { localInferenceService } from '../local-inference-service'
import {
speechTranscriptionInputSchema,
speechTranscriptionResultSchema,
@@ -190,9 +191,10 @@ export const runSherpaTranscription: SpeechTranscriptionRunner = (
signal.removeEventListener('abort', abort)
worker.removeAllListeners()
if (terminate) {
void worker.terminate()
void worker.terminate().then(action, reject)
} else {
action()
}
action()
}
const abort = (): void =>
finish(() => reject(createAbortError()))
@@ -265,21 +267,23 @@ export class SpeechTranscriptionService {
}
const controller = new AbortController()
this.active.set(request.requestId, controller)
try {
const model = await this.models.getSelectedRuntimeModel()
if (!model) {
throw new Error('请先在设置中安装并选择本地语音模型')
return localInferenceService.run('asr', '语音输入', async () => {
try {
const model = await this.models.getSelectedRuntimeModel()
if (!model) {
throw new Error('请先在设置中安装并选择本地语音模型')
}
const text = await this.runner(
createSherpaRecognizerConfig(model),
samples,
request.sampleRate,
controller.signal
)
return speechTranscriptionResultSchema.parse({ text })
} finally {
this.active.delete(request.requestId)
}
const text = await this.runner(
createSherpaRecognizerConfig(model),
samples,
request.sampleRate,
controller.signal
)
return speechTranscriptionResultSchema.parse({ text })
} finally {
this.active.delete(request.requestId)
}
}, { cancel: () => controller.abort() })
}
cancel(requestId: SpeechTranscriptionInput['requestId']): boolean {
+21
View File
@@ -0,0 +1,21 @@
import { expect, it, vi } from 'vitest'
import type { DesktopApi } from '../shared/contracts'
import { ipcChannels } from '../shared/ipc-channels'
const electron = vi.hoisted(() => ({
contextBridge: { exposeInMainWorld: vi.fn() },
ipcRenderer: { invoke: vi.fn(), on: vi.fn(), removeListener: vi.fn() },
webUtils: {}
}))
vi.mock('electron', () => electron)
it('reads execution statistics through the explicit typed bridge', async () => {
await import('./index')
const api = electron.contextBridge.exposeInMainWorld.mock.calls[0]![1] as DesktopApi
const summary = { durationMs: 1000, requestCount: 1, incompleteRequestCount: 0, activeRequestCount: 0, asOf: 2000, taskDurations: [{ id: 'task', durationMs: 1000, incompleteRequestCount: 0 }] }
electron.ipcRenderer.invoke.mockResolvedValue(summary)
for (const scope of [{ conversationId: 'conversation' }, { projectId: 'project' }]) {
expect(await api.tasks.getExecutionStats(scope)).toBe(summary)
expect(electron.ipcRenderer.invoke).toHaveBeenLastCalledWith(ipcChannels.tasksExecutionStats, scope)
}
})
+17
View File
@@ -65,6 +65,7 @@ import type {
AssistantHeartbeatRun,
AssistantExpert,
AssistantTask,
ExecutionStats,
ActivityHistorySnapshot,
ActivityRecord,
TokenUsageSummary,
@@ -674,6 +675,14 @@ const desktopApi: DesktopApi = {
}
},
updates: {
onSettingsChanged: (listener) => {
const handler = (
_event: Electron.IpcRendererEvent,
settings: ApplicationSettings
): void => listener(settings)
ipcRenderer.on(ipcChannels.applicationSettingsChanged, handler)
return () => ipcRenderer.removeListener(ipcChannels.applicationSettingsChanged, handler)
},
getSettings: () =>
ipcRenderer.invoke(
ipcChannels.applicationSettingsGet
@@ -779,6 +788,12 @@ const desktopApi: DesktopApi = {
)
}
},
localInference: {
openSettings: () => ipcRenderer.invoke(ipcChannels.localInferenceOpenSettings),
getSnapshot: () => ipcRenderer.invoke(ipcChannels.localInferenceGet),
act: (input) => ipcRenderer.invoke(ipcChannels.localInferenceAct, input),
cancel: (taskId) => ipcRenderer.invoke(ipcChannels.localInferenceCancel, { taskId })
},
speechModels: {
getSnapshot: () =>
ipcRenderer.invoke(
@@ -1216,6 +1231,8 @@ const desktopApi: DesktopApi = {
tasks: {
list: () =>
ipcRenderer.invoke(ipcChannels.tasksList) as Promise<AssistantTask[]>,
getExecutionStats: (input) =>
ipcRenderer.invoke(ipcChannels.tasksExecutionStats, input) as Promise<ExecutionStats>,
setStatus: async (
taskId: string,
status: 'completed' | 'cancelled'
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useEffectEvent, useLayoutEffect, useRef, type ReactNode, type RefObject } from 'react'
import { createPortal, flushSync } from 'react-dom'
import './anchored-menu.css'
export function AnchoredMenu({ anchorRef, id, label, onClose, children }: {
anchorRef: RefObject<HTMLButtonElement | null>
id: string
label: string
onClose: () => void
children: ReactNode
}): React.JSX.Element {
const menuRef = useRef<HTMLDivElement>(null)
const dismiss = useEffectEvent(onClose)
const position = useEffectEvent(() => {
const menu = menuRef.current
const anchor = anchorRef.current
if (!menu || !anchor) return
const viewport = window.visualViewport
const left = viewport?.offsetLeft ?? 0
const top = viewport?.offsetTop ?? 0
const width = viewport?.width ?? window.innerWidth
const height = viewport?.height ?? window.innerHeight
const rect = anchor.getBoundingClientRect()
const menuWidth = Math.max(0, Math.min(280, width - 32))
const bottom = Math.max(top + 16, Math.min(rect.top - 8, top + height - 16))
menu.style.width = `${menuWidth}px`
menu.style.maxHeight = `${Math.max(0, bottom - top - 16)}px`
menu.style.left = `${Math.max(left + 16, Math.min(rect.left, left + width - menuWidth - 16))}px`
menu.style.top = `${Math.max(top + 16, bottom - menu.getBoundingClientRect().height)}px`
})
useLayoutEffect(() => { position() })
useEffect(() => {
const menu = menuRef.current!
const anchor = anchorRef.current
const focusFirst = (): void => {
if (document.activeElement === document.body || document.activeElement === menu) {
(menu.querySelector<HTMLElement>('[role="menuitem"]:not(:disabled)') ?? menu).focus()
}
}
menu.focus()
focusFirst()
const outside = (event: Event): void => {
if (!menu.contains(event.target as Node) && !anchor?.contains(event.target as Node)) dismiss()
}
const keydown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
flushSync(() => dismiss())
anchor?.focus()
return
}
if (!menu.contains(event.target as Node)) return
if (event.key === 'Tab') {
flushSync(() => dismiss())
anchor?.focus()
return
}
if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
const items = Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled)'))
const index = items.indexOf(document.activeElement as HTMLElement)
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1
: (index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length
items[next]?.focus()
}
const reposition = (): void => { position(); focusFirst() }
const observer = new ResizeObserver(reposition)
observer.observe(menu)
if (anchor) observer.observe(anchor)
document.addEventListener('pointerdown', outside)
document.addEventListener('focusin', outside)
document.addEventListener('keydown', keydown, true)
window.addEventListener('resize', reposition)
window.addEventListener('scroll', reposition, true)
window.visualViewport?.addEventListener('resize', reposition)
window.visualViewport?.addEventListener('scroll', reposition)
return () => {
observer.disconnect()
document.removeEventListener('pointerdown', outside)
document.removeEventListener('focusin', outside)
document.removeEventListener('keydown', keydown, true)
window.removeEventListener('resize', reposition)
window.removeEventListener('scroll', reposition, true)
window.visualViewport?.removeEventListener('resize', reposition)
window.visualViewport?.removeEventListener('scroll', reposition)
if (document.activeElement === document.body || menu.contains(document.activeElement)) anchor?.focus()
}
}, [anchorRef])
return createPortal(<div ref={menuRef} id={id} role="menu" aria-label={label}
tabIndex={-1} className="anchored-menu">{children}</div>, document.body)
}
+374 -45
View File
@@ -19,6 +19,7 @@ import type {
} from "../../shared/contracts";
import {
defaultLocalToolEnvironmentSettings,
defaultApplicationNavigation,
type ApplicationSettings,
} from "../../shared/application-settings-contracts";
import type { GlobalShortcutSettingsSnapshot } from "../../shared/shortcut";
@@ -181,6 +182,12 @@ const terminalSnapshot: TerminalSnapshot = {
};
const api: DesktopApi = {
localInference: {
openSettings: vi.fn(async () => undefined),
getSnapshot: vi.fn(async () => ({ services: [], tasks: [] })),
act: vi.fn(async () => undefined),
cancel: vi.fn(async () => undefined)
},
storageUpgrade: {
getProgress: vi.fn(),
act: vi.fn(async () => undefined)
@@ -572,6 +579,10 @@ const api: DesktopApi = {
},
tasks: {
list: vi.fn(async () => []),
getExecutionStats: vi.fn(async () => ({
durationMs: 0, requestCount: 0, incompleteRequestCount: 0,
activeRequestCount: 0, asOf: Date.now(), taskDurations: [],
})),
setStatus: vi.fn(async () => {}),
},
activityHistory: {
@@ -1048,6 +1059,7 @@ function installRemoteProjectsSetting(enabled: boolean): {
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: enabled,
magicNotesEnabled: false,
@@ -1067,6 +1079,7 @@ function installRemoteProjectsSetting(enabled: boolean): {
check: vi.fn(),
openReleasePage: vi.fn(),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
return { updateSettings };
}
@@ -1224,7 +1237,7 @@ describe("App", () => {
vi.mocked(api.activityHistory.replace).mockReset().mockResolvedValue();
vi.mocked(api.schedules.list).mockReset().mockResolvedValue([]);
api.channels = undefined;
api.updates = undefined;
installRemoteProjectsSetting(false);
newConversationListener = undefined;
beforeQuitListener = undefined;
browserListener = undefined;
@@ -1431,7 +1444,7 @@ describe("App", () => {
expect(selected).toHaveClass("conversation-item--active");
const summary = await screen.findByRole("button", { name: /全项目活动/u });
expect(summary).toHaveTextContent("1 个运行中");
fireEvent.click(screen.getByRole("button", { name: /本地工作区/u }));
fireEvent.click(screen.getByRole("button", { name: /^设置$/u }));
const settings = await screen.findByRole("dialog", { name: "设置中心" });
expect(selected).toHaveClass("conversation-item--active");
vi.mocked(api.tasks.list).mockResolvedValue([{ ...currentTask, status: "completed" }]);
@@ -1514,7 +1527,7 @@ describe("App", () => {
it("respects the Settings leave guard before changing project and conversation", async () => {
render(<App />);
await screen.findByRole("button", { name: /全项目活动/u });
fireEvent.click(await screen.findByText("本地工作区"));
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
await screen.findByRole("heading", { name: "设置中心" });
fireEvent.change(await screen.findByLabelText("默认工作区目录"), { target: { value: "C:\\Unsaved activity draft" } });
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
@@ -1749,6 +1762,105 @@ describe("App", () => {
expect(screen.queryByText("All retained text")).not.toBeInTheDocument();
});
describe("custom task creation", () => {
const currentId = "00000000-0000-4000-8000-000000000811";
const otherId = "00000000-0000-4000-8000-000000000812";
const snapshots: ConversationSnapshot[] = [
{ id: currentId, projectId, title: "Task current", updatedAt: 300, messages: [] },
{ id: otherId, projectId, title: "Task other", updatedAt: 200, messages: [] },
{ id: "00000000-0000-4000-8000-000000000813", projectId, title: "Task remote", updatedAt: 100, messages: [],
remote: { channel: "weixin", accountDisplay: "Remote account", conversationType: "direct" } },
{ id: "00000000-0000-4000-8000-000000000814", projectId: "other-project", title: "Task elsewhere", updatedAt: 50, messages: [] },
];
beforeEach(() => {
vi.mocked(api.conversations.list).mockResolvedValue(snapshots);
vi.mocked(api.schedules.create).mockReset().mockImplementation(async (input) => ({
...input,
workMode: "execute",
id: "00000000-0000-4000-8000-000000000815",
taskId: "00000000-0000-4000-8000-000000000816",
conversationId: input.conversationId ?? "00000000-0000-4000-8000-000000000817",
enabled: !input.runImmediately,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
});
async function openDialog(): Promise<HTMLElement> {
render(<App />);
fireEvent.click(await screen.findByRole("button", { name: "切换助手工作栏" }));
fireEvent.click(await screen.findByRole("tab", { name: "任务中心" }));
fireEvent.click(await screen.findByRole("button", { name: "新建任务" }));
return screen.getByRole("dialog", { name: "新建定制任务" });
}
it("creates immediately in the current conversation with one IPC call and no follow-up runNow", async () => {
const dialog = await openDialog();
expect(within(dialog).getByLabelText("关联会话")).toHaveValue("current");
fireEvent.change(within(dialog).getByLabelText("任务内容"), { target: { value: "立即检查项目" } });
fireEvent.click(within(dialog).getByRole("button", { name: "创建任务" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "新建定制任务" })).not.toBeInTheDocument());
expect(api.schedules.create).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
conversationId: currentId, projectId, title: "立即检查项目", prompt: "立即检查项目",
recurrence: "once", runImmediately: true,
}));
expect(api.schedules.runNow).not.toHaveBeenCalled();
});
it.each([otherId, "new"])("creates in the selected destination %s while excluding remote and other-project conversations", async (destination) => {
const dialog = await openDialog();
expect(within(dialog).getByRole("option", { name: "Task other" })).toBeInTheDocument();
expect(within(dialog).queryByRole("option", { name: "Task remote" })).not.toBeInTheDocument();
expect(within(dialog).queryByRole("option", { name: "Task elsewhere" })).not.toBeInTheDocument();
fireEvent.change(within(dialog).getByLabelText("关联会话"), { target: { value: destination } });
fireEvent.change(within(dialog).getByLabelText("任务内容"), { target: { value: "检查所选会话" } });
fireEvent.click(within(dialog).getByRole("button", { name: "创建任务" }));
await waitFor(() => expect(api.schedules.create).toHaveBeenCalledOnce());
const input = vi.mocked(api.schedules.create).mock.calls[0]![0];
if (destination === "new") expect(input).not.toHaveProperty("conversationId");
else expect(input.conversationId).toBe(otherId);
expect(input.runImmediately).toBe(true);
expect(api.schedules.runNow).not.toHaveBeenCalled();
await waitFor(() => expect(screen.queryByRole("dialog", { name: "新建定制任务" })).not.toBeInTheDocument());
});
it("validates future scheduled time before creating and retains recurrence", async () => {
const dialog = await openDialog();
fireEvent.change(within(dialog).getByLabelText("任务内容"), { target: { value: "每周检查" } });
fireEvent.click(within(dialog).getByRole("button", { name: "定时执行" }));
fireEvent.change(within(dialog).getByLabelText("运行频率"), { target: { value: "weekly" } });
fireEvent.change(within(dialog).getByLabelText("首次运行"), { target: { value: "2020-01-01T12:00" } });
fireEvent.click(within(dialog).getByRole("button", { name: "创建任务" }));
expect(await within(dialog).findByRole("alert")).toHaveTextContent("首次运行时间必须晚于当前时间");
expect(api.schedules.create).not.toHaveBeenCalled();
fireEvent.change(within(dialog).getByLabelText("首次运行"), { target: { value: "2099-01-01T12:00" } });
fireEvent.click(within(dialog).getByRole("button", { name: "创建任务" }));
await waitFor(() => expect(api.schedules.create).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
recurrence: "weekly", runImmediately: false, nextRunAt: new Date("2099-01-01T12:00").toISOString(),
})));
expect(api.schedules.runNow).not.toHaveBeenCalled();
await waitFor(() => expect(screen.queryByRole("dialog", { name: "新建定制任务" })).not.toBeInTheDocument());
});
it("retains the draft after create failure and does not retry creation after a refresh failure", async () => {
const dialog = await openDialog();
vi.mocked(api.schedules.create).mockRejectedValueOnce(new Error("队列写入失败"));
fireEvent.change(within(dialog).getByLabelText("任务内容"), { target: { value: "保留任务内容" } });
fireEvent.click(within(dialog).getByRole("button", { name: "创建任务" }));
expect(await within(dialog).findByRole("alert")).toHaveTextContent("队列写入失败");
expect(within(dialog).getByLabelText("任务内容")).toHaveValue("保留任务内容");
expect(api.schedules.create).toHaveBeenCalledOnce();
vi.mocked(api.conversations.listSummaries).mockRejectedValueOnce(new Error("刷新失败"));
vi.mocked(api.tasks.list).mockRejectedValueOnce(new Error("刷新失败"));
vi.mocked(api.schedules.list).mockRejectedValueOnce(new Error("刷新失败"));
fireEvent.click(within(dialog).getByRole("button", { name: "创建任务" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "新建定制任务" })).not.toBeInTheDocument());
expect(api.schedules.create).toHaveBeenCalledTimes(2);
expect(api.schedules.runNow).not.toHaveBeenCalled();
});
});
it("provides custom minimize, maximize, and close controls", async () => {
const { unmount } = render(<App />);
@@ -1803,7 +1915,7 @@ describe("App", () => {
expect(
screen.getByRole("button", { name: "Current project" }),
).toHaveTextContent("Default project");
expect(screen.getByText("Project: Default project")).toHaveClass(
expect(screen.getByLabelText("Project: Default project")).toHaveClass(
"scope-badge",
);
expect(
@@ -1877,7 +1989,7 @@ describe("App", () => {
within(assistantSidebar).getByText("Project: Default project"),
).toBeInTheDocument();
const taskIndexHeading = within(assistantSidebar).getByRole("heading", {
name: "Task index",
name: /Project tasks/,
});
const newTaskButton = within(assistantSidebar).getByRole("button", {
name: "New task",
@@ -1891,10 +2003,8 @@ describe("App", () => {
within(taskDialog).getByText("Default project"),
).toBeInTheDocument();
expect(
within(taskDialog).getByRole("button", {
name: "Current conversation",
}),
).toHaveAttribute("aria-pressed", "true");
within(taskDialog).getByLabelText("Conversation"),
).toHaveValue("current");
fireEvent.click(
within(taskDialog).getByRole("button", {
name: "Close new custom task",
@@ -2582,7 +2692,7 @@ describe("App", () => {
fireEvent.click(
await screen.findByRole("button", {
name: /本地工作区/u,
name: /^设置$/u,
}),
);
await screen.findByRole("heading", { name: "设置中心" }, { timeout: 3000 });
@@ -2713,7 +2823,7 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: "运行记录" }));
await screen.findByRole("heading", { name: "运行记录" });
fireEvent.click(screen.getByRole("button", { name: "对话" }));
fireEvent.click(screen.getByRole("button", { name: /本地工作区/u }));
fireEvent.click(screen.getByRole("button", { name: /^设置$/u }));
await screen.findByRole("heading", { name: "设置中心" });
expect(document.querySelectorAll(".workspace-route-cache")).toHaveLength(4);
@@ -2867,6 +2977,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
@@ -2878,6 +2989,7 @@ describe("App", () => {
check: vi.fn(),
openReleasePage: vi.fn(),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
try {
render(
@@ -2888,7 +3000,7 @@ describe("App", () => {
fireEvent.click(
await screen.findByRole("button", {
name: /本地工作区/u,
name: /^设置$/u,
}),
);
await screen.findByRole(
@@ -2966,6 +3078,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
@@ -2978,6 +3091,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
@@ -2988,6 +3102,7 @@ describe("App", () => {
check,
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
try {
render(<App />);
@@ -3075,6 +3190,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
@@ -3087,6 +3203,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
@@ -3097,6 +3214,7 @@ describe("App", () => {
check,
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
try {
const first = render(<App />);
@@ -3545,7 +3663,13 @@ describe("App", () => {
target: { value: "方案讨论更新" },
});
fireEvent.submit(renameInput.closest("form")!);
expect(await screen.findAllByText("方案讨论更新")).toHaveLength(2);
expect(
await within(container.querySelector(".conversation-list") as HTMLElement)
.findByText("方案讨论更新"),
).toBeInTheDocument();
expect(container.querySelector(".conversation-title")).toHaveTextContent(
/^方案讨论更新$/,
);
vi.mocked(api.conversations.saveLocal).mockClear();
fireEvent.click(screen.getByLabelText("更多会话操作 方案讨论更新"));
@@ -4442,7 +4566,7 @@ describe("App", () => {
for (const reasoning of completedReasoning) {
expect(reasoning.closest("details")).not.toHaveAttribute("open");
}
expect(screen.getByText("项目:默认项目")).toHaveClass("scope-badge");
expect(screen.getByLabelText("项目:默认项目")).toHaveClass("scope-badge");
});
it("keeps every activity detail in a long tool chain", async () => {
@@ -5088,7 +5212,7 @@ describe("App", () => {
});
});
fireEvent.click(screen.getByRole("button", { name: /本地工作区/u }));
fireEvent.click(screen.getByRole("button", { name: /^设置$/u }));
await screen.findByRole("heading", { name: "设置中心" });
fireEvent.click(screen.getByRole("tab", { name: "上下文控制" }));
const trigger = await screen.findByLabelText("压缩触发阈值");
@@ -5113,7 +5237,7 @@ describe("App", () => {
it("guards sidebar navigation away from dirty Settings drafts", async () => {
render(<App />);
fireEvent.click(await screen.findByText("本地工作区"));
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
await screen.findByRole("heading", { name: "设置中心" });
fireEvent.change(await screen.findByLabelText("默认工作区目录"), {
target: { value: "C:\\Unsaved from App" },
@@ -5137,7 +5261,7 @@ describe("App", () => {
await screen.findByLabelText("向 GoodBuddy 提问");
const settingsTrigger = screen.getByRole("button", {
name: /本地工作区/u,
name: /^设置$/u,
});
fireEvent.click(settingsTrigger);
await screen.findByRole("heading", { name: "设置中心" });
@@ -5158,7 +5282,7 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: label }));
const workspace = document.querySelector(`[data-route="${route}"]`)!;
workspace.scrollTop = 120;
const trigger = screen.getByRole("button", { name: /本地工作区/u });
const trigger = screen.getByRole("button", { name: /^设置$/u });
trigger.focus();
fireEvent.click(trigger);
const dialog = await screen.findByRole("dialog", { name: "设置中心" });
@@ -5204,6 +5328,7 @@ describe("App", () => {
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
@@ -5235,6 +5360,7 @@ describe("App", () => {
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
api.shortcuts = {
getSettings: vi.fn(async () => shortcutSnapshot),
@@ -5256,7 +5382,7 @@ describe("App", () => {
try {
render(<App />);
expect(await screen.findByText("Ctrl+Shift+Space")).toBeInTheDocument();
fireEvent.click(await screen.findByText("本地工作区"));
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
await screen.findByRole("heading", { name: "设置中心" });
fireEvent.click(screen.getByRole("tab", { name: "平台功能" }));
const shortcutInput = await screen.findByLabelText("快捷键");
@@ -5273,7 +5399,7 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: "对话" }));
expect(await screen.findByText("Ctrl+Alt+K")).toBeInTheDocument();
fireEvent.click(await screen.findByText("本地工作区"));
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
await screen.findByRole("heading", { name: "设置中心" });
fireEvent.click(screen.getByRole("tab", { name: "平台功能" }));
const shortcutSwitch = await screen.findByRole("switch", {
@@ -5657,7 +5783,7 @@ describe("App", () => {
);
});
it("restores an independent knowledge scope for each conversation", async () => {
it("restores independent knowledge scopes across conversations and the application center", async () => {
const libraryId = "11111111-1111-4111-8111-111111111111";
const firstConversationId = "22222222-2222-4222-8222-222222222222";
const secondConversationId = "33333333-3333-4333-8333-333333333333";
@@ -5725,6 +5851,14 @@ describe("App", () => {
name: "选择知识库,本次已启用 1 个",
}),
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '应用中心' }));
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }));
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '知识库' } });
expect(within(screen.getByRole('dialog')).queryByRole('switch')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }));
openComposerOptions();
expect(screen.getByRole('button', { name: '选择知识库,本次已启用 1 个' })).toBeInTheDocument();
expect(document.querySelector('.composer__option-summary')).toHaveTextContent('每次先检索');
});
it("persists and submits always-retrieve mode for the active conversation", async () => {
@@ -6555,7 +6689,7 @@ describe("App", () => {
it("opens chat and focuses the composer for tray conversations", async () => {
render(<App />);
fireEvent.click(await screen.findByText("本地工作区"));
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
expect(
await screen.findByRole("heading", { name: "设置中心" }),
).toBeInTheDocument();
@@ -7008,7 +7142,7 @@ describe("App", () => {
it("applies and persists a dark appearance from Settings", async () => {
render(<App />);
fireEvent.click(await screen.findByText("本地工作区"));
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
await screen.findByRole("heading", { name: "设置中心" }, { timeout: 3000 });
fireEvent.click(screen.getByRole("tab", { name: "外观" }));
fireEvent.click(screen.getByRole("radio", { name: /暗色/u }));
@@ -8194,7 +8328,7 @@ describe("App", () => {
).toHaveTextContent(remoteProject.name),
);
fireEvent.click(screen.getByText("本地工作区"));
fireEvent.click(screen.getByRole('button', { name: '设置' }));
fireEvent.click(await screen.findByRole("tab", { name: "平台功能" }));
fireEvent.click(
await screen.findByRole("tab", {
@@ -9444,14 +9578,14 @@ describe("App", () => {
render(<App />);
expect(await screen.findByText('当前聊天模型无法自动调用图片工具,可使用已有直连图片工作流。')).toBeVisible();
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: 'Keep my image request' } });
fireEvent.click(screen.getByRole('button', { name: /本地工作区/u }));
fireEvent.click(screen.getByRole('button', { name: /^设置$/u }));
fireEvent.click(await screen.findByRole('tab', { name: '外观' }));
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }));
fireEvent.click(screen.getByRole('button', { name: '前往图片模型设置' }));
expect(await screen.findByRole('tab', { name: '模型连接' })).toHaveAttribute('aria-selected', 'true');
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }));
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveValue('Keep my image request');
fireEvent.click(screen.getByRole('button', { name: /本地工作区/u }));
fireEvent.click(screen.getByRole('button', { name: /^设置$/u }));
expect(await screen.findByRole('tab', { name: 'Agent Runtime' })).toHaveAttribute('aria-selected', 'true');
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }));
fireEvent.click(screen.getByRole('button', { name: '前往图片模型设置' }));
@@ -11635,7 +11769,7 @@ describe("App", () => {
it("configures a runtime without reading an existing API key", async () => {
render(<App />);
fireEvent.click(await screen.findByText("本地工作区"));
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
expect(
await screen.findByRole("heading", {
name: "设置中心",
@@ -11738,7 +11872,7 @@ describe("App", () => {
"true",
);
expect(
screen.getByText("没有活动项目。请选择其他任务范围或先打开一个项目。"),
screen.getByText("没有活动项目。请先打开一个项目以查看和创建任务。"),
).toBeInTheDocument();
expect(
screen.queryByRole("region", { name: "当前会话的任务" }),
@@ -12284,7 +12418,7 @@ describe("App", () => {
it("opens Smart Heartbeat as a first-class workspace", async () => {
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "智能心跳" }));
fireEvent.click(await screen.findByRole("button", { name: "智能心跳" }));
expect(
await screen.findByRole("heading", { name: "智能心跳" }),
@@ -12378,7 +12512,7 @@ describe("App", () => {
);
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "知识库" }));
fireEvent.click(await screen.findByRole("button", { name: "知识库" }));
expect(await screen.findByText("知识库加载失败")).toBeInTheDocument();
expect(screen.getAllByText("知识数据库暂时不可用")).toHaveLength(1);
expect(screen.queryByText("建立第一个知识库")).not.toBeInTheDocument();
@@ -12434,7 +12568,7 @@ describe("App", () => {
});
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "知识库" }));
fireEvent.click(await screen.findByRole("button", { name: "知识库" }));
fireEvent.click(
await screen.findByRole(
"button",
@@ -12465,7 +12599,7 @@ describe("App", () => {
);
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "智能心跳" }));
fireEvent.click(await screen.findByRole("button", { name: "智能心跳" }));
expect(await screen.findByText("智能心跳加载失败")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "配置智能心跳" }),
@@ -12511,7 +12645,7 @@ describe("App", () => {
]);
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "智能心跳" }));
fireEvent.click(await screen.findByRole("button", { name: "智能心跳" }));
fireEvent.click(await screen.findByRole("tab", { name: "心跳计划" }));
expect(await screen.findAllByText("旧项目心跳")).not.toHaveLength(0);
selectProjectOption(secondProject.name);
@@ -12522,7 +12656,7 @@ describe("App", () => {
it("automatically snapshots the conversation project on new activity", async () => {
render(<App />);
await screen.findByText("项目:默认项目");
expect(await screen.findByLabelText("项目:默认项目")).toHaveClass("scope-badge");
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
target: { value: "记录项目范围" },
@@ -12564,7 +12698,7 @@ describe("App", () => {
);
render(<App />);
await screen.findByText("项目:默认项目");
expect(await screen.findByLabelText("项目:默认项目")).toHaveClass("scope-badge");
await waitFor(() => {
expect(api.activityHistory.replace).toHaveBeenCalledWith(
@@ -12588,7 +12722,7 @@ describe("App", () => {
name: "主导航",
});
const chat = within(navigation).getByRole("button", { name: "对话" });
const knowledge = within(navigation).getByRole("button", {
const knowledge = await within(navigation).findByRole("button", {
name: "知识库",
});
@@ -12782,6 +12916,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
@@ -12794,6 +12929,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
@@ -12804,10 +12940,11 @@ describe("App", () => {
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
try {
render(<App />);
await screen.findByText("项目:默认项目");
await screen.findByLabelText("项目:默认项目");
const magicNotesEntry = await screen.findByRole("button", {
name: "魔法笔记",
});
@@ -12842,6 +12979,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
@@ -12853,6 +12991,7 @@ describe("App", () => {
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
try {
render(<App />);
@@ -12897,6 +13036,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
@@ -12908,6 +13048,7 @@ describe("App", () => {
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
try {
render(<App />);
@@ -12929,6 +13070,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
@@ -12941,6 +13083,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
@@ -12951,6 +13094,7 @@ describe("App", () => {
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
try {
render(<App />);
@@ -12998,6 +13142,7 @@ describe("App", () => {
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
@@ -13017,6 +13162,7 @@ describe("App", () => {
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
render(<App />);
@@ -13025,7 +13171,7 @@ describe("App", () => {
await screen.findByTitle("Agent 回复 HTML 静态预览"),
).toHaveAttribute("sandbox", "");
fireEvent.click(await screen.findByRole("button", { name: /本地工作区/u }));
fireEvent.click(await screen.findByRole("button", { name: /^设置$/u }));
fireEvent.click(await screen.findByRole("tab", { name: "平台功能" }));
const toggle = await screen.findByRole("switch", {
name: "在会话中渲染 HTML",
@@ -13046,12 +13192,13 @@ describe("App", () => {
expect(screen.getByText(/Rendered dashboard/u)).toBeInTheDocument();
});
it("keeps platform-feature switches in Settings without navigating", async () => {
it("omits global notes settings while retaining application center settings without navigating the workspace", async () => {
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false,
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
@@ -13071,25 +13218,34 @@ describe("App", () => {
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {}),
};
try {
const { container } = render(<App />);
fireEvent.click(
await screen.findByRole("button", {
name: /本地工作区/u,
name: /^设置$/u,
}),
);
await screen.findByRole("heading", { name: "设置中心" });
fireEvent.click(screen.getByRole("tab", { name: "平台功能" }));
fireEvent.click(await screen.findByRole("tab", { name: "魔法笔记" }));
await screen.findByRole("tab", { name: "通用设置" });
expect(screen.queryByRole("tab", { name: "魔法笔记" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "应用设置" })).not.toBeInTheDocument();
expect(api.updates.updateSettings).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "关闭设置" }));
fireEvent.click(screen.getByRole("button", { name: "应用中心" }));
fireEvent.click(screen.getByRole("menuitem", { name: "管理应用" }));
fireEvent.click(screen.getByRole("button", { name: "魔法笔记 应用设置" }));
const toggle = await screen.findByRole("switch", {
name: "显示魔法笔记入口",
name: "启用应用",
});
await waitFor(() => expect(toggle).toBeEnabled());
fireEvent.click(toggle);
await waitFor(() => expect(toggle).toBeChecked());
expect(
screen.getByRole("heading", { name: "设置中心" }),
screen.getByRole("heading", { name: "魔法笔记" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "魔法笔记" }),
@@ -13101,7 +13257,7 @@ describe("App", () => {
fireEvent.click(toggle);
await waitFor(() => expect(toggle).not.toBeChecked());
expect(
screen.getByRole("heading", { name: "设置中心" }),
screen.getByRole("heading", { name: "魔法笔记" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "魔法笔记" }),
@@ -13111,10 +13267,183 @@ describe("App", () => {
}
});
it('refreshes externally changed navigation before editing and keeps Knowledge in its fixed slot', async () => {
const updates = api.updates!
const original = await updates.getSettings()
render(<App />)
await screen.findByRole('button', { name: '智能心跳' })
const external = {
...original,
applicationNavigation: {
...original.applicationNavigation,
order: ['local-inference', 'magic-notes'] as const,
},
}
vi.mocked(updates.getSettings).mockResolvedValue({ ...external, applicationNavigation: { ...external.applicationNavigation, order: [...external.applicationNavigation.order] } })
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' }))
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
expect(pin).toBeDisabled()
await waitFor(() => expect(pin).not.toBeDisabled())
const nav = screen.getByRole('navigation', { name: '主导航' })
expect(within(nav).getAllByRole('button').map(button => button.textContent)).toEqual([
'对话', '知识库', '智能心跳', '本机推理', '运行记录',
])
fireEvent.click(pin)
await waitFor(() => expect(updates.updateSettings).toHaveBeenCalledWith({
applicationNavigation: {
order: ['local-inference', 'magic-notes'],
pinned: { ...original.applicationNavigation.pinned, 'local-inference': false },
},
}))
})
it.each(['read', 'mutation'] as const)('keeps a settings event authoritative over a stale %s reply', async (operation) => {
const updates = api.updates!
const original = await updates.getSettings()
let changed!: Parameters<typeof updates.onSettingsChanged>[0]
vi.mocked(updates.onSettingsChanged).mockImplementation(listener => {
changed = listener
return vi.fn()
})
render(<App />)
await screen.findByRole('button', { name: '智能心跳' })
const reply = deferred<ApplicationSettings>()
if (operation === 'read') vi.mocked(updates.getSettings).mockReturnValueOnce(reply.promise)
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' }))
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
if (operation === 'mutation') {
await waitFor(() => expect(pin).not.toBeDisabled())
vi.mocked(updates.updateSettings).mockReturnValueOnce(reply.promise)
fireEvent.click(pin)
}
const external: ApplicationSettings = {
...original,
applicationNavigation: {
order: ['local-inference', 'magic-notes'],
pinned: { ...original.applicationNavigation.pinned, 'local-inference': false },
},
}
await act(async () => changed(external))
expect(pin).not.toBeChecked()
await act(async () => reply.resolve(original))
await waitFor(() => expect(pin).not.toBeDisabled())
expect(pin).not.toBeChecked()
expect(within(screen.getByRole('navigation', { name: '主导航' })).queryByRole('button', { name: '本机推理' })).not.toBeInTheDocument()
expect(within(screen.getByRole('navigation', { name: '主导航' })).getByRole('button', { name: '智能心跳' })).toBeInTheDocument()
fireEvent.click(pin)
expect(updates.updateSettings).toHaveBeenLastCalledWith({
applicationNavigation: { ...external.applicationNavigation, pinned: { ...external.applicationNavigation.pinned, 'local-inference': true } },
})
})
it('keeps fixed apps available when application settings cannot be loaded', async () => {
vi.mocked(api.updates!.getSettings).mockRejectedValue(new Error('unavailable'))
render(<App />)
fireEvent.click(screen.getByRole('button', { name: '知识库' }))
expect(await screen.findByLabelText('知识工作区')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '智能心跳' }))
expect(await screen.findByRole('heading', { name: '智能心跳' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
expect(api.heartbeats.update).not.toHaveBeenCalled()
})
it.each(['sidebar', 'menu', 'center'])('opens local inference from %s as a modal preserving the workspace', async (entry) => {
render(<App />)
const nav = await screen.findByRole('button', { name: '本机推理' })
fireEvent.click(screen.getByRole('button', { name: '知识库' }))
const workspace = await screen.findByLabelText('知识工作区')
if (entry === 'sidebar') {
nav.focus()
fireEvent.click(nav)
} else {
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
if (entry === 'menu') fireEvent.click(screen.getByRole('menuitem', { name: '本机推理' }))
else {
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
const item = screen.getByRole('button', { name: '本机推理 应用设置' }).closest('article')!
fireEvent.click(within(item).getByRole('button', { name: '打开' }))
}
}
const dialog = screen.getByRole('dialog', { name: '本机推理' })
expect(workspace).toBeInTheDocument()
expect(workspace.closest('.app-shell')).toHaveProperty('inert', true)
expect(nav).not.toHaveAttribute('aria-current', 'page')
expect(dialog.closest('main')).toBeNull()
const close = within(dialog).getByRole('button', { name: '关闭本机推理' })
expect(close).toHaveFocus()
fireEvent.click(close)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.getByLabelText('知识工作区')).toBe(workspace)
expect(workspace.closest('.app-shell')).toHaveProperty('inert', false)
expect(entry === 'sidebar' ? nav : screen.getByRole('button', { name: '应用中心' })).toHaveFocus()
})
it('opens an unpinned app and recovers a disabled current page without remounting it', async () => {
let changed!: Parameters<NonNullable<typeof api.updates>['onSettingsChanged']>[0]
vi.mocked(api.updates!.onSettingsChanged).mockImplementation(listener => {
changed = listener
return vi.fn()
})
render(<App />)
const inferenceNav = await screen.findByRole('button', { name: '本机推理' })
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' }))
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
await waitFor(() => expect(pin).not.toBeDisabled())
fireEvent.click(pin)
await waitFor(() => expect(inferenceNav).not.toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
const trigger = screen.getByRole('button', { name: '应用中心' })
expect(trigger).toHaveFocus()
fireEvent.click(trigger)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('menuitem', { name: '本机推理' }))
const workspace = await screen.findByRole('region', { name: '本机推理服务' })
expect(within(screen.getByRole('navigation', { name: '主导航' })).queryByRole('button', { name: '本机推理' })).not.toBeInTheDocument()
await act(async () => changed(await api.updates!.updateSettings({ localInferenceEnabled: false })))
expect(screen.getByText('应用已关闭')).toBeInTheDocument()
expect(workspace).toBeInTheDocument()
expect(workspace.closest('.local-inference-workspace')).toHaveAttribute('hidden')
fireEvent.click(screen.getByRole('button', { name: '应用设置' }))
await waitFor(() => expect(screen.getByRole('switch', { name: '启用应用' })).not.toBeDisabled())
fireEvent.click(screen.getByRole('switch', { name: '启用应用' }))
await waitFor(() => expect(screen.getByRole('switch', { name: '启用应用' })).toBeChecked())
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
expect(screen.getByRole('region', { name: '本机推理服务' })).toBe(workspace)
expect(workspace.closest('.local-inference-workspace')).not.toHaveAttribute('hidden')
expect((await api.updates!.getSettings()).applicationNavigation.pinned['local-inference']).toBe(false)
})
it('keeps confirmed application preferences and locks edits until an unknown save is read back', async () => {
render(<App />)
await screen.findByRole('button', { name: '知识库' })
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
const updates = api.updates!
fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' }))
await waitFor(() => expect(screen.getByRole('switch', { name: '常驻左侧菜单' })).not.toBeDisabled())
vi.mocked(updates.updateSettings).mockRejectedValueOnce(new Error('response lost'))
vi.mocked(updates.getSettings).mockRejectedValueOnce(new Error('read unavailable'))
fireEvent.click(screen.getByRole('switch', { name: '常驻左侧菜单' }))
await screen.findByText('无法确认保存结果,请重新读取后再修改。')
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
expect(pin).toBeChecked()
expect(pin).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '重新读取' }))
await waitFor(() => expect(pin).not.toBeDisabled())
expect(updates.updateSettings).toHaveBeenCalledOnce()
})
it("gives the knowledge workspace the full content width", async () => {
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "知识库" }));
fireEvent.click(await screen.findByRole("button", { name: "知识库" }));
expect(await screen.findByLabelText("知识工作区")).toBeInTheDocument();
expect(screen.queryByLabelText("切换助手工作栏")).toBeInTheDocument();
+271 -113
View File
@@ -1,8 +1,24 @@
import { ImageCapabilityNotice } from "./ImageCapabilityNotice";
import LocalInferencePage from "./LocalInferencePage";
import { ApplicationMenu } from './ApplicationMenu';
import {
ApplicationAvailability,
ApplicationCenter,
ApplicationSettingsNavigation,
applicationDefinitions,
isApplicationEnabled,
} from "./ApplicationCenter";
import {
defaultApplicationNavigation,
type ApplicationSettings,
type ApplicationSettingsUpdate,
type EditableApplicationId,
} from "../../shared/application-settings-contracts";
import type { ImageOperation } from "../../shared/image-generation-contracts";
import {
ArrowDown,
Bot,
ChartColumn,
Check,
CheckCircle2,
ChevronDown,
@@ -14,7 +30,7 @@ import {
Edit3,
FileText,
GitFork,
HeartPulse,
Grid2X2,
Info,
Library,
LoaderCircle,
@@ -172,6 +188,7 @@ import { ProjectSwitcher } from "./ProjectSwitcher";
import { ProjectActivity } from "./ProjectActivity";
import { deriveConversationActivity } from "./conversation-activity";
import { useUnviewedCompletions } from "./use-unviewed-completions";
import { useExecutionStats } from "./use-execution-stats";
import {
RightAssistantSidebar,
type AssistantSidebarTab,
@@ -180,6 +197,7 @@ import {
} from "./RightAssistantSidebar";
import {
CustomTaskDialog,
type CustomTaskCreateOptions,
type CustomTaskDestination,
} from "./CustomTaskDialog";
import { ConversationTaskStrip } from "./ConversationTaskStrip";
@@ -508,7 +526,7 @@ type ActiveRun = {
};
type WorkspaceView =
"chat" | "magic-notes" | "knowledge" | "heartbeat" | "activity" | "settings";
"chat" | "magic-notes" | "knowledge" | "heartbeat" | "local-inference" | "activity" | "settings";
const intentRoutePreloaders: Partial<
Record<WorkspaceView, () => Promise<unknown>>
@@ -2399,6 +2417,17 @@ function App(): React.JSX.Element {
>({});
const [view, setViewState] = useState<WorkspaceView>("chat");
const [settingsOpen, setSettingsOpen] = useState(false);
const [localInferenceOpen, setLocalInferenceOpen] = useState(false);
const [applicationCenterOpen, setApplicationCenterOpen] = useState(false);
const [applicationMenuOpen, setApplicationMenuOpen] = useState(false);
const applicationMenuTriggerRef = useRef<HTMLButtonElement>(null);
const [applicationSettingsTarget, setApplicationSettingsTarget] = useState<EditableApplicationId>();
const [applicationSettings, setApplicationSettings] = useState<ApplicationSettings>();
const [applicationSettingsPending, setApplicationSettingsPending] = useState(false);
const [applicationSettingsUnconfirmed, setApplicationSettingsUnconfirmed] = useState(false);
const applicationSettingsPendingRef = useRef(0);
const applicationSettingsRevisionRef = useRef(0);
const [applicationSettingsError, setApplicationSettingsError] = useState<string>();
const settingsOpenRef = useRef(false);
const [settingsInitialCategory, setSettingsInitialCategory] =
useState<SettingsCategoryId>();
@@ -2484,6 +2513,10 @@ function App(): React.JSX.Element {
(update: SetStateAction<WorkspaceView>): void => {
const next =
typeof update === "function" ? update(viewRef.current) : update;
if (next === "local-inference") {
setLocalInferenceOpen(true);
return;
}
if (
!settingsOpenRef.current &&
next === "settings" &&
@@ -2569,6 +2602,78 @@ function App(): React.JSX.Element {
setMagicNotesShowIncompleteTodoCount,
] = useState(true);
const [incompleteMagicTodoCount, setIncompleteMagicTodoCount] = useState(0);
const applicationNavigation = applicationSettings?.applicationNavigation ?? defaultApplicationNavigation;
const visibleApplications = useMemo(() => ['knowledge' as const, 'heartbeat' as const, ...applicationNavigation.order].filter(id =>
id === 'knowledge' || id === 'heartbeat' || (applicationNavigation.pinned[id] && (id === 'magic-notes' ? magicNotesEnabled : isApplicationEnabled(applicationSettings, id)))
), [applicationNavigation, applicationSettings, magicNotesEnabled]);
const applyApplicationSettings = useCallback((settings: ApplicationSettings): void => {
setApplicationSettings(settings);
setMagicNotesEnabled(settings.magicNotesEnabled);
setMagicNotesShowIncompleteTodoCount(settings.magicNotesShowIncompleteTodoCount);
setConversationHtmlRenderingEnabled(settings.conversationHtmlRenderingEnabled !== false);
setRemoteProjectsEnabled(settings.remoteProjectsEnabled);
}, []);
const reloadApplicationSettings = useCallback(async (): Promise<void> => {
const revision = ++applicationSettingsRevisionRef.current;
applicationSettingsPendingRef.current++;
setApplicationSettingsPending(true);
try {
if (!window.goodbuddy.updates) throw new Error(t('applications.loadFailed'));
const settings = await window.goodbuddy.updates.getSettings();
if (revision !== applicationSettingsRevisionRef.current) return;
applyApplicationSettings(settings);
setApplicationSettingsUnconfirmed(false);
setApplicationSettingsError(undefined);
} catch (error) {
if (revision !== applicationSettingsRevisionRef.current) return;
setApplicationSettingsUnconfirmed(true);
setApplicationSettingsError(displayErrorMessage(error, t('applications.loadFailed')));
} finally {
setApplicationSettingsPending(--applicationSettingsPendingRef.current > 0);
}
}, [applyApplicationSettings, t]);
const updateApplicationSettings = useCallback(async (patch: ApplicationSettingsUpdate): Promise<boolean> => {
if (applicationSettingsPendingRef.current || applicationSettingsUnconfirmed) return false;
const revision = ++applicationSettingsRevisionRef.current;
applicationSettingsPendingRef.current++;
setApplicationSettingsPending(true);
setApplicationSettingsError(undefined);
try {
if (!window.goodbuddy.updates) throw new Error(t('applications.loadFailed'));
const settings = await window.goodbuddy.updates.updateSettings(patch);
if (revision === applicationSettingsRevisionRef.current) applyApplicationSettings(settings);
return true;
} catch (error) {
if (revision !== applicationSettingsRevisionRef.current) return false;
setApplicationSettingsError(displayErrorMessage(error, t('applications.saveFailed')));
try {
if (!window.goodbuddy.updates) throw error;
const settings = await window.goodbuddy.updates.getSettings();
if (revision === applicationSettingsRevisionRef.current) applyApplicationSettings(settings);
} catch {
if (revision !== applicationSettingsRevisionRef.current) return false;
// Keep edits locked until Retry obtains the authoritative settings.
setApplicationSettingsError(t('applications.confirmFailed'));
setApplicationSettingsUnconfirmed(true);
}
return false;
} finally {
setApplicationSettingsPending(--applicationSettingsPendingRef.current > 0);
}
}, [applicationSettingsUnconfirmed, applyApplicationSettings, t]);
const openApplicationSettings = useCallback((id: EditableApplicationId): void => {
const open = (): void => {
setApplicationSettingsTarget(id);
setApplicationMenuOpen(false);
setApplicationCenterOpen(true);
void reloadApplicationSettings();
};
if (settingsOpenRef.current) {
const leave = (): void => { commitView(viewRef.current); requestAnimationFrame(open); };
if (settingsLeaveRequesterRef.current) settingsLeaveRequesterRef.current(leave);
else leave();
} else open();
}, [commitView, reloadApplicationSettings]);
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
const deferredSearchQuery = useDeferredValue(searchQuery);
@@ -3144,27 +3249,33 @@ function App(): React.JSX.Element {
saveAppearanceTheme(appearanceTheme);
}, [appearanceTheme]);
useEffect(() => {
const revision = applicationSettingsRevisionRef;
const remove = window.goodbuddy.updates?.onSettingsChanged((settings) => {
// A persisted change supersedes reads and mutation replies already in flight.
revision.current++;
applyApplicationSettings(settings);
setApplicationSettingsUnconfirmed(false);
setApplicationSettingsError(undefined);
});
return () => {
remove?.();
revision.current++;
};
}, [applyApplicationSettings]);
useEffect(() => {
const updates = window.goodbuddy.updates;
if (!updates || startupUpdateCheckStartedRef.current) {
return;
}
startupUpdateCheckStartedRef.current = true;
const revision = applicationSettingsRevisionRef.current;
let updateCheckSource: "github" | "mirror" | undefined;
void updates
.getSettings()
.then(async (settings) => {
setConversationHtmlRenderingEnabled(
settings.conversationHtmlRenderingEnabled !== false,
);
setRemoteProjectsEnabled(settings.remoteProjectsEnabled);
setMagicNotesEnabled(settings.magicNotesEnabled);
setMagicNotesShowIncompleteTodoCount(
settings.magicNotesShowIncompleteTodoCount,
);
if (!settings.magicNotesEnabled) {
setView((current) => (current === "magic-notes" ? "chat" : current));
}
if (revision === applicationSettingsRevisionRef.current) applyApplicationSettings(settings);
if (!settings.checkUpdatesOnStartup) {
return;
}
@@ -3183,6 +3294,8 @@ function App(): React.JSX.Element {
})
.catch((reason: unknown) => {
if (!updateCheckSource) {
if (revision !== applicationSettingsRevisionRef.current) return;
setApplicationSettingsError(displayErrorMessage(reason, i18n.t('applications.loadFailed', { ns: 'app' })));
return;
}
notify({
@@ -3201,7 +3314,7 @@ function App(): React.JSX.Element {
dedupeKey: "startup-update-check",
});
});
}, [i18n, setView]);
}, [i18n, applyApplicationSettings]);
useEffect(() => {
const magicNotes = window.goodbuddy.magicNotes;
@@ -3903,6 +4016,25 @@ function App(): React.JSX.Element {
),
[assistantTasks],
);
const executionStatsRevision = useMemo(
() => assistantTasks.map((task) => `${task.id}:${task.status}:${task.completedAt ?? ""}`).join("|"),
[assistantTasks],
);
const statsConversation = activeConversation?.projectId === activeProjectId
? activeConversation : undefined;
const statsMessageCount = statsConversation?.messageSummary?.count ?? statsConversation?.messages.length ?? 0;
const executionStats = useExecutionStats(
statsConversation?.id,
activeProjectId || undefined,
`${executionStatsRevision}:${statsMessageCount}:${activeConversationIds.has(activeId)}`,
assistantSidebarOpen && assistantSidebarTab === "tasks",
);
const taskDurations = useMemo(() => new Map(
executionStats.project?.taskDurations.map((task) => [task.id, {
durationMs: task.durationMs,
incomplete: task.incompleteRequestCount > 0,
}]) ?? [],
), [executionStats.project]);
const tasksByConversation = useMemo(() => {
const grouped = new Map<string, AssistantTask[]>();
for (const task of productAssistantTasks) {
@@ -3945,7 +4077,7 @@ function App(): React.JSX.Element {
);
const { completedConversationIds, markConversationCompleted, clearConversationCompleted } =
useUnviewedCompletions(assistantTasks,
view === "chat" && !settingsOpen && activeConversation && !activeConversation.messageSummary
view === "chat" && !settingsOpen && !applicationCenterOpen && activeConversation && !activeConversation.messageSummary
? activeId : undefined);
const projectActivity = useMemo(
() => deriveConversationActivity(
@@ -8331,12 +8463,19 @@ function App(): React.JSX.Element {
const createCustomTask = async (
input: Parameters<typeof window.goodbuddy.schedules.create>[0],
options?: CustomTaskCreateOptions,
): Promise<AssistantSchedule> => {
if (!options?.runImmediately && !(Date.parse(input.nextRunAt) > Date.now())) {
throw new Error(t("customTask.errors.futureTime"));
}
if (input.conversationId) {
persistLocalConversationChanges();
await conversationPersistenceQueueRef.current;
}
const schedule = await window.goodbuddy.schedules.create(input);
const schedule = await window.goodbuddy.schedules.create({
...input,
runImmediately: options?.runImmediately ?? false,
});
setAssistantSchedules((current) => [
schedule,
...current.filter((item) => item.id !== schedule.id),
@@ -8682,6 +8821,7 @@ function App(): React.JSX.Element {
};
return (
<ApplicationSettingsNavigation value={openApplicationSettings}>
<div className="app-shell">
<aside
aria-label={
@@ -8797,78 +8937,34 @@ function App(): React.JSX.Element {
<MessageSquare aria-hidden="true" size={17} />
<span>{t("navigation.chat")}</span>
</button>
{magicNotesEnabled && (
<button
aria-current={view === "magic-notes" ? "page" : undefined}
className={
view === "magic-notes"
? "nav-item nav-item--active"
: "nav-item"
}
onFocus={() => preloadWorkspaceRouteOnIntent("magic-notes")}
onClick={(event) =>
navigateFromSidebar("magic-notes", event.currentTarget)
}
onPointerEnter={() =>
preloadWorkspaceRouteOnIntent("magic-notes")
}
type="button"
>
<Sparkles aria-hidden="true" size={17} />
<span>{t("navigation.magicNotes")}</span>
{magicNotesShowIncompleteTodoCount &&
incompleteMagicTodoCount > 0 && (
<span
aria-label={t("navigation.incompleteTodos", {
count: incompleteMagicTodoCount,
})}
className="nav-item__badge"
>
{incompleteMagicTodoCount > 99
? "99+"
: incompleteMagicTodoCount}
{visibleApplications.map((id) => {
const definition = applicationDefinitions[id];
const Icon = definition.icon;
return (
<button
key={id}
type="button"
className={view === id ? "nav-item nav-item--active" : "nav-item"}
aria-current={view === id ? "page" : undefined}
onClick={(event) => navigateFromSidebar(id, event.currentTarget)}
onFocus={() => preloadWorkspaceRouteOnIntent(id)}
onPointerEnter={() => preloadWorkspaceRouteOnIntent(id)}
>
<Icon size={17} aria-hidden="true" />
<span>{t(definition.title)}</span>
{id === "magic-notes" && magicNotesShowIncompleteTodoCount && incompleteMagicTodoCount > 0 && (
<span className="nav-item__badge" aria-label={t("navigation.incompleteTodos", { count: incompleteMagicTodoCount })}>
{incompleteMagicTodoCount > 99 ? "99+" : incompleteMagicTodoCount}
</span>
)}
</button>
)}
<button
aria-current={view === "knowledge" ? "page" : undefined}
className={
view === "knowledge" ? "nav-item nav-item--active" : "nav-item"
}
onFocus={() => preloadWorkspaceRouteOnIntent("knowledge")}
onClick={(event) =>
navigateFromSidebar("knowledge", event.currentTarget)
}
onPointerEnter={() => preloadWorkspaceRouteOnIntent("knowledge")}
type="button"
>
<Library aria-hidden="true" size={17} />
<span>{t("navigation.knowledge")}</span>
</button>
<button
aria-current={view === "heartbeat" ? "page" : undefined}
className={
view === "heartbeat" ? "nav-item nav-item--active" : "nav-item"
}
onClick={(event) =>
navigateFromSidebar("heartbeat", event.currentTarget)
}
type="button"
>
<HeartPulse aria-hidden="true" size={17} />
<span>{t("navigation.heartbeat")}</span>
{pendingHeartbeatSuggestionCount > 0 && (
<span
aria-label={t("navigation.pendingSuggestions", {
count: pendingHeartbeatSuggestionCount,
})}
className="nav-item__badge"
>
{pendingHeartbeatSuggestionCount}
</span>
)}
</button>
{id === "heartbeat" && pendingHeartbeatSuggestionCount > 0 && (
<span className="nav-item__badge" aria-label={t("navigation.pendingSuggestions", { count: pendingHeartbeatSuggestionCount })}>
{pendingHeartbeatSuggestionCount}
</span>
)}
</button>
);
})}
<button
aria-current={view === "activity" ? "page" : undefined}
className={
@@ -8881,7 +8977,7 @@ function App(): React.JSX.Element {
onPointerEnter={() => preloadWorkspaceRouteOnIntent("activity")}
type="button"
>
<TerminalSquare aria-hidden="true" size={17} />
<ChartColumn aria-hidden="true" size={17} />
<span>{t("navigation.activity")}</span>
</button>
</nav>
@@ -9415,9 +9511,34 @@ function App(): React.JSX.Element {
) : null}
</div>
<div className="sidebar-footer">
<div className="sidebar-footer sidebar-footer--applications">
<button
className="user-card"
className="nav-item"
type="button"
ref={applicationMenuTriggerRef}
aria-haspopup="menu"
aria-expanded={applicationMenuOpen}
aria-controls="application-menu"
onKeyDown={(event) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
if (!applicationMenuOpen) {
setApplicationMenuOpen(true);
void reloadApplicationSettings();
}
}}
onClick={() => {
setApplicationMenuOpen(!applicationMenuOpen);
if (!applicationMenuOpen) void reloadApplicationSettings();
}}
>
<Grid2X2 size={17} aria-hidden="true" />
<span>{t("applications.title")}</span>
</button>
<button
className="icon-button"
aria-label={t('navigation.settings')}
title={t('navigation.settings')}
type="button"
onFocus={() => preloadWorkspaceRouteOnIntent("settings")}
onClick={(event) =>
@@ -9425,15 +9546,6 @@ function App(): React.JSX.Element {
}
onPointerEnter={() => preloadWorkspaceRouteOnIntent("settings")}
>
<span className="avatar">GB</span>
<span className="user-card__copy">
<strong>{t("sidebar.localWorkspace")}</strong>
<small>
{appInfo
? `${appInfo.platform} · ${appInfo.arch}`
: t("sidebar.loading")}
</small>
</span>
<Settings size={16} />
</button>
</div>
@@ -10830,13 +10942,13 @@ function App(): React.JSX.Element {
</PageShell>
</KeepAliveRoute>
)}
{magicNotesEnabled &&
(view === "magic-notes" ||
{(view === "magic-notes" ||
cachedWorkspaceViewKeys.has("magic-notes")) && (
<KeepAliveRoute
active={view === "magic-notes"}
route="magic-notes"
>
<ApplicationAvailability id="magic-notes" enabled={magicNotesEnabled}>
<PageShell variant="master-detail">
<RouteErrorBoundary
key="magic-notes"
@@ -10852,10 +10964,11 @@ function App(): React.JSX.Element {
<RouteLoadingStatus label={t("route.loading")} />
}
>
<MagicNotesWorkspace onNotify={notify} />
<MagicNotesWorkspace onNotify={notify} applicationSettings={applicationSettings} />
</Suspense>
</RouteErrorBoundary>
</PageShell>
</ApplicationAvailability>
</KeepAliveRoute>
)}
{(view === "knowledge" ||
@@ -11247,6 +11360,51 @@ function App(): React.JSX.Element {
</PageShell>
</KeepAliveRoute>
)}
{localInferenceOpen && (
<LocalInferencePage
enabled={isApplicationEnabled(applicationSettings, "local-inference")}
onClose={() => setLocalInferenceOpen(false)}
restoreFocus={() => applicationMenuTriggerRef.current?.closest('[aria-hidden="true"]')
? document.querySelector<HTMLElement>('.sidebar-toggle')
: applicationMenuTriggerRef.current}
/>
)}
{applicationCenterOpen && (
<ApplicationCenter
settings={applicationSettings}
pending={applicationSettingsPending}
locked={applicationSettingsUnconfirmed}
error={applicationSettingsError}
initialApplication={applicationSettingsTarget}
onClose={() => setApplicationCenterOpen(false)}
onOpen={(id) => {
if (!isApplicationEnabled(applicationSettings, id)) return;
setApplicationCenterOpen(false);
setView(id);
if (window.innerWidth < 900) setSidebarOpen(false);
}}
onUpdate={updateApplicationSettings}
onRetry={() => void reloadApplicationSettings()}
/>
)}
{applicationMenuOpen && (
<ApplicationMenu
anchorRef={applicationMenuTriggerRef}
settings={applicationSettings}
pending={applicationSettingsPending || (!applicationSettings && !applicationSettingsError)}
error={applicationSettingsError}
onClose={() => setApplicationMenuOpen(false)}
onOpen={(id) => {
setView(id);
if (window.innerWidth < 900) setSidebarOpen(false);
}}
onManage={() => {
setApplicationSettingsTarget(undefined);
setApplicationCenterOpen(true);
}}
onRetry={() => void reloadApplicationSettings()}
/>
)}
{settingsOpen && (
<RouteErrorBoundary
key="settings"
@@ -11303,18 +11461,6 @@ function App(): React.JSX.Element {
setSelectedExpertId("");
}
}}
onMagicNotesEnabledChange={(enabled) => {
setMagicNotesEnabled(enabled);
if (!enabled) {
setIncompleteMagicTodoCount(0);
}
}}
onMagicNotesShowIncompleteTodoCountChange={(enabled) => {
setMagicNotesShowIncompleteTodoCount(enabled);
if (!enabled) {
setIncompleteMagicTodoCount(0);
}
}}
onRemoteProjectsEnabledChange={
handleRemoteProjectsEnabledChange
}
@@ -11462,6 +11608,10 @@ function App(): React.JSX.Element {
)}
{customTaskDialog && activeProject?.kind === "user" && (
<CustomTaskDialog
conversations={conversations.filter(
(conversation) => !conversation.remote &&
conversation.projectId === activeProject.id,
)}
currentConversationAvailable={Boolean(
activeConversation &&
!activeConversation.remote &&
@@ -11480,6 +11630,13 @@ function App(): React.JSX.Element {
)}
<RightAssistantSidebar
activeConversationId={activeId}
conversationStats={statsConversation && executionStats.conversation ? {
conversationId: statsConversation.id,
messageCount: statsMessageCount,
replyDurationMs: executionStats.conversation.durationMs,
incomplete: executionStats.conversation.incompleteRequestCount > 0,
} : undefined}
taskDurations={taskDurations}
approvals={pendingSidebarApprovals}
artifacts={sidebarArtifacts}
browserStates={browserStates}
@@ -11559,6 +11716,7 @@ function App(): React.JSX.Element {
</div>
</div>
</div>
</ApplicationSettingsNavigation>
);
}
+236
View File
@@ -0,0 +1,236 @@
import {
cleanup,
fireEvent,
render,
screen,
within,
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
applicationSettingsSchema,
defaultApplicationNavigation,
defaultLocalToolEnvironmentSettings,
} from '../../shared/application-settings-contracts'
import {
ApplicationAvailability,
ApplicationCenter,
ApplicationSettingsNavigation,
ApplicationSettingsView,
} from './ApplicationCenter'
const settings = applicationSettingsSchema.parse({
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
})
const props = () => ({
settings,
pending: false,
onClose: vi.fn(),
onOpen: vi.fn(),
onUpdate: vi.fn(async () => true),
onRetry: vi.fn(),
})
afterEach(cleanup)
describe('Application Center', () => {
it('returns from compact settings through header navigation without losing the search', () => {
render(<ApplicationCenter {...props()} />)
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '笔记' } })
const body = screen.getByRole('searchbox').closest('.application-center__body')!
body.scrollTop = 200
fireEvent.click(screen.getByRole('button', { name: '魔法笔记 应用设置' }))
expect(body.scrollTop).toBe(0)
const dialog = screen.getByRole('dialog', { name: '魔法笔记' })
expect(dialog).toHaveClass('application-center--detail')
const back = within(dialog).getByRole('button', { name: '返回应用中心' })
expect(back.closest('.settings-panel__header')).not.toBeNull()
fireEvent.click(back)
expect(screen.getByRole('searchbox')).toHaveValue('笔记')
expect(screen.getByRole('searchbox')).toHaveFocus()
expect(dialog).not.toHaveClass('application-center--detail')
})
it.each([['知识库', 'knowledge'], ['智能心跳', 'heartbeat']])('keeps %s fixed with Open and no settings, pin, or order controls', (title, id) => {
const handlers = props()
render(<ApplicationCenter {...handlers} />)
const card = screen.getByText(title, { exact: true }).closest('article')!
expect(card).toHaveAttribute('draggable', 'false')
expect(within(card).queryByRole('switch')).not.toBeInTheDocument()
expect(within(card).getAllByRole('button')).toHaveLength(1)
fireEvent.click(within(card).getByRole('button', { name: '打开' }))
expect(handlers.onOpen).toHaveBeenCalledWith(id)
const notes = screen.getByText('魔法笔记', { exact: true }).closest('article')!
fireEvent.dragStart(notes)
fireEvent.drop(card)
expect(handlers.onUpdate).not.toHaveBeenCalled()
})
it('searches disabled and unpinned applications, opens without pinning, and restores focus', () => {
const handlers = props()
const trigger = document.createElement('button')
document.body.append(trigger)
trigger.focus()
const { unmount } = render(
<ApplicationCenter
{...handlers}
settings={{
...settings,
localInferenceEnabled: false,
applicationNavigation: {
...defaultApplicationNavigation,
pinned: {
...defaultApplicationNavigation.pinned,
'magic-notes': false,
},
},
}}
/>,
)
const search = screen.getByRole('searchbox', { name: '搜索内置应用' })
expect(search).toHaveFocus()
fireEvent.change(search, { target: { value: '笔记' } })
fireEvent.click(screen.getByRole('button', { name: '打开' }))
expect(handlers.onOpen).toHaveBeenCalledWith('magic-notes')
expect(handlers.onUpdate).not.toHaveBeenCalled()
fireEvent.change(search, { target: { value: '推理' } })
expect(screen.getByText('已关闭', { exact: false })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '打开' }),
).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' }))
expect(screen.getByRole('switch', { name: '启用应用' })).not.toBeChecked()
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
expect(handlers.onClose).toHaveBeenCalledOnce()
unmount()
expect(trigger).toHaveFocus()
trigger.remove()
})
it('saves complete order and independent pin preferences without optimistic changes', () => {
const handlers = props()
render(<ApplicationCenter {...handlers} />)
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' }))
fireEvent.click(screen.getByRole('switch', { name: '常驻左侧菜单' }))
expect(handlers.onUpdate).toHaveBeenLastCalledWith({
applicationNavigation: {
...defaultApplicationNavigation,
pinned: { ...defaultApplicationNavigation.pinned, 'local-inference': false },
},
})
expect(
screen.getByRole('switch', { name: '常驻左侧菜单' }),
).toBeChecked()
fireEvent.click(screen.getByRole('button', { name: '上移 本机推理' }))
expect(handlers.onUpdate).toHaveBeenLastCalledWith({
applicationNavigation: {
...defaultApplicationNavigation,
order: ['local-inference', 'magic-notes'],
},
})
expect(screen.getByRole('button', { name: '下移 本机推理' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '返回应用中心' }))
fireEvent.click(screen.getByRole('button', { name: '魔法笔记 应用设置' }))
expect(screen.getByRole('button', { name: '上移 魔法笔记' })).toBeDisabled()
})
it('uses the same order patch for drag and keyboard controls', () => {
const handlers = props()
render(<ApplicationCenter {...handlers} />)
const notes = screen
.getByText('魔法笔记', { exact: true })
.closest('article')!
const inference = screen
.getByText('本机推理', { exact: true })
.closest('article')!
fireEvent.dragStart(inference)
fireEvent.dragOver(notes)
fireEvent.drop(notes)
expect(handlers.onUpdate).toHaveBeenCalledWith({
applicationNavigation: {
...defaultApplicationNavigation,
order: ['local-inference', 'magic-notes'],
},
})
})
it('locks all preference controls while saving or awaiting a confirmed read', () => {
const handlers = props()
const { rerender } = render(<ApplicationCenter {...handlers} pending initialApplication="local-inference" />)
for (const toggle of screen.getAllByRole('switch'))
expect(toggle).toBeDisabled()
expect(screen.getByRole('button', { name: '上移 本机推理' })).toBeDisabled()
rerender(
<ApplicationCenter {...handlers} locked error="无法确认保存结果" />,
)
expect(screen.getByRole('alert')).toHaveTextContent('无法确认保存结果')
for (const toggle of screen.getAllByRole('switch'))
expect(toggle).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '重新读取' }))
expect(handlers.onRetry).toHaveBeenCalledOnce()
})
it('provides a recoverable search empty state and no market or system tools', () => {
render(<ApplicationCenter {...props()} />)
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
expect(screen.queryByText('安装')).not.toBeInTheDocument()
expect(screen.queryByText('终端')).not.toBeInTheDocument()
fireEvent.change(screen.getByRole('searchbox'), {
target: { value: 'absent' },
})
expect(screen.getByText('未找到相关应用')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
expect(screen.getAllByRole('article')).toHaveLength(4)
})
it('edits all notes preferences through the shared settings form', () => {
const onUpdate = vi.fn(async () => true)
render(
<ApplicationSettingsView
id="magic-notes"
settings={settings}
pending={false}
onUpdate={onUpdate}
/>,
)
fireEvent.click(screen.getByRole('switch', { name: '启用应用' }))
expect(onUpdate).toHaveBeenLastCalledWith({ magicNotesEnabled: false })
fireEvent.click(screen.getByRole('switch', { name: '显示未完成待办数量' }))
expect(onUpdate).toHaveBeenLastCalledWith({
magicNotesShowIncompleteTodoCount: false,
})
fireEvent.click(screen.getByRole('button', { name: '保存后自动' }))
expect(onUpdate).toHaveBeenLastCalledWith({
magicNoteCommentMode: 'after-save-auto',
})
fireEvent.click(screen.getByRole('button', { name: '要点' }))
expect(onUpdate).toHaveBeenLastCalledWith({
magicNoteCommentFormat: 'structured',
})
})
it('keeps disabled page drafts mounted with a recovery entry', () => {
const open = vi.fn()
const page = (enabled: boolean) => (
<ApplicationSettingsNavigation value={open}>
<ApplicationAvailability id="local-inference" enabled={enabled}>
<input aria-label="draft" defaultValue="original" />
</ApplicationAvailability>
</ApplicationSettingsNavigation>
)
const { rerender, container } = render(page(true))
const draft = screen.getByRole('textbox', { name: 'draft' })
fireEvent.change(draft, { target: { value: 'keep this draft' } })
rerender(page(false))
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(draft).toBeInTheDocument()
expect(screen.getByText('应用已关闭')).toBeInTheDocument()
fireEvent.click(within(container).getByRole('button', { name: '应用设置' }))
expect(open).toHaveBeenCalledWith('local-inference')
rerender(page(true))
expect(screen.getByRole('textbox')).toHaveValue('keep this draft')
})
})
+519
View File
@@ -0,0 +1,519 @@
import {
createContext,
useContext,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import {
ArrowDown,
ArrowLeft,
ArrowUp,
Cpu,
HeartPulse,
Library,
Settings,
Sparkles,
X,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
defaultApplicationNavigation,
type ApplicationSettings,
type ApplicationSettingsUpdate,
type BuiltInApplicationId,
type EditableApplicationId,
} from '../../shared/application-settings-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
import './application-center.css'
export const applicationDefinitions = {
'magic-notes': {
icon: Sparkles,
title: 'navigation.magicNotes',
enabled: 'magicNotesEnabled',
},
knowledge: {
icon: Library,
title: 'navigation.knowledge',
},
heartbeat: {
icon: HeartPulse,
title: 'navigation.heartbeat',
},
'local-inference': {
icon: Cpu,
title: 'applications.localInference',
enabled: 'localInferenceEnabled',
},
} as const
export function isApplicationEnabled(
settings: ApplicationSettings | undefined,
id: BuiltInApplicationId,
): boolean {
if (id === 'knowledge' || id === 'heartbeat') return true
return (
settings !== undefined &&
settings[applicationDefinitions[id].enabled] !== false
)
}
export const ApplicationSettingsNavigation = createContext<
((id: EditableApplicationId) => void) | undefined
>(undefined)
export function ApplicationSettingsLink({
id,
}: {
id: EditableApplicationId
}): React.JSX.Element | null {
const open = useContext(ApplicationSettingsNavigation)
const { t } = useTranslation('app')
return open ? (
<button className="secondary-button" type="button" onClick={() => open(id)}>
<Settings size={15} aria-hidden="true" />
{t('applications.settings')}
</button>
) : null
}
export function ApplicationAvailability({
id,
enabled,
children,
}: {
id: EditableApplicationId
enabled: boolean
children: ReactNode
}): React.JSX.Element {
const { t } = useTranslation('app')
return (
<div className="application-page">
<div className="application-page-controls">
{!enabled && <strong>{t('applications.disabledPage')}</strong>}
<ApplicationSettingsLink id={id} />
</div>
<div
className="application-page-content"
hidden={!enabled}
inert={!enabled}
>
{children}
</div>
</div>
)
}
export function ApplicationSettingsView({
id,
settings,
pending,
onUpdate,
}: {
id: EditableApplicationId
settings: ApplicationSettings
pending: boolean
onUpdate: (patch: ApplicationSettingsUpdate) => Promise<boolean>
}): React.JSX.Element {
const { t } = useTranslation(['app', 'settingsSections'])
const navigation =
settings.applicationNavigation ?? defaultApplicationNavigation
return (
<div className="application-settings">
<div className="application-settings__group">
<label className="toggle-row">
<input
role="switch"
type="checkbox"
checked={isApplicationEnabled(settings, id)}
disabled={pending}
onChange={(event) =>
void onUpdate({
[applicationDefinitions[id].enabled]: event.target.checked,
})
}
/>
<span>{t('applications.enable')}</span>
</label>
<label className="toggle-row">
<input
role="switch"
type="checkbox"
checked={navigation.pinned[id]}
disabled={pending}
onChange={(event) =>
void onUpdate({
applicationNavigation: {
...navigation,
pinned: { ...navigation.pinned, [id]: event.target.checked },
},
})
}
/>
<span>{t('applications.pin')}</span>
</label>
<p className="settings-notice">{t('applications.enableHelp')}</p>
{id === 'magic-notes' && (
<label className="toggle-row">
<input
role="switch"
type="checkbox"
checked={settings.magicNotesShowIncompleteTodoCount}
disabled={pending}
onChange={(event) =>
void onUpdate({
magicNotesShowIncompleteTodoCount: event.target.checked,
})
}
/>
<span>
{t('platformFeatures.magicNotes.showIncompleteTodoCount', {
ns: 'settingsSections',
})}
</span>
</label>
)}
</div>
{id === 'magic-notes' && (
<div className="application-settings__group">
<div className="platform-feature-option">
<span>
{t('platformFeatures.magicNotes.commentMode', {
ns: 'settingsSections',
})}
</span>
<SegmentedControl
ariaLabel={t('platformFeatures.magicNotes.commentModeAria', {
ns: 'settingsSections',
})}
disabled={pending}
value={settings.magicNoteCommentMode}
onChange={(magicNoteCommentMode) =>
void onUpdate({ magicNoteCommentMode })
}
options={[
{
value: 'immediate',
label: t('platformFeatures.magicNotes.modes.immediate', {
ns: 'settingsSections',
}),
},
{
value: 'after-save-auto',
label: t('platformFeatures.magicNotes.modes.afterSaveAuto', {
ns: 'settingsSections',
}),
},
{
value: 'after-save-manual',
label: t(
'platformFeatures.magicNotes.modes.afterSaveManual',
{ ns: 'settingsSections' },
),
},
]}
/>
</div>
<div className="platform-feature-option">
<span>
{t('platformFeatures.magicNotes.commentFormat', {
ns: 'settingsSections',
})}
</span>
<SegmentedControl
ariaLabel={t('platformFeatures.magicNotes.commentFormatAria', {
ns: 'settingsSections',
})}
disabled={pending}
value={settings.magicNoteCommentFormat}
onChange={(magicNoteCommentFormat) =>
void onUpdate({ magicNoteCommentFormat })
}
options={[
{
value: 'combined',
label: t('platformFeatures.magicNotes.formats.combined', {
ns: 'settingsSections',
}),
},
{
value: 'narrative',
label: t('platformFeatures.magicNotes.formats.narrative', {
ns: 'settingsSections',
}),
},
{
value: 'structured',
label: t('platformFeatures.magicNotes.formats.structured', {
ns: 'settingsSections',
}),
},
]}
/>
</div>
</div>
)}
</div>
)
}
export function ApplicationCenter({
settings,
pending,
locked = false,
error,
initialApplication,
onClose,
onOpen,
onUpdate,
onRetry,
}: {
settings?: ApplicationSettings
pending: boolean
locked?: boolean
error?: string
initialApplication?: EditableApplicationId
onClose: () => void
onOpen: (id: BuiltInApplicationId) => void
onUpdate: (patch: ApplicationSettingsUpdate) => Promise<boolean>
onRetry: () => void
}): React.JSX.Element {
const { t } = useTranslation('app')
const [query, setQuery] = useState('')
const [selected, setSelected] = useState(initialApplication)
const [dragged, setDragged] = useState<EditableApplicationId>()
const [announcement, setAnnouncement] = useState('')
const dialog = useRef<HTMLElement>(null)
const body = useRef<HTMLDivElement>(null)
const search = useRef<HTMLInputElement>(null)
const close = useRef<HTMLButtonElement>(null)
useLayoutEffect(
() => activateModalFocus(() => search.current ?? close.current),
[],
)
useLayoutEffect(() => {
if (body.current) body.current.scrollTop = 0
;(selected ? close.current : search.current)?.focus()
}, [selected])
const navigation =
settings?.applicationNavigation ?? defaultApplicationNavigation
const editingDisabled = pending || locked
const results = useMemo(
() =>
['knowledge' as const, 'heartbeat' as const, ...navigation.order].filter((id) =>
`${t(applicationDefinitions[id].title)} ${t(`applications.descriptions.${id}`)}`
.toLocaleLowerCase()
.includes(query.trim().toLocaleLowerCase()),
),
[navigation.order, query, t],
)
const move = async (
from: EditableApplicationId,
to: EditableApplicationId,
): Promise<void> => {
if (editingDisabled || from === to) return
const order = [...navigation.order]
order.splice(order.indexOf(from), 1)
order.splice(navigation.order.indexOf(to), 0, from)
if (await onUpdate({ applicationNavigation: { ...navigation, order } }))
setAnnouncement(t('applications.orderUpdated'))
}
return createPortal(
<div className="settings-backdrop" role="presentation">
<section
className={`settings-panel application-center${selected ? ' application-center--detail' : ''}`}
role="dialog"
aria-modal="true"
aria-labelledby="application-center-title"
id="application-center"
ref={dialog}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === 'Escape') {
event.preventDefault()
onClose()
}
trapTabFocus(event, dialog.current)
}}
>
<div className="settings-panel__header">
{selected && (
<button
type="button"
className="icon-button"
aria-label={t('applications.back')}
title={t('applications.back')}
onClick={() => setSelected(undefined)}
>
<ArrowLeft size={19} aria-hidden="true" />
</button>
)}
<PageHeader
headingId="application-center-title"
title={
selected
? t(applicationDefinitions[selected].title)
: t('applications.title')
}
description={t(
selected
? `applications.descriptions.${selected}`
: 'applications.description',
)}
actions={
<button
ref={close}
type="button"
className="icon-button"
aria-label={t('applications.close')}
title={t('applications.close')}
onClick={onClose}
>
<X size={19} />
</button>
}
/>
</div>
<div ref={body} className="application-center__body" aria-busy={pending}>
{error && (
<div role="alert" className="settings-warning">
{error}{' '}
<button
type="button"
className="secondary-button"
disabled={pending}
onClick={onRetry}
>
{t('applications.retry')}
</button>
</div>
)}
{!settings ? (
<p role="status">{t('route.loading')}</p>
) : selected ? (
<>
<ApplicationSettingsView
id={selected}
settings={settings}
pending={editingDisabled}
onUpdate={onUpdate}
/>
<div className="application-center__order">
{([-1, 1] as const).map((offset) => {
const target =
navigation.order[navigation.order.indexOf(selected) + offset]
const Icon = offset === -1 ? ArrowUp : ArrowDown
return (
<button
key={offset}
className="secondary-button"
type="button"
disabled={editingDisabled || !target}
onClick={() => { if (target) void move(selected, target) }}
>
<Icon size={16} aria-hidden="true" />
{t(offset === -1 ? 'applications.moveUp' : 'applications.moveDown', {
name: t(applicationDefinitions[selected].title),
})}
</button>
)
})}
</div>
</>
) : (
<>
<label className="application-center__search">
<span>{t('applications.search')}</span>
<input
ref={search}
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</label>
{results.length === 0 && (
<div>
<p>{t('applications.noResults')}</p>
<button
className="secondary-button"
type="button"
onClick={() => setQuery('')}
>
{t('applications.clear')}
</button>
</div>
)}
<div className="application-center__grid">{results.map((id) => {
const definition = applicationDefinitions[id]
const Icon = definition.icon
const enabled = isApplicationEnabled(settings, id)
const fixed = id === 'knowledge' || id === 'heartbeat'
return (
<article
className="application-center__item"
key={id}
draggable={!fixed && !editingDisabled && !query}
onDragStart={() => { if (!fixed) setDragged(id) }}
onDragEnd={() => setDragged(undefined)}
onDragOver={(event) => {
if (!fixed && dragged && !editingDisabled) event.preventDefault()
}}
onDrop={(event) => {
event.preventDefault()
if (!fixed && dragged) void move(dragged, id)
setDragged(undefined)
}}
>
<div className="application-center__summary">
<Icon size={22} aria-hidden="true" />
<div>
<div className="application-center__title">
<strong>{t(definition.title)}</strong>
<small>
{fixed
? t('applications.fixed')
: `${t('applications.optional')} · ${t(enabled ? 'applications.enabled' : 'applications.disabled')}`}
</small>
</div>
<p>{t(`applications.descriptions.${id}`)}</p>
</div>
</div>
<div className="application-center__actions">
<button
className="secondary-button"
type="button"
disabled={!enabled}
onClick={() => onOpen(id)}
>
{t('applications.open')}
</button>
{!fixed && (
<button
className="secondary-button"
type="button"
title={t('applications.settings')}
aria-label={`${t(definition.title)} ${t('applications.settings')}`}
onClick={() => setSelected(id)}
>
<Settings size={16} aria-hidden="true" />
{t('applications.settings')}
</button>
)}
</div>
</article>
)
})}</div>
</>
)}
<span className="sr-only" role="status">{announcement}</span>
</div>
</section>
</div>,
document.body,
)
}
+105
View File
@@ -0,0 +1,105 @@
import { cleanup, fireEvent, render, screen, act } from '@testing-library/react'
import { useRef, useState } from 'react'
import { afterEach, expect, it, vi } from 'vitest'
import { applicationSettingsSchema, defaultLocalToolEnvironmentSettings, type ApplicationSettings } from '../../shared/application-settings-contracts'
import { ApplicationMenu } from './ApplicationMenu'
import { isBrowserViewportOccluded } from './browser-viewport-occlusion'
const settings = applicationSettingsSchema.parse({
checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false,
})
function Harness({ value = settings, pending = false, error, onOpen = vi.fn(), onManage = vi.fn(), onRetry = vi.fn() }: {
value?: ApplicationSettings | null; pending?: boolean; error?: string
onOpen?: (id: string) => void; onManage?: () => void; onRetry?: () => void
}): React.JSX.Element {
const anchorRef = useRef<HTMLButtonElement>(null)
const [open, setOpen] = useState(false)
return <><button ref={anchorRef} onClick={() => setOpen(!open)}>Launcher</button><button>Outside</button>
{open && <ApplicationMenu anchorRef={anchorRef} settings={value ?? undefined} pending={pending} error={error}
onClose={() => setOpen(false)} onOpen={onOpen} onManage={onManage} onRetry={onRetry} />}</>
}
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
it('lists enabled apps regardless of pins and keeps Knowledge and Heartbeat fixed before filtering', () => {
const value = { ...settings, magicNotesEnabled: true, localInferenceEnabled: true,
applicationNavigation: { order: ['local-inference', 'magic-notes'] as ApplicationSettings['applicationNavigation']['order'],
pinned: { 'magic-notes': false, 'local-inference': false } } }
const onOpen = vi.fn()
const { rerender } = render(<Harness value={value} onOpen={onOpen} />)
fireEvent.click(screen.getByText('Launcher'))
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['知识库', '智能心跳', '本机推理', '魔法笔记', '管理应用'])
rerender(<Harness value={{ ...value, magicNotesEnabled: false }} onOpen={onOpen} />)
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['知识库', '智能心跳', '本机推理', '管理应用'])
fireEvent.click(screen.getByRole('menuitem', { name: '本机推理' }))
expect(onOpen).toHaveBeenCalledWith('local-inference')
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('shows loading and actionable errors without treating unknown settings as disabled', () => {
const onRetry = vi.fn()
const { rerender } = render(<Harness value={null} pending onRetry={onRetry} />)
fireEvent.click(screen.getByText('Launcher'))
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: '知识库' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: '智能心跳' })).toBeInTheDocument()
rerender(<Harness value={null} error="Cannot read settings" onRetry={onRetry} />)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByRole('alert')).toHaveTextContent('Cannot read settings')
fireEvent.click(screen.getByRole('menuitem', { name: '重新读取' }))
expect(onRetry).toHaveBeenCalledOnce()
})
it('supports arrow, Home, End, Escape, outside focus and pointer dismissal and management focus handoff', () => {
const onManage = vi.fn()
render(<Harness onManage={onManage} />)
const trigger = screen.getByText('Launcher')
fireEvent.click(trigger)
const items = screen.getAllByRole('menuitem')
expect(items[0]).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'End' })
expect(items.at(-1)).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' })
expect(items[0]).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' })
expect(items.at(-1)).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'Home' })
expect(items[0]).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(trigger).toHaveFocus()
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
fireEvent.click(trigger)
act(() => screen.getByText('Outside').focus())
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(screen.getByText('Outside')).toHaveFocus()
fireEvent.click(trigger)
fireEvent.pointerDown(screen.getByText('Outside'))
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
expect(onManage).toHaveBeenCalledOnce()
expect(trigger).toHaveFocus()
})
it('anchors upward, clamps to a narrow viewport, repositions on resize and uses shared native viewport occlusion', () => {
vi.stubGlobal('innerWidth', 320)
vi.stubGlobal('innerHeight', 400)
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
return this.getAttribute('role') === 'menu' ? new DOMRect(24, 92, 280, 200) : new DOMRect(270, 300, 34, 34)
})
render(<Harness />)
fireEvent.click(screen.getByText('Launcher'))
const menu = screen.getByRole('menu')
expect(menu).toHaveStyle({ left: '24px', top: '92px', width: '280px', maxHeight: '276px' })
const host = screen.getByText('Outside')
expect(isBrowserViewportOccluded(host, new DOMRect(200, 100, 100, 100))).toBe(true)
expect(isBrowserViewportOccluded(host, new DOMRect(400, 100, 100, 100))).toBe(false)
vi.stubGlobal('innerWidth', 240)
vi.stubGlobal('innerHeight', 220)
fireEvent(window, new Event('resize'))
expect(menu).toHaveStyle({ left: '16px', top: '16px', width: '208px', maxHeight: '188px' })
fireEvent.keyDown(menu, { key: 'Tab' })
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(isBrowserViewportOccluded(host, new DOMRect(200, 100, 100, 100))).toBe(false)
})
+42
View File
@@ -0,0 +1,42 @@
import { useMemo, type RefObject } from 'react'
import { Settings } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { defaultApplicationNavigation, type ApplicationSettings, type BuiltInApplicationId } from '../../shared/application-settings-contracts'
import { applicationDefinitions, isApplicationEnabled } from './ApplicationCenter'
import { AnchoredMenu } from './AnchoredMenu'
export function ApplicationMenu({ anchorRef, settings, pending, error, onClose, onOpen, onManage, onRetry }: {
anchorRef: RefObject<HTMLButtonElement | null>
settings?: ApplicationSettings
pending: boolean
error?: string
onClose: () => void
onOpen: (id: BuiltInApplicationId) => void
onManage: () => void
onRetry: () => void
}): React.JSX.Element {
const { t } = useTranslation('app')
const applications = useMemo(() => {
const order = (settings?.applicationNavigation ?? defaultApplicationNavigation).order
return ['knowledge' as const, 'heartbeat' as const, ...order]
.filter(id => isApplicationEnabled(settings, id))
}, [settings])
return <AnchoredMenu anchorRef={anchorRef} id="application-menu" label={t('applications.title')} onClose={onClose}>
{pending && <p role="status">{t('route.loading')}</p>}
{error && <>
<p role="alert">{error}</p>
<button type="button" role="menuitem" tabIndex={-1} disabled={pending} onClick={onRetry}>{t('applications.retry')}</button>
</>}
{applications.map(id => {
const definition = applicationDefinitions[id]
const Icon = definition.icon
return <button key={id} type="button" role="menuitem" tabIndex={-1} onClick={() => { onClose(); onOpen(id) }}>
<Icon size={16} aria-hidden="true" /><span>{t(definition.title)}</span>
</button>
})}
<div role="separator" />
<button type="button" role="menuitem" tabIndex={-1} onClick={() => { onClose(); anchorRef.current?.focus(); onManage() }}>
<Settings size={16} aria-hidden="true" /><span>{t('applications.manage')}</span>
</button>
</AnchoredMenu>
}
+155 -8
View File
@@ -69,18 +69,16 @@ describe('CustomTaskDialog', () => {
screen.getByRole('dialog', { name: '新建定制任务' })
).toHaveAttribute('aria-modal', 'true')
expect(screen.queryByRole('button', { name: 'Execute' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '当前会话' })).toHaveAttribute(
'aria-pressed',
'true'
)
expect(screen.getByLabelText('任务名称')).toHaveFocus()
expect(screen.getByLabelText('关联会话')).toHaveValue('current')
expect(screen.getByLabelText('任务内容')).toHaveFocus()
fireEvent.change(screen.getByLabelText('任务名称'), {
target: { value: '每周项目总结' }
})
fireEvent.change(screen.getByLabelText('任务要求'), {
fireEvent.change(screen.getByLabelText('任务内容'), {
target: { value: '总结完成和失败的工作' }
})
fireEvent.click(screen.getByRole('button', { name: '定时执行' }))
fireEvent.change(screen.getByLabelText('首次运行'), {
target: { value: futureLocalDateTime() }
})
@@ -93,7 +91,8 @@ describe('CustomTaskDialog', () => {
title: '每周项目总结',
prompt: '总结完成和失败的工作',
recurrence: 'once'
})
}),
{ runImmediately: false }
)
expect(onCreate.mock.calls[0]![0]).not.toHaveProperty('workMode')
expect(onCreate.mock.calls[0]![0]).not.toHaveProperty('runtimeSelection')
@@ -112,6 +111,154 @@ describe('CustomTaskDialog', () => {
)
expect(screen.queryByRole('button', { name: 'Ask' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '当前会话' })).toBeDisabled()
expect(screen.getByRole('option', { name: '当前会话' })).toBeDisabled()
expect(screen.getByLabelText('关联会话')).toHaveValue('new')
})
const conversationId = '00000000-0000-4000-8000-000000000801'
const projectId = '00000000-0000-4000-8000-000000000804'
const otherId = '00000000-0000-4000-8000-000000000805'
const props = {
currentConversationAvailable: true,
currentConversationId: conversationId,
projectId,
projectName: 'GoodBuddy Desktop',
workspaceLabel: 'C:\\Workspace',
onClose: vi.fn(),
onCreate: vi.fn()
}
it('requires content and generates a bounded name for immediate execution by default', async () => {
const onCreate = vi.fn()
render(<CustomTaskDialog {...props} onCreate={onCreate} />)
expect(screen.getByRole('button', { name: '立即执行' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.queryByLabelText('首次运行')).not.toBeInTheDocument()
expect(screen.queryByLabelText('运行频率')).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: ' \n ' } })
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
expect(onCreate).not.toHaveBeenCalled()
expect(screen.getByLabelText('任务内容')).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByRole('alert')).toHaveTextContent('请输入任务内容。')
const content = ` 检查项目\n ${'内容'.repeat(80)} `
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: content } })
const before = Date.now()
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
await waitFor(() => expect(onCreate).toHaveBeenCalledOnce())
const [input, options] = onCreate.mock.calls[0]!
expect(input).toMatchObject({
projectId,
conversationId,
title: content.trim().replace(/\s+/gu, ' ').slice(0, 120),
prompt: content.trim(),
recurrence: 'once'
})
expect(Date.parse(input.nextRunAt)).toBeGreaterThanOrEqual(before)
expect(Date.parse(input.nextRunAt)).toBeLessThanOrEqual(Date.now())
expect(options).toEqual({ runImmediately: true })
})
it.each(['daily', 'weekly'])('preserves %s scheduling and delegates future-time validation to App', async (recurrence) => {
const onCreate = vi.fn()
render(<CustomTaskDialog {...props} onCreate={onCreate} />)
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: '检查项目' } })
fireEvent.click(screen.getByRole('button', { name: '定时执行' }))
fireEvent.change(screen.getByLabelText('运行频率'), { target: { value: recurrence } })
fireEvent.change(screen.getByLabelText('首次运行'), { target: { value: '' } })
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
expect(onCreate).not.toHaveBeenCalled()
expect(screen.getByLabelText('首次运行')).toHaveAttribute('aria-invalid', 'true')
fireEvent.change(screen.getByLabelText('首次运行'), { target: { value: '2020-01-01T12:00' } })
fireEvent.click(screen.getByRole('button', { name: '立即执行' }))
fireEvent.click(screen.getByRole('button', { name: '定时执行' }))
expect(screen.getByLabelText('运行频率')).toHaveValue(recurrence)
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
await waitFor(() => expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({ recurrence, nextRunAt: new Date('2020-01-01T12:00').toISOString() }),
{ runImmediately: false }
))
})
it('only offers other conversations in this project and submits the chosen conversation', async () => {
const onCreate = vi.fn()
render(<CustomTaskDialog {...props} onCreate={onCreate} conversations={[
{ id: conversationId, projectId, title: '当前项目会话' },
{ id: otherId, projectId, title: '另一个会话' },
{ id: 'elsewhere', projectId: 'another-project', title: '其他项目会话' }
]} />)
expect(screen.queryByRole('option', { name: '其他项目会话' })).not.toBeInTheDocument()
expect(screen.queryByRole('option', { name: '当前项目会话' })).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('关联会话'), { target: { value: otherId } })
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: '检查项目' } })
fireEvent.change(screen.getByLabelText('任务名称'), { target: { value: ' 自定义名称 ' } })
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
await waitFor(() => expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({ conversationId: otherId, title: '自定义名称' }),
{ runImmediately: true }
))
})
it('rejects a removed conversation without silently creating a new one', () => {
const onCreate = vi.fn()
const { rerender } = render(<CustomTaskDialog {...props} onCreate={onCreate}
conversations={[{ id: otherId, projectId, title: '另一个会话' }]} />)
fireEvent.change(screen.getByLabelText('关联会话'), { target: { value: otherId } })
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: '检查项目' } })
rerender(<CustomTaskDialog {...props} onCreate={onCreate} conversations={[]} />)
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
expect(onCreate).not.toHaveBeenCalled()
expect(screen.getByLabelText('关联会话')).toHaveAttribute('aria-invalid', 'true')
})
it('creates a new conversation without capturing execution settings or hidden recurrence', async () => {
const onCreate = vi.fn()
render(<CustomTaskDialog {...props} currentConversationId={undefined} onCreate={onCreate} />)
expect(screen.getByLabelText('关联会话')).toHaveValue('new')
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: '检查项目' } })
fireEvent.click(screen.getByRole('button', { name: '定时执行' }))
fireEvent.change(screen.getByLabelText('运行频率'), { target: { value: 'weekly' } })
fireEvent.click(screen.getByRole('button', { name: '立即执行' }))
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
await waitFor(() => expect(onCreate).toHaveBeenCalledOnce())
expect(onCreate.mock.calls[0]![0]).toEqual({
projectId, title: '检查项目', prompt: '检查项目', recurrence: 'once', nextRunAt: expect.any(String)
})
})
it('locks submission and dismissal while pending, then preserves the draft on failure', async () => {
let rejectCreate!: (reason: Error) => void
const onCreate = vi.fn(() => new Promise<never>((_, reject) => { rejectCreate = reject }))
const onClose = vi.fn()
render(<CustomTaskDialog {...props} onCreate={onCreate} onClose={onClose} />)
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: '保留草稿' } })
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
expect(screen.getByLabelText('任务内容')).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '创建中…' }))
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
fireEvent.mouseDown(screen.getByRole('dialog').parentElement!)
expect(onClose).not.toHaveBeenCalled()
expect(onCreate).toHaveBeenCalledOnce()
rejectCreate(new Error('请选择未来时间'))
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('请选择未来时间'))
expect(screen.getByLabelText('任务内容')).toHaveValue('保留草稿')
expect(screen.getByLabelText('任务内容')).toBeEnabled()
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
it('portals to the body, traps focus and restores the opener on close', () => {
const opener = document.createElement('button')
document.body.append(opener)
opener.focus()
const { unmount, container } = render(<CustomTaskDialog {...props} />)
const dialog = screen.getByRole('dialog')
expect(container).not.toContainElement(dialog)
const create = screen.getByRole('button', { name: '创建任务' })
create.focus()
fireEvent.keyDown(create, { key: 'Tab' })
expect(screen.getByRole('button', { name: '关闭新建定制任务' })).toHaveFocus()
unmount()
expect(opener).toHaveFocus()
opener.remove()
})
})
+159 -117
View File
@@ -8,18 +8,32 @@ import type {
} from '../../shared/assistant-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { SegmentedControl } from './WorkspacePrimitives'
import './custom-task-dialog.css'
export type CustomTaskDestination = 'current' | 'new'
// App translates the UI execution choice into the schedule creation request.
export type CustomTaskCreateOptions = { runImmediately?: boolean }
export type CustomTaskConversation = {
id: string
title: string
projectId?: string
}
const emptyConversations: readonly CustomTaskConversation[] = []
type CustomTaskDialogProps = {
currentConversationAvailable: boolean
currentConversationId?: string
defaultDestination: CustomTaskDestination
defaultDestination?: CustomTaskDestination
conversations?: readonly CustomTaskConversation[]
projectId?: string
projectName: string
workspaceLabel: string
onClose: () => void
onCreate: (input: ScheduleCreateInput) => Promise<AssistantSchedule>
onCreate: (
input: ScheduleCreateInput,
options?: CustomTaskCreateOptions
) => Promise<AssistantSchedule>
}
function toLocalDateTimeValue(date: Date): string {
@@ -30,7 +44,8 @@ function toLocalDateTimeValue(date: Date): string {
export function CustomTaskDialog({
currentConversationAvailable,
currentConversationId,
defaultDestination,
defaultDestination = 'current',
conversations = emptyConversations,
projectId,
projectName,
workspaceLabel,
@@ -39,81 +54,80 @@ export function CustomTaskDialog({
}: CustomTaskDialogProps): React.JSX.Element {
const { t } = useTranslation('app')
const dialogRef = useRef<HTMLElement>(null)
const titleRef = useRef<HTMLInputElement>(null)
const promptRef = useRef<HTMLTextAreaElement>(null)
const submittingRef = useRef(false)
const [title, setTitle] = useState('')
const [prompt, setPrompt] = useState('')
const [destination, setDestination] = useState<CustomTaskDestination>(
defaultDestination === 'current' && currentConversationAvailable
const [destination, setDestination] = useState<string>(
defaultDestination === 'current' &&
currentConversationAvailable && currentConversationId
? 'current'
: 'new'
)
const [timing, setTiming] = useState<'now' | 'scheduled'>('now')
const [recurrence, setRecurrence] =
useState<ScheduleCreateInput['recurrence']>('once')
const [nextRunAt, setNextRunAt] = useState(() =>
toLocalDateTimeValue(new Date(Date.now() + 60 * 60 * 1_000))
)
const [errors, setErrors] = useState<
Partial<Record<'title' | 'prompt' | 'destination' | 'nextRunAt' | 'form', string>>
Partial<Record<'prompt' | 'destination' | 'nextRunAt' | 'form', string>>
>({})
const [submitting, setSubmitting] = useState(false)
useEffect(
() => activateModalFocus(() => titleRef.current),
() => activateModalFocus(() => promptRef.current),
[]
)
const destinationOptions = useMemo(
() => [
{
value: 'current' as const,
label: t('customTask.destination.current'),
disabled: !currentConversationAvailable
},
{
value: 'new' as const,
label: t('customTask.destination.new')
}
],
[currentConversationAvailable, t]
const otherConversations = useMemo(
() => conversations.filter((conversation) =>
conversation.projectId === projectId &&
conversation.id !== currentConversationId
),
[conversations, projectId, currentConversationId]
)
const generatedTitle = prompt.trim().replace(/\s+/gu, ' ').slice(0, 120)
const submit = async (): Promise<void> => {
if (submittingRef.current) return
const nextErrors: typeof errors = {}
if (!title.trim()) {
nextErrors.title = t('customTask.errors.title')
}
if (!prompt.trim()) {
nextErrors.prompt = t('customTask.errors.instructions')
}
if (
destination === 'current' &&
(!currentConversationAvailable || !currentConversationId)
(destination === 'current' &&
(!currentConversationAvailable || !currentConversationId)) ||
(destination !== 'current' && destination !== 'new' &&
!otherConversations.some((conversation) => conversation.id === destination))
) {
nextErrors.destination = t('customTask.errors.destination')
}
const runAt = new Date(nextRunAt)
if (!nextRunAt || Number.isNaN(runAt.getTime())) {
const runAt = timing === 'now' ? new Date() : new Date(nextRunAt)
if (Number.isNaN(runAt.getTime())) {
nextErrors.nextRunAt = t('customTask.errors.time')
} else if (runAt.getTime() <= Date.now()) {
nextErrors.nextRunAt = t('customTask.errors.futureTime')
}
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
return
}
submittingRef.current = true
setSubmitting(true)
try {
await onCreate({
...(projectId ? { projectId } : {}),
...(destination === 'current' && currentConversationId
? { conversationId: currentConversationId }
: {}),
title: title.trim(),
prompt: prompt.trim(),
recurrence,
nextRunAt: runAt.toISOString()
})
await onCreate(
{
...(projectId ? { projectId } : {}),
...(destination !== 'new'
? { conversationId: destination === 'current' ? currentConversationId : destination }
: {}),
title: title.trim() || generatedTitle,
prompt: prompt.trim(),
recurrence: timing === 'now' ? 'once' : recurrence,
nextRunAt: runAt.toISOString()
},
{ runImmediately: timing === 'now' }
)
onClose()
} catch (reason) {
setErrors((current) => ({
@@ -124,6 +138,7 @@ export function CustomTaskDialog({
: t('customTask.errors.create')
}))
} finally {
submittingRef.current = false
setSubmitting(false)
}
}
@@ -176,38 +191,24 @@ export function CustomTaskDialog({
</button>
</header>
<div className="custom-task-dialog__content">
<fieldset
className="custom-task-dialog__content custom-task-dialog__form"
disabled={submitting}
>
<label className="custom-task-dialog__field">
<span>{t('customTask.fields.name')}</span>
<input
aria-describedby={errors.title ? 'custom-task-title-error' : undefined}
aria-invalid={Boolean(errors.title)}
maxLength={120}
onChange={(event) => {
setTitle(event.target.value)
setErrors((current) => ({ ...current, title: undefined }))
}}
ref={titleRef}
value={title}
/>
{errors.title && (
<small id="custom-task-title-error" role="alert">
{errors.title}
</small>
)}
</label>
<label className="custom-task-dialog__field">
<span>{t('customTask.fields.instructions')}</span>
<span id="custom-task-prompt-label">{t('customTask.fields.instructions')}</span>
<textarea
aria-labelledby="custom-task-prompt-label"
aria-describedby={errors.prompt ? 'custom-task-prompt-error' : undefined}
aria-invalid={Boolean(errors.prompt)}
aria-required="true"
maxLength={100_000}
onChange={(event) => {
setPrompt(event.target.value)
setErrors((current) => ({ ...current, prompt: undefined }))
}}
rows={5}
ref={promptRef}
value={prompt}
/>
{errors.prompt && (
@@ -217,73 +218,114 @@ export function CustomTaskDialog({
)}
</label>
<div className="custom-task-dialog__choice">
<span>{t('customTask.fields.destination')}</span>
<SegmentedControl
ariaLabel={t('customTask.fields.destination')}
onChange={(value) => {
setDestination(value)
<label className="custom-task-dialog__field">
<span id="custom-task-name-label">{t('customTask.fields.name')}</span>
<input
aria-labelledby="custom-task-name-label"
aria-describedby="custom-task-name-help"
maxLength={120}
onChange={(event) => setTitle(event.target.value)}
placeholder={generatedTitle}
value={title}
/>
<small id="custom-task-name-help">{t('customTask.nameHelp')}</small>
</label>
<label className="custom-task-dialog__field">
<span id="custom-task-destination-label">{t('customTask.fields.destination')}</span>
<select
aria-labelledby="custom-task-destination-label"
aria-describedby={errors.destination ? 'custom-task-destination-error' : 'custom-task-destination-help'}
aria-invalid={Boolean(errors.destination)}
onChange={(event) => {
setDestination(event.target.value)
setErrors((current) => ({
...current,
destination: undefined
}))
}}
options={destinationOptions}
value={destination}
/>
<small>
{destination === 'current'
? t('customTask.destination.currentHelp')
: t('customTask.destination.newHelp')}
>
<option value="current" disabled={!currentConversationAvailable || !currentConversationId}>
{t('customTask.destination.current')}
</option>
{otherConversations.map((conversation) => (
<option key={conversation.id} value={conversation.id}>{conversation.title}</option>
))}
<option value="new">{t('customTask.destination.new')}</option>
</select>
<small id="custom-task-destination-help">
{destination === 'new'
? t('customTask.destination.newHelp')
: t('customTask.destination.currentHelp')}
</small>
{!currentConversationAvailable && (
{(!currentConversationAvailable || !currentConversationId) && (
<small>{t('customTask.destination.currentUnavailable')}</small>
)}
{errors.destination && (
<small role="alert">{errors.destination}</small>
)}
</div>
<div className="custom-task-dialog__two-columns">
<label className="custom-task-dialog__field">
<span>{t('customTask.fields.recurrence')}</span>
<select
onChange={(event) =>
setRecurrence(
event.target.value as ScheduleCreateInput['recurrence']
)
}
value={recurrence}
>
<option value="once">{t('customTask.recurrence.once')}</option>
<option value="daily">{t('customTask.recurrence.daily')}</option>
<option value="weekly">{t('customTask.recurrence.weekly')}</option>
</select>
</label>
</div>
<label className="custom-task-dialog__field">
<span>{t('customTask.fields.time')}</span>
<input
aria-describedby={errors.nextRunAt ? 'custom-task-time-error' : undefined}
aria-invalid={Boolean(errors.nextRunAt)}
onChange={(event) => {
setNextRunAt(event.target.value)
setErrors((current) => ({
...current,
nextRunAt: undefined
}))
}}
type="datetime-local"
value={nextRunAt}
/>
{errors.nextRunAt && (
<small id="custom-task-time-error" role="alert">
{errors.nextRunAt}
</small>
<small id="custom-task-destination-error" role="alert">{errors.destination}</small>
)}
</label>
<div className="custom-task-dialog__choice">
<span>{t('customTask.fields.timing')}</span>
<SegmentedControl
ariaLabel={t('customTask.fields.timing')}
onChange={(value) => {
setTiming(value)
setErrors((current) => ({ ...current, nextRunAt: undefined }))
}}
options={[
{ value: 'now', label: t('customTask.timing.now'), disabled: submitting },
{ value: 'scheduled', label: t('customTask.timing.scheduled'), disabled: submitting }
]}
value={timing}
/>
</div>
{timing === 'scheduled' && (
<>
<label className="custom-task-dialog__field">
<span>{t('customTask.fields.recurrence')}</span>
<select
onChange={(event) =>
setRecurrence(
event.target.value as ScheduleCreateInput['recurrence']
)
}
value={recurrence}
>
<option value="once">{t('customTask.recurrence.once')}</option>
<option value="daily">{t('customTask.recurrence.daily')}</option>
<option value="weekly">{t('customTask.recurrence.weekly')}</option>
</select>
</label>
<label className="custom-task-dialog__field">
<span id="custom-task-time-label">{t('customTask.fields.time')}</span>
<input
aria-labelledby="custom-task-time-label"
aria-describedby={errors.nextRunAt ? 'custom-task-time-error' : undefined}
aria-invalid={Boolean(errors.nextRunAt)}
onChange={(event) => {
setNextRunAt(event.target.value)
setErrors((current) => ({
...current,
nextRunAt: undefined
}))
}}
type="datetime-local"
value={nextRunAt}
/>
{errors.nextRunAt && (
<small id="custom-task-time-error" role="alert">
{errors.nextRunAt}
</small>
)}
</label>
</>
)}
<section
aria-label={t('customTask.scope.title')}
className="custom-task-dialog__scope"
@@ -316,7 +358,7 @@ export function CustomTaskDialog({
{errors.form}
</p>
)}
</div>
</fieldset>
<footer className="custom-task-dialog__actions">
<button
@@ -0,0 +1,228 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import LocalInferencePage from './LocalInferencePage'
import type { LocalInferenceSnapshot } from '../../shared/local-inference-contracts'
import * as ocrBridge from './document-ocr-bridge'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
const styles = readFileSync(join(process.cwd(), 'src/renderer/src/local-inference.css'), 'utf8')
const snapshot: LocalInferenceSnapshot = {
services: [{ id: 'embedding', name: '向量生成', engine: 'Granite', ownership: 'managed-process', state: 'running', detail: '共享执行服务', actions: ['stop', 'restart'] }],
tasks: [{ id: 'real-task', serviceId: 'embedding', source: '知识索引', state: 'running', startedAt: 1 }]
}
const api = { getSnapshot: vi.fn(), act: vi.fn(), cancel: vi.fn() }
beforeEach(() => {
vi.resetAllMocks()
api.getSnapshot.mockResolvedValue(snapshot)
api.act.mockResolvedValue(undefined)
api.cancel.mockResolvedValue(undefined)
vi.stubGlobal('goodbuddy', { localInference: api })
})
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
describe('LocalInferencePage', () => {
it('shows services directly with truthful resource availability and no task history or external endpoints', async () => {
const name = 'A very long inference source and model service name '.repeat(8).trim()
api.getSnapshot.mockResolvedValue({
...snapshot,
tasks: [...snapshot.tasks, { ...snapshot.tasks[0], id: 'finished', source: '历史任务', state: 'completed', finishedAt: 2 }],
services: [{ ...snapshot.services[0], name }],
externalConnections: [{ id: 'external', name: '外部连接', model: 'remote' }],
})
render(<LocalInferencePage onClose={vi.fn()} />)
const serviceHeading = await screen.findByRole('heading', { name })
expect(serviceHeading).not.toHaveTextContent('运行中')
expect(within(serviceHeading.parentElement!).getByText('运行中')).toBeInTheDocument()
expect(screen.getByText('CPU / 内存:暂不可用')).toBeInTheDocument()
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
expect(screen.queryByText('知识索引')).not.toBeInTheDocument()
expect(screen.queryByText('历史任务')).not.toBeInTheDocument()
expect(screen.queryByText('外部连接')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '取消任务' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '停止服务' })).toBeEnabled()
expect(screen.getByRole('button', { name: '重启服务' })).toBeEnabled()
})
it('keeps the service toolbar responsive', async () => {
render(<LocalInferencePage onClose={vi.fn()} />)
await screen.findByRole('heading', { name: '向量生成' })
const toolbar = screen.getByText('每 5 秒刷新 · CPU 100% = 一个逻辑核心').parentElement!
expect(toolbar).toHaveClass('local-inference-toolbar')
expect(within(toolbar).getByRole('button', { name: '刷新状态' })).toBeInTheDocument()
expect(styles).toMatch(/\.local-inference-toolbar\s*\{[^}]*flex: 0 0 auto/)
expect(screen.getByRole('region', { name: '本机推理服务' })).toBeInTheDocument()
})
it('does not present an active ASR request as a running service', async () => {
api.getSnapshot.mockResolvedValue({
services: [{ ...snapshot.services[0], id: 'asr', name: '语音识别', state: 'unknown', actions: [] }],
tasks: [{ ...snapshot.tasks[0], serviceId: 'asr' }]
})
render(<LocalInferencePage onClose={vi.fn()} />)
expect(await screen.findByText('按需运行(状态未知)')).toBeInTheDocument()
expect(screen.queryByText('运行中')).not.toBeInTheDocument()
})
it('shows process scope, measured units and shared-worker unavailability per service', async () => {
api.getSnapshot.mockResolvedValue({ ...snapshot, services: [
{ ...snapshot.services[0], resources: { scope: 'service-process', pid: 42, sampledAt: 1, cpuPercent: 150, workingSetBytes: 2097152 } },
{ ...snapshot.services[0], id: 'asr', name: '语音识别', resources: { scope: 'unavailable', reason: '识别线程与主进程共享资源,无法独立统计 CPU / 内存' } }
] })
render(<LocalInferencePage onClose={vi.fn()} />)
expect(await screen.findByText('服务进程 PID 42 · CPU 150.0% · 工作集 2.0 MiB')).toBeInTheDocument()
expect(screen.getByText('CPU / 内存:识别线程与主进程共享资源,无法独立统计 CPU / 内存')).toBeInTheDocument()
api.getSnapshot.mockResolvedValue({ ...snapshot, services: [{ ...snapshot.services[0], resources: { scope: 'service-process', pid: 43, sampledAt: 2, cpuUnavailableReason: '正在建立采样基线', workingSetBytes: 1048576 } }] })
fireEvent.click(screen.getByRole('button', { name: '刷新状态' }))
expect(await screen.findByText('服务进程 PID 43 · CPU 正在建立采样基线 · 工作集 1.0 MiB')).toBeInTheDocument()
expect(screen.queryByText(/PID 42/)).not.toBeInTheDocument()
})
it('blocks service actions on stale data until refresh succeeds', async () => {
render(<LocalInferencePage onClose={vi.fn()} />)
await screen.findByRole('heading', { name: '向量生成' })
api.getSnapshot.mockRejectedValueOnce(new Error('offline'))
fireEvent.click(screen.getByRole('button', { name: '刷新状态' }))
expect(await screen.findByRole('alert')).toHaveTextContent('上次成功读取')
expect(screen.getByRole('button', { name: '停止服务' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '重试' }))
await waitFor(() => expect(screen.getByRole('button', { name: '停止服务' })).toBeEnabled())
expect(api.act).not.toHaveBeenCalled()
})
it('only shows affected active tasks when confirming service stop', async () => {
render(<LocalInferencePage onClose={vi.fn()} />)
await screen.findByRole('heading', { name: '向量生成' })
fireEvent.click(screen.getByRole('button', { name: '停止服务' }))
const dialog = screen.getByRole('dialog', { name: '停止服务:向量生成' })
expect(within(dialog).getByText(/知识索引/)).toBeInTheDocument()
expect(api.act).not.toHaveBeenCalled()
fireEvent.click(within(dialog).getByRole('button', { name: '确认停止服务' }))
await waitFor(() => expect(api.act).toHaveBeenCalledWith({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: ['real-task'] }))
})
it('recovers from initial load failure and does not stop anything on unmount', async () => {
api.getSnapshot.mockRejectedValueOnce(new Error('offline')).mockResolvedValue({ services: [], tasks: [] })
const page = render(<LocalInferencePage onClose={vi.fn()} />)
expect(await screen.findByRole('alert')).toHaveTextContent('offline')
fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByText('暂无本机推理服务')).toBeInTheDocument()
page.unmount()
expect(api.act).not.toHaveBeenCalled()
expect(api.cancel).not.toHaveBeenCalled()
})
it('refreshes after a rejected confirmation and requires confirmation of the new affected tasks', async () => {
api.act.mockRejectedValueOnce(new Error('任务已变化'))
render(<LocalInferencePage onClose={vi.fn()} />)
await screen.findByRole('heading', { name: '向量生成' })
fireEvent.click(screen.getByRole('button', { name: '停止服务' }))
api.getSnapshot.mockResolvedValue({ ...snapshot, tasks: [...snapshot.tasks, { ...snapshot.tasks[0], id: 'new-task', source: '新索引' }] })
fireEvent.click(screen.getByRole('button', { name: '确认停止服务' }))
expect(await screen.findByRole('alert')).toHaveTextContent('任务已变化')
expect(screen.queryByRole('dialog', { name: '停止服务:向量生成' })).not.toBeInTheDocument()
await waitFor(() => expect(screen.getByRole('button', { name: '停止服务' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: '停止服务' }))
expect(screen.getByText(/新索引/)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '确认停止服务' }))
await waitFor(() => expect(api.act).toHaveBeenLastCalledWith({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: ['real-task', 'new-task'] }))
})
it('isolates nested Escape, traps focus, and restores the service action', async () => {
const onClose = vi.fn()
render(<LocalInferencePage onClose={onClose} />)
expect(screen.getByRole('button', { name: '关闭本机推理' })).toHaveFocus()
await screen.findByRole('heading', { name: '向量生成' })
fireEvent.keyDown(screen.getByRole('button', { name: '关闭本机推理' }), { key: 'Tab', shiftKey: true })
expect(screen.getByRole('button', { name: '重启服务' })).toHaveFocus()
fireEvent.keyDown(screen.getByRole('button', { name: '重启服务' }), { key: 'Tab' })
expect(screen.getByRole('button', { name: '关闭本机推理' })).toHaveFocus()
const stop = screen.getByRole('button', { name: '停止服务' })
stop.focus()
fireEvent.click(stop)
const cancel = screen.getByRole('button', { name: '保留运行状态' })
const confirm = screen.getByRole('button', { name: '确认停止服务' })
expect(cancel).toHaveFocus()
expect(confirm).toHaveClass('danger-button')
fireEvent.keyDown(cancel, { key: 'Tab', shiftKey: true })
expect(confirm).toHaveFocus()
fireEvent.keyDown(confirm, { key: 'Tab' })
expect(cancel).toHaveFocus()
fireEvent.keyDown(cancel, { key: 'Escape' })
expect(onClose).not.toHaveBeenCalled()
expect(stop).toHaveFocus()
expect(screen.getAllByRole('dialog')).toHaveLength(1)
fireEvent.keyDown(stop, { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
it('blocks dismissal and duplicate execution while a service operation is pending', async () => {
let resolve!: () => void
api.act.mockReturnValue(new Promise<void>((done) => { resolve = done }))
const onClose = vi.fn()
render(<LocalInferencePage onClose={onClose} />)
await screen.findByRole('heading', { name: '向量生成' })
fireEvent.click(screen.getByRole('button', { name: '停止服务' }))
const confirm = screen.getByRole('button', { name: '确认停止服务' })
fireEvent.click(confirm)
fireEvent.click(confirm)
fireEvent.keyDown(confirm, { key: 'Escape' })
expect(screen.getByRole('button', { name: '保留运行状态' })).toBeDisabled()
expect(screen.getByRole('button', { name: '关闭本机推理' })).toBeDisabled()
expect(onClose).not.toHaveBeenCalled()
expect(api.act).toHaveBeenCalledOnce()
resolve()
await waitFor(() => expect(screen.queryByRole('dialog', { name: '停止服务:向量生成' })).not.toBeInTheDocument())
expect(screen.getByRole('button', { name: '关闭本机推理' })).not.toBeDisabled()
})
it('restores fallback focus when the entry was removed and clears background isolation', () => {
const background = document.createElement('div')
background.className = 'app-shell'
const entry = document.createElement('button')
const fallback = document.createElement('button')
background.append(entry, fallback)
document.body.append(background)
entry.focus()
const modal = render(<LocalInferencePage onClose={vi.fn()} restoreFocus={() => fallback} />)
expect(background.inert).toBe(true)
entry.remove()
modal.unmount()
expect(background.inert).toBe(false)
expect(fallback).toHaveFocus()
background.remove()
})
it('confirms OCR release with a specific danger action and leaves the management modal open', async () => {
vi.spyOn(ocrBridge, 'getOcrInferenceState').mockReturnValue({ state: 'running', loaded: true, busy: false })
const release = vi.spyOn(ocrBridge, 'releaseOcrInference').mockImplementation(() => undefined)
api.getSnapshot.mockResolvedValue({ services: [{ id: 'ocr', name: '文字识别', engine: 'Paddle', ownership: 'renderer-worker', state: 'running', detail: '', actions: [] }], tasks: [] })
const onClose = vi.fn()
render(<LocalInferencePage onClose={onClose} />)
await screen.findByRole('heading', { name: '文字识别' })
fireEvent.click(screen.getByRole('button', { name: '释放模型' }))
const confirmation = screen.getByRole('dialog', { name: '释放模型:文字识别' })
expect(confirmation).toHaveClass('local-inference-confirm')
expect(within(confirmation).getByText('当前没有已登记的活动任务。执行前将再次核对。')).toBeInTheDocument()
const confirm = within(confirmation).getByRole('button', { name: '确认释放模型' })
expect(confirm).toHaveClass('danger-button')
expect(release).not.toHaveBeenCalled()
fireEvent.click(confirm)
await waitFor(() => expect(confirmation).not.toBeInTheDocument())
expect(release).toHaveBeenCalledOnce()
expect(api.act).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByRole('dialog', { name: '本机推理' })).toBeInTheDocument()
})
it('keeps keyboard focus in the modal when the application content is disabled', () => {
render(<LocalInferencePage onClose={vi.fn()} enabled={false} />)
const close = screen.getByRole('button', { name: '关闭本机推理' })
fireEvent.keyDown(close, { key: 'Tab', shiftKey: true })
expect(close).toHaveFocus()
fireEvent.keyDown(close, { key: 'Tab' })
expect(close).toHaveFocus()
expect(api.getSnapshot).not.toHaveBeenCalled()
})
})
+158
View File
@@ -0,0 +1,158 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import type { InferenceAction, InferenceService, InferenceTask, LocalInferenceSnapshot } from '../../shared/local-inference-contracts'
import { PageHeader } from './WorkspacePrimitives'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { getOcrInferenceState, loadOcrInference, releaseOcrInference } from './document-ocr-bridge'
import { ApplicationSettingsLink } from './ApplicationCenter'
import './local-inference.css'
const stateLabels: Record<string, string> = {
idle: '未加载 / 按需运行', running: '运行中', starting: '启动中', stopping: '停止中',
stopped: '已停止', error: '异常', unavailable: '不可用', unknown: '未知',
cancelling: '等待取消确认', completed: '已完成', cancelled: '已取消', failed: '失败'
}
const actionLabels = { start: '启动服务', stop: '停止服务', restart: '重启服务', load: '加载模型', release: '释放模型' }
type Control = { service: InferenceService; action: InferenceAction['action'] | 'load' | 'release'; tasks: InferenceTask[] }
function ImpactConfirmation({ control, busy, onClose, onConfirm }: {
control: Control; busy: boolean; onClose: () => void; onConfirm: () => void
}): React.JSX.Element {
const cancel = useRef<HTMLButtonElement>(null)
useLayoutEffect(() => activateModalFocus(() => cancel.current, () => document.querySelector<HTMLElement>('.local-inference-modal')), [])
return createPortal(<div className="custom-task-dialog"><section className="custom-task-dialog__surface local-inference-confirm" role="dialog" aria-modal="true" aria-labelledby="inference-confirm-title" aria-describedby="inference-confirm-description" tabIndex={-1} onKeyDown={(event) => {
event.stopPropagation()
trapTabFocus(event, event.currentTarget)
if (event.key === 'Escape') { event.preventDefault(); if (!busy) onClose() }
}}><div className="custom-task-dialog__content">
<h2 id="inference-confirm-title">{actionLabels[control.action]}:{control.service.name}</h2>
<p id="inference-confirm-description">此操作影响整个执行服务。被中断的任务不会在服务重新启动后自动重放。</p>
{control.tasks.length ? <ul>{control.tasks.map((task) => <li key={task.id}>{task.source} · {new Date(task.startedAt).toLocaleTimeString()} · {stateLabels[task.state]}</li>)}</ul> : <p>当前没有已登记的活动任务。执行前将再次核对。</p>}
</div><div className="custom-task-dialog__actions">
<button ref={cancel} type="button" className="secondary-button" disabled={busy} onClick={onClose}>保留运行状态</button>
<button type="button" className="danger-button" disabled={busy} onClick={onConfirm}>{busy ? '操作中…' : `确认${actionLabels[control.action]}`}</button>
</div>
</section></div>, document.body)
}
export default function LocalInferencePage({ onClose, enabled = true, restoreFocus }: {
onClose: () => void; enabled?: boolean; restoreFocus?: () => HTMLElement | null
}): React.JSX.Element {
const close = useRef<HTMLButtonElement>(null)
const restoreFocusRef = useRef(restoreFocus)
useLayoutEffect(() => activateModalFocus(() => close.current, () => restoreFocusRef.current?.() ?? null), [])
const [snapshot, setSnapshot] = useState<LocalInferenceSnapshot>()
const [error, setError] = useState('')
const [operationError, setOperationError] = useState('')
const [busy, setBusy] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [control, setControl] = useState<Control>()
const [reload, setReload] = useState(0)
const operation = useRef(false)
const [ocr, setOcr] = useState(getOcrInferenceState)
useEffect(() => {
let alive = true
let inFlight = false
const refresh = async (): Promise<void> => {
if (inFlight || !enabled || document.hidden || document.getElementById('local-inference-title')?.closest('[hidden], [inert]')) return
inFlight = true
setRefreshing(true)
try {
if (!window.goodbuddy.localInference) throw new Error('本机推理管理接口不可用,请重启应用后重试')
const next = await window.goodbuddy.localInference.getSnapshot()
if (alive) { setSnapshot(next); setOcr(getOcrInferenceState()); setError('') }
} catch (reason) {
if (alive) setError(reason instanceof Error ? reason.message : String(reason))
} finally {
inFlight = false
if (alive) setRefreshing(false)
}
}
void refresh()
const timer = window.setInterval(() => void refresh(), 5000)
const onVisible = (): void => { void refresh() }
document.addEventListener('visibilitychange', onVisible)
return () => { alive = false; window.clearInterval(timer); document.removeEventListener('visibilitychange', onVisible) }
}, [reload, enabled])
const services = useMemo(() => snapshot?.services.map((service) => service.id === 'ocr' ? {
...service,
state: ocr.state === 'idle' && (service.state === 'unavailable' || service.state === 'error') ? service.state : ocr.state,
model: ocr.model ?? service.model, error: ocr.error ?? service.error
} : service), [snapshot, ocr])
const activeTasks = useMemo(() => snapshot?.tasks.filter((task) => task.finishedAt === undefined) ?? [], [snapshot])
const execute = async (selected: Control): Promise<void> => {
if (operation.current) return
operation.current = true
setBusy(true)
setOperationError('')
try {
if (selected.action === 'load') await loadOcrInference()
else if (selected.action === 'release') releaseOcrInference()
else await window.goodbuddy.localInference.act({ serviceId: selected.service.id, action: selected.action, confirmedTaskIds: selected.tasks.map((task) => task.id) })
setControl(undefined)
} catch (reason) {
setOperationError(reason instanceof Error ? reason.message : String(reason))
setControl(undefined)
} finally {
operation.current = false
setBusy(false)
setOcr(getOcrInferenceState())
setReload((value) => value + 1)
}
}
const selectAction = (service: InferenceService, action: Control['action']): void => {
if (operation.current) return
const selected = { service, action, tasks: activeTasks.filter((task) => task.serviceId === service.id) }
setOperationError('')
if (action === 'start' || action === 'load') void execute(selected)
else setControl(selected)
}
return createPortal(<div className="custom-task-dialog"><section className="custom-task-dialog__surface local-inference-modal" role="dialog" aria-modal="true" aria-label="本机推理" tabIndex={-1} onKeyDown={(event) => {
if (event.target instanceof Element && event.target.closest('[aria-modal="true"]') !== event.currentTarget) return
trapTabFocus(event, event.currentTarget)
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
if (!operation.current && !control) onClose()
}
}}>
<div className="custom-task-dialog__header">
<PageHeader headingId="local-inference-title" title="本机推理" scope={{ kind: 'global' }}
actions={<button ref={close} type="button" className="icon-button" aria-label="关闭本机推理" title="关闭本机推理" disabled={busy || Boolean(control)} onClick={() => { if (!operation.current && !control) onClose() }}><X size={20} aria-hidden="true" /></button>} />
</div>
<div className="local-inference-modal__body">
{!enabled && <div className="local-inference-actions"><strong>应用已关闭</strong><ApplicationSettingsLink id="local-inference" /></div>}
<div className="local-inference-workspace" hidden={!enabled} inert={!enabled}>
<div className="local-inference-toolbar">
<p className="local-inference-note">每 5 秒刷新 · CPU 100% = 一个逻辑核心</p>
<div className="local-inference-actions"><button className="secondary-button" type="button" disabled={busy} onClick={() => void window.goodbuddy.localInference.openSettings().catch((reason: unknown) => setOperationError(String(reason)))}>模型与连接设置</button><button className="secondary-button" type="button" disabled={refreshing || busy} onClick={() => setReload((value) => value + 1)}>刷新状态</button></div>
</div>
{error && <div className="local-inference-feedback" role="alert"><p>{snapshot ? '状态刷新失败;以下为上次成功读取的状态,操作已禁用。' : '无法读取本机推理状态。'} {error}</p><button type="button" className="secondary-button" onClick={() => setReload((value) => value + 1)}>重试</button></div>}
{!snapshot && !error && <p role="status">正在读取服务状态…</p>}
{operationError && !control && <div className="local-inference-feedback" role="alert">{operationError}<button type="button" className="secondary-button" onClick={() => setOperationError('')}>关闭提示</button></div>}
{snapshot && <section aria-label="本机推理服务" className="local-inference-content">
{services?.length === 0 && <p role="status">暂无本机推理服务</p>}
{services?.map((service) => <article className="local-inference-row" key={service.id}>
<div><div className="local-inference-row__heading"><h2>{service.name}</h2><span className="local-inference-state">{service.id === 'asr' && service.state === 'unknown' ? '按需运行(状态未知)' : stateLabels[service.state]}</span></div>
<p className="local-inference-metadata">{service.engine}{service.model ? ` · ${service.model}` : ''}</p>
<p className="local-inference-metadata">
{service.resources?.scope === 'service-process'
? `服务进程 PID ${service.resources.pid} · CPU ${service.resources.cpuPercent === undefined ? service.resources.cpuUnavailableReason : `${service.resources.cpuPercent.toFixed(1)}%`} · 工作集 ${service.resources.workingSetBytes === undefined ? service.resources.memoryUnavailableReason : `${(service.resources.workingSetBytes / 1024 / 1024).toFixed(1)} MiB`}`
: `CPU / 内存:${service.resources?.reason ?? '暂不可用'}`}
</p>
{service.error && <p className="local-inference-error">{service.error}</p>}
</div>
<div className="local-inference-actions">
{service.actions.map((action) => <button type="button" className="secondary-button" key={action} disabled={busy || refreshing || Boolean(error)} onClick={() => selectAction(service, action)}>{actionLabels[action]}</button>)}
{service.id === 'ocr' && <button type="button" className="secondary-button" disabled={busy || refreshing || Boolean(error) || ocr.busy || service.state === 'unavailable'} onClick={() => selectAction(service, ocr.loaded ? 'release' : 'load')}>{ocr.loaded ? '释放模型' : '加载模型'}</button>}
</div>
</article>)}
</section>}
{control && <ImpactConfirmation control={control} busy={busy} onClose={() => { if (!operation.current) { setControl(undefined); setOperationError('') } }} onConfirm={() => void execute(control)} />}
</div></div></section></div>, document.body)
}
@@ -15,10 +15,8 @@ import {
import { useTranslation } from 'react-i18next'
import type {
ApplicationSettings,
MagicNoteCommentMode,
ModelDownloadSource
} from '../../shared/application-settings-contracts'
import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts'
import type {
AgentPackageDownloadProgress,
AgentPackageInventory
@@ -34,8 +32,7 @@ import {
} from '../../shared/shortcut'
import type { AppNotificationInput } from './notifications'
import {
PageTabs,
SegmentedControl
PageTabs
} from './WorkspacePrimitives'
import {
SettingsCategoryHeader,
@@ -47,10 +44,6 @@ type PlatformFeaturesSettingsSectionProps = {
onConversationHtmlRenderingEnabledChange: (
enabled: boolean
) => void
onMagicNotesEnabledChange: (enabled: boolean) => void
onMagicNotesShowIncompleteTodoCountChange: (
enabled: boolean
) => void
onRemoteProjectsEnabledChange: (enabled: boolean) => void
onNotify?: (notification: AppNotificationInput) => void
onDirtyChange?: (dirty: boolean) => void
@@ -62,7 +55,6 @@ type PlatformFeaturesSettingsSectionProps = {
type PlatformFeaturesTab =
| 'general'
| 'remote-projects'
| 'magic-notes'
const shortcutErrorTranslationKeys: Record<
GlobalShortcutUpdateErrorCode,
@@ -78,8 +70,6 @@ const shortcutErrorTranslationKeys: Record<
export function PlatformFeaturesSettingsSection({
onConversationHtmlRenderingEnabledChange,
onMagicNotesEnabledChange,
onMagicNotesShowIncompleteTodoCountChange,
onRemoteProjectsEnabledChange,
onNotify,
onDirtyChange,
@@ -472,26 +462,6 @@ export function PlatformFeaturesSettingsSection({
}
}
const changeMagicNotes = async (enabled: boolean): Promise<void> => {
const updates = window.goodbuddy.updates
if (!updates || !settings) {
return
}
setSaving(true)
setError(undefined)
try {
const nextSettings = await updates.updateSettings({
magicNotesEnabled: enabled
})
setSettings(nextSettings)
onMagicNotesEnabledChange(nextSettings.magicNotesEnabled)
} catch {
setError(t('platformFeatures.errors.saveMagicNotesFailed'))
} finally {
setSaving(false)
}
}
const changeConversationHtmlRendering = async (
enabled: boolean
): Promise<void> => {
@@ -546,72 +516,6 @@ export function PlatformFeaturesSettingsSection({
}
}
const changeCommentMode = async (
magicNoteCommentMode: MagicNoteCommentMode
): Promise<void> => {
const updates = window.goodbuddy.updates
if (!updates || !settings) {
return
}
setSaving(true)
setError(undefined)
try {
setSettings(
await updates.updateSettings({ magicNoteCommentMode })
)
} catch {
setError(t('platformFeatures.errors.saveCommentModeFailed'))
} finally {
setSaving(false)
}
}
const changeIncompleteTodoCount = async (
magicNotesShowIncompleteTodoCount: boolean
): Promise<void> => {
const updates = window.goodbuddy.updates
if (!updates || !settings) {
return
}
setSaving(true)
setError(undefined)
try {
const nextSettings = await updates.updateSettings({
magicNotesShowIncompleteTodoCount
})
setSettings(nextSettings)
onMagicNotesShowIncompleteTodoCountChange(
nextSettings.magicNotesShowIncompleteTodoCount
)
} catch {
setError(
t('platformFeatures.errors.saveIncompleteTodoCountFailed')
)
} finally {
setSaving(false)
}
}
const changeCommentFormat = async (
magicNoteCommentFormat: MagicNoteCommentFormat
): Promise<void> => {
const updates = window.goodbuddy.updates
if (!updates || !settings) {
return
}
setSaving(true)
setError(undefined)
try {
setSettings(
await updates.updateSettings({ magicNoteCommentFormat })
)
} catch {
setError(t('platformFeatures.errors.saveCommentFormatFailed'))
} finally {
setSaving(false)
}
}
return (
<>
<SettingsCategoryHeader
@@ -633,10 +537,6 @@ export function PlatformFeaturesSettingsSection({
{
id: 'remote-projects',
label: t('platformFeatures.remoteProjects.title')
},
{
id: 'magic-notes',
label: t('platformFeatures.tabs.magicNotes')
}
]}
value={activeSection}
@@ -1146,141 +1046,6 @@ export function PlatformFeaturesSettingsSection({
</article>
</section>
<section
aria-labelledby="platform-features-tab-magic-notes"
className="settings-section"
hidden={activeSection !== 'magic-notes'}
id="platform-features-panel-magic-notes"
role="tabpanel"
>
{settings ? (
<article className="capability-card">
<div className="capability-card__header">
<div>
<strong>{t('platformFeatures.magicNotes.title')}</strong>
<small>
{t('platformFeatures.magicNotes.description')}
</small>
</div>
</div>
<label className="toggle-row">
<input
checked={settings.magicNotesEnabled}
disabled={saving}
onChange={(event) =>
void changeMagicNotes(event.target.checked)
}
role="switch"
type="checkbox"
/>
<span>{t('platformFeatures.magicNotes.showEntry')}</span>
</label>
<label className="toggle-row">
<input
checked={
settings.magicNotesShowIncompleteTodoCount
}
disabled={saving}
onChange={(event) =>
void changeIncompleteTodoCount(
event.target.checked
)
}
role="switch"
type="checkbox"
/>
<span>
{t(
'platformFeatures.magicNotes.showIncompleteTodoCount'
)}
</span>
</label>
<p className="settings-notice">
{t(
'platformFeatures.magicNotes.showIncompleteTodoCountHelp'
)}
</p>
<div className="platform-feature-option">
<span>
{t('platformFeatures.magicNotes.commentMode')}
</span>
<SegmentedControl
ariaLabel={t(
'platformFeatures.magicNotes.commentModeAria'
)}
disabled={saving}
onChange={(value) => void changeCommentMode(value)}
options={[
{
value: 'immediate',
label: t(
'platformFeatures.magicNotes.modes.immediate'
)
},
{
value: 'after-save-auto',
label: t(
'platformFeatures.magicNotes.modes.afterSaveAuto'
)
},
{
value: 'after-save-manual',
label: t(
'platformFeatures.magicNotes.modes.afterSaveManual'
)
}
]}
value={settings.magicNoteCommentMode}
/>
<small>
{t('platformFeatures.magicNotes.commentModeHelp')}
</small>
</div>
<div className="platform-feature-option">
<span>
{t('platformFeatures.magicNotes.commentFormat')}
</span>
<SegmentedControl
ariaLabel={t(
'platformFeatures.magicNotes.commentFormatAria'
)}
disabled={saving}
onChange={(value) => void changeCommentFormat(value)}
options={[
{
value: 'combined',
label: t(
'platformFeatures.magicNotes.formats.combined'
)
},
{
value: 'narrative',
label: t(
'platformFeatures.magicNotes.formats.narrative'
)
},
{
value: 'structured',
label: t(
'platformFeatures.magicNotes.formats.structured'
)
}
]}
value={settings.magicNoteCommentFormat}
/>
<small>
{t('platformFeatures.magicNotes.commentFormatHelp')}
</small>
</div>
</article>
) : (
!error && (
<p className="settings-notice" role="status">
{t('platformFeatures.loading')}
</p>
)
)}
</section>
</>
)
}
@@ -151,12 +151,16 @@ function sidebarElement({
workspaceProjectId,
restoreFocusRef,
activeConversationId,
conversationStats,
taskDurations,
selectedTaskId,
browserStates,
onBackBrowser,
onNavigateBrowser,
onReloadBrowser,
onStopLoadingBrowser,
onOpenTask = vi.fn(),
onCreateCustomTask = vi.fn(),
onRespondApproval = vi.fn(),
onTabChange = vi.fn()
}: {
@@ -185,12 +189,16 @@ function sidebarElement({
workspaceProjectId?: string
restoreFocusRef?: { current: HTMLElement | null }
activeConversationId?: string
conversationStats?: React.ComponentProps<typeof RightAssistantSidebar>['conversationStats']
taskDurations?: React.ComponentProps<typeof RightAssistantSidebar>['taskDurations']
selectedTaskId?: string
browserStates?: Readonly<Record<string, Readonly<Record<string, BrowserLiveState>>>>
onBackBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onNavigateBrowser?: (conversationId: string, tabId: BrowserTabId, url: string) => Promise<void>
onReloadBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onStopLoadingBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onOpenTask?: (task: AssistantTask) => void
onCreateCustomTask?: () => void
onRespondApproval?: React.ComponentProps<typeof RightAssistantSidebar>['onRespondApproval']
onTabChange?: (tab: AssistantSidebarTab) => void
} = {}): React.JSX.Element {
@@ -200,6 +208,9 @@ function sidebarElement({
<RightAssistantSidebar
approvals={approvals}
activeConversationId={activeConversationId}
conversationStats={conversationStats}
taskDurations={taskDurations}
selectedTaskId={selectedTaskId}
artifacts={artifacts}
browserStates={browserStates}
schedules={[]}
@@ -207,7 +218,7 @@ function sidebarElement({
conversationTitles={new Map()}
currentProject={activeProject ?? undefined}
projectNames={new Map()}
onCreateCustomTask={vi.fn()}
onCreateCustomTask={onCreateCustomTask}
onBackBrowser={onBackBrowser}
onImportArtifacts={vi.fn(async () => undefined)}
onListWorkspaceDirectory={onListWorkspaceDirectory}
@@ -336,6 +347,87 @@ describe('RightAssistantSidebar tab titles', () => {
})
describe('RightAssistantSidebar resizing', () => {
it('updates conversation statistics without narrowing project tasks and rejects stale statistics', () => {
const tasks: AssistantTask[] = ['a', 'b'].map((id) => ({
id, conversationId: id, projectId: currentProject.id, title: `Project task ${id}`,
instructions: '', origin: 'schedule', status: id === 'a' ? 'running' : 'completed',
createdAt: '2026-09-01T00:00:00Z', startedAt: '2026-09-01T00:00:00Z',
completedAt: id === 'b' ? '2026-09-17T00:00:00Z' : undefined
}))
const props = {
tasks, selectedTaskId: 'a', activeConversationId: 'a',
conversationStats: { conversationId: 'a', title: 'Conversation A', messageCount: 24, replyDurationMs: 3_661_000, incomplete: true },
taskDurations: new Map([['a', { durationMs: 125_000, incomplete: true }]])
}
const view = render(sidebarElement(props))
const stats = within(screen.getByRole('region', { name: '当前会话统计' }))
expect(stats.getByText('01:01:01')).toBeVisible()
expect(stats.getByText('24')).toBeVisible()
expect(stats.getByText('部分回复缺少计时记录,时长仅包含已知记录。')).toBeVisible()
expect(screen.getByText('任务累计用时: 00:02:05 (部分记录)')).toBeVisible()
expect(screen.getByText('Project task a').closest('button')).toHaveAttribute('aria-current', 'true')
expect(screen.getByText('Project task b').closest('article')).not.toHaveTextContent('任务累计用时')
view.rerender(sidebarElement({ ...props, activeConversationId: 'b' }))
expect(stats.queryByText('Conversation A')).not.toBeInTheDocument()
expect(stats.getAllByText('暂无统计')).toHaveLength(2)
for (const task of tasks) expect(screen.getByText(task.title)).toBeVisible()
view.rerender(sidebarElement({ ...props, activeConversationId: 'b', conversationStats: {
conversationId: 'b', title: 'Conversation B', messageCount: 0, replyDurationMs: 0, incomplete: false
} }))
expect(stats.getByText('00:00:00')).toBeVisible()
expect(stats.getByText('0')).toBeVisible()
expect(stats.queryByText('部分回复缺少计时记录,时长仅包含已知记录。')).not.toBeInTheDocument()
view.rerender(sidebarElement({ ...props, activeProject: { ...currentProject, id: 'other' } }))
for (const task of tasks) expect(screen.queryByText(task.title)).not.toBeInTheDocument()
})
it('keeps an applied filter visible when collapsed and clears it without opening the filter panel', () => {
renderSidebar({ tasks: [{
id: 'completed', projectId: currentProject.id, title: 'Completed task', instructions: '',
origin: 'schedule', status: 'completed', createdAt: '2026-09-17T00:00:00Z'
}] })
const toggle = screen.getByRole('button', { name: '筛选任务' })
expect(screen.getByText('Completed task')).toBeVisible()
fireEvent.click(toggle)
fireEvent.click(screen.getByRole('button', { name: '进行中' }))
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(toggle).toHaveAttribute('data-active', 'true')
expect(screen.queryByRole('group', { name: '筛选任务' })).not.toBeInTheDocument()
expect(screen.getByText('筛选:进行中 · 0 项')).toBeVisible()
expect(screen.getByText('当前筛选条件下没有任务。')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
expect(toggle).toHaveAttribute('data-active', 'false')
expect(screen.getByText('Completed task')).toBeVisible()
})
it('scrolls the focused task filter into view when keyboard navigation reaches either end', () => {
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: '筛选任务' }))
const filters = within(screen.getByRole('group', { name: '筛选任务' }))
const first = filters.getByRole('button', { name: '全部' })
const last = filters.getByRole('button', { name: '已结束' })
const scrollFirst = vi.fn()
const scrollLast = vi.fn()
Object.defineProperty(first, 'scrollIntoView', { value: scrollFirst })
Object.defineProperty(last, 'scrollIntoView', { value: scrollLast })
act(() => first.focus())
scrollFirst.mockClear()
fireEvent.keyDown(first, { key: 'End' })
expect(last).toHaveFocus()
expect(last).toHaveAttribute('aria-pressed', 'true')
expect(scrollLast).toHaveBeenCalledExactlyOnceWith({ block: 'nearest', inline: 'nearest' })
expect(scrollFirst).not.toHaveBeenCalled()
fireEvent.keyDown(last, { key: 'Home' })
expect(first).toHaveFocus()
expect(first).toHaveAttribute('aria-pressed', 'true')
expect(scrollFirst).toHaveBeenCalledExactlyOnceWith({ block: 'nearest', inline: 'nearest' })
expect(scrollLast).toHaveBeenCalledTimes(1)
})
it('resizes with pointer capture and preserves compact pane minima', () => {
const sidebar = renderSidebar()
const separator = screen.getByRole('separator', {
@@ -553,12 +645,13 @@ describe('RightAssistantSidebar resizing', () => {
})
it('keeps the product Task index in the task center', () => {
renderSidebar({ tab: 'tasks' })
const onCreateCustomTask = vi.fn()
renderSidebar({ tab: 'tasks', onCreateCustomTask })
expect(screen.queryByText('等待审批')).not.toBeInTheDocument()
expect(screen.queryByText(i18n.t('sidebar.tasks.noApprovals', { ns: 'workspace' }))).not.toBeInTheDocument()
const taskIndexHeading = screen.getByRole('heading', {
name: '任务索引'
name: '项目任务 0'
})
const newTaskButton = screen.getByRole('button', {
name: '新建任务'
@@ -566,9 +659,12 @@ describe('RightAssistantSidebar resizing', () => {
expect(taskIndexHeading.parentElement).toContainElement(
newTaskButton
)
fireEvent.click(newTaskButton)
expect(onCreateCustomTask).toHaveBeenCalledOnce()
expect(
screen.getByRole('group', { name: '筛选任务' })
).toBeInTheDocument()
screen.queryByRole('group', { name: '筛选任务' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '筛选任务' })).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByLabelText('定时任务标题')).not.toBeInTheDocument()
expect(screen.queryByText('最近任务')).not.toBeInTheDocument()
})
@@ -600,9 +696,11 @@ describe('RightAssistantSidebar resizing', () => {
approvals: [approval], onOpenTask, onRespondApproval
})
fireEvent.click(screen.getByRole('button', { name: '筛选任务' }))
const filters = within(screen.getByRole('group', { name: '筛选任务' }))
expect(filters.getAllByRole('button').map((button) => button.textContent))
.toEqual(['进行中', '暂停', '已结束'])
.toEqual(['全部', '进行中', '暂停', '已结束'])
fireEvent.click(filters.getByRole('button', { name: '进行中' }))
expect(filters.getByRole('button', { name: '进行中' })).toHaveAttribute('aria-pressed', 'true')
for (const task of tasks.slice(0, 6)) {
const row = screen.getByText(task.title).closest('article')!
@@ -644,6 +742,7 @@ describe('RightAssistantSidebar resizing', () => {
}
const onRespondApproval = vi.fn()
renderSidebar({ tasks: [task], approvals: [approval], onRespondApproval })
fireEvent.click(screen.getByRole('button', { name: '筛选任务' }))
const row = screen.getByText(task.title).closest('article')!
expect(within(row).getByLabelText('等待审批: Confirm write')).toBeVisible()
expect(screen.queryByRole('heading', { name: '等待审批' })).not.toBeInTheDocument()
@@ -685,9 +784,7 @@ describe('RightAssistantSidebar resizing', () => {
expect(within(row).queryByText(`Approval ${index}`)).not.toBeInTheDocument()
}
expect(screen.queryByText('Approval 4')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '所有项目' }))
expect(screen.getByText('Approval 4')).toBeVisible()
expect(within(row).queryByText('Approval 4')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '所有项目' })).not.toBeInTheDocument()
expect(screen.getAllByText('Approval 0')).toHaveLength(1)
})
@@ -842,18 +939,11 @@ describe('RightAssistantSidebar resizing', () => {
expect(screen.getByText('当前项目审批')).toBeInTheDocument()
expect(screen.queryByText('全局审批')).not.toBeInTheDocument()
fireEvent.click(
within(screen.getByRole('group', { name: '任务范围' })).getByRole(
'button',
{ name: '所有项目' }
)
)
expect(screen.getByText('全局审批')).toBeInTheDocument()
expect(screen.getByText('当前项目审批')).toBeInTheDocument()
expect(screen.getByLabelText('等待审批: 2')).toBeInTheDocument()
expect(screen.queryByRole('group', { name: '任务范围' })).not.toBeInTheDocument()
expect(screen.getByLabelText('等待审批: 1')).toBeInTheDocument()
})
it('scopes tasks to the current or all projects, labels unbound tasks and persists the choice', () => {
it('keeps task scope fixed to the current project', () => {
const createTask = (
id: string,
title: string,
@@ -882,44 +972,31 @@ describe('RightAssistantSidebar resizing', () => {
expect(screen.getByText('当前项目任务')).toBeInTheDocument()
expect(screen.queryByText('全局任务')).not.toBeInTheDocument()
expect(screen.queryByText('其他项目任务')).not.toBeInTheDocument()
expect(within(screen.getByRole('group', { name: '任务范围' })).getAllByRole('button'))
.toHaveLength(2)
expect(screen.queryByRole('group', { name: '任务范围' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '全局' })).not.toBeInTheDocument()
fireEvent.click(
within(screen.getByRole('group', { name: '任务范围' })).getByRole(
'button',
{ name: '所有项目' }
)
)
expect(screen.getByText('当前项目任务')).toBeInTheDocument()
expect(screen.getByText('全局任务')).toBeInTheDocument()
expect(screen.getByText('其他项目任务')).toBeInTheDocument()
expect(screen.getAllByText('未绑定项目')).toHaveLength(1)
expect(screen.getByText('项目:00000000-0000-4000-8000-000000000302')).toBeInTheDocument()
expect(JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!))
.toEqual(expect.objectContaining({ taskScope: 'all-projects' }))
.toEqual(expect.objectContaining({ taskScope: 'current-project' }))
})
it('restores the legacy global scope as all projects and saves the updated preference', () => {
it.each(['global', 'all-projects'])('ignores the persisted %s scope and saves current project scope', (taskScope) => {
localStorage.setItem('goodbuddy.workbar-layout.v1', JSON.stringify({
instances: DEFAULT_WORKBAR_INSTANCES,
activeInstanceId: DEFAULT_WORKBAR_INSTANCES[0]!.id,
expanded: true,
dock: 'right',
widthRatio: 0.3,
taskScope: 'global'
taskScope
}))
renderSidebar({ activeProject: null, tasks: [{
id: 'legacy-unbound-task', title: '历史任务', instructions: '历史任务',
origin: 'schedule', status: 'running', createdAt: '2026-09-09T00:00:00.000Z'
}] })
expect(screen.getByRole('button', { name: '所有项目' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByText('历史任务')).toBeInTheDocument()
expect(screen.getByText('未绑定项目')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '所有项目' })).not.toBeInTheDocument()
expect(screen.queryByText('历史任务')).not.toBeInTheDocument()
expect(JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!).taskScope)
.toBe('all-projects')
.toBe('current-project')
})
it('shows a clear current-project empty state when no project is active', () => {
@@ -927,7 +1004,7 @@ describe('RightAssistantSidebar resizing', () => {
expect(
screen.getByText(
'没有活动项目。请选择其他任务范围或先打开一个项目。'
'没有活动项目。请先打开一个项目以查看和创建任务。'
)
).toBeInTheDocument()
expect(screen.queryByText('等待审批')).not.toBeInTheDocument()
+87 -51
View File
@@ -7,6 +7,7 @@ import {
Copy,
ExternalLink,
FileText,
Filter,
ClockFading,
Maximize2,
Minimize2,
@@ -60,7 +61,6 @@ import {
normalizeWorkbarLayoutPreferences,
workbarLayoutPreferencesSchema,
type WorkbarAppDefinition,
type WorkbarTaskScope,
type WorkbarTabInstance,
type WorkbarTargetRef
} from '../../shared/workbar-contracts'
@@ -71,6 +71,7 @@ import {
} from './WorkbarShell'
import type { TerminalAdapter } from './TerminalPanel'
import './terminal-panel.css'
import './task-center.css'
const TerminalPanel = lazy(async () => {
const module = await import('./TerminalPanel')
@@ -102,7 +103,22 @@ export type PendingSidebarApproval = {
toolName?: string
}
type RightAssistantSidebarProps = {
export type SidebarConversationStats = {
// Must match activeConversationId; late results for other conversations are ignored.
conversationId: string
title?: string
messageCount: number
// Authoritative cumulative reply time, excluding idle gaps. Never a lifecycle delta.
replyDurationMs: number
incomplete?: boolean
}
export type SidebarTaskDuration = {
durationMs: number
incomplete?: boolean
}
export type RightAssistantSidebarProps = {
open: boolean
tab: AssistantSidebarTab
approvals: PendingSidebarApproval[]
@@ -115,6 +131,8 @@ type RightAssistantSidebarProps = {
workspaceChanges?: WorkspaceChanges
workspaceProjectId?: string
activeConversationId?: string
conversationStats?: SidebarConversationStats
taskDurations?: ReadonlyMap<string, SidebarTaskDuration>
browserStates?: Readonly<Record<string, Readonly<Record<string, BrowserLiveState>>>>
currentProject?: AssistantProject
restoreFocusRef?: { current: HTMLElement | null }
@@ -210,8 +228,7 @@ function persistWorkbarLayout(
instances: readonly WorkbarTabInstance[],
activeInstanceId: string | null,
expanded: boolean,
widthRatio: number,
taskScope: WorkbarTaskScope
widthRatio: number
): void {
try {
localStorage.setItem(
@@ -223,7 +240,7 @@ function persistWorkbarLayout(
expanded,
dock: 'right',
widthRatio,
taskScope
taskScope: 'current-project'
})
)
)
@@ -664,6 +681,8 @@ export function RightAssistantSidebar({
workspaceChanges,
workspaceProjectId,
activeConversationId,
conversationStats,
taskDurations,
browserStates = {},
currentProject,
restoreFocusRef,
@@ -688,6 +707,13 @@ export function RightAssistantSidebar({
}: RightAssistantSidebarProps): React.JSX.Element {
const { i18n, t } = useTranslation('workspace')
const locale = i18n.resolvedLanguage || 'zh-CN'
const currentStats = activeConversationId && conversationStats?.conversationId === activeConversationId
? conversationStats
: undefined
const formatDuration = (durationMs: number): string => {
const seconds = Math.floor(Math.max(0, durationMs) / 1000)
return `${Math.floor(seconds / 3600).toString().padStart(2, '0')}:${Math.floor(seconds / 60 % 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`
}
const sidebarTimeFormatter = useMemo(
() =>
new Intl.DateTimeFormat(locale, {
@@ -857,13 +883,9 @@ export function RightAssistantSidebar({
const [workspacePreviewLoadMoreError, setWorkspacePreviewLoadMoreError] =
useState('')
const [taskFilter, setTaskFilter] = useState<
'active' | 'paused' | 'finished'
>('active')
const [taskScope, setTaskScope] = useState<Exclude<WorkbarTaskScope, 'global'>>(
initialLayout?.taskScope === 'global'
? 'all-projects'
: initialLayout?.taskScope ?? 'current-project'
)
'all' | 'active' | 'paused' | 'finished'
>('all')
const [taskFiltersExpanded, setTaskFiltersExpanded] = useState(false)
const [actionErrorState, setActionErrorState] = useState({
scope: { instanceId: activeWorkbarInstanceId, open },
message: ''
@@ -950,12 +972,9 @@ export function RightAssistantSidebar({
sidebarWidthLimits.maximum > sidebarWidthLimits.minimum
const taskMatchesScope = useCallback(
(projectId?: string): boolean => {
if (taskScope === 'all-projects') {
return true
}
return Boolean(currentProject && projectId === currentProject.id)
},
[currentProject, taskScope]
[currentProject]
)
const scopedApprovals = useMemo(
() => approvals.filter((approval) => taskMatchesScope(approval.projectId)),
@@ -988,6 +1007,7 @@ export function RightAssistantSidebar({
const filteredTasks = useMemo(
() =>
topLevelTasks.filter((task) => {
if (taskFilter === 'all') return true
if (approvalsByTask.has(task.id)) return taskFilter === 'active'
if (taskFilter === 'active') {
return (
@@ -1067,14 +1087,12 @@ export function RightAssistantSidebar({
workbarInstances,
activeWorkbarInstanceId,
open,
sidebarRatio,
taskScope
sidebarRatio
)
}, [
activeWorkbarInstanceId,
open,
sidebarRatio,
taskScope,
workbarInstances
])
@@ -1914,59 +1932,61 @@ export function RightAssistantSidebar({
</Suspense>
) : null}
{instance.appId === 'tasks' && (
<section className="assistant-sidebar__section">
<div className="task-center__filters">
<SegmentedControl
ariaLabel={t('sidebar.tasks.scope.ariaLabel')}
onChange={setTaskScope}
options={[
{
value: 'current-project',
label: t('sidebar.tasks.scope.currentProject')
},
{
value: 'all-projects',
label: t('sidebar.tasks.scope.allProjects')
}
]}
value={taskScope}
/>
</div>
{taskScope === 'current-project' && !currentProject ? (
<section className="assistant-sidebar__section task-center">
<p className="task-center__scope">{currentProject
? t('sidebar.tasks.projectScope', { project: currentProject.name })
: t('sidebar.tasks.scope.currentProject')}</p>
<section className="task-center__stats" aria-label={t('sidebar.tasks.stats.label')}>
<span className="task-center__eyebrow">{t('sidebar.tasks.stats.currentConversation')}</span>
<h3>{activeConversationId
? currentStats?.title ?? conversationTitles.get(activeConversationId) ?? t('sidebar.tasks.stats.currentConversation')
: t('sidebar.tasks.stats.noConversation')}</h3>
<dl className="task-center__stat-grid">
<div><dt>{t('sidebar.tasks.stats.replyDuration')}</dt><dd>{currentStats ? formatDuration(currentStats.replyDurationMs) : t('sidebar.tasks.stats.unavailable')}</dd></div>
<div><dt>{t('sidebar.tasks.stats.messages')}</dt><dd>{currentStats ? currentStats.messageCount.toLocaleString(locale) : t('sidebar.tasks.stats.unavailable')}</dd></div>
</dl>
{currentStats?.incomplete && <p className="task-center__incomplete">{t('sidebar.tasks.stats.incomplete')}</p>}
<details><summary>{t('sidebar.tasks.stats.timeHelp')}</summary><p>{t('sidebar.tasks.stats.timeDescription')}</p></details>
</section>
{!currentProject ? (
<p className="assistant-sidebar__empty">
{t('sidebar.tasks.scope.noCurrentProject')}
</p>
) : (
<>
{unassociatedApprovals.length > 0 && <>
<h3>
<ShieldAlert size={15} />
{t('sidebar.tasks.approvalsTitle')}
</h3>
{unassociatedApprovals.map((approval) => (
<SidebarApproval key={approval.approvalId} approval={approval} onRespondApproval={onRespondApproval} />
))}
</>}
<div className="task-center__index-heading">
<h3>
<ClockFading size={15} />
{t('sidebar.tasks.taskIndexTitle')}
{t('sidebar.tasks.taskIndexTitle')}{' '}
<span className="task-center__count">{topLevelTasks.length}</span>
</h3>
<button
className="secondary-button task-center__create"
aria-label={t('taskStrip.create')}
title={t('taskStrip.create')}
onClick={onCreateCustomTask}
type="button"
>
<Plus aria-hidden="true" size={13} />
{t('taskStrip.create')}
</button>
<button
className="secondary-button task-center__filter-toggle"
aria-label={t('sidebar.tasks.filters.ariaLabel')}
title={t('sidebar.tasks.filters.ariaLabel')}
aria-expanded={taskFiltersExpanded}
aria-controls={`task-filters-${instance.id}`}
data-active={taskFilter !== 'all'}
onClick={() => setTaskFiltersExpanded((expanded) => !expanded)}
type="button"
><Filter aria-hidden="true" size={14} /></button>
</div>
<div className="task-center__filters">
<div className="task-center__filters" id={`task-filters-${instance.id}`} hidden={!taskFiltersExpanded}
onFocusCapture={(event) => event.target.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })}>
<SegmentedControl
ariaLabel={t('sidebar.tasks.filters.ariaLabel')}
onChange={setTaskFilter}
options={[
{ value: 'all', label: t('sidebar.tasks.filters.all') },
{
value: 'active',
label: t('sidebar.tasks.filters.active')
@@ -1983,6 +2003,16 @@ export function RightAssistantSidebar({
value={taskFilter}
/>
</div>
{taskFilter !== 'all' && <div className="task-center__filter-summary">
<span>{t('sidebar.tasks.filters.summary', { filter: t(`sidebar.tasks.filters.${taskFilter}`), count: filteredTasks.length })}</span>
<button type="button" onClick={() => setTaskFilter('all')}>{t('sidebar.tasks.filters.clear')}</button>
</div>}
{unassociatedApprovals.length > 0 && <>
<h3><ShieldAlert size={15} />{t('sidebar.tasks.approvalsTitle')}</h3>
{unassociatedApprovals.map((approval) => (
<SidebarApproval key={approval.approvalId} approval={approval} onRespondApproval={onRespondApproval} />
))}
</>}
{filteredTasks.length === 0 ? (
<p className="assistant-sidebar__empty">
{topLevelTasks.length === 0
@@ -1998,6 +2028,7 @@ export function RightAssistantSidebar({
const projectName = task.projectId
? projectNames.get(task.projectId)
: undefined
const duration = taskDurations?.get(task.id)
return (
<article
className={
@@ -2010,6 +2041,7 @@ export function RightAssistantSidebar({
<button
className="task-center__item-main"
onClick={() => onOpenTask(task)}
aria-current={selectedTaskId === task.id ? 'true' : undefined}
type="button"
>
<span
@@ -2045,6 +2077,10 @@ export function RightAssistantSidebar({
: t('task.mode.unavailable')}
</span>
<span>{t(`task.status.${task.status}`)}</span>
{duration && <span title={t('sidebar.tasks.stats.taskDuration')}>
{t('sidebar.tasks.stats.taskDuration')}: {formatDuration(duration.durationMs)}
{duration.incomplete && ` (${t('sidebar.tasks.stats.partial')})`}
</span>}
</div>
<p>
{task.error ??
+28 -98
View File
@@ -25,6 +25,7 @@ import type {
} from '../../shared/capability-contracts'
import {
defaultLocalToolEnvironmentSettings,
defaultApplicationNavigation,
type ApplicationSettings
} from '../../shared/application-settings-contracts'
import type {
@@ -547,6 +548,7 @@ let applicationSettings: ApplicationSettings = {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
@@ -838,6 +840,7 @@ describe('SettingsPanel runtime files', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
@@ -998,7 +1001,8 @@ describe('SettingsPanel runtime files', () => {
updateSettings: updateApplicationSettings,
check: vi.fn(),
openReleasePage: vi.fn(),
onResult: vi.fn(() => () => {})
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {})
},
localToolEnvironment: {
getSnapshot: getLocalToolEnvironmentSnapshot,
@@ -1500,71 +1504,19 @@ describe('SettingsPanel runtime files', () => {
).toBeInTheDocument()
})
it('toggles Magic Notes navigation settings', async () => {
const onMagicNotesEnabledChange = vi.fn()
const onMagicNotesShowIncompleteTodoCountChange = vi.fn()
it('omits Magic Notes from global platform settings without changing stored settings', async () => {
const storedSettings = structuredClone(applicationSettings)
render(
<SettingsPanel
{...heartbeatSettingsProps}
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
onMagicNotesShowIncompleteTodoCountChange={
onMagicNotesShowIncompleteTodoCountChange
}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(
await screen.findByRole('tab', { name: '魔法笔记' })
)
const toggle = await screen.findByRole('switch', {
name: '显示魔法笔记入口'
})
expect(toggle).not.toBeChecked()
expect(screen.getByText(/默认关闭/)).toBeInTheDocument()
fireEvent.click(toggle)
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
magicNotesEnabled: true
})
)
expect(onMagicNotesEnabledChange).toHaveBeenCalledWith(true)
const countToggle = screen.getByRole('switch', {
name: '显示未完成待办数量'
})
expect(countToggle).toBeChecked()
fireEvent.click(countToggle)
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
magicNotesShowIncompleteTodoCount: false
})
)
expect(
onMagicNotesShowIncompleteTodoCountChange
).toHaveBeenCalledWith(false)
fireEvent.click(
screen.getByRole('button', { name: '保存后自动' })
)
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
magicNoteCommentMode: 'after-save-auto'
})
)
expect(
screen.getByRole('button', { name: '长评 + 要点' })
).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(screen.getByRole('button', { name: '要点' }))
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
magicNoteCommentFormat: 'structured'
})
)
expect(await screen.findByRole('tab', { name: '通用设置' })).toHaveAttribute('aria-selected', 'true')
expect(screen.queryByRole('tab', { name: '魔法笔记', hidden: true })).not.toBeInTheDocument()
expect(document.getElementById('platform-features-panel-magic-notes')).toBeNull()
expect(screen.queryByRole('switch', { name: '显示魔法笔记入口' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
expect(updateApplicationSettings).not.toHaveBeenCalled()
expect(applicationSettings).toEqual(storedSettings)
})
it('switches the global model download source from General settings', async () => {
@@ -1609,9 +1561,9 @@ describe('SettingsPanel runtime files', () => {
dedupeKey: 'model-download-source'
})
fireEvent.click(screen.getByRole('tab', { name: '魔法笔记' }))
fireEvent.click(screen.getByRole('tab', { name: '远程项目(技术预览)' }))
expect(
screen.getByRole('switch', { name: '显示魔法笔记入口' })
screen.getByRole('tabpanel', { name: '远程项目(技术预览)' })
).toBeInTheDocument()
})
@@ -1712,24 +1664,17 @@ describe('SettingsPanel runtime files', () => {
})
it('refreshes built-in Notes MCP after enabling Magic Notes', async () => {
function Harness(): React.JSX.Element {
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
return (
<SettingsPanel
{...heartbeatSettingsProps}
magicNotesEnabled={magicNotesEnabled}
onMagicNotesEnabledChange={setMagicNotesEnabled}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
}
render(
<Harness />
const panel = (magicNotesEnabled: boolean): React.JSX.Element => (
<SettingsPanel
{...heartbeatSettingsProps}
magicNotesEnabled={magicNotesEnabled}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
const { rerender } = render(panel(false))
openCapabilitySettingsTab('MCP')
const noteServerToggle = await screen.findByRole('button', {
@@ -1739,22 +1684,7 @@ describe('SettingsPanel runtime files', () => {
'capability-card--disabled'
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(
await screen.findByRole('tab', { name: '魔法笔记' })
)
fireEvent.click(
await screen.findByRole('switch', {
name: '显示魔法笔记入口'
})
)
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
magicNotesEnabled: true
})
)
openCapabilitySettingsTab('MCP')
rerender(panel(true))
await waitFor(() =>
expect(
screen
-10
View File
@@ -242,11 +242,7 @@ type SettingsPanelProps = {
onConversationHtmlRenderingEnabledChange?: (
enabled: boolean
) => void
onMagicNotesEnabledChange?: (enabled: boolean) => void
onRemoteProjectsEnabledChange?: (enabled: boolean) => void
onMagicNotesShowIncompleteTodoCountChange?: (
enabled: boolean
) => void
onShortcutSettingsChanged?: (
snapshot: GlobalShortcutSettingsSnapshot
) => void
@@ -655,9 +651,7 @@ export function SettingsPanel({
magicNotesEnabled = false,
remoteProjectsEnabled = false,
onConversationHtmlRenderingEnabledChange = () => {},
onMagicNotesEnabledChange = () => {},
onRemoteProjectsEnabledChange = () => {},
onMagicNotesShowIncompleteTodoCountChange = () => {},
onShortcutSettingsChanged = () => {},
onLeaveRequestReady = () => {}
}: SettingsPanelProps): React.JSX.Element | null {
@@ -2301,10 +2295,6 @@ export function SettingsPanel({
onConversationHtmlRenderingEnabledChange={
onConversationHtmlRenderingEnabledChange
}
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
onMagicNotesShowIncompleteTodoCountChange={
onMagicNotesShowIncompleteTodoCountChange
}
onRemoteProjectsEnabledChange={
onRemoteProjectsEnabledChange
}
@@ -0,0 +1,48 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AssistantSchedule } from '../../shared/assistant-contracts'
import { TaskScheduleActions } from './TaskScheduleActions'
afterEach(cleanup)
const triggeredAt = '2026-09-17T12:00:00.000Z'
const schedule: AssistantSchedule = {
id: '00000000-0000-4000-8000-000000000801',
taskId: '00000000-0000-4000-8000-000000000802',
conversationId: '00000000-0000-4000-8000-000000000803',
title: 'Immediate task', prompt: 'Run once', workMode: 'ask',
recurrence: 'once', enabled: false,
nextRunAt: triggeredAt, lastRunAt: triggeredAt,
createdAt: triggeredAt, updatedAt: triggeredAt
}
describe('TaskScheduleActions', () => {
it('offers an explicit new run, not automatic resume, for a cancelled immediate task', async () => {
const onRunSchedule = vi.fn().mockResolvedValue(undefined)
const onSetScheduleEnabled = vi.fn()
render(<TaskScheduleActions schedule={schedule} taskStatus="cancelled" taskTitle={schedule.title}
onError={vi.fn()} onRemoveSchedule={vi.fn()} onRunSchedule={onRunSchedule}
onSetScheduleEnabled={onSetScheduleEnabled} />)
expect(screen.queryByRole('button', { name: '恢复' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '立即运行' }))
await waitFor(() => expect(onRunSchedule).toHaveBeenCalledExactlyOnceWith(schedule.id))
expect(onSetScheduleEnabled).not.toHaveBeenCalled()
})
it('does not allow resume or another run while the immediate occurrence is queued', () => {
render(<TaskScheduleActions schedule={schedule} taskStatus="queued" taskTitle={schedule.title}
onError={vi.fn()} onRemoveSchedule={vi.fn()} onRunSchedule={vi.fn()}
onSetScheduleEnabled={vi.fn()} />)
expect(screen.queryByRole('button', { name: '恢复' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '立即运行' })).toBeDisabled()
})
it.each(['once', 'daily', 'weekly'] as const)('retains resume for a paused untriggered %s schedule', async (recurrence) => {
const onSetScheduleEnabled = vi.fn().mockResolvedValue(undefined)
render(<TaskScheduleActions schedule={{ ...schedule, recurrence, lastRunAt: undefined }}
taskStatus="paused" taskTitle={schedule.title} onError={vi.fn()}
onRemoveSchedule={vi.fn()} onRunSchedule={vi.fn()} onSetScheduleEnabled={onSetScheduleEnabled} />)
fireEvent.click(screen.getByRole('button', { name: '恢复' }))
await waitFor(() => expect(onSetScheduleEnabled).toHaveBeenCalledExactlyOnceWith(schedule.id, true))
})
})
@@ -8,6 +8,7 @@ import {
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
defaultLocalToolEnvironmentSettings,
defaultApplicationNavigation,
type ApplicationSettings
} from '../../shared/application-settings-contracts'
import type { DesktopApi } from '../../shared/contracts'
@@ -25,6 +26,7 @@ describe('UpdateSettingsSection', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
@@ -88,7 +90,8 @@ describe('UpdateSettingsSection', () => {
updateSettings,
check,
openReleasePage: vi.fn(),
onResult: vi.fn(() => () => {})
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {})
}
} as unknown as DesktopApi
})
@@ -193,7 +196,8 @@ describe('UpdateSettingsSection', () => {
)
}),
openReleasePage: vi.fn(),
onResult: vi.fn(() => () => {})
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {})
}
} as unknown as DesktopApi
})
@@ -237,7 +241,8 @@ describe('UpdateSettingsSection', () => {
openReleasePage: vi.fn(async () => {
throw new Error('Shell refused the release URL')
}),
onResult: vi.fn(() => () => {})
onResult: vi.fn(() => () => {}),
onSettingsChanged: vi.fn(() => () => {})
}
} as unknown as DesktopApi
})
+11 -4
View File
@@ -23,6 +23,10 @@ const stylesheet = readFileSync(
join(process.cwd(), 'src', 'renderer', 'src', 'styles.css'),
'utf8'
)
const taskCenterStylesheet = readFileSync(
join(process.cwd(), 'src', 'renderer', 'src', 'task-center.css'),
'utf8'
)
const rendererEntry = readFileSync(
join(process.cwd(), 'src', 'renderer', 'src', 'main.tsx'),
'utf8'
@@ -346,11 +350,14 @@ describe('WorkspacePrimitives', () => {
expect(stylesheet).not.toMatch(
/@media \(max-width: 720px\)\s*\{[\s\S]*?\.conversation-task-strip__header\s*\{[^}]*flex-direction:\s*column;/u
)
expect(stylesheet).toMatch(
/\.task-center__filters \.segmented-control\s*\{[^}]*width:\s*100%;[^}]*overflow:\s*hidden;/u
})
it('keeps narrow task filters scrollable without shrinking their labels', () => {
expect(taskCenterStylesheet).toMatch(
/\.task-center \.task-center__filters \.segmented-control\s*\{[^}]*overflow-x:\s*auto;/u
)
expect(stylesheet).toMatch(
/\.task-center__filters \.segmented-control__option\s*\{[^}]*min-width:\s*0;[^}]*flex:\s*1;/u
expect(taskCenterStylesheet).toMatch(
/\.task-center \.task-center__filters \.segmented-control__option\s*\{[^}]*flex:\s*1 0 auto;[^}]*min-width:\s*max-content;/u
)
})
+37
View File
@@ -0,0 +1,37 @@
.anchored-menu {
position: fixed;
z-index: calc(var(--z-dialog) - 1);
box-sizing: border-box;
overflow-y: auto;
padding: var(--space-2);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-raised);
color: var(--text-primary);
font-size: var(--font-body);
box-shadow: var(--shadow-dialog);
-webkit-app-region: no-drag;
}
.anchored-menu [role="menuitem"] {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
min-height: 32px;
padding: var(--space-2);
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: inherit;
font: inherit;
text-align: left;
overflow-wrap: anywhere;
cursor: pointer;
}
.anchored-menu [role="menuitem"] svg { flex-shrink: 0; color: var(--text-secondary); }
.anchored-menu [role="menuitem"]:hover { background: var(--surface-subtle); }
.anchored-menu [role="menuitem"]:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.anchored-menu [role="separator"] { border-top: 1px solid var(--border-subtle); margin: var(--space-2) 0; }
.anchored-menu p { margin: var(--space-2); color: var(--text-secondary); overflow-wrap: anywhere; }
+199
View File
@@ -0,0 +1,199 @@
.settings-backdrop .settings-panel.application-center {
width: min(960px, calc(100vw - 2 * var(--space-4)));
height: auto;
max-height: calc(100vh - 2 * var(--space-4));
}
.settings-backdrop .settings-panel.application-center--detail {
width: min(640px, calc(100vw - 2 * var(--space-4)));
}
.application-center .settings-panel__header {
gap: var(--space-3);
flex: 0 0 auto;
}
.application-center .settings-panel__header > .icon-button {
flex: 0 0 auto;
}
.application-center .settings-panel__header .page-header {
flex: 1;
align-items: center;
flex-direction: row;
}
.application-center .settings-panel__header .page-header__content {
padding-right: 0;
}
.application-center .settings-panel__header .page-header .page-header__actions {
position: static;
flex: 0 0 auto;
}
.application-center .settings-panel__header h1 {
font-size: var(--font-section-title);
}
.application-center__body {
overflow: auto;
min-height: 0;
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-0);
font-size: var(--font-body);
}
.application-center__search {
display: grid;
gap: var(--space-2);
margin-bottom: var(--space-3);
color: var(--text-secondary);
}
.application-center__search input {
background: var(--surface-raised);
color: var(--text-primary);
border: 1px solid var(--border-control);
border-radius: var(--radius-control);
padding: var(--space-2) var(--space-3);
min-height: 36px;
min-width: 0;
}
.application-center__item {
display: flex;
flex-direction: column;
gap: var(--space-4);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
padding: var(--space-4);
min-width: 0;
}
.application-center__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.application-center__summary {
display: flex;
align-items: flex-start;
gap: var(--space-3);
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
.application-center__summary > svg {
flex-shrink: 0;
color: var(--text-secondary);
}
.application-center__title {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-1) var(--space-2);
}
.application-center__title strong {
font-size: var(--font-section-title);
}
.application-center__summary p {
color: var(--text-secondary);
margin: var(--space-1) 0 0;
line-height: 1.6;
}
.application-center__summary small {
color: var(--text-secondary);
font-size: var(--font-caption);
}
.application-center__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
margin-top: auto;
}
.application-center__order {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
border-top: 1px solid var(--border-subtle);
margin-top: var(--space-4);
padding-top: var(--space-4);
}
.application-settings {
display: grid;
gap: var(--space-4);
}
.application-settings__group {
display: grid;
gap: var(--space-3);
min-width: 0;
}
.application-settings__group + .application-settings__group {
border-top: 1px solid var(--border-subtle);
padding-top: var(--space-4);
}
.application-settings .toggle-row {
justify-content: flex-start;
}
.application-settings .toggle-row span {
font-weight: 500;
}
.application-settings .settings-notice {
margin: 0 0 var(--space-2);
font-size: var(--font-body);
line-height: 1.6;
}
.application-settings .platform-feature-option {
grid-template-columns: minmax(100px, 1fr) auto;
align-items: center;
border: 0;
padding: 0;
gap: var(--space-3);
}
.application-settings .segmented-control {
width: fit-content;
max-width: 100%;
}
@media (max-width: 720px) {
.application-center .settings-panel__header {
padding: var(--space-4);
}
.application-center__body {
padding: var(--space-4);
}
.application-center__grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 540px) {
.application-settings .platform-feature-option {
grid-template-columns: minmax(0, 1fr);
}
}
.application-page {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.application-page-controls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-4);
padding: var(--space-2) var(--space-4);
flex: 0 0 auto;
}
.application-page-content {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.application-page-content[hidden] {
display: none;
}
.sidebar-footer--applications {
display: flex;
align-items: center;
gap: var(--space-2);
}
.sidebar-footer--applications > .nav-item {
flex: 1;
}
+5
View File
@@ -0,0 +1,5 @@
.custom-task-dialog__form {
min-width: 0;
margin: 0;
border: 0;
}
+9 -3
View File
@@ -14,8 +14,9 @@ export function trapTabFocus(
if (event.key !== 'Tab' || !container) {
return
}
const focusable =
const focusable = Array.from(
container.querySelectorAll<HTMLElement>(focusableSelector)
).filter((element) => !element.closest('[hidden], [inert], [aria-hidden="true"]'))
if (focusable.length === 0) {
event.preventDefault()
container.focus()
@@ -36,7 +37,8 @@ export function trapTabFocus(
}
export function activateModalFocus(
initialFocus: () => HTMLElement | null
initialFocus: () => HTMLElement | null,
fallbackFocus?: () => HTMLElement | null
): () => void {
const restoreFocus =
document.activeElement instanceof HTMLElement
@@ -86,6 +88,10 @@ export function activateModalFocus(
for (const { element, wasInert } of isolatedElements) {
element.inert = wasInert
}
restoreFocus?.focus()
const canRestore = restoreFocus?.isConnected &&
restoreFocus !== document.body &&
!restoreFocus.closest('[hidden], [inert], [aria-hidden="true"], :disabled')
const target = canRestore ? restoreFocus : fallbackFocus?.()
target?.focus()
}
}
@@ -83,6 +83,29 @@ async function setup() {
}
describe('document OCR worker idle release', () => {
it('reports a failed model load and recovers on the next real request', async () => {
const api = await setup()
const { getOcrInferenceState } = await import('./document-ocr-bridge')
api.getOcrAssets.mockRejectedValueOnce(new Error('model unavailable'))
await api.start()
expect(getOcrInferenceState()).toMatchObject({ state: 'error', loaded: false, busy: false, error: 'model unavailable' })
const next = await api.start()
expect(getOcrInferenceState()).toMatchObject({ state: 'running', loaded: true, error: undefined })
await api.finish(next)
})
it('reports the actual worker and refuses manual release while busy', async () => {
const api = await setup()
const { getOcrInferenceState, releaseOcrInference } = await import('./document-ocr-bridge')
expect(getOcrInferenceState()).toMatchObject({ state: 'idle', loaded: false })
const input = await api.start()
expect(getOcrInferenceState()).toMatchObject({ state: 'running', loaded: true, busy: true })
expect(() => releaseOcrInference()).toThrow('活动或排队任务')
expect(TestWorker.instances[0]!.terminate).not.toHaveBeenCalled()
await api.finish(input)
releaseOcrInference()
expect(TestWorker.instances[0]!.terminate).toHaveBeenCalledOnce()
expect(getOcrInferenceState()).toMatchObject({ state: 'idle', loaded: false })
})
it('releases after 60 idle seconds and reloads the model for the next request', async () => {
const api = await setup()
const input = await api.start()
+56 -1
View File
@@ -18,6 +18,10 @@ type PendingWorkerRequest = {
}
let worker: Worker | undefined
let workerLoaded = false
let workerError: string | undefined
let managementLoading = false
let startupTimer: number | undefined
let workerModelId: string | undefined
let workerReady: Promise<void> | undefined
let resolveWorkerReady: (() => void) | undefined
@@ -55,9 +59,12 @@ function safeError(error: unknown): string {
function terminateWorker(error: Error): void {
clearIdleTimer()
window.clearTimeout(startupTimer)
startupTimer = undefined
worker?.terminate()
rejectWorkerReady?.(error)
worker = undefined
workerLoaded = false
workerModelId = undefined
workerReady = undefined
resolveWorkerReady = undefined
@@ -97,15 +104,27 @@ async function ensureWorker(modelId: string): Promise<Worker> {
new URL('./document-ocr-worker.ts', import.meta.url),
{ type: 'module', name: 'goodbuddy-document-ocr' }
)
workerModelId = modelId
workerReady = new Promise<void>((resolve, reject) => {
resolveWorkerReady = resolve
rejectWorkerReady = reject
})
const ready = workerReady
void ready.catch(() => undefined)
// A worker that never becomes ready must not leave manual loading locked forever.
startupTimer = window.setTimeout(() => {
workerError = '本地 OCR 模型加载超时,请重试'
terminateWorker(new Error(workerError))
}, 120_000)
worker.addEventListener(
'message',
(event: MessageEvent<WorkerOutput>) => {
const output = event.data
if (output.type === 'ready') {
window.clearTimeout(startupTimer)
startupTimer = undefined
workerLoaded = true
workerError = undefined
resolveWorkerReady?.()
return
}
@@ -141,6 +160,7 @@ async function ensureWorker(modelId: string): Promise<Worker> {
}
)
worker.addEventListener('error', (event) => {
workerError = event.message || '本地 OCR Worker 异常'
terminateWorker(
new Error(event.message || '本地 OCR Worker 异常')
)
@@ -159,8 +179,9 @@ async function ensureWorker(modelId: string): Promise<Worker> {
assets.dictionary
]
)
await workerReady
await ready
} catch (error) {
workerError = safeError(error)
terminateWorker(
error instanceof Error ? error : new Error('本地 OCR 初始化失败')
)
@@ -169,6 +190,40 @@ async function ensureWorker(modelId: string): Promise<Worker> {
return worker
}
export function getOcrInferenceState(): {
state: 'idle' | 'running' | 'starting' | 'error'
model?: string
busy: boolean
loaded: boolean
error?: string
} {
return {
state: worker ? workerLoaded ? 'running' : 'starting' : workerError ? 'error' : 'idle',
model: workerModelId, loaded: workerLoaded,
busy: managementLoading || queuedRequestCount > 0 || Boolean(activeRequestId) || pending.size > 0,
error: workerError
}
}
export async function loadOcrInference(): Promise<void> {
if (getOcrInferenceState().busy) throw new Error('OCR 正在处理任务,请等待完成后再加载')
managementLoading = true
try {
const snapshot = await window.goodbuddy.documentParsing?.getSnapshot()
if (!snapshot) throw new Error('文档解析服务不可用')
if (!snapshot.status.localOcr.available) throw new Error(snapshot.status.localOcr.detail)
await ensureWorker(snapshot.settings.localOcrModelId)
} finally {
managementLoading = false
scheduleIdleRelease()
}
}
export function releaseOcrInference(): void {
if (getOcrInferenceState().busy) throw new Error('OCR 仍有活动或排队任务,无法释放共享 Worker;请等待任务结束')
terminateWorker(new Error('用户已释放 OCR 模型'))
}
async function recognize(
request: DocumentOcrRequest
): Promise<DocumentOcrResult> {
+25 -5
View File
@@ -2,6 +2,22 @@ import type { TranslationShape } from '../../resource-types'
import type { app as chineseApp } from '../zh-CN/app'
export const app = {
applications: {
fixed: 'Fixed entry', optional: 'Optional',
manage: 'Manage Apps',
title: 'Application Center', description: 'Open built-in applications and manage optional apps, pins, and order. Knowledge and Smart Heartbeat have fixed sidebar entries.',
localInference: 'Local Inference', settings: 'Application settings', enable: 'Enable application', pin: 'Pin to sidebar',
enableHelp: 'Pinning only controls the sidebar shortcut. Disabling preserves data and pins without stopping existing requests or background services.',
disabledPage: 'Application is disabled', close: 'Close application center', retry: 'Reload settings', back: 'Back to application center',
search: 'Search built-in applications', noResults: 'No matching applications', clear: 'Clear search', builtin: 'Built-in',
enabled: 'Enabled', disabled: 'Disabled', open: 'Open', moveUp: 'Move {{name}} up', moveDown: 'Move {{name}} down',
orderUpdated: 'Application order updated', loadFailed: 'Could not load application settings', saveFailed: 'Could not save application settings',
confirmFailed: 'Could not confirm the save result. Reload settings before editing again.',
descriptions: {
'magic-notes': 'Write notes, organize to-dos, and receive AI comments.', knowledge: 'Manage knowledge sources and retrieve reference material for conversations.',
heartbeat: 'Review heartbeat suggestions and schedules.', 'local-inference': 'Inspect local inference tasks and execution services.'
}
},
notifications: {
success: 'Success',
error: 'Error',
@@ -39,6 +55,7 @@ export const app = {
}
},
navigation: {
settings: 'Settings',
label: 'Main navigation',
chat: 'Chat',
magicNotes: 'Magic Notes',
@@ -137,11 +154,14 @@ export const app = {
eyebrow: 'CUSTOM TASK',
title: 'New custom task',
description:
'Send task instructions to an ordinary conversation on schedule. Replies stay in the conversation just like manually sent messages.',
'Send task content to a conversation now or on schedule. Replies stay in the linked conversation.',
nameHelp: 'Optional. Leave blank to generate a name from the task content.',
timing: { now: 'Run immediately', scheduled: 'Schedule' },
close: 'Close new custom task',
fields: {
name: 'Task name',
instructions: 'Task instructions',
instructions: 'Task content',
timing: 'Execution time',
destination: 'Conversation',
mode: 'Work mode',
recurrence: 'Frequency',
@@ -151,11 +171,11 @@ export const app = {
current: 'Current conversation',
new: 'New conversation',
currentHelp:
'Use this conversation\'s history, Runtime, work mode, and enabled capabilities when the message is sent.',
'Use the selected conversation\'s history, Runtime, work mode, and enabled capabilities when the message is sent.',
newHelp:
'Create an independent ordinary conversation with project defaults and the task name as its title.',
currentUnavailable:
'The current conversation cannot host this task. Choose a new conversation.'
'The current conversation cannot host this task. Choose another conversation in this project or a new conversation.'
},
mode: {
execute: 'Execute',
@@ -183,7 +203,7 @@ export const app = {
},
errors: {
title: 'Enter a task name.',
instructions: 'Enter task instructions.',
instructions: 'Enter task content.',
destination: 'Choose an available conversation.',
time: 'Choose a valid first run time.',
futureTime: 'The first run must be in the future.',
@@ -557,8 +557,7 @@ export const settingsSections = {
label: 'Platform feature options',
tabs: {
ariaLabel: 'Platform feature settings',
general: 'General',
magicNotes: 'Magic Notes'
general: 'General'
},
conversationHtml: {
title: 'Conversation HTML rendering',
@@ -680,7 +679,7 @@ export const settingsSections = {
magicNotes: {
title: 'Magic Notes',
description:
'Off by default. Enable it to capture notes and to-dos and analyze content with AI.',
'Capture notes and to-dos and analyze content with AI. Manage preferences in Application settings.',
showEntry: 'Show Magic Notes',
showIncompleteTodoCount: 'Show incomplete to-do count',
showIncompleteTodoCountHelp:
@@ -322,7 +322,20 @@ export const workspace = {
noApprovals: 'There are no operations awaiting approval.',
deny: 'Deny',
allowOnce: 'Allow once',
taskIndexTitle: 'Task index',
taskIndexTitle: 'Project tasks',
stats: {
label: 'Current conversation statistics',
currentConversation: 'Current conversation',
noConversation: 'No conversation selected',
replyDuration: 'Cumulative Agent reply time',
messages: 'Messages',
unavailable: 'Not available',
incomplete: 'Some replies have no timing records. Only known durations are included.',
partial: 'Partial records',
timeHelp: 'About timing',
timeDescription: 'Adds the actual duration of each Agent reply, excluding idle time between replies. Missing durations are not estimated from conversation or task start and end times.',
taskDuration: 'Cumulative task time'
},
empty: 'Explicitly created tasks will appear here.',
noFilterResults: 'No tasks match this filter.',
conversationUnavailable: 'Conversation unavailable',
@@ -336,9 +349,12 @@ export const workspace = {
currentProject: 'Current project',
allProjects: 'All projects',
noCurrentProject:
'No project is active. Choose another task scope or open a project.'
'No project is active. Open a project to view and create tasks.'
},
filters: {
all: 'All',
summary: 'Filter: {{filter}} · {{count}} tasks',
clear: 'Clear filter',
ariaLabel: 'Filter tasks',
active: 'Active',
paused: 'Paused',
+25 -5
View File
@@ -1,4 +1,20 @@
export const app = {
applications: {
fixed: '固定入口', optional: '可选',
manage: '管理应用',
title: '应用中心', description: '打开内置应用,管理可选应用的启用、常驻和顺序。知识库和智能心跳固定显示在左侧菜单。',
localInference: '本机推理', settings: '应用设置', enable: '启用应用', pin: '常驻左侧菜单',
enableHelp: '常驻只控制左侧捷径;关闭应用保留数据和常驻偏好,不停止已有请求或后台服务。',
disabledPage: '应用已关闭', close: '关闭应用中心', retry: '重新读取', back: '返回应用中心',
search: '搜索内置应用', noResults: '未找到相关应用', clear: '清除搜索', builtin: '内置',
enabled: '已启用', disabled: '已关闭', open: '打开', moveUp: '上移 {{name}}', moveDown: '下移 {{name}}',
orderUpdated: '应用顺序已更新', loadFailed: '无法读取应用设置', saveFailed: '应用设置保存失败',
confirmFailed: '无法确认保存结果,请重新读取后再修改。',
descriptions: {
'magic-notes': '记录笔记、整理待办并获取 AI 评论。', knowledge: '管理知识来源,为会话检索资料。',
heartbeat: '查看智能心跳建议与计划。', 'local-inference': '查看本机推理任务与执行服务。'
}
},
notifications: {
success: '成功',
error: '错误',
@@ -36,6 +52,7 @@ export const app = {
}
},
navigation: {
settings: '设置',
label: '主导航',
chat: '对话',
magicNotes: '魔法笔记',
@@ -129,11 +146,14 @@ export const app = {
customTask: {
eyebrow: '定制任务',
title: '新建定制任务',
description: '按计划向普通会话发送任务要求,回复和手动发送的消息一样保留在会话中。',
description: '立即或按计划向会话发送任务内容,回复保留在关联会话中。',
nameHelp: '可选,留空时从任务内容生成名称。',
timing: { now: '立即执行', scheduled: '定时执行' },
close: '关闭新建定制任务',
fields: {
name: '任务名称',
instructions: '任务要求',
instructions: '任务内容',
timing: '执行时间',
destination: '关联会话',
mode: '执行模式',
recurrence: '运行频率',
@@ -142,9 +162,9 @@ export const app = {
destination: {
current: '当前会话',
new: '新建会话',
currentHelp: '发送时使用当前会话的历史、Runtime、工作模式和已启用能力。',
currentHelp: '发送时使用所选会话的历史、Runtime、工作模式和已启用能力。',
newHelp: '创建独立的普通会话,使用项目默认设置,以任务名称作为标题。',
currentUnavailable: '当前会话不能关联此任务,请选择新建会话。'
currentUnavailable: '当前会话不能关联此任务,请选择本项目其他会话或新建会话。'
},
mode: {
execute: 'Execute',
@@ -170,7 +190,7 @@ export const app = {
},
errors: {
title: '请输入任务名称。',
instructions: '请输入任务要求。',
instructions: '请输入任务内容。',
destination: '请选择可用的关联会话。',
time: '请选择有效的首次运行时间。',
futureTime: '首次运行时间必须晚于当前时间。',
@@ -528,8 +528,7 @@ export const settingsSections = {
label: '平台功能选项',
tabs: {
ariaLabel: '平台功能设置',
general: '通用设置',
magicNotes: '魔法笔记'
general: '通用设置'
},
conversationHtml: {
title: '会话 HTML 渲染',
@@ -634,7 +633,7 @@ export const settingsSections = {
},
magicNotes: {
title: '魔法笔记',
description: '默认关闭;开启后可记录笔记与待办,并使用 AI 分析内容',
description: '记录笔记与待办,使用 AI 分析内容;相关配置统一在应用设置中管理。',
showEntry: '显示魔法笔记入口',
showIncompleteTodoCount: '显示未完成待办数量',
showIncompleteTodoCountHelp:
@@ -314,7 +314,20 @@ export const workspace = {
noApprovals: '当前没有等待审批的操作。',
deny: '拒绝',
allowOnce: '仅此次允许',
taskIndexTitle: '任务索引',
taskIndexTitle: '项目任务',
stats: {
label: '当前会话统计',
currentConversation: '当前会话',
noConversation: '尚未选择会话',
replyDuration: 'Agent 累计回复时长',
messages: '消息数',
unavailable: '暂无统计',
incomplete: '部分回复缺少计时记录,时长仅包含已知记录。',
partial: '部分记录',
timeHelp: '时间说明',
timeDescription: '累计每次 Agent 回复的实际用时,不包含回复之间的空闲时间。缺少计时记录的回复不按会话或任务的起止时间估算。',
taskDuration: '任务累计用时'
},
empty: '明确创建的任务会显示在这里。',
noFilterResults: '当前筛选条件下没有任务。',
conversationUnavailable: '关联会话不可用',
@@ -327,9 +340,12 @@ export const workspace = {
ariaLabel: '任务范围',
currentProject: '当前项目',
allProjects: '所有项目',
noCurrentProject: '没有活动项目。请选择其他任务范围或先打开一个项目。'
noCurrentProject: '没有活动项目。请先打开一个项目以查看和创建任务。'
},
filters: {
all: '全部',
summary: '筛选:{{filter}} · {{count}} 项',
clear: '清除筛选',
ariaLabel: '筛选任务',
active: '进行中',
paused: '暂停',
+38
View File
@@ -0,0 +1,38 @@
.local-inference-content { display: flex; flex-direction: column; font-size: var(--font-body); line-height: 1.6; }
.local-inference-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: var(--space-3) var(--space-4); padding-block: var(--space-4); border-bottom: 1px solid var(--border-subtle); }
.local-inference-row:last-child { border-bottom: 0; }
.local-inference-row > div:first-child { min-width: 0; overflow-wrap: anywhere; }
.local-inference-row h2 { margin: 0; font-size: var(--font-section-title); }
.local-inference-row__heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1) var(--space-3); }
.local-inference-state { flex: 0 0 auto; font-size: var(--font-caption); color: var(--text-secondary); }
.local-inference-row p { margin: var(--space-1) 0 0; color: var(--text-secondary); }
.local-inference-row .local-inference-metadata { font-size: var(--font-caption); }
.local-inference-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
.local-inference-actions:empty { display: none; }
.local-inference-row > .local-inference-actions { justify-content: flex-end; }
.local-inference-row button { white-space: nowrap; }
.local-inference-note { color: var(--text-secondary); margin: 0; font-size: var(--font-body); line-height: 1.6; }
.local-inference-feedback { padding: var(--space-4); margin-block: var(--space-4); border: 1px solid var(--danger-border); border-radius: var(--radius-control); }
.local-inference-row .local-inference-error, .local-inference-feedback { color: var(--danger); }
.custom-task-dialog__surface.local-inference-modal { width: min(960px, 100%); height: auto; max-height: min(720px, calc(100dvh - 2 * var(--space-6))); grid-template-rows: auto minmax(0, 1fr); }
.local-inference-modal .custom-task-dialog__header { align-items: center; padding: var(--space-3) var(--space-4); }
.local-inference-modal .custom-task-dialog__header .page-header { width: 100%; margin: 0; padding: 0; border: 0; flex-direction: row; align-items: center; }
.local-inference-modal .custom-task-dialog__header .page-header .page-header__actions { width: auto; flex-shrink: 0; justify-content: flex-end; }
.local-inference-modal .custom-task-dialog__header h1 { font-size: var(--font-section-title); }
.local-inference-modal .custom-task-dialog__header .icon-button { min-width: 40px; min-height: 40px; }
.local-inference-modal__body { min-height: 0; overflow: auto; padding: var(--space-4); }
.local-inference-toolbar { display: flex; flex: 0 0 auto; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-2) var(--space-4); }
.local-inference-workspace[hidden] { display: none; }
.custom-task-dialog__surface.local-inference-confirm { width: min(480px, 100%); grid-template-rows: minmax(0, 1fr) auto; }
.local-inference-confirm .custom-task-dialog__content { padding: var(--space-4); gap: var(--space-3); font-size: var(--font-body); line-height: 1.6; }
.local-inference-confirm h2 { font-size: var(--font-section-title); }
.local-inference-confirm h2, .local-inference-confirm p, .local-inference-confirm ul { margin: 0; }
.local-inference-confirm ul { padding-left: var(--space-4); overflow-wrap: anywhere; }
.local-inference-confirm .custom-task-dialog__actions { padding: var(--space-3) var(--space-4); gap: var(--space-2); justify-content: flex-end; flex-wrap: wrap; }
.local-inference-confirm h2 { overflow-wrap: anywhere; }
@media (max-width: 720px) {
.local-inference-row { grid-template-columns: minmax(0, 1fr); }
.local-inference-row > .local-inference-actions { justify-content: flex-start; }
.local-inference-row > button { justify-self: start; }
.local-inference-toolbar > .local-inference-actions { width: 100%; }
}
+1
View File
@@ -3739,6 +3739,7 @@ textarea:focus-visible {
* rather than adding a per-dialog copy.
*/
:is(
.custom-task-dialog,
.project-create-backdrop,
.remote-directory-picker-backdrop,
.html-preview-viewer-backdrop,
+112
View File
@@ -0,0 +1,112 @@
.task-center .task-center__scope {
margin: 0;
color: var(--text-secondary);
font-size: var(--font-caption);
overflow-wrap: anywhere;
}
.task-center__stats {
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
}
.task-center__eyebrow,
.task-center__stats dt,
.task-center__stats details,
.task-center__incomplete {
color: var(--text-secondary);
font-size: var(--font-caption);
}
.task-center .task-center__stats h3 {
display: block;
margin: var(--space-2) 0 var(--space-4);
overflow-wrap: anywhere;
}
.task-center__stat-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: var(--space-3);
margin: 0 0 var(--space-3);
}
.task-center__stat-grid dd {
margin: var(--space-1) 0 0;
color: var(--text-primary);
font-size: var(--font-section-title);
font-weight: 600;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.task-center__stats summary { cursor: pointer; }
.task-center__stats details p { margin-bottom: 0; }
.task-center .task-center__index-heading {
display: flex;
gap: var(--space-2);
flex: 0 0 auto;
}
.task-center .task-center__index-heading h3 {
flex: 1;
min-width: 0;
flex-wrap: wrap;
}
.task-center__count { color: var(--text-muted); }
.task-center .task-center__create,
.task-center .task-center__filter-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 32px;
height: 32px;
padding: 0;
}
.task-center .task-center__filter-toggle[data-active='true'],
.task-center .task-center__filter-toggle[aria-expanded='true'] {
border-color: var(--accent-selected);
background: var(--accent-subtle);
color: var(--accent);
}
.task-center .task-center__filters { flex: 0 0 auto; }
.task-center .task-center__filters .segmented-control { overflow-x: auto; }
.task-center .task-center__filters .segmented-control__option {
flex: 1 0 auto;
min-width: max-content;
}
.task-center .task-center__filters[hidden] { display: none; }
.task-center__filter-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
color: var(--text-secondary);
font-size: var(--font-caption);
}
.task-center__filter-summary button {
border: 0;
background: transparent;
color: var(--accent);
padding: var(--space-2);
cursor: pointer;
font: inherit;
}
.task-center__filter-summary button:hover { background: var(--accent-subtle); }
.task-center button:focus-visible,
.task-center summary:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@@ -0,0 +1,264 @@
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExecutionStats, ExecutionStatsInput } from '../../shared/assistant-contracts'
import { useExecutionStats } from './use-execution-stats'
function stats(durationMs: number): ExecutionStats {
return {
durationMs, requestCount: 1, incompleteRequestCount: 0,
activeRequestCount: 0, asOf: 1_000, taskDurations: []
}
}
function deferred() {
let resolve!: (value: ExecutionStats) => void
let reject!: (reason: Error) => void
const promise = new Promise<ExecutionStats>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
const empty = { conversation: undefined, project: undefined }
const initialProps = {
conversationId: 'chat-a' as string | undefined,
projectId: 'project-a' as string | undefined,
revision: '1', enabled: true
}
function renderStats() {
return renderHook(({ conversationId, projectId, revision, enabled }) =>
useExecutionStats(conversationId, projectId, revision, enabled), { initialProps })
}
const getExecutionStats = vi.fn<(input: ExecutionStatsInput) => Promise<ExecutionStats>>()
beforeEach(() => {
vi.useFakeTimers()
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
getExecutionStats.mockReset().mockResolvedValue(stats(10))
vi.stubGlobal('goodbuddy', { tasks: { getExecutionStats } })
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('useExecutionStats', () => {
it.each(['project', 'conversation'] as const)(
'clears the previous %s selection and ignores its late polling result', async (scope) => {
const { result, rerender } = renderStats()
await act(async () => {})
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
const oldConversation = deferred()
const oldProject = deferred()
getExecutionStats.mockReturnValueOnce(oldConversation.promise).mockReturnValueOnce(oldProject.promise)
await act(async () => { vi.advanceTimersByTime(5_000) })
const newConversation = deferred()
const newProject = deferred()
getExecutionStats.mockReturnValueOnce(newConversation.promise).mockReturnValueOnce(newProject.promise)
rerender({
...initialProps, conversationId: 'chat-b',
projectId: scope === 'project' ? 'project-b' : 'project-a'
})
expect(result.current).toEqual({
conversation: undefined, project: scope === 'project' ? undefined : stats(10)
})
expect(getExecutionStats.mock.calls.slice(-2)).toEqual([
[{ conversationId: 'chat-b' }],
[{ projectId: scope === 'project' ? 'project-b' : 'project-a' }]
])
await act(async () => {
newConversation.resolve(stats(20))
newProject.resolve(stats(30))
})
await act(async () => {
oldConversation.resolve(stats(90))
oldProject.resolve(stats(99))
})
expect(result.current).toEqual({ conversation: stats(20), project: stats(30) })
}
)
it('does not resurrect cancelled results when switching back to the same IDs', async () => {
const oldConversation = deferred()
const oldProject = deferred()
getExecutionStats.mockReturnValueOnce(oldConversation.promise).mockReturnValueOnce(oldProject.promise)
const { result, rerender } = renderStats()
rerender({ ...initialProps, conversationId: 'chat-b', projectId: 'project-b' })
await act(async () => {})
const currentConversation = deferred()
const currentProject = deferred()
getExecutionStats.mockReturnValueOnce(currentConversation.promise).mockReturnValueOnce(currentProject.promise)
rerender(initialProps)
await act(async () => {
oldConversation.resolve(stats(90))
oldProject.resolve(stats(99))
})
expect(result.current).toEqual(empty)
await act(async () => {
currentConversation.resolve(stats(20))
currentProject.resolve(stats(30))
})
expect(result.current).toEqual({ conversation: stats(20), project: stats(30) })
})
it('does not overlap polling, focus or visibility refreshes while either query is pending', async () => {
const conversation = deferred()
const project = deferred()
getExecutionStats.mockReturnValueOnce(conversation.promise).mockReturnValueOnce(project.promise)
const { result } = renderStats()
await act(async () => {
vi.advanceTimersByTime(15_000)
window.dispatchEvent(new Event('focus'))
document.dispatchEvent(new Event('visibilitychange'))
conversation.resolve(stats(20))
})
await act(async () => {
vi.advanceTimersByTime(5_000)
window.dispatchEvent(new Event('focus'))
})
expect(getExecutionStats).toHaveBeenCalledTimes(2)
expect(result.current).toEqual(empty)
await act(async () => { project.resolve(stats(30)) })
expect(result.current).toEqual({ conversation: stats(20), project: stats(30) })
await act(async () => { vi.advanceTimersByTime(4_999) })
expect(getExecutionStats).toHaveBeenCalledTimes(2)
await act(async () => { vi.advanceTimersByTime(1) })
expect(getExecutionStats).toHaveBeenCalledTimes(4)
})
it('replaces the poller on revision changes and removes timers and listeners on unmount', async () => {
const conversation = deferred()
const project = deferred()
getExecutionStats.mockReturnValueOnce(conversation.promise).mockReturnValueOnce(project.promise)
const { result, rerender, unmount } = renderStats()
await act(async () => { vi.advanceTimersByTime(2_000) })
rerender({ ...initialProps, revision: '2' })
await act(async () => {})
expect(vi.getTimerCount()).toBe(1)
await act(async () => {
conversation.resolve(stats(90))
project.resolve(stats(99))
vi.advanceTimersByTime(3_000)
})
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
expect(getExecutionStats).toHaveBeenCalledTimes(4)
await act(async () => { vi.advanceTimersByTime(2_000) })
expect(getExecutionStats).toHaveBeenCalledTimes(6)
const lastConversation = deferred()
const lastProject = deferred()
getExecutionStats.mockReturnValueOnce(lastConversation.promise).mockReturnValueOnce(lastProject.promise)
await act(async () => { window.dispatchEvent(new Event('focus')) })
unmount()
expect(vi.getTimerCount()).toBe(0)
await act(async () => {
lastConversation.resolve(stats(50))
lastProject.resolve(stats(60))
})
await act(async () => {
vi.advanceTimersByTime(15_000)
window.dispatchEvent(new Event('focus'))
document.dispatchEvent(new Event('visibilitychange'))
})
expect(getExecutionStats).toHaveBeenCalledTimes(8)
})
it.each(['conversation', 'project', 'both'] as const)(
'clears stale statistics when %s queries fail and recovers on the next poll', async (failure) => {
const { result } = renderStats()
await act(async () => {})
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
getExecutionStats.mockImplementation(async (input) => {
if (failure === 'both' ||
(failure === 'conversation' && 'conversationId' in input) ||
(failure === 'project' && 'projectId' in input)) {
throw new Error('stats unavailable')
}
return stats(20)
})
await act(async () => { vi.advanceTimersByTime(5_000) })
expect(result.current).toEqual({
conversation: failure === 'project' ? stats(20) : undefined,
project: failure === 'conversation' ? stats(20) : undefined
})
getExecutionStats.mockResolvedValue(stats(30))
await act(async () => { vi.advanceTimersByTime(5_000) })
expect(result.current).toEqual({ conversation: stats(30), project: stats(30) })
}
)
it('does not query while hidden and resumes when visible', async () => {
const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
const { result } = renderStats()
await act(async () => {
vi.advanceTimersByTime(15_000)
window.dispatchEvent(new Event('focus'))
document.dispatchEvent(new Event('visibilitychange'))
})
expect(getExecutionStats).not.toHaveBeenCalled()
expect(result.current).toEqual(empty)
visibility.mockReturnValue('visible')
await act(async () => { document.dispatchEvent(new Event('visibilitychange')) })
expect(getExecutionStats).toHaveBeenCalledTimes(2)
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
visibility.mockReturnValue('hidden')
await act(async () => {
vi.advanceTimersByTime(10_000)
window.dispatchEvent(new Event('focus'))
})
expect(getExecutionStats).toHaveBeenCalledTimes(2)
visibility.mockReturnValue('visible')
await act(async () => { window.dispatchEvent(new Event('focus')) })
expect(getExecutionStats).toHaveBeenCalledTimes(4)
})
it.each(['disabled', 'no-project'] as const)('stops querying and hides results when %s', async (mode) => {
const { result, rerender } = renderStats()
await act(async () => {})
const conversation = deferred()
const project = deferred()
getExecutionStats.mockReturnValueOnce(conversation.promise).mockReturnValueOnce(project.promise)
await act(async () => { vi.advanceTimersByTime(5_000) })
rerender({
...initialProps, enabled: mode !== 'disabled',
projectId: mode === 'no-project' ? undefined : initialProps.projectId
})
expect(result.current).toEqual(empty)
expect(vi.getTimerCount()).toBe(0)
await act(async () => {
conversation.resolve(stats(90))
project.reject(new Error('late failure'))
})
await act(async () => {
vi.advanceTimersByTime(15_000)
window.dispatchEvent(new Event('focus'))
document.dispatchEvent(new Event('visibilitychange'))
})
expect(getExecutionStats).toHaveBeenCalledTimes(4)
expect(result.current).toEqual(empty)
rerender(initialProps)
await act(async () => {})
expect(getExecutionStats).toHaveBeenCalledTimes(6)
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
})
it('queries only the project when no conversation is selected', async () => {
const { result, rerender } = renderStats()
await act(async () => {})
getExecutionStats.mockClear()
rerender({ ...initialProps, conversationId: undefined })
expect(result.current.conversation).toBeUndefined()
await act(async () => {})
expect(getExecutionStats.mock.calls).toEqual([[{ projectId: 'project-a' }]])
expect(result.current).toEqual({ conversation: undefined, project: stats(10) })
})
})
+52
View File
@@ -0,0 +1,52 @@
import { useEffect, useState } from 'react'
import type { ExecutionStats } from '../../shared/assistant-contracts'
// Keep timing evidence in Main; never extrapolate a stopped or disconnected run.
export function useExecutionStats(
conversationId: string | undefined,
projectId: string | undefined,
revision: string,
enabled: boolean
): { conversation?: ExecutionStats; project?: ExecutionStats } {
const [snapshot, setSnapshot] = useState<{
conversationId?: string; projectId?: string
conversation?: ExecutionStats; project?: ExecutionStats
}>({})
useEffect(() => {
if (!enabled || !projectId) return
let cancelled = false
let pending = false
const refresh = async (): Promise<void> => {
if (pending || document.visibilityState === 'hidden') return
pending = true
const [conversation, project] = await Promise.allSettled([
conversationId
? window.goodbuddy.tasks.getExecutionStats({ conversationId })
: Promise.resolve(undefined),
window.goodbuddy.tasks.getExecutionStats({ projectId })
])
pending = false
if (!cancelled) setSnapshot({
conversationId, projectId,
conversation: conversation.status === 'fulfilled' ? conversation.value : undefined,
project: project.status === 'fulfilled' ? project.value : undefined
})
}
void refresh()
const interval = window.setInterval(() => { void refresh() }, 5_000)
const onFocus = (): void => { void refresh() }
window.addEventListener('focus', onFocus)
document.addEventListener('visibilitychange', onFocus)
return () => {
cancelled = true
window.clearInterval(interval)
window.removeEventListener('focus', onFocus)
document.removeEventListener('visibilitychange', onFocus)
}
}, [conversationId, projectId, revision, enabled])
return {
conversation: enabled && snapshot.projectId === projectId && snapshot.conversationId === conversationId
? snapshot.conversation : undefined,
project: enabled && snapshot.projectId === projectId ? snapshot.project : undefined
}
}
+30 -4
View File
@@ -30,6 +30,22 @@ export type MagicNoteCommentMode = z.infer<typeof magicNoteCommentModeSchema>
export const updateSourceSchema = z.enum(['github', 'mirror'])
export type UpdateSource = z.infer<typeof updateSourceSchema>
export const builtInApplicationIds = ['magic-notes', 'knowledge', 'heartbeat', 'local-inference'] as const
export type BuiltInApplicationId = typeof builtInApplicationIds[number]
export const editableApplicationIds = ['magic-notes', 'local-inference'] as const
export type EditableApplicationId = typeof editableApplicationIds[number]
export const defaultApplicationNavigation = {
order: [...editableApplicationIds],
pinned: { 'magic-notes': true, 'local-inference': true }
}
export const applicationNavigationSchema = z.object({
order: z.array(z.enum(editableApplicationIds)).length(editableApplicationIds.length)
.refine(ids => new Set(ids).size === editableApplicationIds.length, 'Each application must occur exactly once'),
pinned: z.object({
'magic-notes': z.boolean(), 'local-inference': z.boolean()
}).strict()
}).strict()
const applicationPreferencesSchema = z
.object({
checkUpdatesOnStartup: z.boolean(),
@@ -38,10 +54,12 @@ const applicationPreferencesSchema = z
localToolEnvironment: localToolEnvironmentSettingsSchema,
conversationHtmlRenderingEnabled: z.boolean(),
remoteProjectsEnabled: z.boolean(),
magicNotesEnabled: z.boolean(),
magicNotesShowIncompleteTodoCount: z.boolean(),
magicNoteCommentMode: magicNoteCommentModeSchema,
magicNoteCommentFormat: magicNoteCommentFormatSchema
applicationNavigation: applicationNavigationSchema.default(defaultApplicationNavigation),
localInferenceEnabled: z.boolean().default(true),
magicNotesEnabled: z.boolean().default(true),
magicNotesShowIncompleteTodoCount: z.boolean().default(true),
magicNoteCommentMode: magicNoteCommentModeSchema.default('immediate'),
magicNoteCommentFormat: magicNoteCommentFormatSchema.default('combined')
})
.strict()
@@ -53,6 +71,14 @@ export const applicationSettingsSchema = applicationPreferencesSchema
export const applicationSettingsUpdateSchema = applicationPreferencesSchema
.partial()
.extend({
applicationNavigation: applicationNavigationSchema.optional(),
localInferenceEnabled: z.boolean().optional(),
magicNotesEnabled: z.boolean().optional(),
magicNotesShowIncompleteTodoCount: z.boolean().optional(),
magicNoteCommentMode: magicNoteCommentModeSchema.optional(),
magicNoteCommentFormat: magicNoteCommentFormatSchema.optional()
})
.refine((input) => Object.keys(input).length > 0, {
message: 'At least one application setting is required'
})
+10
View File
@@ -4,6 +4,7 @@ import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName,
activityHistorySnapshotSchema,
executionStatsInputSchema,
conversationBranchInputSchema,
conversationMessageSchema,
conversationSubagentActivitySchema,
@@ -37,6 +38,15 @@ const untouchedProject: AssistantProject = {
updatedAt: '2026-08-01T00:00:00.000Z'
}
it('requires exactly one execution statistics scope and rejects renderer timing inputs', () => {
const id = untouchedProject.id
expect(executionStatsInputSchema.parse({ conversationId: id })).toEqual({ conversationId: id })
expect(executionStatsInputSchema.parse({ projectId: id })).toEqual({ projectId: id })
for (const input of [{}, { conversationId: id, projectId: id }, { projectId: 'bad' }, { projectId: id, asOf: 100 }]) {
expect(executionStatsInputSchema.safeParse(input).success).toBe(false)
}
})
describe('agent question contracts', () => {
it('returns recovered questions only in live snapshots, not saved headers', () => {
const id = '00000000-0000-4000-8000-000000000901'
+25 -2
View File
@@ -4,6 +4,24 @@ import { agentRuntimeSelectionSchema } from './runtime-selection-contracts'
import { sshHostIdSchema } from './ssh-host-contracts'
export const assistantIdSchema = z.string().uuid()
export const executionStatsInputSchema = z.union([
z.object({ conversationId: assistantIdSchema }).strict(),
z.object({ projectId: assistantIdSchema }).strict()
])
export type ExecutionStatsInput = z.infer<typeof executionStatsInputSchema>
export interface ExecutionStats {
/** Sum of evidenced top-level reply intervals, including tools and approvals. */
durationMs: number
requestCount: number
/** Requests/replies with missing timing evidence; confirmed live intervals are complete as of asOf. */
incompleteRequestCount: number
/** Requests currently leased by Main; does not authorize renderer extrapolation. */
activeRequestCount: number
/** Epoch milliseconds at query time. */
asOf: number
/** Project card totals; empty for conversation queries. */
taskDurations: Array<{ id: string; durationMs: number; incompleteRequestCount: number }>
}
export const interactiveWorkModes = ['ask', 'execute'] as const
export const workModeSchema = z.enum(interactiveWorkModes)
export const legacyWorkModeSchema = z.enum([
@@ -809,13 +827,18 @@ export const scheduleCreateSchema = z
prompt: z.string().trim().min(1).max(100_000),
workMode: workModeSchema.default('execute'),
recurrence: z.enum(['once', 'daily', 'weekly']),
nextRunAt: z.string().datetime({ offset: true })
nextRunAt: z.string().datetime({ offset: true }),
runImmediately: z.boolean().optional()
})
.strict()
.refine((input) => !input.runImmediately || input.recurrence === 'once', {
message: '立即执行只支持单次任务',
path: ['recurrence']
})
export type ScheduleCreateInput = z.input<typeof scheduleCreateSchema>
export type AssistantSchedule = z.output<typeof scheduleCreateSchema> & {
export type AssistantSchedule = Omit<z.output<typeof scheduleCreateSchema>, 'runImmediately'> & {
id: string
taskId: string
conversationId: string
+5
View File
@@ -39,6 +39,8 @@ import {
type AssistantHeartbeatRun,
type AssistantExpert,
type AssistantTask,
type ExecutionStats,
type ExecutionStatsInput,
type ActivityHistorySnapshot,
type ActivityRecord,
type TokenUsageSummary,
@@ -1744,6 +1746,7 @@ export type KnowledgeSearchReference = {
}
export type DesktopApi = {
localInference: import('./local-inference-contracts').LocalInferenceApi
storageUpgrade: {
getProgress: () => Promise<import('./assistant-storage-contracts').AssistantStorageProgress>
act: (action: 'retry' | 'quit') => Promise<void>
@@ -1907,6 +1910,7 @@ export type DesktopApi = {
}
updates?: {
getSettings: () => Promise<ApplicationSettings>
onSettingsChanged: (listener: (settings: ApplicationSettings) => void) => () => void
updateSettings: (
input: ApplicationSettingsUpdate
) => Promise<ApplicationSettings>
@@ -2107,6 +2111,7 @@ export type DesktopApi = {
}
tasks: {
list: () => Promise<AssistantTask[]>
getExecutionStats: (input: ExecutionStatsInput) => Promise<ExecutionStats>
setStatus: (
taskId: string,
status: Extract<AssistantTask['status'], 'completed' | 'cancelled'>
@@ -15,6 +15,7 @@ import {
goodbuddyConfigTools
} from './goodbuddy-config-tools'
import { defaultLocalToolEnvironmentSettings } from './local-tool-environment-contracts'
import { defaultApplicationNavigation } from './application-settings-contracts'
describe('GoodBuddy configuration contracts', () => {
it('publishes one valid generated example for every operation', () => {
@@ -137,6 +138,8 @@ describe('GoodBuddy configuration contracts', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation,
localInferenceEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
+6
View File
@@ -1,4 +1,8 @@
export const ipcChannels = {
localInferenceGet: 'local-inference:get',
localInferenceAct: 'local-inference:act',
localInferenceCancel: 'local-inference:cancel',
localInferenceOpenSettings: 'local-inference:open-settings',
storageUpgradeProgress: 'app:storage-upgrade:progress',
storageUpgradeAction: 'app:storage-upgrade:action',
externalInstancesList: 'knowledge:external-instances:list',
@@ -111,6 +115,7 @@ export const ipcChannels = {
remoteChannelActivity: 'channels:remote-activity',
applicationSettingsGet: 'settings:application:get',
applicationSettingsUpdate: 'settings:application:update',
applicationSettingsChanged: 'settings:application:changed',
localToolEnvironmentGet: 'settings:local-tools:get',
localToolEnvironmentUpdate: 'settings:local-tools:update',
localToolEnvironmentRefresh: 'settings:local-tools:refresh',
@@ -213,6 +218,7 @@ export const ipcChannels = {
workspaceFileRead: 'workspace:file:read',
workspacePathOpen: 'workspace:path:open',
tasksList: 'tasks:list',
tasksExecutionStats: 'tasks:execution-stats',
tasksSetStatus: 'tasks:set-status',
activityHistoryGet: 'activity-history:get',
activityHistoryReplace: 'activity-history:replace',
+56
View File
@@ -0,0 +1,56 @@
import { z } from 'zod'
export const inferenceServiceIdSchema = z.enum(['asr', 'ocr', 'embedding', 'tts'])
export const inferenceActionSchema = z.object({
serviceId: inferenceServiceIdSchema,
action: z.enum(['start', 'stop', 'restart']),
confirmedTaskIds: z.array(z.string().max(200)).max(256).default([])
}).strict()
export const inferenceCancelSchema = z.object({ taskId: z.string().min(1).max(200) }).strict()
export type InferenceServiceId = z.infer<typeof inferenceServiceIdSchema>
export type InferenceAction = z.infer<typeof inferenceActionSchema>
export type InferenceTask = {
id: string
serviceId: InferenceServiceId
source: string
model?: string
state: 'running' | 'cancelling' | 'completed' | 'cancelled' | 'failed'
startedAt: number
finishedAt?: number
error?: string
cancelUnavailableReason?: string
}
export type InferenceResources =
| { scope: 'unavailable'; reason: string }
| {
scope: 'service-process'
pid: number
sampledAt: number
cpuPercent?: number
cpuUnavailableReason?: string
workingSetBytes?: number
memoryUnavailableReason?: string
}
export type InferenceService = {
id: InferenceServiceId
name: string
engine: string
ownership: 'request-worker' | 'renderer-worker' | 'managed-process' | 'unavailable'
state: 'idle' | 'running' | 'starting' | 'stopping' | 'stopped' | 'error' | 'unavailable' | 'unknown'
model?: string
detail: string
error?: string
actions: InferenceAction['action'][]
resources?: InferenceResources
}
export type LocalInferenceSnapshot = {
services: InferenceService[]
tasks: InferenceTask[]
externalConnections?: { id: string; name: string; model: string }[]
}
export type LocalInferenceApi = {
openSettings: () => Promise<void>
getSnapshot: () => Promise<LocalInferenceSnapshot>
act: (input: InferenceAction) => Promise<void>
cancel: (taskId: string) => Promise<void>
}