Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4c7a770ab | ||
|
|
738de4e359 | ||
|
|
bf90ab66d3 | ||
|
|
335b54dedc | ||
|
|
f67df741ee | ||
|
|
215132c11f | ||
|
|
fc92b64475 | ||
|
|
96feddd23e | ||
|
|
432db250f6 | ||
|
|
8ded8ed728 |
@@ -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,
|
||||
|
||||
+50
@@ -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" />)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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>')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user