feat: 增强 Markdown 代码块高亮和表格样式

This commit is contained in:
2026-06-03 17:23:43 +08:00
parent 714da2d633
commit a896091d27
11 changed files with 499 additions and 6 deletions

View File

@@ -0,0 +1,84 @@
import type { ReactNode } from "react";
import { CopyOutlined } from "@ant-design/icons";
import { Button, message } from "antd";
import { useCallback, useEffect, useState } from "react";
import { codeToHtml } from "shiki";
import { useIsDark } from "../../../shared/hooks/use-is-dark";
interface CodeBlockProps {
children: ReactNode;
className?: string;
isStreaming: boolean;
}
export function CodeBlock({ children, className: _className, isStreaming }: CodeBlockProps) {
const isDark = useIsDark();
const [highlighted, setHighlighted] = useState<null | string>(null);
const { codeText, lang } = extractCode(children);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(codeText).then(
() => message.success("已复制"),
() => message.error("复制失败"),
);
}, [codeText]);
useEffect(() => {
if (isStreaming || !codeText) return;
let cancelled = false;
codeToHtml(codeText, {
lang,
theme: isDark ? "github-dark" : "github-light",
})
.then((html) => {
if (!cancelled) setHighlighted(html);
})
.catch(() => {
if (!cancelled) setHighlighted(null);
});
return () => {
cancelled = true;
};
}, [codeText, lang, isDark, isStreaming]);
if (isStreaming) {
return (
<pre className="code-block">
<code>{codeText}</code>
</pre>
);
}
return (
<div className="code-block">
<div className="code-block-header">
<span className="code-block-lang">{lang}</span>
<Button icon={<CopyOutlined />} onClick={handleCopy} size="small" type="text" />
</div>
{highlighted ? (
<div className="code-block-body" dangerouslySetInnerHTML={{ __html: highlighted }} />
) : (
<pre className="code-block-body">
<code>{codeText}</code>
</pre>
)}
</div>
);
}
function extractCode(children: ReactNode): { codeText: string; lang: string } {
if (children && typeof children === "object" && "props" in children && children.props) {
const props = children.props as Record<string, unknown>;
const codeText = typeof props["children"] === "string" ? props["children"] : "";
const codeClassName = typeof props["className"] === "string" ? props["className"] : "";
const classes = codeClassName.split(/\s+/);
const langClass = classes.find((c) => c.startsWith("lang-") || c.startsWith("language-")) ?? "";
const lang = langClass.replace(/^(language|lang)-/, "") || "text";
return { codeText, lang };
}
return { codeText: typeof children === "string" ? children : "", lang: "text" };
}

View File

@@ -0,0 +1,9 @@
import type { ReactNode } from "react";
interface MarkdownTableProps {
children: ReactNode;
}
export function MarkdownTable({ children }: MarkdownTableProps) {
return <table className="markdown-table">{children}</table>;
}

View File

@@ -3,6 +3,9 @@ import Markdown from "markdown-to-jsx/react";
import type { PartProps } from "./types";
import { CodeBlock } from "./CodeBlock";
import { MarkdownTable } from "./MarkdownTable";
interface TextPartProps extends PartProps {
isStreaming: boolean;
role: string;
@@ -16,7 +19,17 @@ export function TextPart({ isStreaming, part, role }: TextPartProps) {
{role === "user" ? (
<Typography.Paragraph className="message-body-text">{text}</Typography.Paragraph>
) : (
<Markdown options={{ optimizeForStreaming: isStreaming }}>{text}</Markdown>
<Markdown
options={{
optimizeForStreaming: isStreaming,
overrides: {
pre: { component: CodeBlock, props: { isStreaming } },
table: MarkdownTable,
},
}}
>
{text}
</Markdown>
)}
</div>
);