feat(Markdown): 集成 Mermaid 图表和 KaTeX 公式渲染

- MarkdownRenderer 集成 MermaidBlock 组件
- 添加 remark-math 和 rehype-katex 插件支持数学公式
- 添加 isStreaming 参数传递优化流式渲染
- MessageBubble 传递流式状态给渲染器
This commit is contained in:
gaoziman 2025-12-27 22:32:13 +08:00
parent ead84a1921
commit da65cedf28
2 changed files with 30 additions and 9 deletions

View File

@ -332,6 +332,7 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
content={message.content} content={message.content}
onImageLinkClick={handleImageLinkClick} onImageLinkClick={handleImageLinkClick}
onLinkClick={onLinkClick} onLinkClick={onLinkClick}
isStreaming={isStreaming}
/> />
) : isStreaming ? ( ) : isStreaming ? (
<div className="flex items-center gap-2 text-[var(--color-text-tertiary)]"> <div className="flex items-center gap-2 text-[var(--color-text-tertiary)]">

View File

@ -3,9 +3,15 @@
import { memo, useMemo } from 'react'; import { memo, useMemo } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import { CodeBlock } from './CodeBlock'; import { CodeBlock } from './CodeBlock';
import { MermaidBlock } from './MermaidBlock';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
// 导入 KaTeX 样式
import 'katex/dist/katex.min.css';
interface MarkdownRendererProps { interface MarkdownRendererProps {
content: string; content: string;
className?: string; className?: string;
@ -13,6 +19,8 @@ interface MarkdownRendererProps {
onImageLinkClick?: (url: string) => void; onImageLinkClick?: (url: string) => void;
/** 普通链接点击回调,用于在预览窗口中打开 */ /** 普通链接点击回调,用于在预览窗口中打开 */
onLinkClick?: (url: string) => void; onLinkClick?: (url: string) => void;
/** 是否正在流式输出(用于延迟 Mermaid 渲染) */
isStreaming?: boolean;
} }
/** /**
@ -30,12 +38,18 @@ function isImageUrl(url: string): boolean {
* Markdown * Markdown
* 使 * 使
*/ */
function createMarkdownComponents(onImageLinkClick?: (url: string) => void, onLinkClick?: (url: string) => void) { function createMarkdownComponents(
onImageLinkClick?: (url: string) => void,
onLinkClick?: (url: string) => void,
isStreaming?: boolean
) {
return { return {
// 代码块 // 代码块
code({ className, children, ...props }: { className?: string; children?: React.ReactNode }) { code({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
const match = /language-(\w+)/.exec(className || ''); const match = /language-(\w+)/.exec(className || '');
const isInline = !match && !className; const isInline = !match && !className;
const language = match ? match[1] : 'text';
const codeContent = String(children).replace(/\n$/, '');
if (isInline) { if (isInline) {
// 行内代码 - 无特殊样式,仅等宽字体 // 行内代码 - 无特殊样式,仅等宽字体
@ -49,11 +63,16 @@ function createMarkdownComponents(onImageLinkClick?: (url: string) => void, onLi
); );
} }
// 代码块 // Mermaid 图表 - 使用专门的 MermaidBlock 组件
if (language === 'mermaid') {
return <MermaidBlock code={codeContent} isStreaming={isStreaming} />;
}
// 普通代码块
return ( return (
<CodeBlock <CodeBlock
code={String(children).replace(/\n$/, '')} code={codeContent}
language={match ? match[1] : 'text'} language={language}
/> />
); );
}, },
@ -259,17 +278,18 @@ function createMarkdownComponents(onImageLinkClick?: (url: string) => void, onLi
} }
// 使用 memo 包裹组件,避免不必要的重渲染 // 使用 memo 包裹组件,避免不必要的重渲染
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className, onImageLinkClick, onLinkClick }: MarkdownRendererProps) { export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className, onImageLinkClick, onLinkClick, isStreaming }: MarkdownRendererProps) {
// 使用 useMemo 缓存 components 配置,仅在回调变化时重新创建 // 使用 useMemo 缓存 components 配置,仅在回调或流式状态变化时重新创建
const components = useMemo( const components = useMemo(
() => createMarkdownComponents(onImageLinkClick, onLinkClick), () => createMarkdownComponents(onImageLinkClick, onLinkClick, isStreaming),
[onImageLinkClick, onLinkClick] [onImageLinkClick, onLinkClick, isStreaming]
); );
return ( return (
<div className={cn('markdown-content', className)}> <div className={cn('markdown-content', className)}>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={components} components={components}
> >
{content} {content}