feat: add app management, local inference controls, and task statistics
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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]
|
||||
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
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [] })
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.custom-task-dialog__form {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '暂停',
|
||||
|
||||
@@ -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%; }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) })
|
||||
})
|
||||
})
|
||||
@@ -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,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'
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
Reference in New Issue
Block a user