[\s\S]*?<\/li>\n?)+)/g, '');
-
- // 9. Convert double newlines to paragraph breaks, single newlines to spaces
- text = text.replace(/\n\n/g, '');
- text = text.replace(/\n/g, ' ');
-
- // 10. Restore code blocks and inline codes
- text = text.replace(/\x00CODEBLOCK(\d+)\x00/g, (_m: string, idx: string) => codeBlocks[parseInt(idx)] || '');
- text = text.replace(/\x00MERMAID(\d+)\x00/g, (_m: string, idx: string) => mermaidBlocks[parseInt(idx)] || '');
- text = text.replace(/\x00INLINE(\d+)\x00/g, (_m: string, idx: string) => inlineCodes[parseInt(idx)] || '');
- text = text.replace(/\x00TABLE(\d+)\x00/g, (_m: string, idx: string) => tableBlocks[parseInt(idx)] || '');
- text = text.replace(/\x00MATHBLOCK(\d+)\x00/g, (_m: string, idx: string) => mathBlocks[parseInt(idx)] || '');
-
- // 10.5 Apply syntax highlighting to code blocks + add copy button
- text = text.replace(
- /
([\s\S]*?)<\/code><\/pre>/g,
- (_m: string, code: string) => {
- const decoded = code.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
- let highlighted: string;
- try { highlighted = hljs.highlightAuto(decoded).value; } catch { highlighted = code; }
- return ``;
- }
- );
-
- // 11. Wrap in paragraph and clean up
- text = '' + text + '
';
- text = text.replace(/<\/p>/g, '');
- text = text.replace(/
()<\/p>/g, '$1');
- text = text.replace(/(
)<\/p>/g, '$1');
- text = text.replace(/(
)<\/p>/g, '$1');
- text = text.replace(/(
/g, '$1');
- text = text.replace(/(
)<\/p>/g, '$1');
- text = text.replace(/(
)<\/p>/g, '$1');
-
- // 12. 拆分为文本片段与媒体组件
- const parts = text.split(/(\x00MEDIA\d+\x00)/g);
+ // 12. 拆分为文本片段与媒体组件(媒体以 \x00MEDIA\x00 占位符保留在 html 中)
+ const parts = html.split(/(\x00MEDIA\d+\x00)/g);
return parts.map((part: string, i: number) => {
const m = part.match(/^\x00MEDIA(\d+)\x00$/);
if (m) {
- const info = mediaBlocks[parseInt(m[1])];
+ const info = media[parseInt(m[1])];
if (info && info.src) return ;
return null;
}
@@ -339,7 +172,7 @@ const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
if (!s.trim()) return null;
return ;
});
- }, [content]);
+ }, [html, media]);
const containerRef = useRef(null);
useEffect(() => {
@@ -397,14 +230,6 @@ const ClarifyCard: React.FC<{ data: { prompt: string; options: any[] }; onChoose
};
-function escapeHtml(text: string): string {
- return text
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"');
-}
-
// ===== Time Formatting =====
function formatTime(iso: string): string {
return formatChatTime(iso);
diff --git a/frontend/src/utils/markdown.test.ts b/frontend/src/utils/markdown.test.ts
new file mode 100644
index 0000000..1c9d42e
--- /dev/null
+++ b/frontend/src/utils/markdown.test.ts
@@ -0,0 +1,80 @@
+import { describe, it, expect } from 'vitest';
+import { renderMarkdownToHtml } from './markdown';
+
+// 前台聊天 Markdown 渲染曾两次被 bug 击穿:
+// 1) 段落内单个 \n 被转成
,导致 AI 回复被强制大量换行、内容撑成多行;
+// 2) 表格在加粗/链接转换之前被提取,单元格内 **xxx** 原样显示、且
未压缩。
+// 本测试固化「渲染管线」契约,防止回归。管线代码见 ./markdown.ts(从 ChatPage 抽出)。
+describe('renderMarkdownToHtml', () => {
+ it('段落内单个 \\n 转为空格(而非
)', () => {
+ const { html } = renderMarkdownToHtml('第一行\n第二行');
+ expect(html).not.toContain('
{
+ const { html } = renderMarkdownToHtml('段一\n\n段二');
+ expect(html).toContain('段一段二');
+ });
+
+ it('3+ 连续空行压缩为 2 个(与 2 个空行结果一致)', () => {
+ const many = renderMarkdownToHtml('a\n\n\n\n\nb').html;
+ const few = renderMarkdownToHtml('a\n\nb').html;
+ expect(many).toBe(few);
+ expect(many).toContain('a
b');
+ });
+
+ it('表格在加粗之后提取,单元格内的 **xxx** 被渲染为 ', () => {
+ const input = '| 能力 | 说明 |\n| --- | --- |\n| **渠道排序** | 按优先级 |\n';
+ const { html } = renderMarkdownToHtml(input);
+ expect(html).toContain('');
+ expect(html).toContain('渠道排序');
+ });
+
+ it('单元格内连续 被压缩为单个', () => {
+ const input = '| 列 |\n| --- |\n| a
b |\n';
+ const { html } = renderMarkdownToHtml(input);
+ expect(html).toContain('a b');
+ expect(html).not.toContain('
');
+ });
+
+ it('无序列表渲染为 - ', () => {
+ const { html } = renderMarkdownToHtml('- a\n- b');
+ expect(html).toContain('
');
+ expect(html).toContain('- a
');
+ expect(html).toContain('- b
');
+ });
+
+ it('一级标题渲染为 h2', () => {
+ const { html } = renderMarkdownToHtml('# Title');
+ expect(html).toContain('Title');
+ });
+
+ it('代码块内容不被行内加粗转换', () => {
+ const { html } = renderMarkdownToHtml('```\n**not bold**\n```');
+ expect(html).toContain(' ');
+ expect(html).not.toContain('not bold');
+ });
+
+ it('行内代码被抽取保护', () => {
+ const { html } = renderMarkdownToHtml('use `npm run` here');
+ expect(html).toContain('npm run');
+ });
+
+ it('[文本](url) 链接渲染', () => {
+ const { html } = renderMarkdownToHtml('[Google](https://g.com)');
+ expect(html).toContain(')以占位符保留并返回结构化 media 列表', () => {
+ const { html, media } = renderMarkdownToHtml('看这张图 ');
+ expect(media.length).toBe(1);
+ expect(media[0].type).toBe('img');
+ expect(media[0].src).toBe('https://x.com/a.png');
+ expect(media[0].alt).toBe('图');
+ expect(html).toContain('\x00MEDIA0\x00');
+ });
+});
diff --git a/frontend/src/utils/markdown.ts b/frontend/src/utils/markdown.ts
new file mode 100644
index 0000000..c7105cd
--- /dev/null
+++ b/frontend/src/utils/markdown.ts
@@ -0,0 +1,205 @@
+import katex from 'katex';
+import hljs from 'highlight.js';
+
+export interface MediaInfo {
+ type: 'img' | 'video';
+ src: string;
+ alt?: string;
+}
+
+export interface RenderResult {
+ html: string;
+ media: MediaInfo[];
+}
+
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+/**
+ * 将 Markdown 文本转为带占位符的 HTML 字符串。
+ *
+ * 设计契约(曾被线上 bug 击穿、用单测固化,见 markdown.test.ts):
+ * - 段落内【单个 \n 转为空格】而非 ,避免 AI 回复被强制大量换行;
+ * - 【3+ 连续空行压缩为 2 个】(/\n{3,}/ -> \n\n),避免多余空白把内容撑成多行;
+ * - 【表格在加粗/链接转换之后提取】,单元格内的 **xxx** / [文本](url) 才能被渲染;
+ * 且单元格内连续 被压缩为单个(normalizeCell)。
+ *
+ * 媒体( /
|