修复聊天 Markdown 列表渲染
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled

保留有序列表的起始编号属性

区分列表项首段与后续段落样式

补充列表编号和多段落回归测试
This commit is contained in:
2026-09-07 11:35:16 +08:00
parent e428d1345b
commit 6fb2f56faf
2 changed files with 92 additions and 15 deletions
@@ -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