Compare commits

...
32 changed files with 716 additions and 235 deletions
+1 -1
View File
@@ -1713,7 +1713,7 @@
},
"web/app/components/base/markdown-blocks/code-block.tsx": {
"typescript/no-explicit-any": {
"count": 9
"count": 7
}
},
"web/app/components/base/markdown-blocks/form.tsx": {
@@ -54,7 +54,9 @@ vi.mock('@/app/components/base/chat/chat/context', () => ({
}))
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content }: { content: string }) => <div>{`markdown:${content}`}</div>,
Markdown: ({ content, isAnimating }: { content: string; isAnimating?: boolean }) => (
<div data-animating={String(Boolean(isAnimating))}>{`markdown:${content}`}</div>
),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
@@ -67,10 +69,12 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
vi.mock('../workflow-body', () => ({
default: ({
currentTab,
isResponding,
onSubmitHumanInputForm,
onSwitchTab,
}: {
currentTab: string
isResponding?: boolean
onSubmitHumanInputForm: (
token: string,
data: { inputs: Record<string, string>; action: string },
@@ -78,7 +82,7 @@ vi.mock('../workflow-body', () => ({
onSwitchTab: (tab: string) => Promise<void>
}) => (
<div>
<div>{`workflow-body:${currentTab}`}</div>
<div data-responding={String(Boolean(isResponding))}>{`workflow-body:${currentTab}`}</div>
<button
onClick={() =>
void onSubmitHumanInputForm('token-1', { action: 'submit', inputs: { name: 'dify' } })
@@ -147,6 +151,36 @@ describe('GenerationItem', () => {
)
})
it('should mark live text and workflow results as responding', () => {
const { rerender } = render(
<GenerationItem
appSourceType={AppSourceType.webApp}
content="streaming text"
isError={false}
isResponding
onRetry={vi.fn()}
siteInfo={null}
/>,
)
expect(screen.getByText('markdown:streaming text')).toHaveAttribute('data-animating', 'true')
rerender(
<GenerationItem
appSourceType={AppSourceType.webApp}
content="streaming workflow"
isError={false}
isResponding
isWorkflow
onRetry={vi.fn()}
siteInfo={null}
workflowProcessData={{ resultText: 'streaming workflow' } as any}
/>,
)
expect(screen.getByText('workflow-body:RESULT')).toHaveAttribute('data-responding', 'true')
})
it('should open the prompt log modal with normalized log data', async () => {
mockFetchTextGenerationMessage.mockResolvedValue({
answer: 'assistant answer',
@@ -3,8 +3,8 @@ import { render, screen } from '@testing-library/react'
import ResultTab from '../result-tab'
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content }: { content: string }) => (
<div>
Markdown: ({ content, isAnimating }: { content: string; isAnimating?: boolean }) => (
<div data-animating={String(Boolean(isAnimating))}>
markdown:
{content}
</div>
@@ -35,6 +35,7 @@ describe('ResultTab', () => {
<ResultTab
currentTab="RESULT"
content=""
isResponding
data={
{
resultText: 'Hello world',
@@ -50,6 +51,7 @@ describe('ResultTab', () => {
)
expect(screen.getByText('markdown:Hello world')).toBeInTheDocument()
expect(screen.getByText('markdown:Hello world')).toHaveAttribute('data-animating', 'true')
expect(screen.getByText('attachments')).toBeInTheDocument()
expect(screen.getByText('files:1')).toBeInTheDocument()
})
@@ -25,7 +25,9 @@ vi.mock('@/app/components/base/chat/chat/answer/human-input-filled-form-list', (
}))
vi.mock('../result-tab', () => ({
default: ({ currentTab }: { currentTab: string }) => <div>{`result-tab:${currentTab}`}</div>,
default: ({ currentTab, isResponding }: { currentTab: string; isResponding?: boolean }) => (
<div data-responding={String(Boolean(isResponding))}>{`result-tab:${currentTab}`}</div>
),
}))
describe('WorkflowBody', () => {
@@ -40,6 +42,7 @@ describe('WorkflowBody', () => {
currentTab="RESULT"
depth={2}
isError={false}
isResponding
onSubmitHumanInputForm={mockSubmit}
onSwitchTab={mockSwitchTab}
showResultTabs
@@ -58,6 +61,7 @@ describe('WorkflowBody', () => {
expect(screen.getByText('workflow-process')).toBeInTheDocument()
expect(screen.getByText('task-1-1')).toBeInTheDocument()
expect(screen.getByText('result-tab:RESULT')).toBeInTheDocument()
expect(screen.getByText('result-tab:RESULT')).toHaveAttribute('data-responding', 'true')
fireEvent.click(screen.getByText(/(?:^|\.)detail(?=$|:)/))
@@ -220,6 +220,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
depth={depth}
hideProcessDetail={hideProcessDetail}
isError={isError}
isResponding={isResponding}
onSubmitHumanInputForm={handleSubmitHumanInputForm}
onSwitchTab={switchTab}
showResultTabs={showResultTabs}
@@ -247,7 +248,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
)}
{!workflowProcessData && !isError && typeof content === 'string' && (
<div className={cn('p-4', taskId && 'pt-0')}>
<Markdown content={content} />
<Markdown content={content} isAnimating={Boolean(isResponding)} />
</div>
)}
</div>
@@ -9,16 +9,20 @@ const ResultTab = ({
data,
content,
currentTab,
isResponding,
}: {
data?: WorkflowProcess
content: any
currentTab: string
isResponding?: boolean
}) => {
return (
<>
{currentTab === 'RESULT' && (
<div className="space-y-3 p-4">
{data?.resultText && <Markdown content={data?.resultText || ''} />}
{data?.resultText && (
<Markdown content={data.resultText} isAnimating={Boolean(isResponding)} />
)}
{!!data?.files?.length && (
<div className="flex flex-col gap-2">
{data?.files.map((item: any) => (
@@ -18,6 +18,7 @@ type WorkflowBodyProps = {
depth: number
hideProcessDetail?: boolean
isError: boolean
isResponding?: boolean
onSubmitHumanInputForm: (formToken: string, formData: HumanInputFormSubmitData) => Promise<void>
onSwitchTab: (tab: string) => void
showResultTabs: boolean
@@ -32,6 +33,7 @@ const WorkflowBody: FC<WorkflowBodyProps> = ({
depth,
hideProcessDetail,
isError,
isResponding,
onSubmitHumanInputForm,
onSwitchTab,
showResultTabs,
@@ -114,7 +116,12 @@ const WorkflowBody: FC<WorkflowBodyProps> = ({
/>
</div>
)}
<ResultTab data={workflowProcessData} content={content} currentTab={currentTab} />
<ResultTab
data={workflowProcessData}
content={content}
currentTab={currentTab}
isResponding={isResponding}
/>
</>
)}
</>
@@ -11,6 +11,7 @@ vi.mock('@/app/components/base/markdown', () => ({
<div
data-testid={props['data-testid'] || 'markdown'}
data-content={String(props.content)}
data-animating={String(Boolean(props.isAnimating))}
className={props.className}
>
{String(props.content)}
@@ -78,8 +79,15 @@ describe('AgentContent', () => {
})
it('renders content prop if provided and no annotation', () => {
render(<AgentContent item={mockItem} content="Direct Content" />)
expect(screen.getByTestId('agent-content-markdown')).toHaveTextContent('Direct Content')
const { rerender } = render(
<AgentContent item={mockItem} content="Direct Content" responding />,
)
const markdown = screen.getByTestId('agent-content-markdown')
expect(markdown).toHaveTextContent('Direct Content')
expect(markdown).toHaveAttribute('data-animating', 'true')
rerender(<AgentContent item={mockItem} content="Direct Content" responding={false} />)
expect(screen.getByTestId('agent-content-markdown')).toHaveAttribute('data-animating', 'false')
})
it('renders agent_thoughts if content is absent', () => {
@@ -93,9 +101,23 @@ describe('AgentContent', () => {
const thoughtMarkdowns = screen.getAllByTestId('agent-thought-markdown')
expect(thoughtMarkdowns[0]).toHaveTextContent('Thought 1')
expect(thoughtMarkdowns[1]).toHaveTextContent('Thought 2')
expect(thoughtMarkdowns[0]).toHaveAttribute('data-animating', 'false')
expect(thoughtMarkdowns[1]).toHaveAttribute('data-animating', 'false')
expect(screen.getByTestId('thought-component')).toHaveTextContent('Thought 1')
})
it('only marks the latest thought as streaming', () => {
const itemWithThoughts = {
...mockItem,
agent_thoughts: [{ thought: 'Completed thought' }, { thought: 'Current thought' }],
}
render(<AgentContent item={itemWithThoughts as ChatItem} responding />)
const thoughtMarkdowns = screen.getAllByTestId('agent-thought-markdown')
expect(thoughtMarkdowns[0]).toHaveAttribute('data-animating', 'false')
expect(thoughtMarkdowns[1]).toHaveAttribute('data-animating', 'true')
})
it('passes correct isFinished to Thought component', () => {
const itemWithThoughts = {
...mockItem,
@@ -501,6 +501,56 @@ describe('AgentRosterResponseContent', () => {
expect(screen.getByText('direct answer')).toBeInTheDocument()
})
it('should preserve the live message node while streamed content grows', () => {
const item = {
id: 'answer-growing-message',
content: '',
isAnswer: true,
agent_response_parts: [{ type: 'message', content: 'direct' }],
} satisfies ChatItem
const { rerender } = render(<AgentRosterResponseContent item={item} responding />)
const messageNode = screen.getByTestId('agent-content-markdown')
rerender(
<AgentRosterResponseContent
item={{
...item,
agent_response_parts: [{ type: 'message', content: 'direct answer' }],
}}
responding
/>,
)
expect(screen.getByTestId('agent-content-markdown')).toBe(messageNode)
expect(messageNode).toHaveTextContent('direct answer')
})
it('should mark only the active response markdown as animating', async () => {
const item = {
id: 'answer-streaming-table',
content: '',
isAnswer: true,
agent_response_parts: [
{
type: 'message',
content: '| Result |\n| --- |\n| streaming |',
},
],
} satisfies ChatItem
const { rerender } = render(<AgentRosterResponseContent item={item} responding />)
const copyTableButton = await screen.findByTitle('Copy table')
expect(copyTableButton).toBeDisabled()
rerender(<AgentRosterResponseContent item={item} responding={false} />)
await waitFor(() => {
expect(copyTableButton).toBeEnabled()
})
})
it('should omit the activity disclosure for a completed response without activity', () => {
const item = {
id: 'answer-without-activity',
@@ -5,8 +5,13 @@ import BasicContent from '../basic-content'
// Mock Markdown component used only in tests
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content, className }: MarkdownProps) => (
<div data-testid="basic-content-markdown" data-content={String(content)} className={className}>
Markdown: ({ content, className, isAnimating }: MarkdownProps) => (
<div
data-testid="basic-content-markdown"
data-content={String(content)}
data-animating={String(Boolean(isAnimating))}
className={className}
>
{String(content)}
</div>
),
@@ -25,6 +30,14 @@ describe('BasicContent', () => {
expect(markdown).toHaveAttribute('data-content', 'Hello World')
})
it('enables streaming mode only while the answer is responding', () => {
const { rerender } = render(<BasicContent item={mockItem as ChatItem} responding />)
expect(screen.getByTestId('basic-content-markdown')).toHaveAttribute('data-animating', 'true')
rerender(<BasicContent item={mockItem as ChatItem} responding={false} />)
expect(screen.getByTestId('basic-content-markdown')).toHaveAttribute('data-animating', 'false')
})
it('renders logAnnotation content if present', () => {
const itemWithAnnotation = {
...mockItem,
@@ -37,6 +50,7 @@ describe('BasicContent', () => {
render(<BasicContent item={itemWithAnnotation as ChatItem} />)
const markdown = screen.getByTestId('basic-content-markdown')
expect(markdown).toHaveAttribute('data-content', 'Annotated Content')
expect(markdown).toHaveAttribute('data-animating', 'false')
})
it('renders empty string if logAnnotation content is missing', () => {
@@ -6,8 +6,10 @@ import ReasoningPanel from '../reasoning-panel'
// Mock the heavy Markdown renderer to a simple passthrough.
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content }: { content: string }) => (
<div data-testid="reasoning-markdown">{content}</div>
Markdown: ({ content, isAnimating }: { content: string; isAnimating?: boolean }) => (
<div data-testid="reasoning-markdown" data-animating={String(Boolean(isAnimating))}>
{content}
</div>
),
}))
@@ -30,11 +32,13 @@ describe('ReasoningPanel', () => {
render(<ReasoningPanel content={{ llm: 'let me think' }} done={false} />)
expect(screen.getByText(/chat\.thinking/)).toBeInTheDocument()
expect(screen.getByText('let me think')).toBeInTheDocument()
expect(screen.getByTestId('reasoning-markdown')).toHaveAttribute('data-animating', 'true')
})
it('shows the thought state once done (answer started / terminal / history)', () => {
render(<ReasoningPanel content={{ llm: 'done thinking' }} done />)
expect(screen.getByText(/chat\.thought/)).toBeInTheDocument()
expect(screen.getByTestId('reasoning-markdown')).toHaveAttribute('data-animating', 'false')
})
it('counts elapsed time up while thinking', () => {
@@ -1,4 +1,3 @@
import type { FC } from 'react'
import type { ChatItem } from '../../types'
import { memo } from 'react'
import Thought from '@/app/components/base/chat/chat/thought'
@@ -11,7 +10,7 @@ type AgentContentProps = {
responding?: boolean
content?: string
}
const AgentContent: FC<AgentContentProps> = ({ item, responding, content }) => {
function AgentContent({ item, responding, content }: AgentContentProps) {
const { annotation, agent_thoughts } = item
if (annotation?.logAnnotation) {
@@ -26,12 +25,20 @@ const AgentContent: FC<AgentContentProps> = ({ item, responding, content }) => {
return (
<div data-testid="agent-content-container">
{content ? (
<Markdown content={content} data-testid="agent-content-markdown" />
<Markdown
content={content}
isAnimating={Boolean(responding)}
data-testid="agent-content-markdown"
/>
) : (
agent_thoughts?.map((thought, index) => (
<div key={index} className="px-2 py-1" data-testid="agent-thought-item">
{thought.thought && (
<Markdown content={thought.thought} data-testid="agent-thought-markdown" />
<Markdown
content={thought.thought}
isAnimating={Boolean(responding && index === agent_thoughts.length - 1)}
data-testid="agent-thought-markdown"
/>
)}
{/* {item.tool} */}
{/* perhaps not use tool */}
@@ -32,6 +32,7 @@ type AgentActivityEntry =
type: 'message'
content: string
key: string
isAnimating: boolean
}
| {
type: 'thought'
@@ -100,35 +101,35 @@ function getThoughtKey(thought: ThoughtItem) {
return thought.id || `${thought.message_id}-${thought.position}`
}
function hashString(value: string) {
let hash = 5381
for (let i = 0; i < value.length; i++) hash = ((hash << 5) + hash) ^ value.charCodeAt(i)
return (hash >>> 0).toString(36)
}
function hasVisibleActivity(thought: ThoughtItem) {
return !!thought.tool || !!thought.message_files?.length
}
function getAgentActivityEntries(item: ChatItem): AgentActivityEntry[] {
function getAgentActivityEntries(item: ChatItem, responding?: boolean): AgentActivityEntry[] {
if (item.agent_response_parts?.length) {
const keyOccurrences = new Map<string, number>()
return item.agent_response_parts.flatMap<AgentActivityEntry>((part) => {
const baseKey =
part.type === 'message'
? `message-${part.content.length}-${hashString(part.content)}`
: `thought-${getThoughtKey(part.thought)}`
const occurrence = keyOccurrences.get(baseKey) ?? 0
keyOccurrences.set(baseKey, occurrence + 1)
const key = occurrence ? `${baseKey}-${occurrence}` : baseKey
const lastPartIndex = item.agent_response_parts.length - 1
return item.agent_response_parts.flatMap<AgentActivityEntry>((part, index) => {
if (part.type === 'message')
return part.content ? [{ type: 'message', content: part.content, key }] : []
return part.content
? [
{
type: 'message',
content: part.content,
key: `message-${index}`,
isAnimating: Boolean(responding && index === lastPartIndex),
},
]
: []
return hasVisibleActivity(part.thought)
? [{ type: 'thought', thought: part.thought, key }]
? [
{
type: 'thought',
thought: part.thought,
key: `thought-${getThoughtKey(part.thought)}`,
},
]
: []
})
}
@@ -139,7 +140,13 @@ function getAgentActivityEntries(item: ChatItem): AgentActivityEntry[] {
const parts: AgentActivityEntry[] = []
const key = getThoughtKey(thought)
const answer = thought.answer
if (answer?.trim()) parts.push({ type: 'message', content: answer, key: `message-${key}` })
if (answer?.trim())
parts.push({
type: 'message',
content: answer,
key: `message-${key}`,
isAnimating: false,
})
if (hasVisibleActivity(thought))
parts.push({ type: 'thought', thought, key: `thought-${key}` })
@@ -167,13 +174,13 @@ function useWorkingDuration(enabled?: boolean) {
return Math.max(0, Math.floor((now - startedAtRef.current) / 1000))
}
function ResponseMessage({ content }: { content: string }) {
function ResponseMessage({ content, isAnimating }: { content: string; isAnimating?: boolean }) {
return (
<div
className="max-w-full min-w-0 overflow-hidden px-1 body-md-regular text-text-primary"
data-testid="agent-content-markdown"
>
<Markdown content={content} />
<Markdown content={content} isAnimating={isAnimating} />
</div>
)
}
@@ -326,7 +333,11 @@ function AgentActivityDisclosure({
<div className="flex w-full max-w-full min-w-0 flex-col gap-1 overflow-hidden">
{entries.map((entry) =>
entry.type === 'message' ? (
<ResponseMessage key={entry.key} content={entry.content} />
<ResponseMessage
key={entry.key}
content={entry.content}
isAnimating={entry.isAnimating}
/>
) : (
<AgentActivityItem key={entry.key} thought={entry.thought} responding={responding} />
),
@@ -351,7 +362,7 @@ export function AgentRosterResponseContent({
)
}
const entries = getAgentActivityEntries(item)
const entries = getAgentActivityEntries(item, responding)
const hasLiveResponseParts = !!item.agent_response_parts?.length
const hasThinkingStatus =
entries.length === 0 && !!item.agent_response_parts?.some((part) => part.type === 'thought')
@@ -363,7 +374,14 @@ export function AgentRosterResponseContent({
? []
: entries.flatMap((entry) => (entry.type === 'message' ? [entry] : []))
: content
? [{ type: 'message' as const, content, key: 'final-answer' }]
? [
{
type: 'message' as const,
content,
key: 'final-answer',
isAnimating: Boolean(responding),
},
]
: []
return (
@@ -381,7 +399,11 @@ export function AgentRosterResponseContent({
/>
)}
{standaloneMessages.map((message) => (
<ResponseMessage key={message.key} content={message.content} />
<ResponseMessage
key={message.key}
content={message.content}
isAnimating={message.isAnimating}
/>
))}
</div>
)
@@ -1,4 +1,3 @@
import type { FC } from 'react'
import type { ChatItem } from '../../types'
import { cn } from '@langgenius/dify-ui/cn'
import { memo } from 'react'
@@ -6,8 +5,9 @@ import { Markdown } from '@/app/components/base/markdown'
type BasicContentProps = {
item: ChatItem
responding?: boolean
}
const BasicContent: FC<BasicContentProps> = ({ item }) => {
function BasicContent({ item, responding }: BasicContentProps) {
const { annotation, content } = item
if (annotation?.logAnnotation) {
@@ -30,6 +30,7 @@ const BasicContent: FC<BasicContentProps> = ({ item }) => {
<Markdown
className={cn(item.isError && 'text-[#F04438]!')}
content={displayContent}
isAnimating={Boolean(responding)}
data-testid="basic-content-markdown"
/>
)
@@ -268,7 +268,9 @@ const Answer: FC<AnswerProps> = ({
<LoadingAnim type="text" />
</div>
)}
{!contentIsEmpty && !hasAgentContent && <BasicContent item={item} />}
{!contentIsEmpty && !hasAgentContent && (
<BasicContent item={item} responding={responding} />
)}
{hasAgentContent && agentContentNode}
{!!allFiles?.length && (
<FileList
@@ -356,7 +358,9 @@ const Answer: FC<AnswerProps> = ({
<LoadingAnim type="text" />
</div>
)}
{!contentIsEmpty && !hasAgentContent && <BasicContent item={item} />}
{!contentIsEmpty && !hasAgentContent && (
<BasicContent item={item} responding={responding} />
)}
{hasAgentContent && agentContentNode}
{!!allFiles?.length && (
<FileList
@@ -1,4 +1,3 @@
import type { FC } from 'react'
import { Markdown } from '@/app/components/base/markdown'
import ThinkingDetails from '@/app/components/base/markdown-blocks/thinking-details'
import { useElapsedTimer } from '@/app/components/base/markdown-blocks/use-elapsed-timer'
@@ -11,7 +10,7 @@ type ReasoningPanelProps = {
done: boolean
}
const ReasoningPanel: FC<ReasoningPanelProps> = ({ content, done }) => {
function ReasoningPanel({ content, done }: ReasoningPanelProps) {
// First version renders one panel for the run; multiple LLM nodes are concatenated.
// Computed inline (not memoized): the live stream mutates `content` in place under a
// stable reference, so a [content]-keyed memo would never see new deltas.
@@ -22,7 +21,7 @@ const ReasoningPanel: FC<ReasoningPanelProps> = ({ content, done }) => {
return (
<ThinkingDetails className="my-2" isComplete={isComplete} elapsedTime={elapsedTime}>
<Markdown content={text} />
<Markdown content={text} isAnimating={!done} />
</ThinkingDetails>
)
}
@@ -26,6 +26,14 @@ describe('copy icon component', () => {
).toBeInTheDocument()
})
it('merges a custom button class', () => {
render(<CopyIcon className="size-7" content="copy me" />)
expect(
screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' }),
).toHaveClass('size-7')
})
it('shows copy check icon when copied', () => {
copied = true
render(<CopyIcon content="this is some test content for the copy icon component" />)
+7 -2
View File
@@ -1,4 +1,5 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useClipboard } from 'foxact/use-clipboard'
import { useCallback } from 'react'
@@ -6,11 +7,12 @@ import { useTranslation } from 'react-i18next'
type Props = Readonly<{
content: string
className?: string
}>
const prefixEmbedded = 'overview.appInfo.embedded'
const CopyIcon = ({ content }: Props) => {
const CopyIcon = ({ content, className }: Props) => {
const { t } = useTranslation()
const { copied, copy, reset } = useClipboard()
@@ -31,7 +33,10 @@ const CopyIcon = ({ content }: Props) => {
<button
type="button"
aria-label={safeTooltipText}
className="mx-1 inline-flex size-3.5 cursor-pointer border-0 bg-transparent p-0 text-text-tertiary"
className={cn(
'mx-1 inline-flex size-3.5 cursor-pointer border-0 bg-transparent p-0 text-text-tertiary',
className,
)}
onClick={handleCopy}
onMouseLeave={reset}
>
@@ -4,8 +4,9 @@ import * as echarts from 'echarts'
import { Theme } from '@/types/app'
import CodeBlock from '../code-block'
const { mockHighlightCode } = vi.hoisted(() => ({
const { mockHighlightCode, mockIsCodeFenceIncomplete } = vi.hoisted(() => ({
mockHighlightCode: vi.fn(),
mockIsCodeFenceIncomplete: vi.fn(() => false),
}))
type UseThemeReturn = {
@@ -76,6 +77,10 @@ vi.mock('../shiki-highlight', () => ({
highlightCode: mockHighlightCode,
}))
vi.mock('streamdown', () => ({
useIsCodeFenceIncomplete: mockIsCodeFenceIncomplete,
}))
vi.mock('echarts', () => ({
getInstanceByDom: mockEcharts.getInstanceByDom,
}))
@@ -88,9 +93,11 @@ vi.mock('echarts-for-react', async () => {
{
onChartReady,
onEvents,
option,
}: {
onChartReady?: (instance: typeof mockEcharts.echartsInstance) => void
onEvents?: { finished?: (event?: unknown) => void }
option?: unknown
},
ref: React.ForwardedRef<{ getEchartsInstance: () => typeof mockEcharts.echartsInstance }>,
) => {
@@ -107,7 +114,7 @@ vi.mock('echarts-for-react', async () => {
}
}, [onChartReady, onEvents])
return <div className="echarts-for-react" />
return <div className="echarts-for-react" data-option={JSON.stringify(option)} />
},
)
@@ -142,6 +149,7 @@ const findEchartsInstance = async () => {
describe('CodeBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCodeFenceIncomplete.mockReturnValue(false)
mockUseTheme.mockReturnValue({ theme: Theme.light })
mockHighlightCode.mockImplementation(async ({ code, language }) => (
<pre className="shiki">
@@ -211,10 +219,27 @@ describe('CodeBlock', () => {
expect(container.querySelector('code')?.textContent).toBe('plain text')
})
it('should render an unlabeled fenced code block with block semantics', async () => {
const { container } = render(
<CodeBlock data-block="true" node={{ type: 'element' }}>
plain text
</CodeBlock>,
)
await waitFor(() => {
expect(container.querySelector('pre')).not.toBeNull()
})
expect(container.querySelector('[node]')).toBeNull()
})
it('should render syntax-highlighted output when language is standard', async () => {
render(<CodeBlock className="language-javascript">const x = 1;</CodeBlock>)
const { container } = render(
<CodeBlock className="language-javascript">const x = 1;</CodeBlock>,
)
expect(screen.getByText('JavaScript'))!.toBeInTheDocument()
expect(container.querySelector('button button')).toBeNull()
expect(container.querySelectorAll('button')).toHaveLength(1)
await waitFor(() => {
expect(document.querySelector('code.language-javascript')?.textContent).toContain(
'const x = 1;',
@@ -222,6 +247,71 @@ describe('CodeBlock', () => {
})
})
it('should keep syntax highlighting visible while the code fence is incomplete', async () => {
mockIsCodeFenceIncomplete.mockReturnValue(true)
render(<CodeBlock className="language-javascript">const pending = true;</CodeBlock>)
await waitFor(() => {
expect(document.querySelector('code.language-javascript')?.textContent).toContain(
'const pending = true;',
)
})
})
it('should throttle streaming highlights and catch up to the latest code', async () => {
vi.useFakeTimers()
mockIsCodeFenceIncomplete.mockReturnValue(true)
const { rerender } = render(
<CodeBlock className="language-javascript">const value = 1;</CodeBlock>,
)
await act(async () => {
await Promise.resolve()
})
expect(document.querySelector('code.language-javascript')?.textContent).toContain(
'const value = 1;',
)
rerender(<CodeBlock className="language-javascript">const value = 2;</CodeBlock>)
rerender(<CodeBlock className="language-javascript">const value = 3;</CodeBlock>)
expect(document.querySelector('code.language-javascript')?.textContent).toContain(
'const value = 1;',
)
await act(async () => {
await vi.runOnlyPendingTimersAsync()
})
expect(document.querySelector('code.language-javascript')?.textContent).toContain(
'const value = 3;',
)
})
it('should highlight the final code immediately when the fence closes', async () => {
vi.useFakeTimers()
mockIsCodeFenceIncomplete.mockReturnValue(true)
const { rerender } = render(
<CodeBlock className="language-javascript">const value = 1;</CodeBlock>,
)
await act(async () => {
await Promise.resolve()
})
rerender(<CodeBlock className="language-javascript">const value = 2;</CodeBlock>)
mockIsCodeFenceIncomplete.mockReturnValue(false)
rerender(<CodeBlock className="language-javascript">{'const value = 2;\n'}</CodeBlock>)
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
expect(document.querySelector('code.language-javascript')?.textContent).toContain(
'const value = 2;',
)
})
it('should format unknown language labels with capitalized fallback when language is not in map', () => {
render(<CodeBlock className="language-ruby">puts "ok"</CodeBlock>)
@@ -241,6 +331,15 @@ describe('CodeBlock', () => {
expect(await screen.findByTestId('mock-mermaid'))!.toHaveTextContent('graph TD; A-->B;')
})
it('should defer mermaid rendering while the code fence is incomplete', () => {
mockIsCodeFenceIncomplete.mockReturnValue(true)
render(<CodeBlock className="language-mermaid">{'graph TD; A-->B;'}</CodeBlock>)
expect(screen.getByText('graph TD; A-->B;'))!.toBeInTheDocument()
expect(screen.queryByTestId('mock-mermaid')).not.toBeInTheDocument()
})
it('should render abc section header when language is abc', () => {
render(<CodeBlock className="language-abc">X:1\nT:test</CodeBlock>)
@@ -338,28 +437,34 @@ describe('CodeBlock', () => {
expect(await screen.findByText(/Chart loading.../i))!.toBeInTheDocument()
})
it('should keep chart instance stable when window resize is triggered', async () => {
it('should resize the ready chart when the window resizes', async () => {
render(<CodeBlock className="language-echarts">{'{}'}</CodeBlock>)
await findEchartsHost()
await waitFor(() => {
expect(mockEcharts.echartsInstance.resize).toHaveBeenCalled()
})
mockEcharts.echartsInstance.resize.mockClear()
act(() => {
window.dispatchEvent(new Event('resize'))
})
expect(await findEchartsHost())!.toBeInTheDocument()
await waitFor(() => {
expect(mockEcharts.echartsInstance.resize).toHaveBeenCalled()
})
})
it('should keep rendering when echarts content updates repeatedly', async () => {
it('should update the chart option when complete content changes', async () => {
const { rerender } = render(<CodeBlock className="language-echarts">{'{"a":1}'}</CodeBlock>)
await findEchartsHost()
const host = await findEchartsHost()
expect(host).toHaveAttribute('data-option', '{"a":1}')
rerender(<CodeBlock className="language-echarts">{'{"a":2}'}</CodeBlock>)
rerender(<CodeBlock className="language-echarts">{'{"a":3}'}</CodeBlock>)
rerender(<CodeBlock className="language-echarts">{'{"a":4}'}</CodeBlock>)
rerender(<CodeBlock className="language-echarts">{'{"a":5}'}</CodeBlock>)
expect(await findEchartsHost())!.toBeInTheDocument()
await waitFor(() => {
expect(host).toHaveAttribute('data-option', '{"a":2}')
})
})
it('should stop processing extra finished events when chart finished callback fires repeatedly', async () => {
@@ -1,19 +1,31 @@
import type { EChartsOption } from 'echarts'
import type { JSX } from 'react'
import type { BundledLanguage, BundledTheme } from 'shiki/bundle/web'
import ReactEcharts from 'echarts-for-react'
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import ActionButton from '@/app/components/base/action-button'
import { useThrottle } from 'ahooks'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useIsCodeFenceIncomplete } from 'streamdown'
import CopyIcon from '@/app/components/base/copy-icon'
import MarkdownMusic from '@/app/components/base/markdown-blocks/music'
import ErrorBoundary from '@/app/components/base/markdown/error-boundary'
import SVGBtn from '@/app/components/base/svg'
import useTheme from '@/hooks/use-theme'
import dynamic from '@/next/dynamic'
import { Theme } from '@/types/app'
import SVGRenderer from '../svg-gallery' // Assumes svg-gallery.tsx is in /base directory
import { highlightCode } from './shiki-highlight'
const Flowchart = dynamic(() => import('@/app/components/base/mermaid'), { ssr: false })
const ReactEcharts = dynamic(() => import('echarts-for-react'), { ssr: false })
const MarkdownMusic = dynamic(() => import('@/app/components/base/markdown-blocks/music'), {
ssr: false,
})
const SVGRenderer = dynamic(() => import('../svg-gallery'), { ssr: false })
const STREAMING_HIGHLIGHT_THROTTLE_OPTIONS = {
wait: 200,
leading: true,
trailing: true,
} as const
const DEFER_UNTIL_FENCE_COMPLETE = new Set(['mermaid', 'echarts', 'svg', 'abc'])
const capitalizationLanguageNameMap: Record<string, string> = {
sql: 'SQL',
@@ -46,6 +58,23 @@ const getCorrectCapitalizationLanguageName = (language: string) => {
return language.charAt(0).toUpperCase() + language.substring(1)
}
const plainCodeStyle = {
paddingLeft: 12,
borderBottomLeftRadius: '10px',
borderBottomRightRadius: '10px',
backgroundColor: 'var(--color-components-input-bg-normal)',
margin: 0,
overflow: 'auto',
} as const
function PlainCodeBlock({ code }: { code: string }) {
return (
<pre style={plainCodeStyle}>
<code>{code}</code>
</pre>
)
}
// **Add code block
// Avoid error #185 (Maximum update depth exceeded.
// This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate.
@@ -65,19 +94,23 @@ const ShikiCodeBlock = memo(
language,
theme,
initial,
isStreaming,
}: {
code: string
language: string
theme: BundledTheme
initial?: JSX.Element
isStreaming: boolean
}) => {
const [nodes, setNodes] = useState(initial)
const throttledCode = useThrottle(code, STREAMING_HIGHLIGHT_THROTTLE_OPTIONS)
const codeToHighlight = isStreaming ? throttledCode : code
useLayoutEffect(() => {
useEffect(() => {
let cancelled = false
void highlightCode({
code,
code: codeToHighlight,
language: language as BundledLanguage,
theme,
})
@@ -92,24 +125,9 @@ const ShikiCodeBlock = memo(
return () => {
cancelled = true
}
}, [code, language, theme])
}, [codeToHighlight, language, theme])
if (!nodes) {
return (
<pre
style={{
paddingLeft: 12,
borderBottomLeftRadius: '10px',
borderBottomRightRadius: '10px',
backgroundColor: 'var(--color-components-input-bg-normal)',
margin: 0,
overflow: 'auto',
}}
>
<code>{code}</code>
</pre>
)
}
if (!nodes) return <PlainCodeBlock code={code} />
return (
<div
@@ -138,15 +156,81 @@ type EChartsEventParams = {
[key: string]: any
}
const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any) => {
type EChartsRenderResult = {
chartState: 'loading' | 'success' | 'error'
finalChartOption: EChartsOption | null
}
const ECHARTS_LOADING_RESULT: EChartsRenderResult = {
chartState: 'loading',
finalChartOption: null,
}
const ECHARTS_ERROR_RESULT: EChartsRenderResult = {
chartState: 'error',
finalChartOption: null,
}
function parseEChartsContent(content: string): EChartsRenderResult {
const trimmedContent = content.trim()
if (!trimmedContent) return ECHARTS_LOADING_RESULT
const isCompleteJson =
(trimmedContent.startsWith('{') &&
trimmedContent.endsWith('}') &&
trimmedContent.split('{').length === trimmedContent.split('}').length) ||
(trimmedContent.startsWith('[') &&
trimmedContent.endsWith(']') &&
trimmedContent.split('[').length === trimmedContent.split(']').length)
if (isCompleteJson) {
try {
const parsed: unknown = JSON.parse(trimmedContent)
if (typeof parsed === 'object' && parsed !== null)
return { chartState: 'success', finalChartOption: parsed as EChartsOption }
return ECHARTS_LOADING_RESULT
} catch {
return ECHARTS_ERROR_RESULT
}
}
const isIncomplete =
trimmedContent.length < 5 ||
(trimmedContent.startsWith('{') &&
(!trimmedContent.endsWith('}') ||
trimmedContent.split('{').length !== trimmedContent.split('}').length)) ||
(trimmedContent.startsWith('[') &&
(!trimmedContent.endsWith(']') ||
trimmedContent.split('[').length !== trimmedContent.split(']').length)) ||
trimmedContent.split('"').length % 2 !== 1 ||
(trimmedContent.includes('{"') && !trimmedContent.includes('"}'))
if (isIncomplete) return ECHARTS_LOADING_RESULT
try {
const parsed: unknown = JSON.parse(trimmedContent)
if (typeof parsed === 'object' && parsed !== null)
return { chartState: 'success', finalChartOption: parsed as EChartsOption }
return ECHARTS_LOADING_RESULT
} catch {
return ECHARTS_ERROR_RESULT
}
}
const CodeBlock: any = memo((codeBlockProps: any) => {
const {
inline,
className,
children = '',
node: _node,
'data-block': dataBlock,
...props
} = codeBlockProps
const isCodeFenceIncomplete = useIsCodeFenceIncomplete()
const { theme } = useTheme()
const [isSVG, setIsSVG] = useState(true)
const [chartState, setChartState] = useState<'loading' | 'success' | 'error'>('loading')
const [finalChartOption, setFinalChartOption] = useState<any>(null)
const echartsRef = useRef<any>(null)
const contentRef = useRef<string>('')
const processedRef = useRef<boolean>(false) // Track if content was successfully processed
const isInitialRenderRef = useRef<boolean>(true) // Track if this is initial render
const chartInstanceRef = useRef<any>(null) // Direct reference to ECharts instance
const resizeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) // For debounce handling
const chartReadyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -155,6 +239,13 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
const language = match?.[1]
const languageShowName = getCorrectCapitalizationLanguageName(language || '')
const isDarkMode = theme === Theme.dark
const { chartState, finalChartOption } = useMemo(
() =>
language === 'echarts' && !isCodeFenceIncomplete
? parseEChartsContent(String(children).replace(/\n$/, ''))
: ECHARTS_LOADING_RESULT,
[children, isCodeFenceIncomplete, language],
)
const clearResizeTimer = useCallback(() => {
if (!resizeTimerRef.current) return
@@ -234,7 +325,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
// Handle container resize for echarts
useEffect(() => {
if (language !== 'echarts' || !chartInstanceRef.current) return
if (language !== 'echarts') return
const handleResize = () => {
if (chartInstanceRef.current)
@@ -257,96 +348,14 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
clearResizeTimer()
clearChartReadyTimer()
chartInstanceRef.current = null
echartsRef.current = null
}
}, [clearResizeTimer, clearChartReadyTimer])
// Process chart data when content changes
useEffect(() => {
// Only process echarts content
if (language !== 'echarts') return
// Reset state when new content is detected
if (!contentRef.current) {
setChartState('loading')
processedRef.current = false
}
const newContent = String(children).replace(/\n$/, '')
// Skip if content hasn't changed
if (contentRef.current === newContent) return
contentRef.current = newContent
const trimmedContent = newContent.trim()
if (!trimmedContent) return
// Detect if this is historical data (already complete)
// Historical data typically comes as a complete code block with complete JSON
const isCompleteJson =
(trimmedContent.startsWith('{') &&
trimmedContent.endsWith('}') &&
trimmedContent.split('{').length === trimmedContent.split('}').length) ||
(trimmedContent.startsWith('[') &&
trimmedContent.endsWith(']') &&
trimmedContent.split('[').length === trimmedContent.split(']').length)
// If the JSON structure looks complete, try to parse it right away
if (isCompleteJson && !processedRef.current) {
try {
const parsed = JSON.parse(trimmedContent)
if (typeof parsed === 'object' && parsed !== null) {
setFinalChartOption(parsed)
setChartState('success')
processedRef.current = true
return
}
} catch {
// Avoid executing arbitrary code; require valid JSON for chart options.
setChartState('error')
processedRef.current = true
return
}
}
// If we get here, either the JSON isn't complete yet, or we failed to parse it
// Check more conditions for streaming data
const isIncomplete =
trimmedContent.length < 5 ||
(trimmedContent.startsWith('{') &&
(!trimmedContent.endsWith('}') ||
trimmedContent.split('{').length !== trimmedContent.split('}').length)) ||
(trimmedContent.startsWith('[') &&
(!trimmedContent.endsWith(']') ||
trimmedContent.split('[').length !== trimmedContent.split('}').length)) ||
trimmedContent.split('"').length % 2 !== 1 ||
(trimmedContent.includes('{"') && !trimmedContent.includes('"}'))
// Only try to parse streaming data if it looks complete and hasn't been processed
if (!isIncomplete && !processedRef.current) {
let isValidOption = false
try {
const parsed = JSON.parse(trimmedContent)
if (typeof parsed === 'object' && parsed !== null) {
setFinalChartOption(parsed)
isValidOption = true
}
} catch {
// Only accept JSON to avoid executing arbitrary code from the message.
setChartState('error')
processedRef.current = true
}
if (isValidOption) {
setChartState('success')
processedRef.current = true
}
}
}, [language, children])
// Cache rendered content to avoid unnecessary re-renders
const renderCodeContent = useMemo(() => {
const content = String(children).replace(/\n$/, '')
if (isCodeFenceIncomplete && DEFER_UNTIL_FENCE_COMPLETE.has(language || ''))
return <PlainCodeBlock code={content} />
switch (language) {
case 'mermaid':
return <Flowchart PrimitiveCode={content} theme={theme as 'light' | 'dark'} />
@@ -441,12 +450,6 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
>
<ErrorBoundary>
<ReactEcharts
ref={(e) => {
if (e && isInitialRenderRef.current) {
echartsRef.current = e
isInitialRenderRef.current = false
}
}}
option={finalChartOption}
style={echartsStyle}
theme={isDarkMode ? 'dark' : undefined}
@@ -482,7 +485,6 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
>
<ErrorBoundary>
<ReactEcharts
ref={echartsRef}
option={errorOption}
style={echartsStyle}
theme={isDarkMode ? 'dark' : undefined}
@@ -505,26 +507,26 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
case 'abc':
return (
<ErrorBoundary>
<MarkdownMusic children={content} />
<MarkdownMusic>{content}</MarkdownMusic>
</ErrorBoundary>
)
default:
return (
<ShikiCodeBlock
code={content}
language={match?.[1] || 'text'}
isStreaming={isCodeFenceIncomplete}
language={language || 'text'}
theme={isDarkMode ? 'github-dark' : 'github-light'}
/>
)
}
}, [
children,
isCodeFenceIncomplete,
language,
isSVG,
finalChartOption,
props,
theme,
match,
chartState,
isDarkMode,
echartsStyle,
@@ -533,7 +535,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
echartsEvents,
])
if (inline || !match)
if (inline || (!match && dataBlock === undefined))
return (
<code {...props} className={className}>
{children}
@@ -546,9 +548,10 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
<div className="system-xs-semibold-uppercase text-text-secondary">{languageShowName}</div>
<div className="flex items-center gap-1">
{language === 'svg' && <SVGBtn isSVG={isSVG} setIsSVG={setIsSVG} />}
<ActionButton>
<CopyIcon content={String(children).replace(/\n$/, '')} />
</ActionButton>
<CopyIcon
className="m-0 size-7 items-center justify-center rounded-lg outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
content={String(children).replace(/\n$/, '')}
/>
</div>
</div>
{renderCodeContent}
@@ -0,0 +1,23 @@
import type { ComponentProps } from 'react'
import type { ExtraProps } from 'streamdown'
import { memo } from 'react'
type InlineCodeProps = ComponentProps<'code'> & ExtraProps
function InlineCode({ node: _node, ...props }: InlineCodeProps) {
return <code {...props} />
}
function areInlineCodePropsEqual(previousProps: InlineCodeProps, nextProps: InlineCodeProps) {
const previousKeys = Object.keys(previousProps).filter((key) => key !== 'node') as Array<
keyof InlineCodeProps
>
const nextKeys = Object.keys(nextProps).filter((key) => key !== 'node')
return (
previousKeys.length === nextKeys.length &&
previousKeys.every((key) => Object.is(previousProps[key], nextProps[key]))
)
}
export default memo(InlineCode, areInlineCodePropsEqual)
@@ -115,6 +115,31 @@ describe('Markdown', () => {
render(<Markdown content="content" isAnimating={true} />)
const props = getLastWrapperProps()
expect(props.isAnimating).toBe(true)
expect(props.mode).toBe('streaming')
})
it('should use static mode by default', () => {
render(<Markdown content="content" />)
const props = getLastWrapperProps()
expect(props.mode).toBe('static')
})
it('should keep streaming mode after animation completes', () => {
const { rerender } = render(<Markdown content="streaming" isAnimating />)
rerender(<Markdown content="streaming complete" isAnimating={false} />)
const props = getLastWrapperProps()
expect(props.mode).toBe('streaming')
})
it('should preprocess content while streaming', () => {
render(<Markdown content={'<think>Thought</think>\\[x^2\\]'} isAnimating />)
const props = getLastWrapperProps()
expect(props.latexContent).toContain('<details data-think=true>')
expect(props.latexContent).toContain('[ENDTHINKFLAG]</details>')
expect(props.latexContent).toContain('$$x^2$$')
})
it('should pass mode through', () => {
@@ -59,6 +59,23 @@ describe('preprocessLaTeX', () => {
expect(out).toContain('$math$')
})
it('preserves incomplete fenced code while content is streaming', () => {
const input = ['Before', '```js', 'const formula = "\\(not math\\)"'].join('\n')
expect(mod.preprocessLaTeX(input)).toBe(input)
})
it('does not confuse user content with an internal placeholder', () => {
const input = [
'Literal CODE_BLOCK_PLACEHOLDER text',
'```js',
'const formula = "\\(not math\\)"',
'```',
].join('\n')
expect(mod.preprocessLaTeX(input)).toBe(input)
})
it('does not treat escaped dollar \\$ as math delimiter', () => {
const input = 'Price: \\$5 and math $x$'
const out = mod.preprocessLaTeX(input)
@@ -1,12 +1,17 @@
import type { PropsWithChildren, ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { preprocessThinkTag } from '../markdown-utils'
import StreamdownWrapper from '../streamdown-wrapper'
const TILDE_RANGE_RE = /0\.3~8mm/
const { mockCodeBlock } = vi.hoisted(() => ({
mockCodeBlock: vi.fn(),
}))
vi.mock('@/app/components/base/markdown-blocks', () => ({
AudioBlock: ({ children }: PropsWithChildren) => <div data-testid="audio-block">{children}</div>,
Img: ({ alt }: { alt?: string }) => <span data-testid="img">{alt}</span>,
InlineCode: ({ children }: PropsWithChildren) => <code>{children}</code>,
Link: ({ children, href }: { children?: ReactNode; href?: string }) => (
<a href={href}>{children}</a>
),
@@ -23,7 +28,10 @@ vi.mock('@/app/components/base/markdown-blocks', () => ({
}))
vi.mock('@/app/components/base/markdown-blocks/code-block', () => ({
default: ({ children }: PropsWithChildren) => <code>{children}</code>,
default: ({ children }: PropsWithChildren) => {
mockCodeBlock({ children })
return <code>{children}</code>
},
}))
describe('StreamdownWrapper', () => {
@@ -80,6 +88,30 @@ describe('StreamdownWrapper', () => {
})
describe('Basic rendering', () => {
it('should render a level-one heading immediately after a think block', () => {
const { rerender } = render(
<StreamdownWrapper
latexContent={preprocessThinkTag('<think>reasoning</think>')}
isAnimating
mode="streaming"
/>,
)
rerender(
<StreamdownWrapper
latexContent={preprocessThinkTag(
'<think>reasoning</think># Markdown 流式渲染\n\n这是一段普通文本',
)}
isAnimating={false}
mode="streaming"
/>,
)
expect(
screen.getByRole('heading', { level: 1, name: 'Markdown 流式渲染' }),
).toBeInTheDocument()
})
it('should render plain text content', () => {
// Arrange
const content = 'Hello World'
@@ -138,6 +170,14 @@ describe('StreamdownWrapper', () => {
// We mocked code block to return <code>{children}</code>
const codeElement = await screen.findByText('console.log("hello")')
expect(codeElement)!.toBeInTheDocument()
expect(mockCodeBlock).toHaveBeenCalled()
})
it('should render inline code without loading the block code renderer', () => {
render(<StreamdownWrapper latexContent="Use `inline` code" />)
expect(screen.getByText('inline'))!.toBeInTheDocument()
expect(mockCodeBlock).not.toHaveBeenCalled()
})
})
+6 -2
View File
@@ -1,7 +1,7 @@
import type { SimplePluginInfo, StreamdownWrapperProps } from './streamdown-wrapper'
import { cn } from '@langgenius/dify-ui/cn'
import { flow } from 'es-toolkit/compat'
import { memo, useMemo } from 'react'
import { memo, useMemo, useRef } from 'react'
import dynamic from '@/next/dynamic'
import { preprocessLaTeX, preprocessThinkTag } from './markdown-utils'
@@ -44,6 +44,10 @@ export const Markdown = memo((props: MarkdownProps) => {
mode,
className,
} = props
const hasEnteredStreamingModeRef = useRef(Boolean(isAnimating))
if (isAnimating) hasEnteredStreamingModeRef.current = true
const resolvedMode = mode ?? (hasEnteredStreamingModeRef.current ? 'streaming' : 'static')
const latexContent = useMemo(() => preprocess(content), [content])
return (
@@ -59,7 +63,7 @@ export const Markdown = memo((props: MarkdownProps) => {
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
isAnimating={isAnimating}
mode={mode}
mode={resolvedMode}
/>
</div>
)
@@ -6,39 +6,43 @@
import { flow } from 'es-toolkit/compat'
import { ALLOW_UNSAFE_DATA_SCHEME } from '@/config'
const FENCED_CODE_BLOCK_REGEX = /(```[\s\S]*?(?:```|$))/g
const LATEX_DELIMITER_MARKER_REGEX = /\\[[(]/
const THINK_TAG_MARKER_REGEX = /<think>|<\/think>|<\/details>/
const THINK_OPEN_TAG_REGEX = /(<think>\s*)+/g
const THINK_CLOSE_TAG_REGEX = /(\s*<\/think>)+/g
const DETAILS_FOLLOWED_BY_CONTENT_ON_SAME_LINE_REGEX = /(<\/details>)[^\S\r\n]*(?=\S)/g
const DETAILS_FOLLOWED_BY_CONTENT_ON_NEXT_LINE_REGEX =
/(<\/details>)[^\S\r\n]*\r?\n(?=[^\S\r\n]*\S)/g
const preprocessTextLaTeX = flow([
(str: string) => str.replace(/\\\[(.*?)\\\]/g, (_, equation) => `$$${equation}$$`),
(str: string) => str.replace(/\\\[([\s\S]*?)\\\]/g, (_, equation) => `$$${equation}$$`),
(str: string) => str.replace(/\\\((.*?)\\\)/g, (_, equation) => `$$${equation}$$`),
(str: string) =>
str.replace(/(^|[^\\])\$(.+?)\$/g, (_, prefix, equation) => `${prefix}$${equation}$`),
])
const replaceThinkTags = flow([
(str: string) => str.replace(THINK_OPEN_TAG_REGEX, '<details data-think=true>\n'),
(str: string) => str.replace(THINK_CLOSE_TAG_REGEX, '\n[ENDTHINKFLAG]</details>'),
(str: string) => str.replace(DETAILS_FOLLOWED_BY_CONTENT_ON_NEXT_LINE_REGEX, '$1\n\n'),
(str: string) => str.replace(DETAILS_FOLLOWED_BY_CONTENT_ON_SAME_LINE_REGEX, '$1\n\n'),
])
export const preprocessLaTeX = (content: string) => {
if (typeof content !== 'string') return content
if (!LATEX_DELIMITER_MARKER_REGEX.test(content)) return content
const codeBlockRegex = /```[\s\S]*?```/g
const codeBlocks = content.match(codeBlockRegex) || []
const escapeReplacement = (str: string) => str.replace(/\$/g, '_TMP_REPLACE_DOLLAR_')
let processedContent = content.replace(codeBlockRegex, 'CODE_BLOCK_PLACEHOLDER')
processedContent = flow([
(str: string) => str.replace(/\\\[(.*?)\\\]/g, (_, equation) => `$$${equation}$$`),
(str: string) => str.replace(/\\\[([\s\S]*?)\\\]/g, (_, equation) => `$$${equation}$$`),
(str: string) => str.replace(/\\\((.*?)\\\)/g, (_, equation) => `$$${equation}$$`),
(str: string) =>
str.replace(/(^|[^\\])\$(.+?)\$/g, (_, prefix, equation) => `${prefix}$${equation}$`),
])(processedContent)
codeBlocks.forEach((block) => {
processedContent = processedContent.replace('CODE_BLOCK_PLACEHOLDER', escapeReplacement(block))
})
processedContent = processedContent.replace(/_TMP_REPLACE_DOLLAR_/g, '$')
return processedContent
return content
.split(FENCED_CODE_BLOCK_REGEX)
.map((segment) => (segment.startsWith('```') ? segment : preprocessTextLaTeX(segment)))
.join('')
}
export const preprocessThinkTag = (content: string) => {
const thinkOpenTagRegex = /(<think>\s*)+/g
const thinkCloseTagRegex = /(\s*<\/think>)+/g
return flow([
(str: string) => str.replace(thinkOpenTagRegex, '<details data-think=true>\n'),
(str: string) => str.replace(thinkCloseTagRegex, '\n[ENDTHINKFLAG]</details>'),
(str: string) => str.replace(/(<\/details>)(?![^\S\r\n]*[\r\n])(?![^\S\r\n]*$)/g, '$1\n'),
])(content)
if (!THINK_TAG_MARKER_REGEX.test(content)) return content
return replaceThinkTags(content)
}
/**
@@ -16,6 +16,7 @@ import {
ThinkBlock,
VideoBlock,
} from '@/app/components/base/markdown-blocks'
import InlineCode from '@/app/components/base/markdown-blocks/inline-code'
import { ALLOW_INLINE_STYLES, ENABLE_SINGLE_DOLLAR_LATEX } from '@/config'
import dynamic from '@/next/dynamic'
import { customUrlTransform } from './markdown-utils'
@@ -165,7 +166,7 @@ const StreamdownWrapper = (props: StreamdownWrapperProps) => {
pluginInfo,
isAnimating,
className,
mode = 'streaming',
mode = 'static',
} = props
const remarkPlugins = useMemo(
@@ -210,6 +211,7 @@ const StreamdownWrapper = (props: StreamdownWrapperProps) => {
const components: Components = useMemo(
() => ({
inlineCode: InlineCode,
code: CodeBlock,
img: (imgProps) =>
pluginInfo ? (
@@ -1,6 +1,7 @@
import {
cleanUpSvgCode,
isMermaidCodeComplete,
LruCache,
prepareMermaidCode,
processSvgForTheme,
sanitizeMermaidCode,
@@ -10,6 +11,31 @@ import {
const FILL_HEX_RE = /fill="#[a-fA-F0-9]{6}"/g
describe('LruCache', () => {
it('evicts the least recently used entry at the configured limit', () => {
const cache = new LruCache<string, string>(2)
cache.set('first', '1')
cache.set('second', '2')
expect(cache.get('first')).toBe('1')
cache.set('third', '3')
expect(cache.get('second')).toBeUndefined()
expect(cache.get('first')).toBe('1')
expect(cache.get('third')).toBe('3')
expect(cache.size).toBe(2)
})
it('clears all entries', () => {
const cache = new LruCache<string, string>(1)
cache.set('key', 'value')
cache.clear()
expect(cache.size).toBe(0)
expect(cache.get('key')).toBeUndefined()
})
})
describe('cleanUpSvgCode', () => {
it('should replace old-style <br> tags with self-closing <br/>', () => {
const result = cleanUpSvgCode('<br>test<br>')
+6 -3
View File
@@ -10,6 +10,7 @@ import { Theme } from '@/types/app'
import {
cleanUpSvgCode,
isMermaidCodeComplete,
LruCache,
prepareMermaidCode,
processSvgForTheme,
sanitizeMermaidCode,
@@ -18,8 +19,9 @@ import {
} from './utils'
// Global flags and cache for mermaid
const MAX_DIAGRAM_CACHE_ENTRIES = 50
let isMermaidInitialized = false
const diagramCache = new Map<string, string>()
const diagramCache = new LruCache<string, string>(MAX_DIAGRAM_CACHE_ENTRIES)
let mermaidAPI: typeof mermaid.mermaidAPI | null = null
if (typeof window !== 'undefined') mermaidAPI = mermaid.mermaidAPI
@@ -408,9 +410,10 @@ const Flowchart = (props: FlowchartProps) => {
}
const cacheKey = `${props.PrimitiveCode}-${look}-${currentTheme}`
if (diagramCache.has(cacheKey)) {
const cachedDiagram = diagramCache.get(cacheKey)
if (cachedDiagram !== undefined) {
setErrMsg('')
setSvgString(diagramCache.get(cacheKey)!)
setSvgString(cachedDiagram)
setIsLoading(false)
return
}
+36
View File
@@ -1,3 +1,39 @@
export class LruCache<K, V> {
private readonly entries = new Map<K, V>()
private readonly maxEntries: number
constructor(maxEntries: number) {
this.maxEntries = Math.max(1, maxEntries)
}
get size(): number {
return this.entries.size
}
get(key: K): V | undefined {
const value = this.entries.get(key)
if (value === undefined) return undefined
this.entries.delete(key)
this.entries.set(key, value)
return value
}
set(key: K, value: V): void {
this.entries.delete(key)
this.entries.set(key, value)
if (this.entries.size <= this.maxEntries) return
const oldestEntry = this.entries.keys().next()
if (!oldestEntry.done) this.entries.delete(oldestEntry.value)
}
clear(): void {
this.entries.clear()
}
}
export function cleanUpSvgCode(svgCode: string): string {
return svgCode.replaceAll('<br>', '<br/>')
}
@@ -14,7 +14,11 @@ vi.mock('@/app/components/base/file-uploader', () => ({
}))
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content }: { content: string }) => <div data-testid="markdown">{content}</div>,
Markdown: ({ content, isAnimating }: { content: string; isAnimating?: boolean }) => (
<div data-animating={String(Boolean(isAnimating))} data-testid="markdown">
{content}
</div>
),
}))
vi.mock('@/app/components/workflow/run/status-container', () => ({
@@ -57,9 +61,10 @@ describe('ResultText', () => {
})
it('renders markdown content when text outputs are available', () => {
render(<ResultText outputs="hello workflow" />)
render(<ResultText isRunning outputs="hello workflow" />)
expect(screen.getByTestId('markdown')).toHaveTextContent('hello workflow')
expect(screen.getByTestId('markdown')).toHaveAttribute('data-animating', 'true')
})
it('renders file groups when file outputs are available', () => {
@@ -61,7 +61,7 @@ const ResultText: FC<ResultTextProps> = ({
<div className="px-4 py-2">
{/* ThinkBlock's timer reads isResponding from ChatContext, which the run panel otherwise lacks. */}
<ChatContextProvider chatList={[]} isResponding={!!isRunning}>
<Markdown content={outputs} />
<Markdown content={outputs} isAnimating={Boolean(isRunning)} />
</ChatContextProvider>
</div>
)}