feat: improve streaming and startup responsiveness
This commit is contained in:
+64
-48
@@ -82,6 +82,7 @@ import {
|
||||
type DeepSeekHarnessFork
|
||||
} from './agent/deepseek-harness-utility-launcher'
|
||||
import { buildControlledHarnessEnvironment } from './agent/process-environment'
|
||||
import { runStartupPrerequisites } from './startup-prerequisites'
|
||||
|
||||
const shortcut = 'CommandOrControl+Shift+Space'
|
||||
const mainModuleDirectory = dirname(fileURLToPath(import.meta.url))
|
||||
@@ -380,9 +381,11 @@ if (hasSingleInstanceLock) {
|
||||
join(app.getPath('userData'), 'runtime-settings.json'),
|
||||
secureCipher
|
||||
)
|
||||
const initialRuntimeSettings =
|
||||
await settingsStore.getPublicSettings()
|
||||
const initialSettings = await settingsStore.getResolvedSettings()
|
||||
const [initialRuntimeSettings, initialResolvedSettings] =
|
||||
await Promise.all([
|
||||
settingsStore.getPublicSettings(),
|
||||
settingsStore.getResolvedSettings()
|
||||
])
|
||||
globalTlsPolicy = new GlobalTlsPolicy(app)
|
||||
globalTlsPolicy.install()
|
||||
const capabilityService = new CapabilityService(
|
||||
@@ -444,10 +447,6 @@ if (hasSingleInstanceLock) {
|
||||
app.getPath('userData'),
|
||||
'deepseek-harness'
|
||||
)
|
||||
await mkdir(deepSeekHarnessHome, {
|
||||
recursive: true,
|
||||
mode: 0o700
|
||||
})
|
||||
const launchDeepSeekHarness =
|
||||
createDeepSeekHarnessUtilityLauncher({
|
||||
bundledHostPath: bundledRuntimePaths.deepseekHarness,
|
||||
@@ -458,56 +457,29 @@ if (hasSingleInstanceLock) {
|
||||
fork: forkDeepSeekHarness,
|
||||
terminateProcess: terminateHarnessUtilityProcess
|
||||
})
|
||||
knowledgeService = new KnowledgeService({
|
||||
const startupKnowledgeService = new KnowledgeService({
|
||||
databasePath: join(app.getPath('userData'), 'knowledge.sqlite'),
|
||||
managedRoot: join(app.getPath('userData'), 'knowledge'),
|
||||
extractStructured: createModelGraphExtractor(settingsStore),
|
||||
parseDocument: documentParsingService.parse
|
||||
})
|
||||
await knowledgeService.initialize()
|
||||
const knowledgeRuntimeSettings =
|
||||
await settingsStore.getResolvedSettings()
|
||||
void knowledgeService
|
||||
.setEmbeddingProvider(
|
||||
createEmbeddingProvider(knowledgeRuntimeSettings)
|
||||
)
|
||||
.catch(() => undefined)
|
||||
void knowledgeService
|
||||
.setRerankProvider(
|
||||
createRerankProvider(knowledgeRuntimeSettings)
|
||||
)
|
||||
.catch(() => undefined)
|
||||
assistantDatabase = new AssistantDatabase(
|
||||
knowledgeService = startupKnowledgeService
|
||||
const startupAssistantDatabase = new AssistantDatabase(
|
||||
join(app.getPath('userData'), 'assistant.sqlite')
|
||||
)
|
||||
assistantDatabase.initialize(defaultWorkspace)
|
||||
assistantDatabase.ensureChannelProjects(
|
||||
defaultWorkspace,
|
||||
initialRuntimeSettings.defaultModelProfileId
|
||||
)
|
||||
channelSettingsStore.reportRuntimeSelectionRepairs(
|
||||
assistantDatabase.repairConversationRuntimeSelections(
|
||||
initialRuntimeSettings
|
||||
)
|
||||
)
|
||||
assistantDatabase = startupAssistantDatabase
|
||||
const goodbuddyConfigService = new GoodBuddyConfigService(
|
||||
applicationSettingsStore,
|
||||
capabilityService
|
||||
)
|
||||
knowledgeGateway = new KnowledgeMcpGateway(knowledgeService, {
|
||||
magicNotesDatabase: assistantDatabase,
|
||||
configService: goodbuddyConfigService
|
||||
})
|
||||
await knowledgeGateway.start()
|
||||
const subagentService = new SubagentService(
|
||||
createDefaultModelRuntime(defaultWorkspace, initialSettings),
|
||||
assistantDatabase,
|
||||
undefined,
|
||||
createSubagentProfileRuntimes(
|
||||
defaultWorkspace,
|
||||
initialSettings
|
||||
)
|
||||
const startupKnowledgeGateway = new KnowledgeMcpGateway(
|
||||
startupKnowledgeService,
|
||||
{
|
||||
magicNotesDatabase: startupAssistantDatabase,
|
||||
configService: goodbuddyConfigService
|
||||
}
|
||||
)
|
||||
knowledgeGateway = startupKnowledgeGateway
|
||||
const createRuntimeWithCapabilities = async (
|
||||
settings: ResolvedRuntimeSettings,
|
||||
target: SelectedRuntimeTarget
|
||||
@@ -547,7 +519,7 @@ if (hasSingleInstanceLock) {
|
||||
browserCapability?.enabled && browserCapability.supported
|
||||
? browserService
|
||||
: undefined,
|
||||
knowledgeGateway,
|
||||
knowledgeGateway: startupKnowledgeGateway,
|
||||
webSearchEnabled: webSearchCapability?.enabled
|
||||
})
|
||||
}
|
||||
@@ -576,9 +548,53 @@ if (hasSingleInstanceLock) {
|
||||
resolved.target
|
||||
)
|
||||
}
|
||||
runtime = new AgentRuntimeController(
|
||||
await createConfiguredRuntime()
|
||||
const configuredRuntime = await runStartupPrerequisites({
|
||||
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(
|
||||
createSelectedRuntime
|
||||
)
|
||||
|
||||
@@ -120,6 +120,69 @@ describe('KnowledgeDatabase', () => {
|
||||
.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 () => {
|
||||
const { database } = await createDatabase()
|
||||
const defaultLibrary = database.createKnowledgeBase({
|
||||
|
||||
@@ -4426,6 +4426,31 @@ export class KnowledgeDatabase {
|
||||
}
|
||||
|
||||
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')
|
||||
try {
|
||||
database.exec(`
|
||||
@@ -4443,6 +4468,17 @@ export class KnowledgeDatabase {
|
||||
`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) {
|
||||
this.migrateToVersion1(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)
|
||||
}
|
||||
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
delete document.documentElement.dataset.theme
|
||||
document.documentElement.style.colorScheme = ''
|
||||
vi.clearAllMocks()
|
||||
api.channels = undefined
|
||||
vi.mocked(api.conversations.list).mockReset().mockResolvedValue([])
|
||||
vi.mocked(api.conversations.replace)
|
||||
.mockReset()
|
||||
@@ -712,6 +722,7 @@ describe('App', () => {
|
||||
vi.mocked(api.conversations.onChanged)
|
||||
.mockReset()
|
||||
.mockReturnValue(() => undefined)
|
||||
api.channels = undefined
|
||||
newConversationListener = undefined
|
||||
beforeQuitListener = undefined
|
||||
browserListener = undefined
|
||||
@@ -744,6 +755,11 @@ describe('App', () => {
|
||||
supportsToolExecution: true,
|
||||
detail: 'Ready'
|
||||
})
|
||||
vi.stubGlobal(
|
||||
'requestIdleCallback',
|
||||
vi.fn(() => 1)
|
||||
)
|
||||
vi.stubGlobal('cancelIdleCallback', vi.fn())
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: api
|
||||
@@ -752,6 +768,7 @@ describe('App', () => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
@@ -823,6 +840,39 @@ describe('App', () => {
|
||||
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 () => {
|
||||
lazyRouteMocks.suspendKnowledgeRoute()
|
||||
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