|
1 | | -import { useMemo } from 'react' |
2 | | -import ReactMarkdown from 'react-markdown' |
3 | | -import remarkGfm from 'remark-gfm' |
4 | | -import CodeBlock from './CodeBlock' |
| 1 | +import { |
| 2 | + Check, |
| 3 | + Copy, |
| 4 | + Download, |
| 5 | + ExternalLink, |
| 6 | + type LucideIcon, |
| 7 | + Loader2, |
| 8 | + Maximize2, |
| 9 | + RotateCcw, |
| 10 | + X, |
| 11 | + ZoomIn, |
| 12 | + ZoomOut, |
| 13 | +} from 'lucide-react' |
| 14 | +import type { SVGProps } from 'react' |
| 15 | +import { code } from '@streamdown/code' |
| 16 | +import { cjk } from '@streamdown/cjk' |
| 17 | +import { Streamdown } from 'streamdown' |
| 18 | +import 'streamdown/styles.css' |
5 | 19 |
|
6 | 20 | interface MarkdownContentProps { |
7 | 21 | content: string |
8 | 22 | streaming?: boolean |
9 | 23 | } |
10 | 24 |
|
11 | | -/** 轻量级代码块:融入文本流的行内样式 */ |
12 | | -function InlineCodeBlock({ code }: { code: string }) { |
13 | | - return ( |
14 | | - <code |
15 | | - style={{ |
16 | | - fontFamily: 'var(--font-mono)', |
17 | | - fontSize: '0.9em', |
18 | | - padding: '0.15em 0.35em', |
19 | | - borderRadius: 'var(--radius-sm)', |
20 | | - background: 'var(--bg-tertiary)', |
21 | | - color: 'var(--text-primary)', |
22 | | - }} |
23 | | - > |
24 | | - {code} |
25 | | - </code> |
26 | | - ) |
27 | | -} |
28 | | - |
29 | | -/** 将代码块映射到 CodeBlock 组件 */ |
30 | | -function CodeComponent({ |
31 | | - inline, |
32 | | - className, |
33 | | - children, |
34 | | - ...props |
35 | | -}: { |
36 | | - inline?: boolean |
37 | | - className?: string |
38 | | - children?: React.ReactNode |
39 | | -}) { |
40 | | - if (inline) { |
41 | | - return ( |
42 | | - <code className="markdown-inline-code" {...props}> |
43 | | - {children} |
44 | | - </code> |
45 | | - ) |
46 | | - } |
| 25 | +const streamdownPlugins = { code, cjk } |
47 | 26 |
|
48 | | - const match = /language-(\w+)/.exec(className || '') |
49 | | - const language = match ? match[1] : 'text' |
50 | | - const code = String(children).replace(/\n$/, '') |
51 | | - const lines = code.split('\n') |
| 27 | +type StreamdownIconProps = SVGProps<SVGSVGElement> & { size?: number } |
52 | 28 |
|
53 | | - // 单行且无明确语言的代码块降级为轻量渲染 |
54 | | - if (lines.length <= 1 && language === 'text') { |
55 | | - return <InlineCodeBlock code={code} /> |
| 29 | +// 将 lucide 图标的 size 收敛为 number,匹配 streamdown 的 IconMap 类型约束。 |
| 30 | +function adaptIcon(Icon: LucideIcon) { |
| 31 | + return ({ size, ...props }: StreamdownIconProps) => { |
| 32 | + const normalizedSize = typeof size === 'number' ? size : undefined |
| 33 | + return <Icon {...props} size={normalizedSize} /> |
56 | 34 | } |
57 | | - |
58 | | - return <CodeBlock code={code} language={language} /> |
59 | 35 | } |
60 | 36 |
|
61 | | -function splitStreamingContent(content: string): { completed: string; pending: string } { |
62 | | - if (!content) return { completed: '', pending: '' } |
63 | | - |
64 | | - // 1. 检测未闭合的代码块 |
65 | | - const fenceMatches = content.match(/```/g) |
66 | | - if (fenceMatches && fenceMatches.length % 2 === 1) { |
67 | | - const lastFenceIdx = content.lastIndexOf('```') |
68 | | - return { |
69 | | - completed: content.slice(0, lastFenceIdx), |
70 | | - pending: content.slice(lastFenceIdx), |
71 | | - } |
72 | | - } |
73 | | - |
74 | | - // 2. 不在代码块中,按段落分割 |
75 | | - const lastDoubleNewline = content.lastIndexOf('\n\n') |
76 | | - if (lastDoubleNewline !== -1) { |
77 | | - if (lastDoubleNewline >= content.length - 2) { |
78 | | - // \n\n 在末尾,找上一段 |
79 | | - const prevDoubleNewline = content.lastIndexOf('\n\n', lastDoubleNewline - 1) |
80 | | - if (prevDoubleNewline !== -1) { |
81 | | - return { |
82 | | - completed: content.slice(0, prevDoubleNewline + 2), |
83 | | - pending: content.slice(prevDoubleNewline + 2), |
84 | | - } |
85 | | - } |
86 | | - return { completed: '', pending: content } |
87 | | - } |
88 | | - return { |
89 | | - completed: content.slice(0, lastDoubleNewline + 2), |
90 | | - pending: content.slice(lastDoubleNewline + 2), |
91 | | - } |
92 | | - } |
93 | | - |
94 | | - // 3. 单段:包含完整行内语法或较长时尝试渲染 |
95 | | - const hasBold = /\*\*[^*\n]+\*\*/.test(content) |
96 | | - const hasCode = /`[^`\n]+`/.test(content) |
97 | | - const hasItalic = /(?<!\*)\*[^*\n]+\*(?!\*)/.test(content) |
98 | | - if (hasBold || hasCode || hasItalic || content.length > 300) { |
99 | | - return { completed: content, pending: '' } |
100 | | - } |
| 37 | +const streamdownIcons = { |
| 38 | + CheckIcon: adaptIcon(Check), |
| 39 | + CopyIcon: adaptIcon(Copy), |
| 40 | + DownloadIcon: adaptIcon(Download), |
| 41 | + ExternalLinkIcon: adaptIcon(ExternalLink), |
| 42 | + Loader2Icon: adaptIcon(Loader2), |
| 43 | + Maximize2Icon: adaptIcon(Maximize2), |
| 44 | + RotateCcwIcon: adaptIcon(RotateCcw), |
| 45 | + XIcon: adaptIcon(X), |
| 46 | + ZoomInIcon: adaptIcon(ZoomIn), |
| 47 | + ZoomOutIcon: adaptIcon(ZoomOut), |
| 48 | +} |
101 | 49 |
|
102 | | - return { completed: '', pending: content } |
| 50 | +const streamdownTranslations = { |
| 51 | + copyCode: '复制代码', |
| 52 | + copied: '已复制', |
| 53 | + copyLink: '复制链接', |
| 54 | + openExternalLink: '打开外部链接?', |
| 55 | + externalLinkWarning: '你即将访问外部网站。', |
| 56 | + close: '关闭', |
| 57 | + downloadFile: '下载文件', |
| 58 | + viewFullscreen: '全屏查看', |
| 59 | + exitFullscreen: '退出全屏', |
103 | 60 | } |
104 | 61 |
|
105 | 62 | /** Markdown 渲染器,支持 GFM;流式输出时分段增量渲染 */ |
106 | 63 | export default function MarkdownContent({ content, streaming }: MarkdownContentProps) { |
107 | | - const { completed, pending } = useMemo( |
108 | | - () => (streaming ? splitStreamingContent(content) : { completed: content, pending: '' }), |
109 | | - [content, streaming], |
110 | | - ) |
111 | | - |
112 | 64 | return ( |
113 | 65 | <div className="markdown-body"> |
114 | | - {completed && ( |
115 | | - <ReactMarkdown remarkPlugins={[remarkGfm]} components={{ code: CodeComponent as any }}> |
116 | | - {completed} |
117 | | - </ReactMarkdown> |
118 | | - )} |
119 | | - {pending && <span style={{ whiteSpace: 'pre-wrap' }}>{pending}</span>} |
| 66 | + <Streamdown |
| 67 | + className="markdown-streamdown" |
| 68 | + mode={streaming ? 'streaming' : 'static'} |
| 69 | + parseIncompleteMarkdown={!!streaming} |
| 70 | + controls={{ |
| 71 | + code: { copy: true, download: false }, |
| 72 | + table: false, |
| 73 | + mermaid: false, |
| 74 | + }} |
| 75 | + plugins={streamdownPlugins} |
| 76 | + icons={streamdownIcons} |
| 77 | + translations={streamdownTranslations} |
| 78 | + isAnimating={!!streaming} |
| 79 | + > |
| 80 | + {content || ''} |
| 81 | + </Streamdown> |
120 | 82 | </div> |
121 | 83 | ) |
122 | 84 | } |
0 commit comments