feat: improve streaming and startup responsiveness
This commit is contained in:
+64
-48
@@ -82,6 +82,7 @@ import {
|
|||||||
type DeepSeekHarnessFork
|
type DeepSeekHarnessFork
|
||||||
} from './agent/deepseek-harness-utility-launcher'
|
} from './agent/deepseek-harness-utility-launcher'
|
||||||
import { buildControlledHarnessEnvironment } from './agent/process-environment'
|
import { buildControlledHarnessEnvironment } from './agent/process-environment'
|
||||||
|
import { runStartupPrerequisites } from './startup-prerequisites'
|
||||||
|
|
||||||
const shortcut = 'CommandOrControl+Shift+Space'
|
const shortcut = 'CommandOrControl+Shift+Space'
|
||||||
const mainModuleDirectory = dirname(fileURLToPath(import.meta.url))
|
const mainModuleDirectory = dirname(fileURLToPath(import.meta.url))
|
||||||
@@ -380,9 +381,11 @@ if (hasSingleInstanceLock) {
|
|||||||
join(app.getPath('userData'), 'runtime-settings.json'),
|
join(app.getPath('userData'), 'runtime-settings.json'),
|
||||||
secureCipher
|
secureCipher
|
||||||
)
|
)
|
||||||
const initialRuntimeSettings =
|
const [initialRuntimeSettings, initialResolvedSettings] =
|
||||||
await settingsStore.getPublicSettings()
|
await Promise.all([
|
||||||
const initialSettings = await settingsStore.getResolvedSettings()
|
settingsStore.getPublicSettings(),
|
||||||
|
settingsStore.getResolvedSettings()
|
||||||
|
])
|
||||||
globalTlsPolicy = new GlobalTlsPolicy(app)
|
globalTlsPolicy = new GlobalTlsPolicy(app)
|
||||||
globalTlsPolicy.install()
|
globalTlsPolicy.install()
|
||||||
const capabilityService = new CapabilityService(
|
const capabilityService = new CapabilityService(
|
||||||
@@ -444,10 +447,6 @@ if (hasSingleInstanceLock) {
|
|||||||
app.getPath('userData'),
|
app.getPath('userData'),
|
||||||
'deepseek-harness'
|
'deepseek-harness'
|
||||||
)
|
)
|
||||||
await mkdir(deepSeekHarnessHome, {
|
|
||||||
recursive: true,
|
|
||||||
mode: 0o700
|
|
||||||
})
|
|
||||||
const launchDeepSeekHarness =
|
const launchDeepSeekHarness =
|
||||||
createDeepSeekHarnessUtilityLauncher({
|
createDeepSeekHarnessUtilityLauncher({
|
||||||
bundledHostPath: bundledRuntimePaths.deepseekHarness,
|
bundledHostPath: bundledRuntimePaths.deepseekHarness,
|
||||||
@@ -458,56 +457,29 @@ if (hasSingleInstanceLock) {
|
|||||||
fork: forkDeepSeekHarness,
|
fork: forkDeepSeekHarness,
|
||||||
terminateProcess: terminateHarnessUtilityProcess
|
terminateProcess: terminateHarnessUtilityProcess
|
||||||
})
|
})
|
||||||
knowledgeService = new KnowledgeService({
|
const startupKnowledgeService = new KnowledgeService({
|
||||||
databasePath: join(app.getPath('userData'), 'knowledge.sqlite'),
|
databasePath: join(app.getPath('userData'), 'knowledge.sqlite'),
|
||||||
managedRoot: join(app.getPath('userData'), 'knowledge'),
|
managedRoot: join(app.getPath('userData'), 'knowledge'),
|
||||||
extractStructured: createModelGraphExtractor(settingsStore),
|
extractStructured: createModelGraphExtractor(settingsStore),
|
||||||
parseDocument: documentParsingService.parse
|
parseDocument: documentParsingService.parse
|
||||||
})
|
})
|
||||||
await knowledgeService.initialize()
|
knowledgeService = startupKnowledgeService
|
||||||
const knowledgeRuntimeSettings =
|
const startupAssistantDatabase = new AssistantDatabase(
|
||||||
await settingsStore.getResolvedSettings()
|
|
||||||
void knowledgeService
|
|
||||||
.setEmbeddingProvider(
|
|
||||||
createEmbeddingProvider(knowledgeRuntimeSettings)
|
|
||||||
)
|
|
||||||
.catch(() => undefined)
|
|
||||||
void knowledgeService
|
|
||||||
.setRerankProvider(
|
|
||||||
createRerankProvider(knowledgeRuntimeSettings)
|
|
||||||
)
|
|
||||||
.catch(() => undefined)
|
|
||||||
assistantDatabase = new AssistantDatabase(
|
|
||||||
join(app.getPath('userData'), 'assistant.sqlite')
|
join(app.getPath('userData'), 'assistant.sqlite')
|
||||||
)
|
)
|
||||||
assistantDatabase.initialize(defaultWorkspace)
|
assistantDatabase = startupAssistantDatabase
|
||||||
assistantDatabase.ensureChannelProjects(
|
|
||||||
defaultWorkspace,
|
|
||||||
initialRuntimeSettings.defaultModelProfileId
|
|
||||||
)
|
|
||||||
channelSettingsStore.reportRuntimeSelectionRepairs(
|
|
||||||
assistantDatabase.repairConversationRuntimeSelections(
|
|
||||||
initialRuntimeSettings
|
|
||||||
)
|
|
||||||
)
|
|
||||||
const goodbuddyConfigService = new GoodBuddyConfigService(
|
const goodbuddyConfigService = new GoodBuddyConfigService(
|
||||||
applicationSettingsStore,
|
applicationSettingsStore,
|
||||||
capabilityService
|
capabilityService
|
||||||
)
|
)
|
||||||
knowledgeGateway = new KnowledgeMcpGateway(knowledgeService, {
|
const startupKnowledgeGateway = new KnowledgeMcpGateway(
|
||||||
magicNotesDatabase: assistantDatabase,
|
startupKnowledgeService,
|
||||||
configService: goodbuddyConfigService
|
{
|
||||||
})
|
magicNotesDatabase: startupAssistantDatabase,
|
||||||
await knowledgeGateway.start()
|
configService: goodbuddyConfigService
|
||||||
const subagentService = new SubagentService(
|
}
|
||||||
createDefaultModelRuntime(defaultWorkspace, initialSettings),
|
|
||||||
assistantDatabase,
|
|
||||||
undefined,
|
|
||||||
createSubagentProfileRuntimes(
|
|
||||||
defaultWorkspace,
|
|
||||||
initialSettings
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
knowledgeGateway = startupKnowledgeGateway
|
||||||
const createRuntimeWithCapabilities = async (
|
const createRuntimeWithCapabilities = async (
|
||||||
settings: ResolvedRuntimeSettings,
|
settings: ResolvedRuntimeSettings,
|
||||||
target: SelectedRuntimeTarget
|
target: SelectedRuntimeTarget
|
||||||
@@ -547,7 +519,7 @@ if (hasSingleInstanceLock) {
|
|||||||
browserCapability?.enabled && browserCapability.supported
|
browserCapability?.enabled && browserCapability.supported
|
||||||
? browserService
|
? browserService
|
||||||
: undefined,
|
: undefined,
|
||||||
knowledgeGateway,
|
knowledgeGateway: startupKnowledgeGateway,
|
||||||
webSearchEnabled: webSearchCapability?.enabled
|
webSearchEnabled: webSearchCapability?.enabled
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -576,9 +548,53 @@ if (hasSingleInstanceLock) {
|
|||||||
resolved.target
|
resolved.target
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
runtime = new AgentRuntimeController(
|
const configuredRuntime = await runStartupPrerequisites({
|
||||||
await createConfiguredRuntime()
|
prepareDeepSeekHome: async () => {
|
||||||
|
await mkdir(deepSeekHarnessHome, {
|
||||||
|
recursive: true,
|
||||||
|
mode: 0o700
|
||||||
|
})
|
||||||
|
},
|
||||||
|
initializeKnowledgeAndGateway: async () => {
|
||||||
|
await startupKnowledgeService.initialize()
|
||||||
|
await Promise.all([
|
||||||
|
startupKnowledgeService.setEmbeddingProvider(
|
||||||
|
createEmbeddingProvider(initialResolvedSettings)
|
||||||
|
).catch(() => undefined),
|
||||||
|
startupKnowledgeService.setRerankProvider(
|
||||||
|
createRerankProvider(initialResolvedSettings)
|
||||||
|
).catch(() => undefined)
|
||||||
|
])
|
||||||
|
await startupKnowledgeGateway.start()
|
||||||
|
},
|
||||||
|
hydrateConfiguredRuntime: () =>
|
||||||
|
createConfiguredRuntime(initialResolvedSettings),
|
||||||
|
initializeAssistant: () => {
|
||||||
|
startupAssistantDatabase.initialize(defaultWorkspace)
|
||||||
|
startupAssistantDatabase.ensureChannelProjects(
|
||||||
|
defaultWorkspace,
|
||||||
|
initialRuntimeSettings.defaultModelProfileId
|
||||||
|
)
|
||||||
|
channelSettingsStore.reportRuntimeSelectionRepairs(
|
||||||
|
startupAssistantDatabase.repairConversationRuntimeSelections(
|
||||||
|
initialRuntimeSettings
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const subagentService = new SubagentService(
|
||||||
|
createDefaultModelRuntime(
|
||||||
|
defaultWorkspace,
|
||||||
|
initialResolvedSettings
|
||||||
|
),
|
||||||
|
startupAssistantDatabase,
|
||||||
|
undefined,
|
||||||
|
createSubagentProfileRuntimes(
|
||||||
|
defaultWorkspace,
|
||||||
|
initialResolvedSettings
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
runtime = new AgentRuntimeController(configuredRuntime)
|
||||||
selectedRuntimeManager = new SelectedRuntimeManager(
|
selectedRuntimeManager = new SelectedRuntimeManager(
|
||||||
createSelectedRuntime
|
createSelectedRuntime
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -120,6 +120,69 @@ describe('KnowledgeDatabase', () => {
|
|||||||
.toHaveLength(1)
|
.toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('repairs a mismatched user version without losing current-schema data', async () => {
|
||||||
|
const { database, path } = await createDatabase()
|
||||||
|
const knowledgeBase = database.createKnowledgeBase({
|
||||||
|
name: 'Mismatch repair',
|
||||||
|
storageMode: 'reference'
|
||||||
|
})
|
||||||
|
seedDocument(database, knowledgeBase.id, 'mismatch-repair')
|
||||||
|
database.close()
|
||||||
|
|
||||||
|
const mismatch = new DatabaseSync(path)
|
||||||
|
mismatch.exec('PRAGMA user_version = 10')
|
||||||
|
mismatch.close()
|
||||||
|
|
||||||
|
const repaired = new KnowledgeDatabase(path)
|
||||||
|
openDatabases.push(repaired)
|
||||||
|
repaired.initialize()
|
||||||
|
|
||||||
|
expect(repaired.getKnowledgeBase(knowledgeBase.id)).toMatchObject({
|
||||||
|
name: 'Mismatch repair'
|
||||||
|
})
|
||||||
|
expect(repaired.listDocuments(knowledgeBase.id)).toHaveLength(1)
|
||||||
|
const inspection = new DatabaseSync(path)
|
||||||
|
expect(inspection.prepare('PRAGMA user_version').get()).toEqual({
|
||||||
|
user_version: 11
|
||||||
|
})
|
||||||
|
inspection.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['migration version', 'INSERT INTO schema_migrations VALUES (12, ?)', true],
|
||||||
|
['user version', 'PRAGMA user_version = 12', false]
|
||||||
|
])('rejects a future %s without downgrading it', async (
|
||||||
|
_label,
|
||||||
|
statement,
|
||||||
|
hasParameter
|
||||||
|
) => {
|
||||||
|
const { database, path } = await createDatabase()
|
||||||
|
database.close()
|
||||||
|
const future = new DatabaseSync(path)
|
||||||
|
if (hasParameter) {
|
||||||
|
future.prepare(statement).run(new Date().toISOString())
|
||||||
|
} else {
|
||||||
|
future.exec(statement)
|
||||||
|
}
|
||||||
|
future.close()
|
||||||
|
|
||||||
|
const unsupported = new KnowledgeDatabase(path)
|
||||||
|
expect(() => unsupported.initialize()).toThrow(
|
||||||
|
'newer than supported version 11'
|
||||||
|
)
|
||||||
|
|
||||||
|
const inspection = new DatabaseSync(path)
|
||||||
|
expect(inspection.prepare('PRAGMA user_version').get()).toEqual({
|
||||||
|
user_version: hasParameter ? 11 : 12
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
inspection
|
||||||
|
.prepare('SELECT MAX(version) AS version FROM schema_migrations')
|
||||||
|
.get()
|
||||||
|
).toEqual({ version: hasParameter ? 12 : 11 })
|
||||||
|
inspection.close()
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps graph generation off unless explicitly enabled', async () => {
|
it('keeps graph generation off unless explicitly enabled', async () => {
|
||||||
const { database } = await createDatabase()
|
const { database } = await createDatabase()
|
||||||
const defaultLibrary = database.createKnowledgeBase({
|
const defaultLibrary = database.createKnowledgeBase({
|
||||||
|
|||||||
@@ -4426,6 +4426,31 @@ export class KnowledgeDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private migrate(database: DatabaseSync): void {
|
private migrate(database: DatabaseSync): void {
|
||||||
|
const migrationTable = database
|
||||||
|
.prepare(
|
||||||
|
`SELECT 1 AS found FROM sqlite_schema
|
||||||
|
WHERE type = 'table' AND name = 'schema_migrations'`
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
if (migrationTable) {
|
||||||
|
const versions = database
|
||||||
|
.prepare(
|
||||||
|
`SELECT
|
||||||
|
(SELECT COALESCE(MAX(version), 0) FROM schema_migrations)
|
||||||
|
AS migration_version,
|
||||||
|
user_version
|
||||||
|
FROM pragma_user_version`
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
if (
|
||||||
|
versions &&
|
||||||
|
asNumber(versions, 'migration_version') === DATABASE_VERSION &&
|
||||||
|
asNumber(versions, 'user_version') === DATABASE_VERSION
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
database.exec('BEGIN IMMEDIATE')
|
database.exec('BEGIN IMMEDIATE')
|
||||||
try {
|
try {
|
||||||
database.exec(`
|
database.exec(`
|
||||||
@@ -4443,6 +4468,17 @@ export class KnowledgeDatabase {
|
|||||||
`Knowledge database version ${currentVersion} is newer than supported version ${DATABASE_VERSION}`
|
`Knowledge database version ${currentVersion} is newer than supported version ${DATABASE_VERSION}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
const userVersionRow = database
|
||||||
|
.prepare('PRAGMA user_version')
|
||||||
|
.get()
|
||||||
|
const userVersion = userVersionRow
|
||||||
|
? asNumber(userVersionRow, 'user_version')
|
||||||
|
: 0
|
||||||
|
if (userVersion > DATABASE_VERSION) {
|
||||||
|
throw new Error(
|
||||||
|
`Knowledge database user version ${userVersion} is newer than supported version ${DATABASE_VERSION}`
|
||||||
|
)
|
||||||
|
}
|
||||||
if (currentVersion < 1) {
|
if (currentVersion < 1) {
|
||||||
this.migrateToVersion1(database)
|
this.migrateToVersion1(database)
|
||||||
database
|
database
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { runStartupPrerequisites } from './startup-prerequisites'
|
||||||
|
|
||||||
|
function deferred<T = void>(): {
|
||||||
|
promise: Promise<T>
|
||||||
|
resolve: (value: T) => void
|
||||||
|
reject: (reason: unknown) => void
|
||||||
|
} {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
let reject!: (reason: unknown) => void
|
||||||
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise
|
||||||
|
reject = rejectPromise
|
||||||
|
})
|
||||||
|
return { promise, resolve, reject }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runStartupPrerequisites', () => {
|
||||||
|
it('starts independent work before synchronous initialization and waits for every branch', async () => {
|
||||||
|
const order: string[] = []
|
||||||
|
const deepSeekHome = deferred()
|
||||||
|
const knowledgeAndGateway = deferred()
|
||||||
|
const configuredRuntime = deferred<{ id: string }>()
|
||||||
|
|
||||||
|
const result = runStartupPrerequisites({
|
||||||
|
prepareDeepSeekHome: () => {
|
||||||
|
order.push('deepseek')
|
||||||
|
return deepSeekHome.promise
|
||||||
|
},
|
||||||
|
initializeKnowledgeAndGateway: () => {
|
||||||
|
order.push('knowledge')
|
||||||
|
return knowledgeAndGateway.promise
|
||||||
|
},
|
||||||
|
hydrateConfiguredRuntime: () => {
|
||||||
|
order.push('runtime')
|
||||||
|
return configuredRuntime.promise
|
||||||
|
},
|
||||||
|
initializeAssistant: () => {
|
||||||
|
order.push('assistant')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const completed = vi.fn()
|
||||||
|
void result.then(completed)
|
||||||
|
|
||||||
|
expect(order).toEqual([
|
||||||
|
'deepseek',
|
||||||
|
'knowledge',
|
||||||
|
'runtime',
|
||||||
|
'assistant'
|
||||||
|
])
|
||||||
|
|
||||||
|
configuredRuntime.resolve({ id: 'configured' })
|
||||||
|
knowledgeAndGateway.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(completed).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
deepSeekHome.resolve()
|
||||||
|
await expect(result).resolves.toEqual({ id: 'configured' })
|
||||||
|
expect(completed).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('settles every started branch before propagating synchronous initialization failure', async () => {
|
||||||
|
const assistantError = new Error('assistant failed')
|
||||||
|
const deepSeekHome = deferred()
|
||||||
|
const knowledgeAndGateway = deferred()
|
||||||
|
const configuredRuntime = deferred<{ id: string }>()
|
||||||
|
|
||||||
|
const result = runStartupPrerequisites({
|
||||||
|
prepareDeepSeekHome: () => deepSeekHome.promise,
|
||||||
|
initializeKnowledgeAndGateway: () =>
|
||||||
|
knowledgeAndGateway.promise,
|
||||||
|
hydrateConfiguredRuntime: () => configuredRuntime.promise,
|
||||||
|
initializeAssistant: () => {
|
||||||
|
throw assistantError
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const rejected = vi.fn()
|
||||||
|
void result.catch(rejected)
|
||||||
|
|
||||||
|
deepSeekHome.resolve()
|
||||||
|
knowledgeAndGateway.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(rejected).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
configuredRuntime.resolve({ id: 'unused' })
|
||||||
|
await expect(result).rejects.toBe(assistantError)
|
||||||
|
expect(rejected).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not publish an async branch failure until the other branches settle', async () => {
|
||||||
|
const runtimeError = new Error('runtime failed')
|
||||||
|
const deepSeekHome = deferred()
|
||||||
|
const knowledgeAndGateway = deferred()
|
||||||
|
|
||||||
|
const result = runStartupPrerequisites({
|
||||||
|
prepareDeepSeekHome: () => deepSeekHome.promise,
|
||||||
|
initializeKnowledgeAndGateway: () =>
|
||||||
|
knowledgeAndGateway.promise,
|
||||||
|
hydrateConfiguredRuntime: () =>
|
||||||
|
Promise.reject(runtimeError),
|
||||||
|
initializeAssistant: () => undefined
|
||||||
|
})
|
||||||
|
const rejected = vi.fn()
|
||||||
|
void result.catch(rejected)
|
||||||
|
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(rejected).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
deepSeekHome.resolve()
|
||||||
|
knowledgeAndGateway.resolve()
|
||||||
|
await expect(result).rejects.toBe(runtimeError)
|
||||||
|
expect(rejected).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export type StartupPrerequisiteDependencies<ConfiguredRuntime> = {
|
||||||
|
prepareDeepSeekHome: () => Promise<void>
|
||||||
|
initializeKnowledgeAndGateway: () => Promise<void>
|
||||||
|
hydrateConfiguredRuntime: () => Promise<ConfiguredRuntime>
|
||||||
|
initializeAssistant: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function startObserved<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
let started: Promise<T>
|
||||||
|
try {
|
||||||
|
started = Promise.resolve(operation())
|
||||||
|
} catch (error) {
|
||||||
|
started = Promise.reject(error)
|
||||||
|
}
|
||||||
|
void started.catch(() => undefined)
|
||||||
|
return started
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runStartupPrerequisites<ConfiguredRuntime>(
|
||||||
|
dependencies: StartupPrerequisiteDependencies<ConfiguredRuntime>
|
||||||
|
): Promise<ConfiguredRuntime> {
|
||||||
|
const deepSeekHomeReady = startObserved(
|
||||||
|
dependencies.prepareDeepSeekHome
|
||||||
|
)
|
||||||
|
const knowledgeAndGatewayReady = startObserved(
|
||||||
|
dependencies.initializeKnowledgeAndGateway
|
||||||
|
)
|
||||||
|
const configuredRuntimeReady = startObserved(
|
||||||
|
dependencies.hydrateConfiguredRuntime
|
||||||
|
)
|
||||||
|
|
||||||
|
let assistantInitializationFailed = false
|
||||||
|
let assistantInitializationError: unknown
|
||||||
|
try {
|
||||||
|
dependencies.initializeAssistant()
|
||||||
|
} catch (error) {
|
||||||
|
assistantInitializationFailed = true
|
||||||
|
assistantInitializationError = error
|
||||||
|
}
|
||||||
|
|
||||||
|
const [deepSeekHome, knowledgeAndGateway, configuredRuntime] =
|
||||||
|
await Promise.allSettled([
|
||||||
|
deepSeekHomeReady,
|
||||||
|
knowledgeAndGatewayReady,
|
||||||
|
configuredRuntimeReady
|
||||||
|
] as const)
|
||||||
|
|
||||||
|
if (assistantInitializationFailed) {
|
||||||
|
throw assistantInitializationError
|
||||||
|
}
|
||||||
|
if (deepSeekHome.status === 'rejected') {
|
||||||
|
throw deepSeekHome.reason
|
||||||
|
}
|
||||||
|
if (knowledgeAndGateway.status === 'rejected') {
|
||||||
|
throw knowledgeAndGateway.reason
|
||||||
|
}
|
||||||
|
if (configuredRuntime.status === 'rejected') {
|
||||||
|
throw configuredRuntime.reason
|
||||||
|
}
|
||||||
|
return configuredRuntime.value
|
||||||
|
}
|
||||||
@@ -692,13 +692,23 @@ function selectComposerOption(
|
|||||||
fireEvent.click(option)
|
fireEvent.click(option)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deferred<T>(): {
|
||||||
|
promise: Promise<T>
|
||||||
|
resolve: (value: T) => void
|
||||||
|
} {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
const promise = new Promise<T>((resolvePromise) => {
|
||||||
|
resolve = resolvePromise
|
||||||
|
})
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
delete document.documentElement.dataset.theme
|
delete document.documentElement.dataset.theme
|
||||||
document.documentElement.style.colorScheme = ''
|
document.documentElement.style.colorScheme = ''
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
api.channels = undefined
|
|
||||||
vi.mocked(api.conversations.list).mockReset().mockResolvedValue([])
|
vi.mocked(api.conversations.list).mockReset().mockResolvedValue([])
|
||||||
vi.mocked(api.conversations.replace)
|
vi.mocked(api.conversations.replace)
|
||||||
.mockReset()
|
.mockReset()
|
||||||
@@ -712,6 +722,7 @@ describe('App', () => {
|
|||||||
vi.mocked(api.conversations.onChanged)
|
vi.mocked(api.conversations.onChanged)
|
||||||
.mockReset()
|
.mockReset()
|
||||||
.mockReturnValue(() => undefined)
|
.mockReturnValue(() => undefined)
|
||||||
|
api.channels = undefined
|
||||||
newConversationListener = undefined
|
newConversationListener = undefined
|
||||||
beforeQuitListener = undefined
|
beforeQuitListener = undefined
|
||||||
browserListener = undefined
|
browserListener = undefined
|
||||||
@@ -744,6 +755,11 @@ describe('App', () => {
|
|||||||
supportsToolExecution: true,
|
supportsToolExecution: true,
|
||||||
detail: 'Ready'
|
detail: 'Ready'
|
||||||
})
|
})
|
||||||
|
vi.stubGlobal(
|
||||||
|
'requestIdleCallback',
|
||||||
|
vi.fn(() => 1)
|
||||||
|
)
|
||||||
|
vi.stubGlobal('cancelIdleCallback', vi.fn())
|
||||||
Object.defineProperty(window, 'goodbuddy', {
|
Object.defineProperty(window, 'goodbuddy', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: api
|
value: api
|
||||||
@@ -752,6 +768,7 @@ describe('App', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -823,6 +840,39 @@ describe('App', () => {
|
|||||||
expect(screen.getByText('GOODBUDDY 工作台')).toBeInTheDocument()
|
expect(screen.getByText('GOODBUDDY 工作台')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('schedules lazy workspace routes for idle preloading', () => {
|
||||||
|
render(<App />)
|
||||||
|
|
||||||
|
expect(window.requestIdleCallback).toHaveBeenCalledWith(
|
||||||
|
expect.any(Function),
|
||||||
|
{ timeout: 2000 }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('waits for project bootstrap before project-scoped startup loads', async () => {
|
||||||
|
const projects = deferred<(typeof project)[]>()
|
||||||
|
vi.mocked(api.projects.list).mockImplementationOnce(
|
||||||
|
() => projects.promise
|
||||||
|
)
|
||||||
|
|
||||||
|
render(<App />)
|
||||||
|
|
||||||
|
expect(api.projects.list).toHaveBeenCalledOnce()
|
||||||
|
expect(api.memory.list).not.toHaveBeenCalled()
|
||||||
|
expect(api.schedules.list).not.toHaveBeenCalled()
|
||||||
|
expect(api.heartbeats.list).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await act(async () => projects.resolve([project]))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(api.memory.list).toHaveBeenCalledOnce()
|
||||||
|
expect(api.memory.list).toHaveBeenCalledWith(projectId)
|
||||||
|
expect(api.schedules.list).toHaveBeenCalledOnce()
|
||||||
|
expect(api.schedules.list).toHaveBeenCalledWith(projectId)
|
||||||
|
expect(api.heartbeats.list).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('shows an accessible fallback while a lazy route loads', async () => {
|
it('shows an accessible fallback while a lazy route loads', async () => {
|
||||||
lazyRouteMocks.suspendKnowledgeRoute()
|
lazyRouteMocks.suspendKnowledgeRoute()
|
||||||
try {
|
try {
|
||||||
|
|||||||
+189
-836
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
|||||||
|
import { cleanup, render } from '@testing-library/react'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
ChatTimeline,
|
||||||
|
type Message
|
||||||
|
} from './ChatTimeline'
|
||||||
|
|
||||||
|
const markdownRenderProbe = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.mock('./MarkdownRenderer', () => ({
|
||||||
|
MarkdownRenderer: ({ children }: { children: string }) => {
|
||||||
|
markdownRenderProbe(children)
|
||||||
|
return <span>{children}</span>
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const callbacks = {
|
||||||
|
onArticleRef: vi.fn(),
|
||||||
|
onDownloadImage: vi.fn(),
|
||||||
|
onOpenCitationContext: vi.fn(async () => undefined),
|
||||||
|
onOpenCitationSource: vi.fn(async () => undefined),
|
||||||
|
onOpenImage: vi.fn(),
|
||||||
|
onRespondApproval: vi.fn(async () => undefined),
|
||||||
|
onRespondQuestion: vi.fn(async () => undefined),
|
||||||
|
onRetry: vi.fn(),
|
||||||
|
onRevealEarlier: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMessages(): Message[] {
|
||||||
|
return Array.from({ length: 80 }, (_, index) => ({
|
||||||
|
id: `message-${index}`,
|
||||||
|
role: 'assistant',
|
||||||
|
content: `content-${index}`,
|
||||||
|
reasoning: index === 0 ? 'preserved reasoning' : undefined,
|
||||||
|
createdAt: 1_775_000_000_000 + index,
|
||||||
|
state: index === 79 ? 'streaming' : 'complete'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChatTimeline', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders only the immutably changed streaming row and retains unchanged DOM state', () => {
|
||||||
|
const messages = createMessages()
|
||||||
|
const props = {
|
||||||
|
artifactById: new Map(),
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
hiddenMessageCount: 0,
|
||||||
|
isUnusedConversation: false,
|
||||||
|
locale: 'en-US' as const,
|
||||||
|
messageStartIndex: 0,
|
||||||
|
...callbacks,
|
||||||
|
retryContent: '',
|
||||||
|
totalMessageCount: messages.length
|
||||||
|
}
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<ChatTimeline {...props} messages={messages} />
|
||||||
|
)
|
||||||
|
const unchangedArticle = container.querySelectorAll('article')[0]
|
||||||
|
const unchangedDetails =
|
||||||
|
unchangedArticle?.querySelector<HTMLDetailsElement>(
|
||||||
|
'.message-reasoning'
|
||||||
|
)
|
||||||
|
expect(unchangedArticle).toBeTruthy()
|
||||||
|
expect(unchangedDetails).toBeTruthy()
|
||||||
|
unchangedDetails!.open = true
|
||||||
|
markdownRenderProbe.mockClear()
|
||||||
|
|
||||||
|
const streamedMessages = messages.map((message, index) =>
|
||||||
|
index === messages.length - 1
|
||||||
|
? { ...message, content: `${message.content} delta` }
|
||||||
|
: message
|
||||||
|
)
|
||||||
|
rerender(<ChatTimeline {...props} messages={streamedMessages} />)
|
||||||
|
|
||||||
|
expect(markdownRenderProbe).toHaveBeenCalledTimes(1)
|
||||||
|
expect(markdownRenderProbe).toHaveBeenCalledWith(
|
||||||
|
'content-79 delta'
|
||||||
|
)
|
||||||
|
expect(container.querySelectorAll('article')[0]).toBe(
|
||||||
|
unchangedArticle
|
||||||
|
)
|
||||||
|
expect(unchangedDetails).toHaveAttribute('open')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,861 @@
|
|||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
Library,
|
||||||
|
ShieldCheck,
|
||||||
|
TerminalSquare,
|
||||||
|
UserRound
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { memo, useEffect, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type {
|
||||||
|
ApprovalDecision,
|
||||||
|
AgentEvent,
|
||||||
|
AgentQuestionAnswer,
|
||||||
|
KnowledgeSearchReference
|
||||||
|
} from '../../shared/contracts'
|
||||||
|
import type {
|
||||||
|
AssistantArtifact,
|
||||||
|
ConversationAttachment,
|
||||||
|
ConversationMessageBlock,
|
||||||
|
ConversationToolActivity
|
||||||
|
} from '../../shared/assistant-contracts'
|
||||||
|
import { AgentQuestionCard } from './AgentQuestionCard'
|
||||||
|
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||||
|
import { formatTime, type TimeFormatLocale } from './time-format'
|
||||||
|
|
||||||
|
export type ToolActivity = ConversationToolActivity
|
||||||
|
|
||||||
|
export type SubagentActivity = {
|
||||||
|
childTaskId: string
|
||||||
|
expertId: string
|
||||||
|
expertName: string
|
||||||
|
routingMode: 'manual' | 'smart'
|
||||||
|
state: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
|
||||||
|
reason?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KnowledgeRetrievalStatus = Omit<
|
||||||
|
Extract<AgentEvent, { type: 'knowledge-retrieval' }>,
|
||||||
|
'requestId' | 'type'
|
||||||
|
>
|
||||||
|
|
||||||
|
export type Message = {
|
||||||
|
id: string
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
reasoning?: string
|
||||||
|
blocks?: ConversationMessageBlock[]
|
||||||
|
createdAt: number
|
||||||
|
state: 'streaming' | 'complete' | 'error'
|
||||||
|
status?: string
|
||||||
|
tools?: ToolActivity[]
|
||||||
|
subagents?: SubagentActivity[]
|
||||||
|
approval?: {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
toolName?: string
|
||||||
|
argumentSummary?: string
|
||||||
|
allowPermanent?: boolean
|
||||||
|
}
|
||||||
|
question?: Extract<AgentEvent, { type: 'question' }>
|
||||||
|
sources?: string[]
|
||||||
|
sourceReferences?: KnowledgeSearchReference[]
|
||||||
|
knowledgeRetrieval?: KnowledgeRetrievalStatus
|
||||||
|
artifactIds?: string[]
|
||||||
|
attachments?: ConversationAttachment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImageViewerItem = {
|
||||||
|
src: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageBlockRenderItem =
|
||||||
|
| {
|
||||||
|
kind: 'block'
|
||||||
|
block: Exclude<ConversationMessageBlock, { type: 'tool' }>
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'tools'
|
||||||
|
id: string
|
||||||
|
tools: ToolActivity[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupMessageBlocks(
|
||||||
|
blocks: ConversationMessageBlock[]
|
||||||
|
): MessageBlockRenderItem[] {
|
||||||
|
const items: MessageBlockRenderItem[] = []
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (block.type !== 'tool') {
|
||||||
|
items.push({ kind: 'block', block })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const previous = items.at(-1)
|
||||||
|
if (previous?.kind === 'tools') {
|
||||||
|
previous.tools.push(block.tool)
|
||||||
|
} else {
|
||||||
|
items.push({
|
||||||
|
kind: 'tools',
|
||||||
|
id: block.id,
|
||||||
|
tools: [block.tool]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAttachmentSize(size: number): string {
|
||||||
|
return `${Math.max(1, Math.ceil(size / 1024))} KB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageReasoning({
|
||||||
|
content,
|
||||||
|
streaming
|
||||||
|
}: {
|
||||||
|
content: string
|
||||||
|
streaming: boolean
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('app')
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!streaming || !contentRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contentRef.current.scrollTo({
|
||||||
|
top: contentRef.current.scrollHeight,
|
||||||
|
behavior: 'auto'
|
||||||
|
})
|
||||||
|
}, [content, streaming])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className="message-reasoning" open={streaming}>
|
||||||
|
<summary>
|
||||||
|
{streaming
|
||||||
|
? t('chat.reasoning.streaming')
|
||||||
|
: t('chat.reasoning.complete')}
|
||||||
|
</summary>
|
||||||
|
<div
|
||||||
|
className="markdown-content message-reasoning__content"
|
||||||
|
ref={contentRef}
|
||||||
|
>
|
||||||
|
<MarkdownRenderer>{content}</MarkdownRenderer>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolExecutionList({
|
||||||
|
tools
|
||||||
|
}: {
|
||||||
|
tools: ToolActivity[]
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('app')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label={t('chat.tools.region', { count: tools.length })}
|
||||||
|
className="tool-execution-list"
|
||||||
|
>
|
||||||
|
<header className="tool-execution-list__header">
|
||||||
|
<TerminalSquare aria-hidden="true" size={15} />
|
||||||
|
<strong>{t('chat.tools.title')}</strong>
|
||||||
|
<small>{t('chat.tools.count', { count: tools.length })}</small>
|
||||||
|
</header>
|
||||||
|
<ol>
|
||||||
|
{tools.map((tool) => {
|
||||||
|
const hasDetails = Boolean(
|
||||||
|
tool.input || tool.output || tool.error
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<li key={tool.callId ?? tool.name}>
|
||||||
|
<details
|
||||||
|
className={`tool-execution tool-execution--${tool.state}`}
|
||||||
|
open={
|
||||||
|
tool.state === 'pending' || tool.state === 'running'
|
||||||
|
? true
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<summary>
|
||||||
|
<span className="tool-execution__identity">
|
||||||
|
<strong>{tool.name}</strong>
|
||||||
|
<span>{tool.summary}</span>
|
||||||
|
</span>
|
||||||
|
<small>{t(`chat.tools.states.${tool.state}`)}</small>
|
||||||
|
</summary>
|
||||||
|
<div className="tool-execution__details">
|
||||||
|
{tool.input && (
|
||||||
|
<section>
|
||||||
|
<strong>{t('chat.tools.input')}</strong>
|
||||||
|
<pre>{tool.input}</pre>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{tool.output && (
|
||||||
|
<section>
|
||||||
|
<strong>{t('chat.tools.output')}</strong>
|
||||||
|
<pre>{tool.output}</pre>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{tool.error && (
|
||||||
|
<section className="tool-execution__error">
|
||||||
|
<strong>{t('chat.tools.error')}</strong>
|
||||||
|
<pre>{tool.error}</pre>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{!hasDetails && <p>{t('chat.tools.noDetails')}</p>}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatMessageRowProps = {
|
||||||
|
artifactById: ReadonlyMap<string, AssistantArtifact>
|
||||||
|
canRetry: boolean
|
||||||
|
conversationId: string
|
||||||
|
greeting: boolean
|
||||||
|
locale: TimeFormatLocale
|
||||||
|
message: Message
|
||||||
|
onArticleRef: (messageId: string, element: HTMLElement | null) => void
|
||||||
|
onDownloadImage: (item: ImageViewerItem) => void
|
||||||
|
onOpenCitationContext: (
|
||||||
|
reference: KnowledgeSearchReference
|
||||||
|
) => Promise<void>
|
||||||
|
onOpenCitationSource: (
|
||||||
|
reference: KnowledgeSearchReference
|
||||||
|
) => Promise<void>
|
||||||
|
onOpenImage: (item: ImageViewerItem, trigger: HTMLElement) => void
|
||||||
|
onRespondApproval: (
|
||||||
|
conversationId: string,
|
||||||
|
messageId: string,
|
||||||
|
approvalId: string,
|
||||||
|
decision: ApprovalDecision
|
||||||
|
) => Promise<void>
|
||||||
|
onRespondQuestion: (
|
||||||
|
conversationId: string,
|
||||||
|
messageId: string,
|
||||||
|
questionId: string,
|
||||||
|
answers?: AgentQuestionAnswer[]
|
||||||
|
) => Promise<void>
|
||||||
|
onRetry: (content: string) => void
|
||||||
|
retryContent?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatMessageRowView({
|
||||||
|
artifactById,
|
||||||
|
canRetry,
|
||||||
|
conversationId,
|
||||||
|
greeting,
|
||||||
|
locale,
|
||||||
|
message,
|
||||||
|
onArticleRef,
|
||||||
|
onDownloadImage,
|
||||||
|
onOpenCitationContext,
|
||||||
|
onOpenCitationSource,
|
||||||
|
onOpenImage,
|
||||||
|
onRespondApproval,
|
||||||
|
onRespondQuestion,
|
||||||
|
onRetry,
|
||||||
|
retryContent
|
||||||
|
}: ChatMessageRowProps): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('app')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`message message--${message.role}`}
|
||||||
|
ref={(element) => onArticleRef(message.id, element)}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<div className="message__avatar">
|
||||||
|
{message.role === 'assistant' ? (
|
||||||
|
<Bot size={18} />
|
||||||
|
) : (
|
||||||
|
<UserRound size={18} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="message__body">
|
||||||
|
<div className="message__meta">
|
||||||
|
<strong>
|
||||||
|
{message.role === 'assistant' ? 'GoodBuddy' : t('chat.user')}
|
||||||
|
</strong>
|
||||||
|
<span>{formatTime(message.createdAt, locale)}</span>
|
||||||
|
</div>
|
||||||
|
{message.attachments && message.attachments.length > 0 && (
|
||||||
|
<div
|
||||||
|
aria-label={t('chat.attachments.region')}
|
||||||
|
className="message-attachments"
|
||||||
|
>
|
||||||
|
{message.attachments.map((attachment) => {
|
||||||
|
const imageSource =
|
||||||
|
attachment.kind === 'image'
|
||||||
|
? attachment.contentUrl ?? attachment.thumbnailUrl
|
||||||
|
: undefined
|
||||||
|
const imageItem = imageSource
|
||||||
|
? {
|
||||||
|
src: imageSource,
|
||||||
|
title: attachment.name
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`message-attachment message-attachment--${attachment.kind}`}
|
||||||
|
key={attachment.id}
|
||||||
|
title={attachment.preview}
|
||||||
|
>
|
||||||
|
{imageItem ? (
|
||||||
|
<button
|
||||||
|
aria-label={t('chat.images.viewNamed', {
|
||||||
|
title: attachment.name
|
||||||
|
})}
|
||||||
|
className="message-image-button"
|
||||||
|
onClick={(event) =>
|
||||||
|
onOpenImage(imageItem, event.currentTarget)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt={attachment.name}
|
||||||
|
loading="lazy"
|
||||||
|
src={imageSource}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="message-attachment__icon"
|
||||||
|
>
|
||||||
|
<FileText size={16} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="message-attachment__details">
|
||||||
|
<strong>{attachment.name}</strong>
|
||||||
|
<small>{formatAttachmentSize(attachment.size)}</small>
|
||||||
|
{imageItem && (
|
||||||
|
<span className="message-image-actions">
|
||||||
|
<button
|
||||||
|
onClick={(event) =>
|
||||||
|
onOpenImage(imageItem, event.currentTarget)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('chat.images.view')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label={t('chat.images.downloadNamed', {
|
||||||
|
title: attachment.name
|
||||||
|
})}
|
||||||
|
onClick={() => onDownloadImage(imageItem)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Download size={12} />
|
||||||
|
{t('chat.images.download')}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{message.blocks && message.blocks.length > 0 ? (
|
||||||
|
<div className="message-blocks">
|
||||||
|
{groupMessageBlocks(message.blocks).map((item) =>
|
||||||
|
item.kind === 'tools' ? (
|
||||||
|
<ToolExecutionList key={item.id} tools={item.tools} />
|
||||||
|
) : item.block.type === 'reasoning' ? (
|
||||||
|
<MessageReasoning
|
||||||
|
content={item.block.content}
|
||||||
|
key={item.block.id}
|
||||||
|
streaming={message.state === 'streaming'}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="markdown-content message__content"
|
||||||
|
key={item.block.id}
|
||||||
|
>
|
||||||
|
<MarkdownRenderer>{item.block.content}</MarkdownRenderer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{message.reasoning && (
|
||||||
|
<MessageReasoning
|
||||||
|
content={message.reasoning}
|
||||||
|
key={`${message.id}-${message.state}`}
|
||||||
|
streaming={message.state === 'streaming'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{message.content && (
|
||||||
|
<div className="markdown-content message__content">
|
||||||
|
<MarkdownRenderer>
|
||||||
|
{greeting ? t('conversation.greeting') : message.content}
|
||||||
|
</MarkdownRenderer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{message.artifactIds?.map((artifactId) => {
|
||||||
|
const candidate = artifactById.get(artifactId)
|
||||||
|
const artifact =
|
||||||
|
candidate?.kind === 'image' &&
|
||||||
|
candidate.content &&
|
||||||
|
/^data:image\/(?:png|jpeg|webp);base64,/u.test(candidate.content)
|
||||||
|
? candidate
|
||||||
|
: undefined
|
||||||
|
return artifact?.content ? (
|
||||||
|
<figure className="message-generated-image" key={artifact.id}>
|
||||||
|
<button
|
||||||
|
aria-label={t('chat.images.viewNamed', {
|
||||||
|
title: artifact.title
|
||||||
|
})}
|
||||||
|
className="message-image-button"
|
||||||
|
onClick={(event) =>
|
||||||
|
onOpenImage(
|
||||||
|
{
|
||||||
|
src: artifact.content!,
|
||||||
|
title: artifact.title
|
||||||
|
},
|
||||||
|
event.currentTarget
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt={artifact.title}
|
||||||
|
loading="lazy"
|
||||||
|
src={artifact.content}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<figcaption>{artifact.title}</figcaption>
|
||||||
|
<div className="message-image-actions">
|
||||||
|
<button
|
||||||
|
onClick={(event) =>
|
||||||
|
onOpenImage(
|
||||||
|
{
|
||||||
|
src: artifact.content!,
|
||||||
|
title: artifact.title
|
||||||
|
},
|
||||||
|
event.currentTarget
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('chat.images.view')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label={t('chat.images.downloadNamed', {
|
||||||
|
title: artifact.title
|
||||||
|
})}
|
||||||
|
onClick={() =>
|
||||||
|
onDownloadImage({
|
||||||
|
src: artifact.content!,
|
||||||
|
title: artifact.title
|
||||||
|
})
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Download size={12} />
|
||||||
|
{t('chat.images.download')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
) : null
|
||||||
|
})}
|
||||||
|
{message.knowledgeRetrieval && (
|
||||||
|
<section
|
||||||
|
aria-live="polite"
|
||||||
|
className={`message-retrieval-status message-retrieval-status--${message.knowledgeRetrieval.state}`}
|
||||||
|
>
|
||||||
|
<Library aria-hidden="true" size={14} />
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
{t(
|
||||||
|
`chat.knowledgeRetrieval.states.${message.knowledgeRetrieval.state}`
|
||||||
|
)}
|
||||||
|
</strong>
|
||||||
|
<small>
|
||||||
|
{t('chat.knowledgeRetrieval.summary', {
|
||||||
|
libraries: message.knowledgeRetrieval.libraryCount,
|
||||||
|
results: message.knowledgeRetrieval.resultCount,
|
||||||
|
duration: message.knowledgeRetrieval.durationMs ?? 0
|
||||||
|
})}
|
||||||
|
</small>
|
||||||
|
{message.knowledgeRetrieval.usedChannels.length > 0 && (
|
||||||
|
<small>
|
||||||
|
{t('chat.knowledgeRetrieval.channels', {
|
||||||
|
channels: message.knowledgeRetrieval.usedChannels
|
||||||
|
.map((channel) =>
|
||||||
|
t(
|
||||||
|
`chat.knowledgeRetrieval.channelNames.${channel}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.join(' + ')
|
||||||
|
})}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
{message.knowledgeRetrieval.warnings.map((warning) => (
|
||||||
|
<p key={warning}>{warning}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{message.sources && message.sources.length > 0 && (
|
||||||
|
<div className="message-sources">
|
||||||
|
<Library size={14} />
|
||||||
|
<span>
|
||||||
|
{t('chat.sources', {
|
||||||
|
sources: [...new Set(message.sources)].join(
|
||||||
|
locale === 'zh-CN' ? '、' : ', '
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{message.sourceReferences &&
|
||||||
|
message.sourceReferences.length > 0 && (
|
||||||
|
<details className="message-citations">
|
||||||
|
<summary>
|
||||||
|
{t('chat.citations.view', {
|
||||||
|
count: message.sourceReferences.length
|
||||||
|
})}
|
||||||
|
</summary>
|
||||||
|
<ol>
|
||||||
|
{message.sourceReferences.map(
|
||||||
|
(reference, referenceIndex) => (
|
||||||
|
<li
|
||||||
|
key={`${reference.documentId}:${reference.chunkId ?? reference.locator ?? referenceIndex}`}
|
||||||
|
>
|
||||||
|
<strong>
|
||||||
|
[{referenceIndex + 1}] {reference.documentName}
|
||||||
|
</strong>
|
||||||
|
{reference.locator && (
|
||||||
|
<small>{reference.locator}</small>
|
||||||
|
)}
|
||||||
|
<p>{reference.snippet}</p>
|
||||||
|
{reference.retrievalChannels && (
|
||||||
|
<small>
|
||||||
|
{t('chat.citations.retrieval')}
|
||||||
|
{reference.retrievalChannels
|
||||||
|
.map((channel) =>
|
||||||
|
channel === 'fts'
|
||||||
|
? t('chat.citations.fullText')
|
||||||
|
: channel === 'cjk'
|
||||||
|
? t('chat.citations.cjk')
|
||||||
|
: channel === 'vector'
|
||||||
|
? t('chat.citations.vector')
|
||||||
|
: t('chat.citations.graph')
|
||||||
|
)
|
||||||
|
.join(' + ')}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
{reference.score !== undefined && (
|
||||||
|
<small>
|
||||||
|
{t('chat.citations.score', {
|
||||||
|
score: reference.score.toFixed(4)
|
||||||
|
})}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
<div className="message-citations__actions">
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() =>
|
||||||
|
void onOpenCitationContext(reference)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('chat.citations.viewContext')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
disabled={!reference.chunkId}
|
||||||
|
onClick={() =>
|
||||||
|
void onOpenCitationSource(reference)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('chat.citations.openSource')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{(!message.blocks || message.blocks.length === 0) &&
|
||||||
|
message.tools &&
|
||||||
|
message.tools.length > 0 && (
|
||||||
|
<ToolExecutionList tools={message.tools} />
|
||||||
|
)}
|
||||||
|
{message.subagents && message.subagents.length > 0 && (
|
||||||
|
<section
|
||||||
|
aria-label={t('chat.subagents.region')}
|
||||||
|
className="subagent-status-list"
|
||||||
|
>
|
||||||
|
{message.subagents.slice(0, 3).map((subagent) => (
|
||||||
|
<article
|
||||||
|
className={`subagent-status-card subagent-status-card--${subagent.state}`}
|
||||||
|
key={subagent.childTaskId}
|
||||||
|
>
|
||||||
|
<Bot aria-hidden="true" size={15} />
|
||||||
|
<div>
|
||||||
|
<strong>{subagent.expertName}</strong>
|
||||||
|
<small>
|
||||||
|
{subagent.routingMode === 'smart'
|
||||||
|
? t('chat.subagents.smart')
|
||||||
|
: t('chat.subagents.manual')}
|
||||||
|
</small>
|
||||||
|
{(subagent.error || subagent.reason) &&
|
||||||
|
(subagent.state === 'failed' ||
|
||||||
|
subagent.state === 'cancelled') && (
|
||||||
|
<p>{subagent.error ?? subagent.reason}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span>
|
||||||
|
{t(`chat.subagents.states.${subagent.state}`)}
|
||||||
|
</span>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{message.approval && (
|
||||||
|
<div className="approval-card">
|
||||||
|
<ShieldCheck size={18} />
|
||||||
|
<div>
|
||||||
|
<strong>{message.approval.title}</strong>
|
||||||
|
<p>{message.approval.description}</p>
|
||||||
|
{message.approval.argumentSummary && (
|
||||||
|
<code>{message.approval.argumentSummary}</code>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="approval-card__deny"
|
||||||
|
onClick={() =>
|
||||||
|
void onRespondApproval(
|
||||||
|
conversationId,
|
||||||
|
message.id,
|
||||||
|
message.approval!.id,
|
||||||
|
'deny'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('chat.approval.deny')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="approval-card__allow"
|
||||||
|
onClick={() =>
|
||||||
|
void onRespondApproval(
|
||||||
|
conversationId,
|
||||||
|
message.id,
|
||||||
|
message.approval!.id,
|
||||||
|
'once'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('chat.approval.once')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="approval-card__allow"
|
||||||
|
onClick={() =>
|
||||||
|
void onRespondApproval(
|
||||||
|
conversationId,
|
||||||
|
message.id,
|
||||||
|
message.approval!.id,
|
||||||
|
'session'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('chat.approval.session')}
|
||||||
|
</button>
|
||||||
|
{message.approval.allowPermanent && (
|
||||||
|
<button
|
||||||
|
className="approval-card__allow"
|
||||||
|
onClick={() =>
|
||||||
|
void onRespondApproval(
|
||||||
|
conversationId,
|
||||||
|
message.id,
|
||||||
|
message.approval!.id,
|
||||||
|
'permanent'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('chat.approval.permanent')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{message.question && (
|
||||||
|
<AgentQuestionCard
|
||||||
|
key={message.question.questionId}
|
||||||
|
onReject={() =>
|
||||||
|
onRespondQuestion(
|
||||||
|
conversationId,
|
||||||
|
message.id,
|
||||||
|
message.question!.questionId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onSubmit={(answers) =>
|
||||||
|
onRespondQuestion(
|
||||||
|
conversationId,
|
||||||
|
message.id,
|
||||||
|
message.question!.questionId,
|
||||||
|
answers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
value={message.question}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{message.status && (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
message.state === 'error'
|
||||||
|
? 'message__status message__status--error'
|
||||||
|
: 'message__status'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={
|
||||||
|
message.state === 'streaming'
|
||||||
|
? 'message__status-dot message__status-dot--active'
|
||||||
|
: 'message__status-dot'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{message.status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{canRetry && (
|
||||||
|
<button
|
||||||
|
className="message-retry"
|
||||||
|
onClick={() => {
|
||||||
|
if (retryContent !== undefined) {
|
||||||
|
onRetry(retryContent)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('chat.retry')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatMessageRow = memo(ChatMessageRowView)
|
||||||
|
|
||||||
|
type ChatTimelineProps = {
|
||||||
|
artifactById: ReadonlyMap<string, AssistantArtifact>
|
||||||
|
conversationId: string
|
||||||
|
hiddenMessageCount: number
|
||||||
|
isUnusedConversation: boolean
|
||||||
|
locale: TimeFormatLocale
|
||||||
|
messageStartIndex: number
|
||||||
|
messages: Message[]
|
||||||
|
onArticleRef: (messageId: string, element: HTMLElement | null) => void
|
||||||
|
onDownloadImage: (item: ImageViewerItem) => void
|
||||||
|
onOpenCitationContext: (
|
||||||
|
reference: KnowledgeSearchReference
|
||||||
|
) => Promise<void>
|
||||||
|
onOpenCitationSource: (
|
||||||
|
reference: KnowledgeSearchReference
|
||||||
|
) => Promise<void>
|
||||||
|
onOpenImage: (item: ImageViewerItem, trigger: HTMLElement) => void
|
||||||
|
onRespondApproval: (
|
||||||
|
conversationId: string,
|
||||||
|
messageId: string,
|
||||||
|
approvalId: string,
|
||||||
|
decision: ApprovalDecision
|
||||||
|
) => Promise<void>
|
||||||
|
onRespondQuestion: (
|
||||||
|
conversationId: string,
|
||||||
|
messageId: string,
|
||||||
|
questionId: string,
|
||||||
|
answers?: AgentQuestionAnswer[]
|
||||||
|
) => Promise<void>
|
||||||
|
onRetry: (content: string) => void
|
||||||
|
onRevealEarlier: () => void
|
||||||
|
retryContent?: string
|
||||||
|
totalMessageCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatTimeline({
|
||||||
|
artifactById,
|
||||||
|
conversationId,
|
||||||
|
hiddenMessageCount,
|
||||||
|
isUnusedConversation,
|
||||||
|
locale,
|
||||||
|
messageStartIndex,
|
||||||
|
messages,
|
||||||
|
onArticleRef,
|
||||||
|
onDownloadImage,
|
||||||
|
onOpenCitationContext,
|
||||||
|
onOpenCitationSource,
|
||||||
|
onOpenImage,
|
||||||
|
onRespondApproval,
|
||||||
|
onRespondQuestion,
|
||||||
|
onRetry,
|
||||||
|
onRevealEarlier,
|
||||||
|
retryContent,
|
||||||
|
totalMessageCount
|
||||||
|
}: ChatTimelineProps): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('app')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="message-list">
|
||||||
|
{hiddenMessageCount > 0 && (
|
||||||
|
<button
|
||||||
|
className="load-earlier-messages"
|
||||||
|
onClick={onRevealEarlier}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('chat.loadEarlierMessages', {
|
||||||
|
count: hiddenMessageCount
|
||||||
|
})}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{messages.map((message, visibleMessageIndex) => {
|
||||||
|
const messageIndex = messageStartIndex + visibleMessageIndex
|
||||||
|
return (
|
||||||
|
<ChatMessageRow
|
||||||
|
artifactById={artifactById}
|
||||||
|
canRetry={
|
||||||
|
message.state === 'error' &&
|
||||||
|
messageIndex === totalMessageCount - 1
|
||||||
|
}
|
||||||
|
conversationId={conversationId}
|
||||||
|
greeting={messageIndex === 0 && isUnusedConversation}
|
||||||
|
key={message.id}
|
||||||
|
locale={locale}
|
||||||
|
message={message}
|
||||||
|
onArticleRef={onArticleRef}
|
||||||
|
onDownloadImage={onDownloadImage}
|
||||||
|
onOpenCitationContext={onOpenCitationContext}
|
||||||
|
onOpenCitationSource={onOpenCitationSource}
|
||||||
|
onOpenImage={onOpenImage}
|
||||||
|
onRespondApproval={onRespondApproval}
|
||||||
|
onRespondQuestion={onRespondQuestion}
|
||||||
|
onRetry={onRetry}
|
||||||
|
retryContent={retryContent}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
preloadRouteModules,
|
||||||
|
scheduleIdleRoutePreload,
|
||||||
|
type RouteModuleLoader
|
||||||
|
} from './idle-route-preload'
|
||||||
|
|
||||||
|
function idleDeadline(): IdleDeadline {
|
||||||
|
return {
|
||||||
|
didTimeout: false,
|
||||||
|
timeRemaining: () => 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('idle route preload scheduling', () => {
|
||||||
|
it('waits for an idle callback and supplies the timeout option', async () => {
|
||||||
|
let idleCallback: IdleRequestCallback | undefined
|
||||||
|
const requestIdleCallback = vi.fn(
|
||||||
|
(callback: IdleRequestCallback): number => {
|
||||||
|
idleCallback = callback
|
||||||
|
return 17
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const cancelIdleCallback = vi.fn()
|
||||||
|
const loader = vi.fn(() => Promise.resolve())
|
||||||
|
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
|
||||||
|
vi.stubGlobal('cancelIdleCallback', cancelIdleCallback)
|
||||||
|
|
||||||
|
const cleanup = scheduleIdleRoutePreload([loader])
|
||||||
|
|
||||||
|
expect(loader).not.toHaveBeenCalled()
|
||||||
|
expect(requestIdleCallback).toHaveBeenCalledWith(expect.any(Function), {
|
||||||
|
timeout: 2000
|
||||||
|
})
|
||||||
|
|
||||||
|
idleCallback?.(idleDeadline())
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
expect(loader).toHaveBeenCalledOnce()
|
||||||
|
cleanup()
|
||||||
|
expect(cancelIdleCallback).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels pending idle work and ignores a stale callback', async () => {
|
||||||
|
let idleCallback: IdleRequestCallback | undefined
|
||||||
|
const requestIdleCallback = vi.fn(
|
||||||
|
(callback: IdleRequestCallback): number => {
|
||||||
|
idleCallback = callback
|
||||||
|
return 29
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const cancelIdleCallback = vi.fn()
|
||||||
|
const loader = vi.fn()
|
||||||
|
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
|
||||||
|
vi.stubGlobal('cancelIdleCallback', cancelIdleCallback)
|
||||||
|
|
||||||
|
const cleanup = scheduleIdleRoutePreload([loader])
|
||||||
|
cleanup()
|
||||||
|
idleCallback?.(idleDeadline())
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
expect(cancelIdleCallback).toHaveBeenCalledWith(29)
|
||||||
|
expect(loader).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reschedules preloading while latency-sensitive work is active', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const idleCallbacks: IdleRequestCallback[] = []
|
||||||
|
const requestIdleCallback = vi.fn(
|
||||||
|
(callback: IdleRequestCallback): number => {
|
||||||
|
idleCallbacks.push(callback)
|
||||||
|
return idleCallbacks.length
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const loader = vi.fn()
|
||||||
|
let latencySensitiveWorkActive = true
|
||||||
|
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
|
||||||
|
vi.stubGlobal('cancelIdleCallback', vi.fn())
|
||||||
|
|
||||||
|
scheduleIdleRoutePreload(
|
||||||
|
[loader],
|
||||||
|
() => !latencySensitiveWorkActive
|
||||||
|
)
|
||||||
|
idleCallbacks[0]?.(idleDeadline())
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
expect(loader).not.toHaveBeenCalled()
|
||||||
|
expect(requestIdleCallback).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
|
||||||
|
expect(requestIdleCallback).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
latencySensitiveWorkActive = false
|
||||||
|
idleCallbacks[1]?.(idleDeadline())
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
expect(loader).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses a zero-delay timer fallback and cancels pending timer work', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.stubGlobal('requestIdleCallback', undefined)
|
||||||
|
vi.stubGlobal('cancelIdleCallback', undefined)
|
||||||
|
const startedLoader = vi.fn()
|
||||||
|
const cancelledLoader = vi.fn()
|
||||||
|
|
||||||
|
scheduleIdleRoutePreload([startedLoader])
|
||||||
|
const cleanup = scheduleIdleRoutePreload([cancelledLoader])
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
expect(startedLoader).not.toHaveBeenCalled()
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
|
||||||
|
expect(startedLoader).toHaveBeenCalledOnce()
|
||||||
|
expect(cancelledLoader).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('route module preloading', () => {
|
||||||
|
it('settles every resolved, rejected, and synchronously thrown loader', async () => {
|
||||||
|
const error = new Error('load failed')
|
||||||
|
const loaders: RouteModuleLoader[] = [
|
||||||
|
vi.fn(() => 'loaded'),
|
||||||
|
vi.fn(() => Promise.reject(error)),
|
||||||
|
vi.fn(() => {
|
||||||
|
throw error
|
||||||
|
}),
|
||||||
|
vi.fn(() => Promise.resolve('also loaded'))
|
||||||
|
]
|
||||||
|
|
||||||
|
const preload = preloadRouteModules(loaders)
|
||||||
|
|
||||||
|
expect(loaders.every((loader) => vi.mocked(loader).mock.calls.length === 0))
|
||||||
|
.toBe(true)
|
||||||
|
await expect(preload).resolves.toEqual([
|
||||||
|
{ status: 'fulfilled', value: 'loaded' },
|
||||||
|
{ status: 'rejected', reason: error },
|
||||||
|
{ status: 'rejected', reason: error },
|
||||||
|
{ status: 'fulfilled', value: 'also loaded' }
|
||||||
|
])
|
||||||
|
expect(loaders.every((loader) => vi.mocked(loader).mock.calls.length === 1))
|
||||||
|
.toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
export type RouteModuleLoader = () => unknown | PromiseLike<unknown>
|
||||||
|
|
||||||
|
export function preloadRouteModules(
|
||||||
|
loaders: readonly RouteModuleLoader[]
|
||||||
|
): Promise<PromiseSettledResult<unknown>[]> {
|
||||||
|
return Promise.allSettled(
|
||||||
|
loaders.map((loader) => Promise.resolve().then(loader))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleIdleRoutePreload(
|
||||||
|
loaders: readonly RouteModuleLoader[],
|
||||||
|
canStart: () => boolean = () => true
|
||||||
|
): () => void {
|
||||||
|
let active = true
|
||||||
|
let started = false
|
||||||
|
let cancelPending: (() => void) | undefined
|
||||||
|
|
||||||
|
const start = (): void => {
|
||||||
|
if (!active || started) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cancelPending = undefined
|
||||||
|
if (!canStart()) {
|
||||||
|
schedule(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
started = true
|
||||||
|
void preloadRouteModules(loaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = (retry = false): void => {
|
||||||
|
if (!active || started) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (retry) {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
cancelPending = undefined
|
||||||
|
schedule()
|
||||||
|
}, 100)
|
||||||
|
cancelPending = () => window.clearTimeout(timeoutId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.requestIdleCallback === 'function') {
|
||||||
|
const idleCallbackId = window.requestIdleCallback(start, {
|
||||||
|
timeout: 2000
|
||||||
|
})
|
||||||
|
cancelPending = () => window.cancelIdleCallback(idleCallbackId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(start, 0)
|
||||||
|
cancelPending = () => window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (started || !active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
active = false
|
||||||
|
cancelPending?.()
|
||||||
|
cancelPending = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { formatTime } from './time-format'
|
||||||
|
|
||||||
|
describe('formatTime', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reuses one formatter per supported locale', () => {
|
||||||
|
const NativeDateTimeFormat = Intl.DateTimeFormat
|
||||||
|
const formatter = vi
|
||||||
|
.spyOn(Intl, 'DateTimeFormat')
|
||||||
|
.mockImplementation(
|
||||||
|
function DateTimeFormat(locales, options) {
|
||||||
|
return new NativeDateTimeFormat(locales, options)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const firstEnglish = formatTime(1_775_000_000_000, 'en-US')
|
||||||
|
const secondEnglish = formatTime(1_775_000_060_000, 'en-US')
|
||||||
|
const firstChinese = formatTime(1_775_000_000_000, 'zh-CN')
|
||||||
|
const secondChinese = formatTime(1_775_000_060_000, 'zh-CN')
|
||||||
|
|
||||||
|
expect(firstEnglish).not.toBe('')
|
||||||
|
expect(secondEnglish).not.toBe('')
|
||||||
|
expect(firstChinese).not.toBe('')
|
||||||
|
expect(secondChinese).not.toBe('')
|
||||||
|
expect(formatter).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export type TimeFormatLocale = 'en-US' | 'zh-CN'
|
||||||
|
|
||||||
|
const timeFormatters: Partial<
|
||||||
|
Record<TimeFormatLocale, Intl.DateTimeFormat>
|
||||||
|
> = {}
|
||||||
|
|
||||||
|
export function formatTime(
|
||||||
|
timestamp: number,
|
||||||
|
locale: TimeFormatLocale
|
||||||
|
): string {
|
||||||
|
const formatter =
|
||||||
|
timeFormatters[locale] ??
|
||||||
|
(timeFormatters[locale] = new Intl.DateTimeFormat(locale, {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}))
|
||||||
|
return formatter.format(timestamp)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user