feat: improve streaming and startup responsiveness

This commit is contained in:
mesalogo
2026-08-15 18:06:03 +08:00
parent ed8d1791b1
commit f27dd37f42
13 changed files with 1795 additions and 885 deletions
+64 -48
View File
@@ -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({
+36
View File
@@ -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
+114
View File
@@ -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()
})
})
+61
View File
@@ -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
}
+51 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+88
View File
@@ -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')
})
})
+861
View File
@@ -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>
)
}
+151
View File
@@ -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)
})
})
+69
View File
@@ -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
}
}
+30
View File
@@ -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)
})
})
+18
View File
@@ -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)
}