Compare commits

...

3 Commits

Author SHA1 Message Date
gaoziman
da65cedf28 feat(Markdown): 集成 Mermaid 图表和 KaTeX 公式渲染
- MarkdownRenderer 集成 MermaidBlock 组件
- 添加 remark-math 和 rehype-katex 插件支持数学公式
- 添加 isStreaming 参数传递优化流式渲染
- MessageBubble 传递流式状态给渲染器
2025-12-27 22:32:13 +08:00
gaoziman
ead84a1921 feat(组件): 新增 Mermaid 图表渲染组件
- 创建 MermaidBlock 组件支持流程图渲染
- 实现亮色/暗色双主题配色方案
- 支持主题切换时自动重新渲染
- 添加防抖处理优化流式输出体验
- 添加 Mermaid 背景色 CSS 变量
2025-12-27 22:31:52 +08:00
gaoziman
011f1d8742 feat(依赖): 添加 Mermaid 图表和 KaTeX 数学公式支持
- 添加 mermaid@11.12.2 用于流程图渲染
- 添加 katex@0.16.27 用于数学公式渲染
- 添加 remark-math 和 rehype-katex 插件
- 添加 @types/katex 类型定义
2025-12-27 22:31:31 +08:00
6 changed files with 1640 additions and 10 deletions

View File

@ -20,8 +20,10 @@
"html2canvas": "^1.4.1",
"jose": "^6.1.3",
"jspdf": "^3.0.4",
"katex": "^0.16.27",
"lucide-react": "^0.561.0",
"mammoth": "^1.11.0",
"mermaid": "^11.12.2",
"nanoid": "^5.1.6",
"next": "16.0.10",
"nodemailer": "^7.0.11",
@ -31,7 +33,9 @@
"react": "19.2.1",
"react-dom": "19.2.1",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"sonner": "^2.0.7",
"word-extractor": "^1.0.4",
"xlsx": "^0.18.5",
@ -40,6 +44,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^3.0.0",
"@types/katex": "^0.16.7",
"@types/node": "^20",
"@types/nodemailer": "^7.0.4",
"@types/pg": "^8.16.0",

File diff suppressed because it is too large Load Diff

View File

@ -77,6 +77,9 @@
--color-code-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
/* Mac 红黄绿按钮 - 亮色主题隐藏 */
--color-code-traffic-light-display: none;
/* Mermaid 图表背景 - 亮色主题,与代码块背景保持一致 */
--color-mermaid-bg: #F5F3F0;
}
/* ========================================
@ -135,6 +138,9 @@
--color-code-shadow: 0 8px 24px rgba(0, 0, 0, 0.25), 0 2px 8px rgba(0, 0, 0, 0.15);
/* Mac 红黄绿按钮 - 暗色主题显示 */
--color-code-traffic-light-display: flex;
/* Mermaid 图表背景 - 暗色主题 */
--color-mermaid-bg: #3A353E;
}
@theme inline {

View File

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

View File

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

View File

@ -0,0 +1,479 @@
'use client';
import { useState, useEffect, useRef, useId, useCallback } from 'react';
import { Copy, Check, Eye, Code, Maximize2, AlertCircle, Loader2, FileCode } from 'lucide-react';
import { cn } from '@/lib/utils';
interface MermaidBlockProps {
code: string;
className?: string;
/** 是否正在流式输出 */
isStreaming?: boolean;
}
type ViewMode = 'diagram' | 'code';
/** 防抖延迟时间(毫秒) */
const DEBOUNCE_DELAY = 600;
/**
*
*/
function isDarkTheme(): boolean {
if (typeof document === 'undefined') return false;
return document.documentElement.getAttribute('data-theme') === 'dark';
}
/**
* Mermaid
* 使 UI
*/
function getDarkThemeConfig() {
return {
theme: 'base' as const,
themeVariables: {
primaryColor: '#4A4553', // 节点背景(深紫灰)
primaryTextColor: '#E4E4E7', // 节点文字(浅灰白)
primaryBorderColor: '#6B6575', // 节点边框(中紫灰)
secondaryColor: '#3A353E', // 次要节点背景
secondaryTextColor: '#D4D4D8',
secondaryBorderColor: '#5A5560',
tertiaryColor: '#332E38', // 第三背景色
tertiaryTextColor: '#A8A8A8',
tertiaryBorderColor: '#4A4553',
lineColor: '#8B8592', // 连接线颜色(浅紫灰)
textColor: '#E4E4E7', // 默认文字颜色
mainBkg: '#4A4553', // 主背景
nodeBorder: '#6B6575', // 节点边框
clusterBkg: '#332E38', // 分组背景
titleColor: '#F5F5F5', // 标题颜色
edgeLabelBackground: '#3A353E', // 边缘标签背景
}
};
}
/**
* Mermaid
* +
*/
function getLightThemeConfig() {
return {
theme: 'base' as const,
themeVariables: {
primaryColor: '#FAF7F4', // 节点背景(米白)
primaryTextColor: '#4A4553', // 节点文字(深灰)
primaryBorderColor: '#D4C4B8', // 节点边框(暖灰)
secondaryColor: '#F5EDE6', // 次要节点背景
secondaryTextColor: '#4A4553',
secondaryBorderColor: '#D4C4B8',
tertiaryColor: '#EDE5DD', // 第三背景色
tertiaryTextColor: '#6B7280',
tertiaryBorderColor: '#C8B8A8',
lineColor: '#B8A898', // 连接线颜色(暖灰)
textColor: '#4A4553', // 默认文字颜色
mainBkg: '#FAF7F4', // 主背景
nodeBorder: '#D4C4B8', // 节点边框
clusterBkg: '#F5EDE6', // 分组背景
titleColor: '#374151', // 标题颜色
edgeLabelBackground: '#F5F3F0', // 边缘标签背景
}
};
}
/**
* DOM Mermaid
* Mermaid document.body
*/
function cleanupMermaidErrorElements() {
// 清理 Mermaid 创建的临时容器和错误元素
const errorElements = document.querySelectorAll('[id^="dmermaid-"], [id^="d"]');
errorElements.forEach((el) => {
// 只清理 Mermaid 相关的临时元素(通常是空的或包含错误信息)
if (el.id.startsWith('dmermaid-') || (el.id.startsWith('d') && el.id.includes('-'))) {
// 检查是否是 Mermaid 生成的元素
if (el.closest('.mermaid') === null && el.parentElement === document.body) {
el.remove();
}
}
});
}
export function MermaidBlock({ code, className, isStreaming = false }: MermaidBlockProps) {
const [viewMode, setViewMode] = useState<ViewMode>('diagram');
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [svgContent, setSvgContent] = useState<string>('');
const [isFullscreen, setIsFullscreen] = useState(false);
// 用于追踪是否正在等待渲染(防抖状态)
const [isWaitingToRender, setIsWaitingToRender] = useState(false);
// 当前主题状态,用于监听主题变化并触发重新渲染
const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>(() =>
typeof document !== 'undefined' && isDarkTheme() ? 'dark' : 'light'
);
const containerRef = useRef<HTMLDivElement>(null);
const uniqueId = useId().replace(/:/g, '-');
// 防抖定时器引用
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
// 记录上次成功渲染的代码,避免重复渲染
const lastRenderedCodeRef = useRef<string>('');
// 使用 ref 跟踪 svgContent 是否有值,避免在 useCallback 中依赖 state
const hasSvgContentRef = useRef<boolean>(false);
// 渲染 Mermaid 图表
const renderMermaid = useCallback(async (codeToRender: string) => {
// 如果代码与上次相同且已有渲染结果,不重复渲染
if (codeToRender === lastRenderedCodeRef.current && hasSvgContentRef.current) {
setIsLoading(false);
setIsWaitingToRender(false);
return;
}
setIsLoading(true);
setError(null);
setIsWaitingToRender(false);
// 清理全局 DOM 中的 Mermaid 错误元素
cleanupMermaidErrorElements();
try {
// 动态导入 mermaid 以避免 SSR 问题
const mermaid = (await import('mermaid')).default;
// 检测当前主题并获取对应配置
const isDark = isDarkTheme();
const themeConfig = isDark ? getDarkThemeConfig() : getLightThemeConfig();
// 初始化 mermaid 配置
mermaid.initialize({
startOnLoad: false,
theme: themeConfig.theme,
themeVariables: themeConfig.themeVariables,
securityLevel: 'loose',
// 禁止 Mermaid 在全局 DOM 显示错误
suppressErrorRendering: true,
flowchart: {
useMaxWidth: true,
htmlLabels: true,
curve: 'basis',
},
sequence: {
useMaxWidth: true,
diagramMarginX: 50,
diagramMarginY: 10,
actorMargin: 50,
width: 150,
height: 65,
boxMargin: 10,
boxTextMargin: 5,
noteMargin: 10,
messageMargin: 35,
},
gantt: {
useMaxWidth: true,
},
});
// 渲染图表
const { svg } = await mermaid.render(`mermaid-${uniqueId}-${Date.now()}`, codeToRender.trim());
setSvgContent(svg);
hasSvgContentRef.current = true;
lastRenderedCodeRef.current = codeToRender;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '渲染失败';
setError(errorMessage);
hasSvgContentRef.current = false;
// 静默处理错误,不在控制台输出(流式输出时会有很多临时错误)
} finally {
setIsLoading(false);
setIsWaitingToRender(false);
// 再次清理,确保错误元素被移除
cleanupMermaidErrorElements();
}
}, [uniqueId]);
// 带防抖的渲染触发
useEffect(() => {
// 清理之前的定时器
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
// 如果正在流式输出,显示等待状态,不触发渲染
if (isStreaming) {
setIsWaitingToRender(true);
setIsLoading(true);
return;
}
// 如果代码为空或太短,不渲染
if (!code || code.trim().length < 10) {
setIsWaitingToRender(true);
return;
}
// 设置防抖:代码稳定后才渲染
setIsWaitingToRender(true);
debounceTimerRef.current = setTimeout(() => {
renderMermaid(code);
}, DEBOUNCE_DELAY);
// 清理函数
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
};
}, [code, isStreaming, renderMermaid, currentTheme]);
// 组件卸载时清理
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
cleanupMermaidErrorElements();
};
}, []);
// 监听主题变化,触发图表重新渲染
useEffect(() => {
if (typeof document === 'undefined') return;
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'data-theme') {
const newTheme = isDarkTheme() ? 'dark' : 'light';
if (newTheme !== currentTheme) {
setCurrentTheme(newTheme);
// 清除缓存,强制重新渲染
lastRenderedCodeRef.current = '';
hasSvgContentRef.current = false;
}
}
});
});
observer.observe(document.documentElement, { attributes: true });
return () => observer.disconnect();
}, [currentTheme]);
// 复制代码
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
// 切换全屏
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
};
// ESC 关闭全屏
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isFullscreen) {
setIsFullscreen(false);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isFullscreen]);
return (
<>
<div
ref={containerRef}
className={cn(
'relative group rounded overflow-hidden my-4 border border-[var(--color-border)]',
className
)}
style={{ boxShadow: 'var(--color-code-shadow)' }}
>
{/* 工具栏 */}
<div
className="flex items-center justify-between px-4 py-2.5 text-[0.9em]"
style={{
backgroundColor: 'var(--color-code-toolbar-bg)',
color: 'var(--color-code-toolbar-text)',
borderBottom: '1px solid var(--color-code-border)'
}}
>
{/* 左侧:语言标识 + 视图切换 */}
<div className="flex items-center gap-3">
{/* Mermaid 图标 */}
<div className="flex items-center gap-2">
<div
className="w-4 h-4 rounded"
style={{ background: 'linear-gradient(135deg, #FF6B6B, #4ECDC4)' }}
/>
<span className="font-mono text-[13px]">mermaid</span>
</div>
{/* 视图切换 */}
<div
className="flex rounded-md overflow-hidden"
style={{
backgroundColor: 'var(--color-bg-tertiary)',
border: '1px solid var(--color-border)'
}}
>
<button
onClick={() => setViewMode('diagram')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors',
viewMode === 'diagram'
? 'bg-[var(--color-primary)] text-white'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
)}
>
<Eye size={12} />
<span></span>
</button>
<button
onClick={() => setViewMode('code')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors',
viewMode === 'code'
? 'bg-[var(--color-primary)] text-white'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
)}
>
<Code size={12} />
<span></span>
</button>
</div>
</div>
{/* 右侧:操作按钮 */}
<div className="flex items-center gap-2">
{/* 全屏按钮 */}
{viewMode === 'diagram' && !error && !isLoading && (
<button
onClick={toggleFullscreen}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded transition-colors hover:bg-white/10"
style={{ color: 'var(--color-code-toolbar-text)' }}
title="全屏预览"
>
<Maximize2 size={14} />
</button>
)}
{/* 复制按钮 */}
<button
onClick={handleCopy}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded transition-colors hover:bg-white/10"
style={{ color: 'var(--color-code-toolbar-text)' }}
title="复制代码"
>
{copied ? (
<>
<Check size={14} className="text-green-400" />
<span className="text-green-400">Copied!</span>
</>
) : (
<>
<Copy size={14} />
<span>Copy</span>
</>
)}
</button>
</div>
</div>
{/* 内容区域 */}
<div style={{ backgroundColor: viewMode === 'diagram' ? 'var(--color-mermaid-bg)' : 'var(--color-code-bg)' }}>
{viewMode === 'diagram' ? (
<div className="p-4 min-h-[200px] flex items-center justify-center">
{/* 流式输出中 - 显示代码输入中状态 */}
{isStreaming ? (
<div className="flex flex-col items-center gap-3 text-[var(--color-text-tertiary)]">
<FileCode size={32} style={{ color: 'var(--color-primary)', opacity: 0.6 }} />
<div className="flex items-center gap-2">
<span></span>
<div className="flex gap-1">
<span className="w-1.5 h-1.5 bg-[var(--color-primary)] rounded-full animate-bounce" style={{ animationDelay: '0s' }} />
<span className="w-1.5 h-1.5 bg-[var(--color-primary)] rounded-full animate-bounce" style={{ animationDelay: '0.2s' }} />
<span className="w-1.5 h-1.5 bg-[var(--color-primary)] rounded-full animate-bounce" style={{ animationDelay: '0.4s' }} />
</div>
</div>
<span className="text-xs opacity-70"></span>
</div>
) : isLoading || isWaitingToRender ? (
<div className="flex items-center gap-2 text-[var(--color-text-tertiary)]">
<Loader2 size={20} className="animate-spin" style={{ color: 'var(--color-primary)' }} />
<span>...</span>
</div>
) : error ? (
<div
className="flex items-start gap-3 p-4 rounded-lg w-full"
style={{
backgroundColor: 'rgba(248, 113, 113, 0.1)',
border: '1px solid rgba(248, 113, 113, 0.3)'
}}
>
<AlertCircle size={20} className="text-red-400 flex-shrink-0 mt-0.5" />
<div>
<div className="font-medium text-red-400 mb-1">Mermaid </div>
<div className="text-sm text-red-300/80">{error}</div>
</div>
</div>
) : (
<div
className="w-full overflow-auto"
dangerouslySetInnerHTML={{ __html: svgContent }}
/>
)}
</div>
) : (
<pre className="p-4 overflow-x-auto">
<code
className="font-mono text-[13px] leading-6"
style={{ color: 'var(--color-code-text)' }}
>
{code}
</code>
</pre>
)}
</div>
</div>
{/* 全屏模态框 */}
{isFullscreen && (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center p-8"
style={{ backgroundColor: 'rgba(0, 0, 0, 0.9)' }}
onClick={() => setIsFullscreen(false)}
>
<button
onClick={() => setIsFullscreen(false)}
className="absolute top-4 right-4 w-12 h-12 flex items-center justify-center rounded-xl transition-colors"
style={{
backgroundColor: 'var(--color-bg-tertiary)',
color: 'var(--color-text-primary)'
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<div
className="rounded p-8 max-w-[90vw] max-h-[90vh] overflow-auto"
style={{ backgroundColor: 'var(--color-mermaid-bg)' }}
onClick={(e) => e.stopPropagation()}
dangerouslySetInnerHTML={{ __html: svgContent }}
/>
</div>
)}
</>
);
}