Skip to content

Commit 0188bbf

Browse files
Div627cursoragent
andcommitted
fix(x-markdown): correct streaming cache when list is followed by backtick
流式渲染时,当 list 后紧跟 ` 时只提交列表前缀(如 "- "),将 ` 及后续内容保留在 pending 中识别为 inline-code,使 IncompleteInlineCode 能拿到完整 data-raw;通过 Recognizer.getCommitPrefix 扩展部分提交逻辑,便于后续类似交接场景。 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 34e6415 commit 0188bbf

File tree

5 files changed

+86
-42
lines changed

5 files changed

+86
-42
lines changed

packages/x-markdown/src/XMarkdown/__tests__/hooks.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,12 +179,12 @@ const streamingTestCases = [
179179
{
180180
title: 'incomplete list with inline-code - single backtick',
181181
input: '- `',
182-
output: '', // 实际实现会过滤掉不完整的列表+行内代码
182+
output: '- ', // list 已完成并提交,当前 token 为 inline-code(未完成且无组件时不展示)
183183
},
184184
{
185185
title: 'incomplete list with inline-code - partial content',
186186
input: '- `code',
187-
output: '', // 实际实现会过滤掉不完整的列表+行内代码
187+
output: '- ', // list 已完成并提交,当前 token 为 inline-code(未完成且无组件时不展示)
188188
},
189189
{
190190
title: 'complete list with inline-code',

packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,20 @@ export interface StreamCache {
1010
completeMarkdown: string;
1111
}
1212

13+
/**
14+
* When a token is about to be committed, if a non-empty string is returned,
15+
* only that prefix is committed and the rest of the pending content is left
16+
* for subsequent recognition (used for handover scenarios like list followed by `).
17+
* Returns null to commit the entire pending content by default.
18+
*/
1319
interface Recognizer {
1420
tokenType: StreamCacheTokenType;
1521
isStartOfToken: (markdown: string) => boolean;
1622
isStreamingValid: (markdown: string) => boolean;
23+
/** Optional: prefix for partial commit, useful for extending handover logic
24+
* when the current token ends and is immediately followed by the start symbol
25+
* of the next token */
26+
getCommitPrefix?: (pending: string) => string | null;
1727
}
1828

1929
/* ------------ Constants ------------ */
@@ -24,12 +34,8 @@ const STREAM_INCOMPLETE_REGEX = {
2434
link: [/^\[[^\]\r\n]{0,1000}$/, /^\[[^\r\n]{0,1000}\]\(*[^)\r\n]{0,1000}$/],
2535
html: [/^<\/$/, /^<\/?[a-zA-Z][a-zA-Z0-9-]{0,100}[^>\r\n]{0,1000}$/],
2636
commonEmphasis: [/^(\*{1,3}|_{1,3})(?!\s)(?!.*\1$)[^\r\n]{0,1000}$/],
27-
// regex2 matches cases like "- **"
28-
list: [
29-
/^[-+*]\s{0,3}$/,
30-
/^[-+*]\s{1,3}(\*{1,3}|_{1,3})(?!\s)(?!.*\1$)[^\r\n]{0,1000}$/,
31-
/^[-+*]\s{1,3}`[^`\r\n]{0,300}$/,
32-
],
37+
// regex2 matches cases like "- **" (list item with emphasis start).
38+
list: [/^[-+*]\s{0,3}$/, /^[-+*]\s{1,3}(\*{1,3}|_{1,3})(?!\s)(?!.*\1$)[^\r\n]{0,1000}$/],
3339
'inline-code': [/^`[^`\r\n]{0,300}$/],
3440
} as const;
3541

@@ -87,6 +93,12 @@ const tokenRecognizerMap: Partial<Record<StreamCacheTokenType, Recognizer>> = {
8793
isStartOfToken: (markdown: string) => /^[-+*]/.test(markdown),
8894
isStreamingValid: (markdown: string) =>
8995
STREAM_INCOMPLETE_REGEX.list.some((re) => re.test(markdown)),
96+
// list 后紧跟 ` 时只提交列表前缀,余下留给 inline-code(- * 可能为多级列表,不处理)
97+
getCommitPrefix: (pending: string) => {
98+
const listPrefix = pending.match(/^([-+*]\s{0,3})/)?.[1];
99+
const rest = listPrefix ? pending.slice(listPrefix.length) : '';
100+
return listPrefix && rest.startsWith('`') ? listPrefix : null;
101+
},
90102
},
91103
[StreamCacheTokenType.Table]: {
92104
tokenType: StreamCacheTokenType.Table,
@@ -112,6 +124,13 @@ const recognize = (cache: StreamCache, tokenType: StreamCacheTokenType): void =>
112124
}
113125

114126
if (token === tokenType && !recognizer.isStreamingValid(pending)) {
127+
const prefix = recognizer.getCommitPrefix?.(pending);
128+
if (prefix != null) {
129+
cache.completeMarkdown += prefix;
130+
cache.pending = pending.slice(prefix.length);
131+
cache.token = StreamCacheTokenType.Text;
132+
return;
133+
}
115134
commitCache(cache);
116135
}
117136
};
@@ -290,6 +309,11 @@ const useStreaming = (
290309
} else {
291310
const handler = recognizeHandlers.find((handler) => handler.tokenType === cache.token);
292311
handler?.recognize(cache);
312+
// After commit (e.g. list → Text), re-run all recognizers so pending (e.g. "`") becomes the new token (e.g. inline-code)
313+
const tokenAfterRecognize = cache.token as StreamCacheTokenType;
314+
if (tokenAfterRecognize === StreamCacheTokenType.Text) {
315+
for (const h of recognizeHandlers) h.recognize(cache);
316+
}
293317
}
294318

295319
if (cache.token === StreamCacheTokenType.Text) {

packages/x/docs/x-markdown/components.en-US.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,9 @@ Incorrect Output:
152152
</think>
153153
```
154154

155-
**Root Cause:** According to [CommonMark](https://spec.commonmark.org/0.30/#html-blocks) specification, HTML block recognition depends on strict formatting rules. Once two consecutive line breaks (i.e., empty lines) appear inside an HTML block and do not meet specific HTML block type continuation conditions (such as <div>, <pre>, etc.), the parser will terminate the current HTML block and process subsequent content as Markdown paragraphs.
155+
**Root Cause:** According to [CommonMark](https://spec.commonmark.org/0.30/#html-blocks) specification, HTML block recognition depends on strict formatting rules. Once two consecutive line breaks (i.e., empty lines) appear inside an HTML block and do not meet specific HTML block type continuation conditions (such as `<div>`, `<pre>`, etc.), the parser will terminate the current HTML block and process subsequent content as Markdown paragraphs.
156156

157-
Custom tags (like <think>) are typically not recognized as "paragraph-spanning" HTML block types, making them highly susceptible to empty line interference.
157+
Custom tags (like `<think>` ) are typically not recognized as "paragraph-spanning" HTML block types, making them highly susceptible to empty line interference.
158158

159159
**Solutions:**
160160

packages/x/docs/x-markdown/components.zh-CN.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,9 @@ const UserCard = ({ domNode, streamStatus }) => {
152152
</think>
153153
```
154154

155-
**根本原因:** 根据 [CommonMark](https://spec.commonmark.org/0.30/#html-blocks) 规范,HTML 块的识别依赖于严格的格式规则。一旦在 HTML 块内部出现两个连续换行(即空行),且未满足特定 HTML 块类型(如 <div><pre> 等)的延续条件,解析器会终止当前 HTML 块,并将后续内容作为 Markdown 段落处理。
155+
**根本原因:** 根据 [CommonMark](https://spec.commonmark.org/0.30/#html-blocks) 规范,HTML 块的识别依赖于严格的格式规则。一旦在 HTML 块内部出现两个连续换行(即空行),且未满足特定 HTML 块类型(如 `<div>``<pre>` 等)的延续条件,解析器会终止当前 HTML 块,并将后续内容作为 Markdown 段落处理。
156156

157-
自定义标签(如 <think>)通常不被识别为“可跨段落”的 HTML 块类型,因此极易受空行干扰。
157+
自定义标签(如 `<think>` )通常不被识别为“可跨段落”的 HTML 块类型,因此极易受空行干扰。
158158

159159
**解决方案:**
160160

packages/x/docs/x-markdown/demo/streaming/format.tsx

Lines changed: 50 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,30 @@ const demos = [
1111
1212
Ant Design X is a comprehensive toolkit for AI applications, integrating a UI component library, streaming Markdown rendering engine, and AI SDK.
1313
14-
\`npm install @ant-design/x-markdown\`
14+
- \`npm install @ant-design/x\`
15+
- \`npm install @ant-design/x-markdown\`
16+
- \`npm install @ant-design/x-sdk\`
1517
16-
### @ant-design/x
18+
### \`@ant-design/x\`
1719
1820
A React UI library based on the Ant Design system, designed for **AI-driven interfaces**. [Click here for details.](/components/introduce/).
1921
20-
### @ant-design/x-markdown
22+
### \`@ant-design/x-markdown\`
2123
2224
An optimized Markdown rendering solution for **streaming content**. [Click here for details.](/x-markdowns/introduce).
2325
24-
### @ant-design/x-sdk
26+
### \`@ant-design/x-sdk\`
2527
2628
Provides a complete set of **tool APIs**. [Click here for details.](/x-sdks/introduce).
27-
<welcome data-icon="https://mdn.alipayobjects.com/huamei_iwk9zp/afts/img/A*s5sNRo5LjfQAAAAAAAAAAAAADgCCAQ/fmt.webp" title="Hello, I'm Ant Design X" data-description="Base on Ant Design, AGI product interface solution, create a better intelligent vision~"></welcome>
29+
2830
2931
| Repo | Description |
3032
| ------ | ----------- |
3133
| @ant-design/x | A React UI library based on the Ant Design system. |
3234
| @ant-design/x-markdown | An optimized Markdown rendering solution for **streaming content**. |
3335
| @ant-design/x-sdk | Provides a complete set of **tool APIs**. |
36+
37+
<welcome data-icon="https://mdn.alipayobjects.com/huamei_iwk9zp/afts/img/A*s5sNRo5LjfQAAAAAAAAAAAAADgCCAQ/fmt.webp" title="Hello, I'm Ant Design X" data-description="Base on Ant Design, AGI product interface solution, create a better intelligent vision~"></welcome>
3438
`,
3539
},
3640
{
@@ -62,7 +66,7 @@ Provides a complete set of **tool APIs**. [Click here for details.](/x-sdks/intr
6266
},
6367
{
6468
title: 'InlineCode',
65-
content: 'This is inline code: `npm install @ant-design/x-markdown`',
69+
content: 'This is inline code: `npm install @ant-design/x-markdown`.',
6670
},
6771
];
6872

@@ -129,32 +133,42 @@ const WelcomeCard = (props: Record<string, any>) => (
129133
);
130134

131135
const StreamDemo: React.FC<{ content: string }> = ({ content }) => {
132-
const [displayText, setDisplayText] = useState(content);
133-
const [isStreaming, setIsStreaming] = useState(false);
136+
const [hasNextChunk, setHasNextChunk] = React.useState(true);
134137
const { theme: antdTheme } = theme.useToken();
135138
const className = antdTheme.id === 0 ? 'x-markdown-light' : 'x-markdown-dark';
139+
const [index, setIndex] = React.useState(0);
140+
const timer = React.useRef<NodeJS.Timeout | null>(null);
141+
const contentRef = React.useRef<HTMLDivElement>(null);
136142

137-
const startStream = React.useCallback(() => {
138-
setDisplayText('');
139-
setIsStreaming(true);
140-
let index = 0;
141-
142-
const stream = () => {
143-
if (index <= content.length) {
144-
setDisplayText(content.slice(0, index));
145-
index++;
146-
setTimeout(stream, 30);
147-
} else {
148-
setIsStreaming(false);
143+
React.useEffect(() => {
144+
if (index >= content.length) {
145+
setHasNextChunk(false);
146+
return;
147+
}
148+
149+
timer.current = setTimeout(() => {
150+
setIndex(Math.min(index + 1, content.length));
151+
}, 30);
152+
153+
return () => {
154+
if (timer.current) {
155+
clearTimeout(timer.current);
156+
timer.current = null;
149157
}
150158
};
151-
152-
stream();
153-
}, [content]);
159+
}, [index]);
154160

155161
React.useEffect(() => {
156-
startStream();
157-
}, [startStream]);
162+
if (contentRef.current && index > 0 && index < content.length) {
163+
const { scrollHeight, clientHeight } = contentRef.current;
164+
if (scrollHeight > clientHeight) {
165+
contentRef.current.scrollTo({
166+
top: scrollHeight,
167+
behavior: 'smooth',
168+
});
169+
}
170+
}
171+
}, [index]);
158172

159173
return (
160174
<div style={{ display: 'flex', gap: 16, width: '100%' }}>
@@ -169,11 +183,11 @@ const StreamDemo: React.FC<{ content: string }> = ({ content }) => {
169183
whiteSpace: 'pre-wrap',
170184
wordBreak: 'break-word',
171185
margin: 0,
172-
maxHeight: 800,
186+
height: 600,
173187
overflow: 'auto',
174188
}}
175189
>
176-
{displayText || 'Click Stream to start'}
190+
{content.slice(0, index)}
177191
</div>
178192
</Card>
179193

@@ -182,7 +196,13 @@ const StreamDemo: React.FC<{ content: string }> = ({ content }) => {
182196
size="small"
183197
style={{ flex: 1, overflow: 'scroll' }}
184198
extra={
185-
<Button type="primary" onClick={startStream} loading={isStreaming}>
199+
<Button
200+
type="primary"
201+
onClick={() => {
202+
setIndex(0);
203+
setHasNextChunk(true);
204+
}}
205+
>
186206
Re-Render
187207
</Button>
188208
}
@@ -197,7 +217,7 @@ const StreamDemo: React.FC<{ content: string }> = ({ content }) => {
197217
}}
198218
>
199219
<XMarkdown
200-
content={displayText}
220+
content={content.slice(0, index)}
201221
className={className}
202222
paragraphTag="div"
203223
openLinksInNewTab
@@ -211,7 +231,7 @@ const StreamDemo: React.FC<{ content: string }> = ({ content }) => {
211231
'incomplete-inline-code': IncompleteInlineCode,
212232
welcome: WelcomeCard,
213233
}}
214-
streaming={{ hasNextChunk: isStreaming }}
234+
streaming={{ hasNextChunk }}
215235
/>
216236
</div>
217237
</Card>

0 commit comments

Comments
 (0)