feat(ui): 添加 Markdown 渲染和代码高亮功能
- 新增 CodeBlock 组件,使用 Shiki 语法高亮 - 新增 Markdown 组件,支持 GFM 语法 - AI 消息自动渲染 Markdown,用户消息保持原样 - 代码块支持一键复制和语言标签显示
This commit is contained in:
@@ -33,6 +33,9 @@
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.23.26",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^3.19.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { motion } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../utils/cn';
|
||||
import { fadeInUp, smoothTransition } from '../utils/animations';
|
||||
import { Markdown } from './Markdown';
|
||||
import type { Message } from '../api/client.js';
|
||||
|
||||
interface ChatMessageProps {
|
||||
@@ -43,7 +44,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
||||
>
|
||||
{isUser ? <User size={18} /> : <Bot size={18} />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-gray-400">
|
||||
{isUser ? 'You' : 'AI Assistant'}
|
||||
@@ -56,8 +57,14 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="message-content whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
<div className="message-content text-gray-200">
|
||||
{isUser ? (
|
||||
// 用户消息:保持原样显示
|
||||
<div className="whitespace-pre-wrap break-words">{message.content}</div>
|
||||
) : (
|
||||
// AI 消息:Markdown 渲染
|
||||
<Markdown content={message.content} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -79,14 +86,14 @@ export function StreamingMessage({ content }: StreamingMessageProps) {
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-green-600">
|
||||
<Bot size={18} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="text-sm text-gray-400 mb-1">AI Assistant</div>
|
||||
<div className="message-content whitespace-pre-wrap break-words">
|
||||
{content}
|
||||
<div className="message-content text-gray-200">
|
||||
<Markdown content={content} />
|
||||
<motion.span
|
||||
animate={{ opacity: [1, 0] }}
|
||||
transition={{ duration: 0.8, repeat: Infinity, repeatType: 'reverse' }}
|
||||
className="inline-block w-2 h-4 bg-primary-400 ml-1 rounded-sm"
|
||||
className="inline-block w-2 h-4 bg-primary-400 ml-1 rounded-sm align-middle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* CodeBlock Component
|
||||
*
|
||||
* 代码块组件,支持语法高亮和一键复制
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { codeToHtml } from 'shiki';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
language?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CodeBlock({ code, language = 'text', className }: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [highlightedHtml, setHighlightedHtml] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 语法高亮
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function highlight() {
|
||||
try {
|
||||
const html = await codeToHtml(code, {
|
||||
lang: language,
|
||||
theme: 'github-dark',
|
||||
});
|
||||
if (!cancelled) {
|
||||
setHighlightedHtml(html);
|
||||
}
|
||||
} catch {
|
||||
// 如果语言不支持,回退到纯文本
|
||||
if (!cancelled) {
|
||||
setHighlightedHtml(`<pre><code>${escapeHtml(code)}</code></pre>`);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
highlight();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, language]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative group rounded-lg overflow-hidden', className)}>
|
||||
{/* 语言标签和复制按钮 */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-gray-900 border-b border-gray-700">
|
||||
<span className="text-xs text-gray-400 font-mono">{language}</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-400 hover:text-gray-200 hover:bg-gray-700 rounded transition-colors"
|
||||
title="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check size={14} className="text-green-500" />
|
||||
<span className="text-green-500">Copied!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={14} />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 代码内容 */}
|
||||
<div className="overflow-x-auto">
|
||||
{isLoading ? (
|
||||
<pre className="p-4 bg-gray-900 text-gray-300 text-sm font-mono">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<div
|
||||
className="shiki-wrapper text-sm [&>pre]:p-4 [&>pre]:m-0 [&>pre]:bg-gray-900"
|
||||
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 简单的内联代码组件
|
||||
interface InlineCodeProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InlineCode({ children, className }: InlineCodeProps) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded bg-gray-700 text-gray-200 text-sm font-mono',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
// HTML 转义
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Markdown Component
|
||||
*
|
||||
* Markdown 渲染组件,支持 GFM 语法和代码高亮
|
||||
*/
|
||||
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { CodeBlock, InlineCode } from './CodeBlock';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
interface MarkdownProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Markdown({ content, className }: MarkdownProps) {
|
||||
return (
|
||||
<div className={cn('markdown-content', className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
// 代码块和内联代码
|
||||
code({ className, children, node, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : '';
|
||||
const codeContent = String(children).replace(/\n$/, '');
|
||||
|
||||
// 检查是否为内联代码:如果父节点不是 pre,则为内联代码
|
||||
const isInline = node?.position?.start.line === node?.position?.end.line &&
|
||||
!codeContent.includes('\n');
|
||||
|
||||
if (isInline && !language) {
|
||||
return <InlineCode {...props}>{children}</InlineCode>;
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
code={codeContent}
|
||||
language={language || 'text'}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
// 段落
|
||||
p({ children }) {
|
||||
return <p className="mb-4 last:mb-0">{children}</p>;
|
||||
},
|
||||
|
||||
// 标题
|
||||
h1({ children }) {
|
||||
return <h1 className="text-2xl font-bold mb-4 mt-6 first:mt-0">{children}</h1>;
|
||||
},
|
||||
h2({ children }) {
|
||||
return <h2 className="text-xl font-bold mb-3 mt-5 first:mt-0">{children}</h2>;
|
||||
},
|
||||
h3({ children }) {
|
||||
return <h3 className="text-lg font-bold mb-2 mt-4 first:mt-0">{children}</h3>;
|
||||
},
|
||||
h4({ children }) {
|
||||
return <h4 className="text-base font-bold mb-2 mt-3 first:mt-0">{children}</h4>;
|
||||
},
|
||||
|
||||
// 列表
|
||||
ul({ children }) {
|
||||
return <ul className="list-disc list-inside mb-4 space-y-1">{children}</ul>;
|
||||
},
|
||||
ol({ children }) {
|
||||
return <ol className="list-decimal list-inside mb-4 space-y-1">{children}</ol>;
|
||||
},
|
||||
li({ children }) {
|
||||
return <li className="text-gray-200">{children}</li>;
|
||||
},
|
||||
|
||||
// 引用
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className="border-l-4 border-gray-600 pl-4 my-4 text-gray-400 italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
|
||||
// 链接
|
||||
a({ href, children }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-400 hover:text-primary-300 underline underline-offset-2"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
|
||||
// 强调
|
||||
strong({ children }) {
|
||||
return <strong className="font-bold text-gray-100">{children}</strong>;
|
||||
},
|
||||
em({ children }) {
|
||||
return <em className="italic">{children}</em>;
|
||||
},
|
||||
|
||||
// 分割线
|
||||
hr() {
|
||||
return <hr className="my-6 border-gray-700" />;
|
||||
},
|
||||
|
||||
// 表格
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="overflow-x-auto my-4">
|
||||
<table className="min-w-full border-collapse border border-gray-700">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return <thead className="bg-gray-800">{children}</thead>;
|
||||
},
|
||||
tbody({ children }) {
|
||||
return <tbody>{children}</tbody>;
|
||||
},
|
||||
tr({ children }) {
|
||||
return <tr className="border-b border-gray-700">{children}</tr>;
|
||||
},
|
||||
th({ children }) {
|
||||
return (
|
||||
<th className="px-4 py-2 text-left text-sm font-semibold text-gray-200 border-r border-gray-700 last:border-r-0">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
},
|
||||
td({ children }) {
|
||||
return (
|
||||
<td className="px-4 py-2 text-sm text-gray-300 border-r border-gray-700 last:border-r-0">
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
},
|
||||
|
||||
// 图片
|
||||
img({ src, alt }) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || ''}
|
||||
className="max-w-full h-auto rounded-lg my-4"
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
// 预格式化文本(非代码块的 pre)
|
||||
pre({ children }) {
|
||||
return (
|
||||
<pre className="bg-gray-900 p-4 rounded-lg overflow-x-auto my-4 text-sm">
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,8 @@ export { FileBrowser } from './components/FileBrowser.js';
|
||||
export { ConfigPanel } from './components/ConfigPanel.js';
|
||||
export { Toaster } from './components/Toaster.js';
|
||||
export { Skeleton, MessageSkeleton, SessionSkeleton, FileSkeleton } from './components/Skeleton.js';
|
||||
export { Markdown } from './components/Markdown.js';
|
||||
export { CodeBlock, InlineCode } from './components/CodeBlock.js';
|
||||
|
||||
// Toast function (re-export from sonner)
|
||||
export { toast } from 'sonner';
|
||||
|
||||
Generated
+1000
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user