feat: improve streaming and startup responsiveness
This commit is contained in:
@@ -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