Compare commits
3 Commits
c5c16ee893
...
da65cedf28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da65cedf28 | ||
|
|
ead84a1921 | ||
|
|
011f1d8742 |
@ -20,8 +20,10 @@
|
|||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jose": "^6.1.3",
|
"jose": "^6.1.3",
|
||||||
"jspdf": "^3.0.4",
|
"jspdf": "^3.0.4",
|
||||||
|
"katex": "^0.16.27",
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"mammoth": "^1.11.0",
|
"mammoth": "^1.11.0",
|
||||||
|
"mermaid": "^11.12.2",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"next": "16.0.10",
|
"next": "16.0.10",
|
||||||
"nodemailer": "^7.0.11",
|
"nodemailer": "^7.0.11",
|
||||||
@ -31,7 +33,9 @@
|
|||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"rehype-katex": "^7.0.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-math": "^6.0.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"word-extractor": "^1.0.4",
|
"word-extractor": "^1.0.4",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
@ -40,6 +44,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
|
"@types/katex": "^0.16.7",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/nodemailer": "^7.0.4",
|
"@types/nodemailer": "^7.0.4",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
|
|||||||
1121
pnpm-lock.yaml
1121
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -77,6 +77,9 @@
|
|||||||
--color-code-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
--color-code-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
/* Mac 红黄绿按钮 - 亮色主题隐藏 */
|
/* Mac 红黄绿按钮 - 亮色主题隐藏 */
|
||||||
--color-code-traffic-light-display: none;
|
--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);
|
--color-code-shadow: 0 8px 24px rgba(0, 0, 0, 0.25), 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
/* Mac 红黄绿按钮 - 暗色主题显示 */
|
/* Mac 红黄绿按钮 - 暗色主题显示 */
|
||||||
--color-code-traffic-light-display: flex;
|
--color-code-traffic-light-display: flex;
|
||||||
|
|
||||||
|
/* Mermaid 图表背景 - 暗色主题 */
|
||||||
|
--color-mermaid-bg: #3A353E;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
|||||||
@ -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)]">
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
479
src/components/markdown/MermaidBlock.tsx
Normal file
479
src/components/markdown/MermaidBlock.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user