新增聊天 Markdown 消息组件
为 assistant 消息提供 GFM 与流式 Markdown 渲染 限制 HTML、链接和图片为纯文本展示 使用 Tailwind 内联样式并提供单条消息降级
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user