diff --git a/packages/pro-components/chat/chat-markdown/README.en-US.md b/packages/pro-components/chat/chat-markdown/README.en-US.md
index 3053806aec..b9d520a138 100644
--- a/packages/pro-components/chat/chat-markdown/README.en-US.md
+++ b/packages/pro-components/chat/chat-markdown/README.en-US.md
@@ -10,10 +10,10 @@ style | Object | - | CSS(Cascading Style Sheets) | N
custom-style | Object | - | CSS(Cascading Style Sheets),used to set style on virtual component | N
content | String | - | required | Y
options | Object | { gfm: true, pedantic: false, breaks: true } | Typescript: `TdChatContentMDOptions ` `interface TdChatContentMDOptions {gfm?: boolean; pedantic?: boolean; smartLists?: boolean; breaks?: boolean}`。[see more ts definition](https://github.com/Tencent/tdesign-miniprogram/blob/develop/packages/pro-components/chat/chat-markdown/type.ts) | N
-streaming | Object | - | Typescript: `TdChatMarkdownStreamingOptions` ` interface TdChatMarkdownStreamingOptions { hasNextChunk?: boolean; tail?: boolean \| { content?: string } }`。[see more ts definition](https://github.com/Tencent/tdesign-miniprogram/blob/develop/packages/pro-components/chat/chat-markdown/type.ts) | N
+streaming | Object | - | Typescript: `TdChatMarkdownStreamingOptions` ` interface TdChatMarkdownStreamingOptions { hasNextChunk?: boolean; completeSyntax?: boolean; tail?: boolean \| { content?: string } }`。[see more ts definition](https://github.com/Tencent/tdesign-miniprogram/blob/develop/packages/pro-components/chat/chat-markdown/type.ts) | N
### ChatMarkdown Events
name | params | description
-- | -- | --
-click | `(detail: TdMarkdownClickContext)` | [see more ts definition](https://github.com/Tencent/tdesign-miniprogram/blob/develop/packages/components/chat-markdown/type.ts)。
`interface TdMarkdownClickContext {event: TouchEvent;node: TdMarkdownNode;}`
`interface TdMarkdownNode { type: string; raw?: string; text?: string; href?: string; title?: string; tokens?: TdMarkdownNode[]; [key: string]: unknown;}`
+click | `(detail: TdMarkdownClickContext)` | [see more ts definition](https://github.com/Tencent/tdesign-miniprogram/blob/develop/packages/pro-components/chat/chat-markdown/type.ts)。
`interface TdMarkdownClickContext {event: TouchEvent;node: TdMarkdownNode;}`
`interface TdMarkdownNode { type: string; raw?: string; text?: string; href?: string; title?: string; tokens?: TdMarkdownNode[]; [key: string]: unknown;}`
diff --git a/packages/pro-components/chat/chat-markdown/README.md b/packages/pro-components/chat/chat-markdown/README.md
index cdb109ac48..0217766426 100644
--- a/packages/pro-components/chat/chat-markdown/README.md
+++ b/packages/pro-components/chat/chat-markdown/README.md
@@ -93,10 +93,10 @@ style | Object | - | 样式 | N
custom-style | Object | - | 样式,一般用于开启虚拟化组件节点场景 | N
content | String | - | 必需。markdown 内容文本 | Y
options | Object | { gfm: true, pedantic: false, breaks: true } | Markdown 解析器基础配置。TS 类型:`TdChatContentMDOptions ` `interface TdChatContentMDOptions {gfm?: boolean; pedantic?: boolean; smartLists?: boolean; breaks?: boolean}`。[详细类型定义](https://github.com/Tencent/tdesign-miniprogram/blob/develop/packages/pro-components/chat/chat-markdown/type.ts) | N
-streaming | Object | - | 流式输出配置,控制尾部光标的显示与隐藏。尾部光标配置,true 使用默认光标 ▋,传对象可自定义光标字符。TS 类型:`TdChatMarkdownStreamingOptions` ` interface TdChatMarkdownStreamingOptions { hasNextChunk?: boolean; tail?: boolean \| { content?: string } }`。[详细类型定义](https://github.com/Tencent/tdesign-miniprogram/blob/develop/packages/pro-components/chat/chat-markdown/type.ts) | N
+streaming | Object | - | 流式输出配置:`hasNextChunk` 表示是否还有后续分片(输出中为 true,结束时为 false);`completeSyntax` 默认为 false,仅控制是否处理流式末尾未闭合的 Markdown 语法。设为 true 时,会对末尾未闭合的链接、图片、加粗、行内代码等语法进行补全或隐藏,避免暴露原始符号与 URL;`tail` 控制尾部光标,true 使用默认光标 ▋,传对象可自定义光标字符。TS 类型:`TdChatMarkdownStreamingOptions` ` interface TdChatMarkdownStreamingOptions { hasNextChunk?: boolean; completeSyntax?: boolean; tail?: boolean \| { content?: string } }`。[详细类型定义](https://github.com/Tencent/tdesign-miniprogram/blob/develop/packages/pro-components/chat/chat-markdown/type.ts) | N
### ChatMarkdown Events
名称 | 参数 | 描述
-- | -- | --
-click | `(detail: TdMarkdownClickContext)` | 点击链接时触发。[详细类型定义](https://github.com/Tencent/tdesign-miniprogram/blob/develop/packages/components/chat-markdown/type.ts)。
`interface TdMarkdownClickContext {event: TouchEvent;node: TdMarkdownNode;}`
`interface TdMarkdownNode { type: string; raw?: string; text?: string; href?: string; title?: string; tokens?: TdMarkdownNode[]; [key: string]: unknown;}`
+click | `(detail: TdMarkdownClickContext)` | 点击链接时触发。[详细类型定义](https://github.com/Tencent/tdesign-miniprogram/blob/develop/packages/pro-components/chat/chat-markdown/type.ts)。
`interface TdMarkdownClickContext {event: TouchEvent;node: TdMarkdownNode;}`
`interface TdMarkdownNode { type: string; raw?: string; text?: string; href?: string; title?: string; tokens?: TdMarkdownNode[]; [key: string]: unknown;}`
diff --git a/packages/pro-components/chat/chat-markdown/__test__/index.test.js b/packages/pro-components/chat/chat-markdown/__test__/index.test.js
new file mode 100644
index 0000000000..1e28546737
--- /dev/null
+++ b/packages/pro-components/chat/chat-markdown/__test__/index.test.js
@@ -0,0 +1,83 @@
+import path from 'path';
+import simulate from 'miniprogram-simulate';
+
+jest.mock('marked', () => ({
+ Lexer: class {
+ lex(source) {
+ const marker = '\uE000TD_CHAT_CODE_0\uE001';
+ if (source.includes(marker)) {
+ return [
+ {
+ type: 'paragraph',
+ raw: source,
+ text: source,
+ tokens: [
+ {
+ type: 'strong',
+ raw: `**${marker}**`,
+ text: marker,
+ tokens: [{ type: 'text', raw: marker, text: marker }],
+ },
+ ],
+ },
+ ];
+ }
+ return [
+ {
+ type: 'paragraph',
+ raw: source,
+ text: source,
+ tokens: [
+ { type: 'text', raw: 'a**', text: 'a**', escaped: false },
+ { type: 'codespan', raw: '`code`', text: 'code' },
+ { type: 'text', raw: '**', text: '**', escaped: false },
+ ],
+ },
+ ];
+ }
+ },
+}));
+
+const chatMarkdown = load(path.resolve(__dirname, '../chat-markdown'));
+const content = 'a**`code`**';
+
+function render(streaming) {
+ const id = simulate.load({
+ template: '',
+ data: { content, streaming },
+ usingComponents: { 't-chat-markdown': chatMarkdown },
+ });
+ const container = simulate.render(id);
+ container.attach(document.createElement('parent-wrapper'));
+ return container.querySelector('#markdown');
+}
+
+function getStrongCodeToken(component) {
+ const strong = component.data.nodes[0].tokens.find((token) => token.type === 'strong');
+ return strong?.tokens?.[0];
+}
+
+describe('ChatMarkdown compatibility', () => {
+ it.each([undefined, { hasNextChunk: true, tail: false }, { hasNextChunk: true, tail: false, completeSyntax: false }])(
+ '无论是否开启 completeSyntax 都修正强调语法中的行内代码:%p',
+ (streaming) => {
+ expect(getStrongCodeToken(render(streaming))).toBeTruthy();
+ },
+ );
+
+ it('无 streaming 的无动画模式正常渲染粗体', () => {
+ expect(getStrongCodeToken(render(undefined))).toEqual({ type: 'codespan', raw: '`code`', text: 'code' });
+ });
+
+ it('光标注入粗体中的最后一个 codespan', () => {
+ expect(getStrongCodeToken(render({ hasNextChunk: true, tail: { content: '...' }, completeSyntax: false }))).toEqual(
+ {
+ type: 'codespan',
+ raw: '`code`',
+ text: 'code',
+ isTail: true,
+ tailContent: '...',
+ },
+ );
+ });
+});
diff --git a/packages/pro-components/chat/chat-markdown/__test__/stream-syntax.test.js b/packages/pro-components/chat/chat-markdown/__test__/stream-syntax.test.js
new file mode 100644
index 0000000000..1b7ddc0688
--- /dev/null
+++ b/packages/pro-components/chat/chat-markdown/__test__/stream-syntax.test.js
@@ -0,0 +1,58 @@
+import completeUnclosedInlineSyntax from '../utils/stream-syntax';
+
+describe('completeUnclosedInlineSyntax', () => {
+ it.each([
+ ['[text', '[text]()'],
+ ['[text]', '[text]()'],
+ ['[text](url', '[text](url)'],
+ ['![alt', ''],
+ ['![alt]', ''],
+ ['('补全或隐藏未闭合的链接和图片:%s', (source, expected) => {
+ expect(completeUnclosedInlineSyntax(source)).toBe(expected);
+ });
+
+ it.each([String.raw`\[literal`, String.raw`\![alt`, String.raw`\!`])('不处理转义语法:%s', (source) => {
+ expect(completeUnclosedInlineSyntax(source)).toBe(source);
+ });
+
+ it.each([
+ ['!', ''],
+ ['text !', 'text '],
+ ['你好!', '你好!'],
+ ])('流式阶段区分图片起始符与普通末尾感叹号:%s', (source, expected) => {
+ expect(completeUnclosedInlineSyntax(source)).toBe(expected);
+ });
+
+ it.each(['`!`', '`[text`', '`![alt`', '``**code``'])('不处理行内代码中的语法:%s', (source) => {
+ expect(completeUnclosedInlineSyntax(source)).toBe(source);
+ });
+
+ it.each([
+ ['[inline](https://example.com)', {}],
+ ['', {}],
+ ['[id]', { id: { href: 'https://example.com' } }],
+ ['![id]', { id: { href: 'https://example.com/image.png' } }],
+ ['[text][id]', { id: { href: 'https://example.com' } }],
+ ['![alt][id]', { id: { href: 'https://example.com/image.png' } }],
+ ['[text][pending]', {}],
+ ['![alt][pending]', {}],
+ ])('不修改完整内联语法和引用式语法:%s', (source, links) => {
+ expect(completeUnclosedInlineSyntax(source, links)).toBe(source);
+ });
+
+ it.each([
+ ['`code', '`code`'],
+ ['``code', '``code``'],
+ ['``code`', '``code``'],
+ ['`code``', '`code`` `'],
+ ])('使用等长反引号补全行内代码:%s', (source, expected) => {
+ expect(completeUnclosedInlineSyntax(source)).toBe(expected);
+ });
+
+ it('忽略已闭合代码围栏中的反引号', () => {
+ const source = ['```js', 'const value = `code`;', '```', 'plain text'].join('\n');
+ expect(completeUnclosedInlineSyntax(source)).toBe(source);
+ });
+});
diff --git a/packages/pro-components/chat/chat-markdown/__test__/tail-cursor.test.js b/packages/pro-components/chat/chat-markdown/__test__/tail-cursor.test.js
new file mode 100644
index 0000000000..b818a71e14
--- /dev/null
+++ b/packages/pro-components/chat/chat-markdown/__test__/tail-cursor.test.js
@@ -0,0 +1,59 @@
+import { injectTailToTokens, resolveTailContent } from '../utils/tail-cursor';
+
+describe('tail cursor', () => {
+ it('解析默认及自定义光标内容', () => {
+ expect(resolveTailContent()).toBeNull();
+ expect(resolveTailContent(false)).toBeNull();
+ expect(resolveTailContent(true)).toBe('▋');
+ expect(resolveTailContent({ content: '' })).toBe('▋');
+ expect(resolveTailContent({ content: '...' })).toBe('...');
+ });
+
+ it('将光标注入最后一个可见的 codespan', () => {
+ const tokens = [
+ { type: 'text', raw: 'prefix ', text: 'prefix ' },
+ { type: 'codespan', raw: '`code`', text: 'code' },
+ ];
+
+ expect(injectTailToTokens(tokens, '...')).toBe(true);
+ expect(tokens).toEqual([
+ { type: 'text', raw: 'prefix ', text: 'prefix ' },
+ { type: 'codespan', raw: '`code`', text: 'code', isTail: true, tailContent: '...' },
+ ]);
+ });
+
+ it('将列表 text 包装节点的光标注入可见子节点', () => {
+ const tokens = [
+ {
+ type: 'text',
+ raw: 'item',
+ text: 'item',
+ tokens: [{ type: 'text', raw: 'item', text: 'item' }],
+ },
+ ];
+
+ expect(injectTailToTokens(tokens, '...')).toBe(true);
+ expect(tokens[0].isTail).toBeUndefined();
+ expect(tokens[0].tokens[0]).toMatchObject({ isTail: true, tailContent: '...' });
+ });
+
+ it('第三个参数继续作为递归深度使用', () => {
+ const tokens = [
+ {
+ type: 'paragraph',
+ tokens: [{ type: 'text', raw: 'nested', text: 'nested' }],
+ },
+ ];
+
+ expect(injectTailToTokens(tokens, '...', 2)).toBe(true);
+ expect(tokens[0].tokens[0]).toMatchObject({ isTail: true, tailContent: '...' });
+ });
+
+ it('将光标注入代码块', () => {
+ const tokens = [{ type: 'code', raw: 'code', text: 'code' }];
+
+ injectTailToTokens(tokens, '...');
+
+ expect(tokens[0]).toEqual({ type: 'code', raw: 'code', text: 'code', isTail: true, tailContent: '...' });
+ });
+});
diff --git a/packages/pro-components/chat/chat-markdown/_example/tail/index.js b/packages/pro-components/chat/chat-markdown/_example/tail/index.js
index e961051323..db8f42df2d 100644
--- a/packages/pro-components/chat/chat-markdown/_example/tail/index.js
+++ b/packages/pro-components/chat/chat-markdown/_example/tail/index.js
@@ -13,7 +13,7 @@ Page({
],
selectedSegment: -1, // 初始不选中任何分段
content: '',
- streaming: { hasNextChunk: false, tail: true },
+ streaming: { hasNextChunk: false, tail: true, completeSyntax: true },
},
onLoad() {
@@ -51,7 +51,7 @@ Page({
this.setData({
selectedSegment: segmentValue,
- streaming: { hasNextChunk: true, tail: true },
+ streaming: { hasNextChunk: true, tail: true, completeSyntax: true },
});
this._timer = setInterval(() => {
@@ -59,7 +59,7 @@ Page({
const isDone = index >= markdownData.length;
this.setData({
content: markdownData.slice(0, index),
- streaming: { hasNextChunk: !isDone, tail: true },
+ streaming: { hasNextChunk: !isDone, tail: true, completeSyntax: true },
});
if (isDone) {
clearInterval(this._timer);
diff --git a/packages/pro-components/chat/chat-markdown/chat-markdown-code/chat-markdown-code.wxml b/packages/pro-components/chat/chat-markdown/chat-markdown-code/chat-markdown-code.wxml
index 20068babdd..ea3a1e6fd9 100644
--- a/packages/pro-components/chat/chat-markdown/chat-markdown-code/chat-markdown-code.wxml
+++ b/packages/pro-components/chat/chat-markdown/chat-markdown-code/chat-markdown-code.wxml
@@ -6,6 +6,7 @@
- {{node.text}}
+ {{node.text}}
diff --git a/packages/pro-components/chat/chat-markdown/chat-markdown-node/chat-markdown-node.wxml b/packages/pro-components/chat/chat-markdown/chat-markdown-node/chat-markdown-node.wxml
index c13664169b..5221dc8fda 100644
--- a/packages/pro-components/chat/chat-markdown/chat-markdown-node/chat-markdown-node.wxml
+++ b/packages/pro-components/chat/chat-markdown/chat-markdown-node/chat-markdown-node.wxml
@@ -55,7 +55,9 @@
- {{''+item.raw+''}}
+
+ {{''+item.raw+''}}
+
@@ -112,6 +114,7 @@
>
{{''+(item.text||item.raw)+''}}
+
{
- if (item.tokens?.length) result.push(...item.tokens);
- return result;
- }, []);
-}
-
-/**
- * 从后往前遍历 token 树,找到最后一个非空 text 叶子节点,打上 isTail 标记。
- * - 有子节点(tokens / items)时优先递归
- * - 末尾是 code / table / image 等非 text 节点时静默跳过,不注入
- * @returns 是否成功注入
- */
-function injectTailToTokens(tokens: any[], tailChar: string, depth = 0): boolean {
- for (let i = tokens.length - 1; i >= 0; i -= 1) {
- const token = tokens[i];
-
- // code 块作为叶子节点,直接注入,不递归
- if (token.type === 'code' && (token.text || token.raw)?.trim()) {
- token.isTail = true;
- token.tailContent = tailChar;
- return true;
- }
-
- // 叶子文本节点且内容非空
- if (token.type === 'text' && (token.text || token.raw)?.trim()) {
- token.isTail = true;
- token.tailContent = tailChar;
- return true;
- }
-
- // table 节点:从后往前遍历 rows,再遍历 header
- if (token.type === 'table') {
- const allRows: any[][] = [...(token.header ? [token.header] : []), ...(token.rows || [])];
- for (let r = allRows.length - 1; r >= 0; r -= 1) {
- const row = allRows[r];
- for (let c = row.length - 1; c >= 0; c -= 1) {
- const cell = row[c];
- if (cell.tokens?.length) {
- if (injectTailToTokens(cell.tokens, tailChar, depth + 1)) return true;
- }
- }
- }
- } else {
- // 有子节点时递归
- let children: any[] | null = null;
- if (token.tokens?.length) {
- children = token.tokens;
- } else if (token.items?.length) {
- children = flatListItems(token.items);
- }
- if (children?.length) {
- if (injectTailToTokens(children, tailChar, depth + 1)) return true;
- }
- }
- }
- return false;
-}
-
export interface ChatMarkdownProps extends TdChatMarkdownProps {}
@wxComponent()
@@ -110,11 +41,23 @@ export default class ChatMarkdown extends SuperComponent {
// 解析markdown文本
parseMarkdown(markdown: string) {
try {
+ const { streaming } = this.data;
+ // 流式输出时对末尾未闭合语法做补全/隐藏(需显式开启 streaming.completeSyntax)
+ const syntaxCompletionEnabled = streaming?.completeSyntax === true;
+ const shouldComplete = streaming?.hasNextChunk && syntaxCompletionEnabled;
const lexer = new Lexer(this.data.options);
- const tokens = lexer.lex(markdown);
+ const originalTokens = lexer.lex(markdown);
+ const source = shouldComplete ? completeUnclosedInlineSyntax(markdown, originalTokens.links) : markdown;
+ let tokens = source === markdown ? originalTokens : new Lexer(this.data.options).lex(source);
+
+ // 修正 marked 对“强调语法内包含行内代码”场景的 token 泄漏;与流式语法补全开关无关。
+ if (hasLeakedInlineFormatting(tokens)) {
+ const { source: maskedSource, masks } = maskInlineCodes(source);
+ tokens = new Lexer(this.data.options).lex(maskedSource);
+ restoreMaskedInlineCodes(tokens, masks);
+ }
// 尾部光标注入
- const { streaming } = this.data;
const tailChar = resolveTailContent(streaming?.tail);
if (streaming?.hasNextChunk && tailChar) {
injectTailToTokens(tokens, tailChar);
diff --git a/packages/pro-components/chat/chat-markdown/props.ts b/packages/pro-components/chat/chat-markdown/props.ts
index 115c234c86..7dbca3980d 100644
--- a/packages/pro-components/chat/chat-markdown/props.ts
+++ b/packages/pro-components/chat/chat-markdown/props.ts
@@ -17,7 +17,7 @@ const props: TdChatMarkdownProps = {
type: Object,
value: { gfm: true, pedantic: false, breaks: true },
},
- /** 流式输出配置,控制尾部光标的显示与隐藏。尾部光标配置,true 使用默认光标 ▋,传对象可自定义光标字符 */
+ /** 流式输出配置:`hasNextChunk` 表示是否还有后续分片(输出中为 true,结束时为 false);`completeSyntax` 默认为 false,仅控制是否处理流式末尾未闭合的 Markdown 语法。设为 true 时,会对末尾未闭合的链接、图片、加粗、行内代码等语法进行补全或隐藏,避免暴露原始符号与 URL;`tail` 控制尾部光标,true 使用默认光标 ▋,传对象可自定义光标字符 */
streaming: {
type: Object,
},
diff --git a/packages/pro-components/chat/chat-markdown/type.ts b/packages/pro-components/chat/chat-markdown/type.ts
index 309028baea..1936b33269 100644
--- a/packages/pro-components/chat/chat-markdown/type.ts
+++ b/packages/pro-components/chat/chat-markdown/type.ts
@@ -23,7 +23,7 @@ export interface TdChatMarkdownProps {
value?: TdChatContentMDOptions;
};
/**
- * 流式输出配置,控制尾部光标的显示与隐藏。尾部光标配置,true 使用默认光标 ▋,传对象可自定义光标字符
+ * 流式输出配置:`hasNextChunk` 表示是否还有后续分片(输出中为 true,结束时为 false);`completeSyntax` 默认为 false,仅控制是否处理流式末尾未闭合的 Markdown 语法。设为 true 时,会对末尾未闭合的链接、图片、加粗、行内代码等语法进行补全或隐藏,避免暴露原始符号与 URL;`tail` 控制尾部光标,true 使用默认光标 ▋,传对象可自定义光标字符
*/
streaming?: {
type: ObjectConstructor;
@@ -40,5 +40,6 @@ export interface TdChatContentMDOptions {
export interface TdChatMarkdownStreamingOptions {
hasNextChunk?: boolean;
+ completeSyntax?: boolean;
tail?: boolean | { content?: string };
}
diff --git a/packages/pro-components/chat/chat-markdown/utils/marked-fallback.ts b/packages/pro-components/chat/chat-markdown/utils/marked-fallback.ts
new file mode 100644
index 0000000000..6b3d3bc744
--- /dev/null
+++ b/packages/pro-components/chat/chat-markdown/utils/marked-fallback.ts
@@ -0,0 +1,203 @@
+interface InlineCodeMask {
+ marker: string;
+ raw: string;
+ text: string;
+}
+
+type InlineCodeMaskMatch = { mask: InlineCodeMask; index: number };
+
+/** 跳过 fenced code block。 */
+function getCodeFenceRanges(markdown: string): Array<[number, number]> {
+ const ranges: Array<[number, number]> = [];
+ let fence: { marker: string; start: number } | null = null;
+ let lineStart = 0;
+
+ while (lineStart < markdown.length) {
+ const newlineIndex = markdown.indexOf('\n', lineStart);
+ const lineEnd = newlineIndex === -1 ? markdown.length : newlineIndex + 1;
+ const line = markdown.slice(lineStart, newlineIndex === -1 ? lineEnd : newlineIndex);
+ const matchedFence = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
+
+ if (fence) {
+ if (
+ matchedFence &&
+ matchedFence[1][0] === fence.marker[0] &&
+ matchedFence[1].length >= fence.marker.length &&
+ /^[ \t]*$/.test(matchedFence[2])
+ ) {
+ ranges.push([fence.start, lineEnd]);
+ fence = null;
+ }
+ } else if (matchedFence) {
+ const [, marker, info] = matchedFence;
+ if (marker[0] === '~' || !info.includes('`')) fence = { marker, start: lineStart };
+ }
+
+ lineStart = lineEnd;
+ }
+
+ if (fence) ranges.push([fence.start, markdown.length]);
+ return ranges;
+}
+
+function isEscaped(markdown: string, index: number): boolean {
+ let slashCount = 0;
+ for (let i = index - 1; i >= 0 && markdown[i] === '\\'; i -= 1) slashCount += 1;
+ return slashCount % 2 === 1;
+}
+
+function normalizeCodeSpanText(text: string): string {
+ const normalized = text.replace(/[\r\n]+/g, ' ');
+ return /^\s/.test(normalized) && /\s$/.test(normalized) && /\S/.test(normalized)
+ ? normalized.slice(1, -1)
+ : normalized;
+}
+
+/** marked 嵌套行内代码解析失败时的回退。 */
+export function maskInlineCodes(markdown: string): { source: string; masks: InlineCodeMask[] } {
+ const masks: InlineCodeMask[] = [];
+ const fenceRanges = getCodeFenceRanges(markdown);
+ let fenceIndex = 0;
+ let source = '';
+ let index = 0;
+
+ while (index < markdown.length) {
+ const range = fenceRanges[fenceIndex];
+ if (range && index === range[0]) {
+ const [rangeStart, rangeEnd] = range;
+ source += markdown.slice(rangeStart, rangeEnd);
+ index = rangeEnd;
+ fenceIndex += 1;
+ } else if (markdown[index] !== '`' || isEscaped(markdown, index)) {
+ source += markdown[index];
+ index += 1;
+ } else {
+ let delimiterEnd = index;
+ while (markdown[delimiterEnd] === '`') delimiterEnd += 1;
+ const delimiterLength = delimiterEnd - index;
+ const nextFenceStart = fenceRanges[fenceIndex]?.[0] ?? markdown.length;
+ let closingStart = delimiterEnd;
+
+ while (closingStart < nextFenceStart) {
+ if (markdown[closingStart] === '`') {
+ let closingEnd = closingStart;
+ while (markdown[closingEnd] === '`') closingEnd += 1;
+ if (closingEnd - closingStart === delimiterLength) break;
+ closingStart = closingEnd;
+ } else {
+ closingStart += 1;
+ }
+ }
+
+ if (closingStart === nextFenceStart) {
+ source += markdown.slice(index, delimiterEnd);
+ index = delimiterEnd;
+ } else {
+ const closingEnd = closingStart + delimiterLength;
+ const raw = markdown.slice(index, closingEnd);
+ const marker = `\uE000TD_CHAT_CODE_${masks.length}\uE001`;
+ masks.push({ marker, raw, text: normalizeCodeSpanText(markdown.slice(delimiterEnd, closingStart)) });
+ source += marker;
+ index = closingEnd;
+ }
+ }
+ }
+
+ return { source, masks };
+}
+
+export function restoreMaskedInlineCodes(tokens: any[], masks: InlineCodeMask[]) {
+ if (!masks.length) return;
+
+ const findMask = (text: string, from: number): InlineCodeMaskMatch | null => {
+ let matchedMask: InlineCodeMask | null = null;
+ let matchedIndex = -1;
+ for (let i = 0; i < masks.length; i += 1) {
+ const mask = masks[i];
+ const index = text.indexOf(mask.marker, from);
+ if (index !== -1 && (matchedIndex === -1 || index < matchedIndex)) {
+ matchedMask = mask;
+ matchedIndex = index;
+ }
+ }
+ return matchedMask ? { mask: matchedMask, index: matchedIndex } : null;
+ };
+ const restoreString = (value: unknown): unknown => {
+ if (typeof value !== 'string') return value;
+ return masks.reduce((result, mask) => result.split(mask.marker).join(mask.raw), value);
+ };
+
+ const restore = (items: any[]): void => {
+ for (let i = 0; i < items.length; i += 1) {
+ const token = items[i];
+ const raw = typeof token.raw === 'string' ? token.raw : token.text;
+ const firstMask = token.type === 'text' && typeof raw === 'string' ? findMask(raw, 0) : null;
+ if (firstMask) {
+ const restored: any[] = [];
+ let offset = 0;
+ let match: InlineCodeMaskMatch | null = firstMask;
+ while (match !== null) {
+ const current: InlineCodeMaskMatch = match;
+ if (current.index > offset) {
+ restored.push({
+ type: 'text',
+ raw: raw.slice(offset, current.index),
+ text: raw.slice(offset, current.index),
+ });
+ }
+ restored.push({ type: 'codespan', raw: current.mask.raw, text: current.mask.text });
+ offset = current.index + current.mask.marker.length;
+ match = findMask(raw, offset);
+ }
+ if (offset < raw.length) restored.push({ type: 'text', raw: raw.slice(offset), text: raw.slice(offset) });
+ if (token.isTail && restored.length) {
+ const last = restored[restored.length - 1];
+ last.isTail = true;
+ last.tailContent = token.tailContent;
+ }
+ items.splice(i, 1, ...restored);
+ i += restored.length - 1;
+ } else {
+ token.raw = restoreString(token.raw);
+ token.text = restoreString(token.text);
+ if (token.tokens?.length) restore(token.tokens);
+ if (token.items?.length) token.items.forEach((item: any) => item.tokens?.length && restore(item.tokens));
+ if (token.type === 'table') {
+ const rows = [...(token.header ? [token.header] : []), ...(token.rows || [])];
+ rows.forEach((row: any[]) => row.forEach((cell: any) => cell.tokens?.length && restore(cell.tokens)));
+ }
+ }
+ }
+ };
+
+ restore(tokens);
+}
+
+/** 检测 marked 未解析的嵌套格式符。 */
+export function hasLeakedInlineFormatting(tokens: any[]): boolean {
+ const containsLeakedFormatting = (items: any[]): boolean => {
+ const text = items
+ .filter((token) => token.type === 'text')
+ .map((token) => token.raw || token.text || '')
+ .join('');
+ const hasCodeSpan = items.some((token) => token.type === 'codespan');
+ const hasFormatting = /(\*{1,3})[\s\S]*\*{1,3}|_{1,3}[\s\S]*_{1,3}|~~[\s\S]*~~|\[[\s\S]*\]\([^)]*\)/.test(text);
+
+ if (hasCodeSpan && hasFormatting) return true;
+
+ return items.some((token): boolean => {
+ if (token.tokens?.length && containsLeakedFormatting(token.tokens)) return true;
+ if (token.items?.some((item: any): boolean => !!item.tokens?.length && containsLeakedFormatting(item.tokens)))
+ return true;
+ if (token.type === 'table') {
+ const rows = [...(token.header ? [token.header] : []), ...(token.rows || [])];
+ return rows.some((row: any[]): boolean =>
+ row.some((cell: any): boolean => !!cell.tokens?.length && containsLeakedFormatting(cell.tokens)),
+ );
+ }
+ return false;
+ });
+ };
+
+ return containsLeakedFormatting(tokens);
+}
diff --git a/packages/pro-components/chat/chat-markdown/utils/stream-syntax.ts b/packages/pro-components/chat/chat-markdown/utils/stream-syntax.ts
new file mode 100644
index 0000000000..6ac18f06e7
--- /dev/null
+++ b/packages/pro-components/chat/chat-markdown/utils/stream-syntax.ts
@@ -0,0 +1,254 @@
+/**
+ * 流式输出时的未闭合 markdown 语法处理。
+ *
+ * 大模型流式输出时,文本会逐字符到达,末尾常出现未输完的语法片段(如
+ * `[text`、`![alt`、`**bold`、`` `code ``)。若直接交给 marked 解析,会暴露原始
+ * 符号与 URL,造成闪烁与光标跳动。此模块在解析前对末尾未闭合语法做补全或隐藏,
+ * 待语法闭合后再正常渲染。
+ */
+
+/** 按 Markdown 围栏规则判断是否存在未闭合代码块。 */
+function hasUnclosedCodeFence(markdown: string): boolean {
+ let fence: string | null = null;
+
+ markdown.split('\n').forEach((line) => {
+ const matchedFence = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
+ if (!matchedFence) return;
+
+ const [, marker, info] = matchedFence;
+ if (fence) {
+ if (marker[0] === fence[0] && marker.length >= fence.length && /^[ \t]*$/.test(info)) fence = null;
+ return;
+ }
+
+ if (marker[0] === '~' || !info.includes('`')) fence = marker;
+ });
+
+ return !!fence;
+}
+
+/** 隐藏未输完的 1~2 位代码围栏标记。 */
+function hidePartialFence(markdown: string): string {
+ return markdown.replace(/(^|\n)[ \t]*[`~]{1,2}[ \t]*$/, '$1');
+}
+
+function isEscaped(markdown: string, index: number): boolean {
+ let slashCount = 0;
+ for (let i = index - 1; i >= 0 && markdown[i] === '\\'; i -= 1) slashCount += 1;
+ return slashCount % 2 === 1;
+}
+
+interface BacktickRun {
+ index: number;
+ length: number;
+}
+
+/** 收集 fenced code block 外的未转义反引号片段。 */
+function getInlineCodeRuns(markdown: string): BacktickRun[] {
+ const runs: BacktickRun[] = [];
+ let fence: string | null = null;
+ let lineStart = 0;
+
+ while (lineStart < markdown.length) {
+ const newlineIndex = markdown.indexOf('\n', lineStart);
+ const lineEnd = newlineIndex === -1 ? markdown.length : newlineIndex;
+ const line = markdown.slice(lineStart, lineEnd);
+ const matchedFence = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
+
+ if (fence) {
+ if (
+ matchedFence &&
+ matchedFence[1][0] === fence[0] &&
+ matchedFence[1].length >= fence.length &&
+ /^[ \t]*$/.test(matchedFence[2])
+ ) {
+ fence = null;
+ }
+ } else if (matchedFence && (matchedFence[1][0] === '~' || !matchedFence[2].includes('`'))) {
+ [, fence] = matchedFence;
+ } else {
+ const re = /`+/g;
+ for (let match = re.exec(line); match; match = re.exec(line)) {
+ const index = lineStart + match.index;
+ if (!isEscaped(markdown, index)) runs.push({ index, length: match[0].length });
+ }
+ }
+
+ if (newlineIndex === -1) break;
+ lineStart = newlineIndex + 1;
+ }
+
+ return runs;
+}
+
+/** 将已闭合行内代码替换为等长空格,避免其中的 Markdown 字面量参与后续语法检测。 */
+function getInlineSyntaxScanSource(markdown: string): string {
+ const chars = markdown.split('');
+ let opening: BacktickRun | null = null;
+
+ const runs = getInlineCodeRuns(markdown);
+ for (let r = 0; r < runs.length; r += 1) {
+ const run = runs[r];
+ if (!opening) {
+ opening = run;
+ } else if (run.length === opening.length) {
+ for (let i = opening.index; i < run.index + run.length; i += 1) chars[i] = ' ';
+ opening = null;
+ }
+ }
+
+ return chars.join('');
+}
+
+type ReferenceLinks = Record;
+
+function normalizeReferenceLabel(label: string): string {
+ return label.trim().replace(/\s+/g, ' ').toLowerCase();
+}
+
+function hasReference(referenceLinks: ReferenceLinks, label: string): boolean {
+ return Object.prototype.hasOwnProperty.call(referenceLinks, normalizeReferenceLabel(label));
+}
+
+/** 隐藏末尾可能发展为图片的孤立 !,下一分片非 [ 或流结束时会由原文恢复。 */
+function hideImageMarker(markdown: string): string | null {
+ const scanSource = getInlineSyntaxScanSource(markdown);
+ const index = scanSource.length - 1;
+ if (
+ index < 0 ||
+ scanSource[index] !== '!' ||
+ isEscaped(markdown, index) ||
+ (index > 0 && !/\s/.test(scanSource[index - 1]))
+ )
+ return null;
+ return markdown.slice(0, index);
+}
+
+/** 隐藏末尾未闭合的图片语法;完整内联图片和已定义的引用式图片交给 marked。 */
+function closeImage(markdown: string, referenceLinks: ReferenceLinks): string | null {
+ const scanSource = getInlineSyntaxScanSource(markdown);
+ const reference = scanSource.match(/!\[([^\]\n]*)\]\[([^\]\n]*)\]$/);
+ if (reference?.index !== undefined && !isEscaped(markdown, reference.index)) return null;
+
+ const openReference = scanSource.match(/!\[([^\]\n]*)\]\[[^\]\n]*$/);
+ if (openReference?.index !== undefined && !isEscaped(markdown, openReference.index)) {
+ return markdown.slice(0, openReference.index);
+ }
+
+ const openAlt = scanSource.match(/!\[([^\]\n]*)$/);
+ const openDestination = scanSource.match(/!\[([^\]\n]*)\]\([^)\n]*$/);
+ const shortcutReference = scanSource.match(/!\[([^\]\n]*)\]$/);
+ const match = openAlt || openDestination || shortcutReference;
+
+ if (!match || match.index === undefined || isEscaped(markdown, match.index)) return null;
+ if (shortcutReference && hasReference(referenceLinks, shortcutReference[1])) return null;
+ return markdown.slice(0, match.index);
+}
+
+function isLinkCandidate(markdown: string, index: number): boolean {
+ if (isEscaped(markdown, index) || markdown[index - 1] === '!') return false;
+ const prefix = markdown.slice(0, index);
+ if (/!\[[^\]\n]*\]$/.test(prefix)) return false;
+ return !/(^|\n)[ \t]*[-+*][ \t]+$/.test(prefix);
+}
+
+/** 补全末尾未闭合的链接语法;完整链接和已定义的引用式链接交给 marked。 */
+function closeLink(markdown: string, referenceLinks: ReferenceLinks): string | null {
+ const scanSource = getInlineSyntaxScanSource(markdown);
+ const reference = scanSource.match(/\[([^\]\n]*)\]\[([^\]\n]*)\]$/);
+ if (reference?.index !== undefined && isLinkCandidate(markdown, reference.index)) return null;
+
+ const openDestination = scanSource.match(/\[([^\]\n]*)\]\([^)\n]*$/);
+ if (openDestination?.index !== undefined && isLinkCandidate(markdown, openDestination.index)) {
+ return `${markdown})`;
+ }
+
+ const shortcutReference = scanSource.match(/\[([^\]\n]*)\]$/);
+ if (shortcutReference?.index !== undefined && isLinkCandidate(markdown, shortcutReference.index)) {
+ return hasReference(referenceLinks, shortcutReference[1]) ? null : `${markdown}()`;
+ }
+
+ const openLabel = scanSource.match(/\[([^\]\n]*)$/);
+ if (openLabel?.index !== undefined && isLinkCandidate(markdown, openLabel.index)) return `${markdown}]()`;
+ return null;
+}
+
+/** 补全末尾未闭合的行内代码;闭合符必须与起始反引号长度一致。 */
+function closeInlineCode(markdown: string): string | null {
+ const runs = getInlineCodeRuns(markdown);
+ let opening: BacktickRun | null = null;
+ for (let i = 0; i < runs.length; i += 1) {
+ const run = runs[i];
+ if (!opening) opening = run;
+ else if (run.length === opening.length) opening = null;
+ }
+
+ if (!opening) return null;
+ if (opening.index + opening.length === markdown.length) return markdown.slice(0, opening.index);
+
+ const lastRun = runs[runs.length - 1];
+ if (lastRun !== opening && lastRun.index + lastRun.length === markdown.length) {
+ if (lastRun.length < opening.length) return `${markdown}${'`'.repeat(opening.length - lastRun.length)}`;
+ return `${markdown} ${'`'.repeat(opening.length)}`;
+ }
+ return `${markdown}${'`'.repeat(opening.length)}`;
+}
+
+/**
+ * 补全最后一行未配对的加粗/斜体(`*` `**` `***`);无需处理时返回 null。
+ * 仅处理 `*` 系语法,`_`、`~~` 未闭合时保持字面量交给 marked 常规解析(成对才渲染)。
+ */
+function closeEmphasis(markdown: string): string | null {
+ const lastLine = markdown.slice(markdown.lastIndexOf('\n') + 1);
+ if (!lastLine || (lastLine.match(/`/g) || []).length % 2 === 1) return null;
+
+ const line = lastLine.replace(/^\s*[*+-]\s+/, '');
+ const trailingMatch = line.match(/(\*)\1*$/);
+ const trailing = trailingMatch ? trailingMatch[0] : '';
+ const scanLine = trailingMatch ? line.slice(0, trailingMatch.index) : line;
+
+ const hasBody = !!scanLine.replace(/[\s*`]/g, '');
+ if (trailing && !hasBody) return markdown.slice(0, markdown.length - lastLine.length);
+
+ const urlLess = getInlineSyntaxScanSource(scanLine).replace(/(?:https?|ftp):\/\/\S+/g, ' ');
+ const stack: string[] = [];
+ const re = /(\*{1,3})/g;
+ for (let m = re.exec(urlLess); m; m = re.exec(urlLess)) {
+ const escaped = m.index > 0 && urlLess[m.index - 1] === '\\';
+ if (!escaped) {
+ if (stack[stack.length - 1] === m[0]) stack.pop();
+ else stack.push(m[0]);
+ }
+ }
+
+ const top = stack[stack.length - 1];
+ const trimmed = markdown.replace(/[ \t]+$/, '');
+ if (trailing && !top) return markdown.slice(0, markdown.length - trailing.length);
+ if (trailing && top && trailing[0] === top[0]) {
+ if (trailing.length === top.length) stack.pop();
+ else if (trailing.length < top.length) return `${trimmed}${top.slice(trailing.length)}`;
+ }
+ return stack.length ? `${trimmed}${stack.reverse().join('')}` : null;
+}
+
+/** 流式输出时补全/隐藏文本末尾未闭合的语法,避免原始符号与 URL 闪现 */
+export default function completeUnclosedInlineSyntax(markdown: string, referenceLinks: ReferenceLinks = {}): string {
+ // 未闭合代码块:仅隐藏未输完的围栏,块内内容交给 marked 渲染
+ if (hasUnclosedCodeFence(markdown)) return hidePartialFence(markdown);
+
+ // 行首未输完的起始围栏(1~2 个反引号):先隐藏避免闪出
+ const hiddenFence = hidePartialFence(markdown);
+ if (hiddenFence !== markdown) return hiddenFence;
+
+ // 先补齐行内代码,避免其内部的 [text] / ![alt] 被误判为链接或图片语法。
+ const codeClosed = closeInlineCode(markdown) ?? markdown;
+
+ // 图片起始符、图片、链接命中即返回(整段隐藏或补全,不与强调叠加)。
+ const imageMarkerHidden = hideImageMarker(codeClosed);
+ if (imageMarkerHidden !== null) return imageMarkerHidden;
+
+ const closed = closeImage(codeClosed, referenceLinks) ?? closeLink(codeClosed, referenceLinks);
+ if (closed !== null) return closed;
+
+ return closeEmphasis(codeClosed) ?? codeClosed;
+}
diff --git a/packages/pro-components/chat/chat-markdown/utils/tail-cursor.ts b/packages/pro-components/chat/chat-markdown/utils/tail-cursor.ts
new file mode 100644
index 0000000000..37c3f0bb81
--- /dev/null
+++ b/packages/pro-components/chat/chat-markdown/utils/tail-cursor.ts
@@ -0,0 +1,82 @@
+/**
+ * 尾部光标(打字光标)注入。
+ *
+ * 流式输出未结束时,需要在已渲染内容的末尾显示一个闪烁光标(如 ▋)。
+ * 本模块负责解析光标字符,并在 marked 生成的 token 树中定位最后一个可见的
+ * 文本/代码叶子节点,为其打上标记,供渲染层追加光标。
+ */
+
+const DEFAULT_TAIL_CONTENT = '▋';
+
+/** 解析 tail 参数,返回光标字符;不需要显示时返回 null */
+export function resolveTailContent(tail?: boolean | { content?: string }): string | null {
+ if (!tail) return null;
+ if (typeof tail === 'boolean') return DEFAULT_TAIL_CONTENT;
+ return tail.content || DEFAULT_TAIL_CONTENT;
+}
+
+/**
+ * 将列表项的子 tokens 展平,供 injectTailToTokens 递归使用。
+ * marked 的 list token 结构:list.items[].tokens(而非 list.tokens)
+ */
+function flatListItems(items: any[]): any[] {
+ return items.reduce((result: any[], item: any) => {
+ if (item.tokens?.length) result.push(...item.tokens);
+ return result;
+ }, []);
+}
+
+/**
+ * 从后往前遍历 token 树,找到最后一个非空文本或代码叶子节点,打上 isTail 标记。
+ * @returns 是否成功注入
+ */
+export function injectTailToTokens(tokens: any[], tailChar: string, depth = 0): boolean {
+ for (let i = tokens.length - 1; i >= 0; i -= 1) {
+ const token = tokens[i];
+
+ // code 块作为叶子节点,直接注入,不递归
+ if (token.type === 'code' && (token.text || token.raw)?.trim()) {
+ token.isTail = true;
+ token.tailContent = tailChar;
+ return true;
+ }
+
+ // text 包装节点需要优先进入子 tokens,确保光标标记在实际渲染的文本之后。
+ if (token.type === 'text' && token.tokens?.length) {
+ if (injectTailToTokens(token.tokens, tailChar, depth + 1)) return true;
+ }
+
+ // 普通文本和行内代码都是可见叶子节点,光标应位于其后。
+ if (['text', 'codespan'].includes(token.type) && (token.text || token.raw)?.trim()) {
+ token.isTail = true;
+ token.tailContent = tailChar;
+ return true;
+ }
+
+ // table 节点:从后往前遍历 rows,再遍历 header
+ if (token.type === 'table') {
+ const allRows: any[][] = [...(token.header ? [token.header] : []), ...(token.rows || [])];
+ for (let r = allRows.length - 1; r >= 0; r -= 1) {
+ const row = allRows[r];
+ for (let c = row.length - 1; c >= 0; c -= 1) {
+ const cell = row[c];
+ if (cell.tokens?.length) {
+ if (injectTailToTokens(cell.tokens, tailChar, depth + 1)) return true;
+ }
+ }
+ }
+ } else {
+ // 有子节点时递归
+ let children: any[] | null = null;
+ if (token.tokens?.length) {
+ children = token.tokens;
+ } else if (token.items?.length) {
+ children = flatListItems(token.items);
+ }
+ if (children?.length) {
+ if (injectTailToTokens(children, tailChar, depth + 1)) return true;
+ }
+ }
+ }
+ return false;
+}
diff --git a/packages/tdesign-miniprogram/.changelog/pr-4627.md b/packages/tdesign-miniprogram/.changelog/pr-4627.md
new file mode 100644
index 0000000000..d7b05869c4
--- /dev/null
+++ b/packages/tdesign-miniprogram/.changelog/pr-4627.md
@@ -0,0 +1,6 @@
+---
+pr_number: 4627
+contributor: anlyyao
+---
+
+- feat(CharMarkdown): 新增可选配置 `streaming.completeSyntax`,默认为 false,仅控制是否处理流式末尾未闭合的 Markdown 语法。设为 true 时,会对末尾未闭合的链接、图片、加粗、行内代码等语法进行补全或隐藏,避免暴露原始符号与 URL。 @anlyyao ([#4627](https://github.com/Tencent/tdesign-miniprogram/pull/4627))