新增聊天 Markdown 消息组件

为 assistant 消息提供 GFM 与流式 Markdown 渲染

限制 HTML、链接和图片为纯文本展示

使用 Tailwind 内联样式并提供单条消息降级
This commit is contained in:
2026-09-04 11:46:47 +08:00
parent 95b08ac3e5
commit 141eff203d
@@ -0,0 +1,141 @@
import type { ErrorInfo, ReactNode } from 'react';
import { Component } from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
export type ChatMarkdownMessageProps = {
text: string;
role: 'assistant' | 'user';
streaming?: boolean;
};
type MarkdownErrorBoundaryProps = {
fallbackText: string;
children: ReactNode;
};
type MarkdownErrorBoundaryState = {
hasError: boolean;
};
class MarkdownErrorBoundary extends Component<
MarkdownErrorBoundaryProps,
MarkdownErrorBoundaryState
> {
state: MarkdownErrorBoundaryState = { hasError: false };
static getDerivedStateFromError(): MarkdownErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(_error: unknown, _errorInfo: ErrorInfo) {
// Keep the original message visible without logging its potentially sensitive content.
}
render() {
if (this.state.hasError) {
return (
<span className="whitespace-pre-wrap break-words">
{this.props.fallbackText}
</span>
);
}
return this.props.children;
}
}
const markdownComponents: Components = {
a: ({ children }) => <span>{children}</span>,
img: ({ alt }) => <span>{alt?.trim() ? `图片:${alt}` : '图片已省略'}</span>,
h1: ({ children }) => (
<h1 className="m-0 mt-4 text-xl font-bold first:mt-0">{children}</h1>
),
h2: ({ children }) => (
<h2 className="m-0 mt-4 text-lg font-bold first:mt-0">{children}</h2>
),
h3: ({ children }) => (
<h3 className="m-0 mt-3 text-base font-semibold first:mt-0">{children}</h3>
),
p: ({ children }) => <p className="m-0 break-words">{children}</p>,
ul: ({ children }) => (
<ul className="m-0 list-disc space-y-1 pl-5">{children}</ul>
),
ol: ({ children }) => (
<ol className="m-0 list-decimal space-y-1 pl-5">{children}</ol>
),
li: ({ children }) => <li className="break-words">{children}</li>,
blockquote: ({ children }) => (
<blockquote className="m-0 border-l-2 border-(--platform-surface-border) pl-3 text-(--platform-text-soft)">
{children}
</blockquote>
),
pre: ({ children }) => (
<pre className="m-0 max-w-full overflow-x-auto rounded-lg bg-black/6 p-3 text-xs leading-5">
{children}
</pre>
),
code: ({ className, children, ...props }) => {
const isBlock = Boolean(className?.includes('language-'));
return isBlock ? (
<code {...props} className="font-mono whitespace-pre">
{children}
</code>
) : (
<code
{...props}
className="rounded bg-black/6 px-1 py-0.5 font-mono text-[0.9em]"
>
{children}
</code>
);
},
table: ({ children }) => (
<div className="max-w-full overflow-x-auto">
<table className="min-w-full border-collapse text-left text-sm">
{children}
</table>
</div>
),
th: ({ children }) => (
<th className="border border-(--platform-surface-border) px-2 py-1 font-semibold">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-(--platform-surface-border) px-2 py-1 align-top">
{children}
</td>
),
hr: () => (
<hr className="border-0 border-t border-(--platform-surface-border)" />
),
};
export function ChatMarkdownMessage({
text,
role,
streaming = false,
}: ChatMarkdownMessageProps) {
if (role === 'user') {
return <span className="whitespace-pre-wrap break-words">{text}</span>;
}
return (
<MarkdownErrorBoundary fallbackText={text}>
<div
className={`chat-markdown min-w-0 space-y-2 break-words leading-6${
streaming ? ' opacity-95' : ''
}`}
data-streaming={streaming ? 'true' : undefined}
>
<ReactMarkdown
skipHtml
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{text}
</ReactMarkdown>
</div>
</MarkdownErrorBoundary>
);
}