feat: render markdown in AI Chat assistant responses
Replace regex-based bold/newline rendering with react-markdown + remark-gfm + rehype-highlight for full markdown support: bold, code blocks with syntax highlighting, bullet/ordered lists, headers, blockquotes, links, tables. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
buildSystemPrompt,
|
||||
} from '../utils/ai-chat'
|
||||
import { useAIChat } from '../hooks/useAIChat'
|
||||
import { MarkdownContent } from './MarkdownContent'
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
@@ -101,10 +102,7 @@ function ContextSearchDropdown({
|
||||
function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => void }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 13, lineHeight: 1.6, whiteSpace: 'pre-wrap' }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: msg.content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br/>'),
|
||||
}} />
|
||||
<MarkdownContent content={msg.content} />
|
||||
<div className="flex items-center gap-3" style={{ marginTop: 4 }}>
|
||||
<button className="border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:underline"
|
||||
style={{ fontSize: 11 }} onClick={() => navigator.clipboard.writeText(msg.content)}>
|
||||
@@ -126,10 +124,7 @@ function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => v
|
||||
function StreamingContent({ content }: { content: string }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 13, lineHeight: 1.6, whiteSpace: 'pre-wrap' }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br/>'),
|
||||
}} />
|
||||
<MarkdownContent content={content} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
75
src/components/MarkdownContent.test.tsx
Normal file
75
src/components/MarkdownContent.test.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MarkdownContent } from './MarkdownContent'
|
||||
|
||||
describe('MarkdownContent', () => {
|
||||
it('renders bold text', () => {
|
||||
render(<MarkdownContent content="Hello **world**" />)
|
||||
const strong = screen.getByText('world')
|
||||
expect(strong.tagName).toBe('STRONG')
|
||||
})
|
||||
|
||||
it('renders inline code', () => {
|
||||
render(<MarkdownContent content="Use `console.log`" />)
|
||||
const code = screen.getByText('console.log')
|
||||
expect(code.tagName).toBe('CODE')
|
||||
})
|
||||
|
||||
it('renders fenced code blocks', () => {
|
||||
const { container } = render(<MarkdownContent content={'```js\nconst x = 1\n```'} />)
|
||||
const pre = container.querySelector('pre')
|
||||
expect(pre).toBeTruthy()
|
||||
expect(pre!.textContent).toContain('const x = 1')
|
||||
})
|
||||
|
||||
it('renders unordered lists', () => {
|
||||
const { container } = render(<MarkdownContent content={'- one\n- two\n- three'} />)
|
||||
const items = container.querySelectorAll('li')
|
||||
expect(items).toHaveLength(3)
|
||||
expect(items[0].textContent).toBe('one')
|
||||
})
|
||||
|
||||
it('renders ordered lists', () => {
|
||||
const { container } = render(<MarkdownContent content={'1. first\n2. second'} />)
|
||||
const ol = container.querySelector('ol')
|
||||
expect(ol).toBeTruthy()
|
||||
expect(ol!.querySelectorAll('li')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders headers', () => {
|
||||
render(<MarkdownContent content="## Section Title" />)
|
||||
const h2 = screen.getByText('Section Title')
|
||||
expect(h2.tagName).toBe('H2')
|
||||
})
|
||||
|
||||
it('renders links', () => {
|
||||
render(<MarkdownContent content="[Click here](https://example.com)" />)
|
||||
const link = screen.getByText('Click here') as HTMLAnchorElement
|
||||
expect(link.tagName).toBe('A')
|
||||
expect(link.getAttribute('href')).toBe('https://example.com')
|
||||
})
|
||||
|
||||
it('renders mixed markdown', () => {
|
||||
const { container } = render(<MarkdownContent content={'**Bold** and `code` and\n\n- item'} />)
|
||||
expect(screen.getByText('Bold').tagName).toBe('STRONG')
|
||||
expect(screen.getByText('code').tagName).toBe('CODE')
|
||||
expect(container.querySelector('li')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('wraps content in .ai-markdown container', () => {
|
||||
const { container } = render(<MarkdownContent content="Hello" />)
|
||||
expect(container.querySelector('.ai-markdown')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders plain text without crashing', () => {
|
||||
render(<MarkdownContent content="Just plain text" />)
|
||||
expect(screen.getByText('Just plain text')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders blockquotes', () => {
|
||||
const { container } = render(<MarkdownContent content="> A quote" />)
|
||||
const bq = container.querySelector('blockquote')
|
||||
expect(bq).toBeTruthy()
|
||||
expect(bq!.textContent).toContain('A quote')
|
||||
})
|
||||
})
|
||||
18
src/components/MarkdownContent.tsx
Normal file
18
src/components/MarkdownContent.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm]
|
||||
const REHYPE_PLUGINS = [rehypeHighlight]
|
||||
|
||||
export const MarkdownContent = memo(function MarkdownContent({ content }: { content: string }) {
|
||||
const rendered = useMemo(() => (
|
||||
<div className="ai-markdown">
|
||||
<Markdown remarkPlugins={REMARK_PLUGINS} rehypePlugins={REHYPE_PLUGINS}>
|
||||
{content}
|
||||
</Markdown>
|
||||
</div>
|
||||
), [content])
|
||||
return rendered
|
||||
})
|
||||
Reference in New Issue
Block a user