修复聊天 Markdown 列表渲染
保留有序列表的起始编号属性 区分列表项首段与后续段落样式 补充列表编号和多段落回归测试
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
import type { ErrorInfo, ReactNode } from 'react';
|
||||
import { Component, createContext, useContext } from 'react';
|
||||
import {
|
||||
Children,
|
||||
Component,
|
||||
createContext,
|
||||
isValidElement,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import ReactMarkdown, { type Components } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
@@ -61,7 +67,9 @@ export class MarkdownErrorBoundary extends Component<
|
||||
|
||||
const ListDepthContext = createContext(0);
|
||||
const ListKindContext = createContext<'unordered' | 'ordered' | null>(null);
|
||||
const ListItemContext = createContext(false);
|
||||
type ListItemParagraphPosition = 'first' | 'continuation';
|
||||
|
||||
const ListItemContext = createContext<ListItemParagraphPosition | null>(null);
|
||||
|
||||
function MarkdownUnorderedList({ children }: { children?: ReactNode }) {
|
||||
const depth = useContext(ListDepthContext);
|
||||
@@ -80,12 +88,21 @@ function MarkdownUnorderedList({ children }: { children?: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownOrderedList({ children }: { children?: ReactNode }) {
|
||||
function MarkdownOrderedList({
|
||||
children,
|
||||
start,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
start?: number;
|
||||
}) {
|
||||
const depth = useContext(ListDepthContext);
|
||||
return (
|
||||
<ListDepthContext.Provider value={depth + 1}>
|
||||
<ListKindContext.Provider value="ordered">
|
||||
<ol className="m-0 mt-2 list-decimal space-y-1 pl-5 first:mt-0">
|
||||
<ol
|
||||
start={start}
|
||||
className="m-0 mt-2 list-decimal space-y-1 pl-5 first:mt-0"
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
</ListKindContext.Provider>
|
||||
@@ -94,10 +111,33 @@ function MarkdownOrderedList({ children }: { children?: ReactNode }) {
|
||||
}
|
||||
|
||||
function MarkdownParagraph({ children }: { children?: ReactNode }) {
|
||||
const inListItem = useContext(ListItemContext);
|
||||
const paragraphPosition = useContext(ListItemContext);
|
||||
return (
|
||||
<p
|
||||
className={`m-0 break-words ${inListItem ? 'inline' : 'mt-2 first:mt-0'}`}
|
||||
className={`m-0 break-words ${
|
||||
paragraphPosition === 'first'
|
||||
? 'inline'
|
||||
: paragraphPosition === 'continuation'
|
||||
? 'mt-2'
|
||||
: 'mt-2 first:mt-0'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamingMarkdownParagraph({ children }: { children?: ReactNode }) {
|
||||
const paragraphPosition = useContext(ListItemContext);
|
||||
return (
|
||||
<p
|
||||
className={`m-0 break-words opacity-95 ${
|
||||
paragraphPosition === 'first'
|
||||
? 'inline'
|
||||
: paragraphPosition === 'continuation'
|
||||
? 'mt-2'
|
||||
: 'mt-2 first:mt-0'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
@@ -106,13 +146,31 @@ function MarkdownParagraph({ children }: { children?: ReactNode }) {
|
||||
|
||||
function MarkdownListItem({ children }: { children?: ReactNode }) {
|
||||
const listKind = useContext(ListKindContext);
|
||||
let paragraphIndex = 0;
|
||||
const childrenWithParagraphContext = Children.map(
|
||||
children,
|
||||
(child, index) => {
|
||||
if (
|
||||
isValidElement(child) &&
|
||||
(child.type === MarkdownParagraph ||
|
||||
child.type === StreamingMarkdownParagraph)
|
||||
) {
|
||||
const position: ListItemParagraphPosition =
|
||||
paragraphIndex++ === 0 ? 'first' : 'continuation';
|
||||
return (
|
||||
<ListItemContext.Provider key={child.key ?? index} value={position}>
|
||||
{child}
|
||||
</ListItemContext.Provider>
|
||||
);
|
||||
}
|
||||
return child;
|
||||
},
|
||||
);
|
||||
return (
|
||||
<ListItemContext.Provider value>
|
||||
<li className="break-words whitespace-normal">
|
||||
{listKind === 'unordered' ? '- ' : null}
|
||||
{children}
|
||||
</li>
|
||||
</ListItemContext.Provider>
|
||||
<li className="break-words whitespace-normal">
|
||||
{listKind === 'unordered' ? '- ' : null}
|
||||
{childrenWithParagraphContext}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,9 +253,7 @@ const markdownComponents: Components = {
|
||||
|
||||
const streamingMarkdownComponents: Components = {
|
||||
...markdownComponents,
|
||||
p: ({ children }) => (
|
||||
<p className="m-0 mt-2 break-words opacity-95 first:mt-0">{children}</p>
|
||||
),
|
||||
p: StreamingMarkdownParagraph,
|
||||
};
|
||||
|
||||
export function ChatMarkdownMessage({
|
||||
|
||||
@@ -79,6 +79,27 @@ describe('ChatMarkdownMessage', () => {
|
||||
expect(items[0]?.textContent).not.toContain('-');
|
||||
});
|
||||
|
||||
it('保留 Markdown 有序列表的起始编号', () => {
|
||||
const { container } = render(
|
||||
<ChatMarkdownMessage role="assistant" text={'3. third\n4. fourth'} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector('ol')?.getAttribute('start')).toBe('3');
|
||||
expect(container.querySelectorAll('ol > li')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('为列表项后续段落保留段落间距', () => {
|
||||
const { container } = render(
|
||||
<ChatMarkdownMessage role="assistant" text={'- first\n\n second'} />,
|
||||
);
|
||||
|
||||
const paragraphs = container.querySelectorAll('ul > li p');
|
||||
expect(paragraphs).toHaveLength(2);
|
||||
expect(paragraphs[0]?.className).toContain('inline');
|
||||
expect(paragraphs[1]?.className).toContain('mt-2');
|
||||
expect(paragraphs[1]?.className).not.toContain('inline');
|
||||
});
|
||||
|
||||
it('不会把 react-markdown 的 node 元数据泄漏到代码节点', () => {
|
||||
const { container } = render(
|
||||
<ChatMarkdownMessage
|
||||
|
||||
Reference in New Issue
Block a user