fix: LaTeX formulas flash and disappear during streaming response (#1056)

Replace token-by-token rendering (marked.lexer + v-for + marked.parser)
with single-pass marked.parse() in botmsg.vue and AgentStreamDisplay.vue.
This prevents Vue's v-for keyed diffing from destroying KaTeX DOM nodes
on each streaming update.

Also adds a dev-only markdown test page (/platform/dev/markdown) with
LaTeX, code, tables, Mermaid, and streaming simulation for visual
regression testing.
This commit is contained in:
wizardchen
2026-04-28 19:30:51 +08:00
committed by lyingbug
parent 1f03741462
commit 4b87239929
4 changed files with 509 additions and 60 deletions

View File

@@ -131,6 +131,13 @@ const router = createRouter({
},
],
},
// Dev-only markdown rendering test page
...(import.meta.env.DEV ? [{
path: '/platform/dev/markdown',
name: 'markdownTest',
component: () => import('../views/dev/MarkdownTestPage.vue'),
meta: { requiresAuth: false, requiresInit: false }
}] : []),
],
});

View File

@@ -42,7 +42,7 @@
</div>
<div v-if="event.content && isEventExpanded(event.event_id)" class="action-details">
<div class="thinking-detail-content markdown-content">
<div v-for="(token, idx) in getTokens(event.content)" :key="idx" v-html="getTokenHTML(token)"></div>
<div v-html="renderMarkdownContent(event.content)"></div>
</div>
</div>
</div>
@@ -64,7 +64,7 @@
</div>
<div v-if="event.tool_data?.thought && isEventExpanded(event.tool_call_id)" class="action-details">
<div class="thinking-detail-content markdown-content">
<div v-for="(token, idx) in getTokens(event.tool_data.thought)" :key="idx" v-html="getTokenHTML(token)"></div>
<div v-html="renderMarkdownContent(event.tool_data.thought)"></div>
</div>
</div>
</div>
@@ -173,7 +173,7 @@
</div>
<div v-if="event.content && isEventExpanded(event.event_id)" class="action-details">
<div class="thinking-detail-content markdown-content">
<div v-for="(token, idx) in getTokens(event.content)" :key="idx" v-html="getTokenHTML(token)"></div>
<div v-html="renderMarkdownContent(event.content)"></div>
</div>
</div>
</div>
@@ -195,7 +195,7 @@
</div>
<div v-if="event.tool_data?.thought && isEventExpanded(event.tool_call_id)" class="action-details">
<div class="thinking-detail-content markdown-content">
<div v-for="(token, idx) in getTokens(event.tool_data.thought)" :key="idx" v-html="getTokenHTML(token)"></div>
<div v-html="renderMarkdownContent(event.tool_data.thought)"></div>
</div>
</div>
</div>
@@ -207,7 +207,7 @@
v-if="event.content && event.content.trim()"
class="answer-content markdown-content"
>
<div v-for="(token, idx) in getTokens(event.content)" :key="idx" v-html="getTokenHTML(token)"></div>
<div v-html="renderMarkdownContent(event.content)"></div>
</div>
<div v-if="event.done" class="answer-toolbar">
<t-button size="small" variant="outline" shape="round" @click.stop="handleCopyAnswer(event)" :title="$t('agent.copy')">
@@ -1657,10 +1657,14 @@ const preprocessMarkdown = (contentStr: string): string => {
);
};
// Get tokens from markdown content (with sanitization for user-friendly display)
const getTokens = (content: any) => {
// 自定义渲染器 - 支持 Mermaid
const agentRenderer = new marked.Renderer();
agentRenderer.code = createMermaidCodeRenderer('mermaid-agent');
// 单次渲染 Markdown 内容(替代 token-by-token修复 KaTeX 公式在 streaming 时闪烁消失的问题)
const renderMarkdownContent = (content: any): string => {
const contentStr = typeof content === 'string' ? content : String(content || '');
if (!contentStr.trim()) return [];
if (!contentStr.trim()) return '';
// Extract <kb.../> and <web.../> tags before sanitization to prevent
// sanitizeForDisplay from stripping chunk_id labels and UUIDs inside them.
@@ -1692,31 +1696,17 @@ const getTokens = (content: any) => {
// Restore preserved wiki links
sanitized = sanitized.replace(/\x00WIKI(\d+)\x00/g, (_, idx) => wikiPlaceholders[Number(idx)]);
// Restore preserved images
sanitized = sanitized.replace(/\x00IMG(\d+)\x00/g, (_, idx) => imagePlaceholders[Number(idx)]);
// Restore preserved tags
sanitized = sanitized.replace(/\x00TAG(\d+)\x00/g, (_, idx) => tagPlaceholders[Number(idx)]);
const processed = preprocessMarkdown(preprocessMathDelimiters(sanitized));
return marked.lexer(processed);
};
// 自定义渲染器 - 支持 Mermaid
const agentRenderer = new marked.Renderer();
agentRenderer.code = createMermaidCodeRenderer('mermaid-agent');
// Render HTML from a single token
const getTokenHTML = (token: any): string => {
try {
const html = marked.parser([token], { renderer: agentRenderer });
const protectedHTML = protectProviderImageSrcInHTML(html);
return DOMPurify.sanitize(protectedHTML, DOMPurifyConfig);
} catch (e) {
console.error('Token rendering error:', e);
return '';
}
const html = marked.parse(processed, { renderer: agentRenderer }) as string;
const protectedHTML = protectProviderImageSrcInHTML(html);
return DOMPurify.sanitize(protectedHTML, DOMPurifyConfig);
};
// Legacy Markdown rendering function (kept for summaries)

View File

@@ -27,8 +27,7 @@
<!-- 直接渲染完整内容避免切分导致的问题样式与 thinking 一致 -->
<!-- 只有当有实际内容时才显示包围框 -->
<div class="content-wrapper" v-if="hasActualContent">
<div class="ai-markdown-template markdown-content">
<div v-for="(token, index) in markdownTokens" :key="index" v-html="renderToken(token)"></div>
<div class="ai-markdown-template markdown-content" v-html="renderedHTML">
</div>
</div>
<!-- Streaming indicator (non-Agent mode) -->
@@ -164,20 +163,15 @@ const mentionedItems = computed(() => {
return props.session?.mentioned_items || [];
});
const markdownTokens = computed(() => {
// 单次渲染整个 Markdown 内容(替代 token-by-token修复 KaTeX 公式在 streaming 时闪烁消失的问题)
const renderedHTML = computed(() => {
const text = props.content || props.session?.content || '';
if (!text || typeof text !== 'string') {
return [];
}
if (!text || typeof text !== 'string') return '';
const processed = replaceIncompleteImageWithPlaceholder(text);
const safeText = preprocessMathDelimiters(processed);
// 首先对 Markdown 内容进行安全处理
const safeMarkdown = safeMarkdownToHTML(safeText);
// 使用 marked.lexer 分词
return marked.lexer(safeMarkdown);
const html = marked.parse(safeMarkdown, { renderer: customRenderer, breaks: true });
return sanitizeHTML(html);
});
// 计算属性:判断是否有实际内容(非空且不只是空白)
@@ -186,27 +180,6 @@ const hasActualContent = computed(() => {
return text && text.trim().length > 0;
});
// 渲染单个 token 为 HTML
const renderToken = (token) => {
try {
// 创建临时的 marked 配置
const markedOptions = {
renderer: customRenderer,
breaks: true
};
// 解析单个 token
// marked.parser 接受 token 数组
let html = marked.parser([token], markedOptions);
// 使用 DOMPurify 进行最终的安全清理
return sanitizeHTML(html);
} catch (e) {
console.error('Token rendering error:', e);
return '';
}
};
// 获取实际内容
const getActualContent = () => {
return (props.content || props.session?.content || '').trim();

View File

@@ -0,0 +1,479 @@
<template>
<div class="markdown-test-page">
<h1 class="page-title">Markdown Rendering Test</h1>
<p class="page-desc">
Dev-only page for visual regression testing of markdown / LaTeX / Mermaid rendering.
Add new test cases or paste arbitrary markdown in the editor below.
</p>
<!-- LaTeX Formulas -->
<section class="test-section">
<h2>LaTeX Formulas</h2>
<div v-for="(tc, i) in latexCases" :key="'latex-'+i" class="test-case">
<div class="test-raw"><code>{{ tc.raw }}</code></div>
<div class="test-rendered markdown-content" v-html="render(tc.raw)"></div>
</div>
</section>
<!-- Code Blocks -->
<section class="test-section">
<h2>Code Blocks</h2>
<div class="test-case">
<div class="test-rendered markdown-content" v-html="render(codeBlockSample)"></div>
</div>
</section>
<!-- Tables -->
<section class="test-section">
<h2>Tables</h2>
<div class="test-case">
<div class="test-rendered markdown-content" v-html="render(tableSample)"></div>
</div>
</section>
<!-- Lists & Blockquotes -->
<section class="test-section">
<h2>Lists &amp; Blockquotes</h2>
<div class="test-case">
<div class="test-rendered markdown-content" v-html="render(listsSample)"></div>
</div>
</section>
<!-- Mixed Content (LaTeX + code + text) -->
<section class="test-section">
<h2>Mixed Content</h2>
<div class="test-case">
<div class="test-rendered markdown-content" v-html="render(mixedSample)"></div>
</div>
</section>
<!-- Mermaid -->
<section class="test-section">
<h2>Mermaid Diagram</h2>
<div class="test-case">
<div ref="mermaidContainer" class="test-rendered markdown-content" v-html="render(mermaidSample)"></div>
</div>
</section>
<!-- Streaming Simulation -->
<section class="test-section">
<h2>Streaming Simulation</h2>
<p class="test-hint">Simulates character-by-character streaming, like during a chat response.</p>
<div class="stream-controls">
<button @click="startStream" :disabled="isStreaming" class="btn">Start</button>
<button @click="resetStream" class="btn">Reset</button>
<label class="speed-label">
Speed:
<input type="range" min="10" max="200" v-model.number="streamSpeed" />
{{ streamSpeed }}ms
</label>
</div>
<div class="test-case">
<div class="test-rendered markdown-content" v-html="render(streamBuffer)"></div>
<div v-if="isStreaming" class="loading-typing">
<span></span><span></span><span></span>
</div>
</div>
</section>
<!-- Custom Editor -->
<section class="test-section">
<h2>Custom Input</h2>
<p class="test-hint">Paste any markdown here to test rendering.</p>
<textarea
v-model="customInput"
class="custom-textarea"
rows="8"
placeholder="Type or paste markdown here..."
></textarea>
<div v-if="customInput.trim()" class="test-case">
<div class="test-rendered markdown-content" v-html="render(customInput)"></div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick, watch } from 'vue';
import { marked } from 'marked';
import markedKatex from 'marked-katex-extension';
import DOMPurify from 'dompurify';
import 'katex/dist/katex.min.css';
import {
ensureMermaidInitialized,
renderMermaidInContainer,
createMermaidCodeRenderer,
} from '@/utils/mermaidShared';
// Configure marked
marked.use({ breaks: true, gfm: true });
marked.use(markedKatex({ throwOnError: false }));
const mermaidRenderer = new marked.Renderer();
mermaidRenderer.code = createMermaidCodeRenderer('mermaid-test');
ensureMermaidInitialized();
const mermaidContainer = ref<HTMLElement | null>(null);
const preprocessMathDelimiters = (rawText: string): string => {
if (!rawText) return '';
return rawText
.replace(/\\\[([\s\S]*?)\\\]/g, '$$$$$1$$$$')
.replace(/\\\(([\s\S]*?)\\\)/g, '$$$1$$');
};
const render = (raw: string): string => {
if (!raw) return '';
const processed = preprocessMathDelimiters(raw);
const html = marked.parse(processed, { renderer: mermaidRenderer }) as string;
return DOMPurify.sanitize(html, { USE_PROFILES: { html: true, svg: true, mathMl: true } });
};
// --- Test Data ---
const latexCases = [
{ raw: 'Inline math: $E = mc^2$ in the middle of text.' },
{ raw: 'Block math:\n$$\\int_0^\\infty e^{-x}\\,dx = 1$$' },
{ raw: 'Chemical formula: $\\mathrm{Mg}^{2+} + 2\\mathrm{OH}^{-} = \\mathrm{Mg(OH)}_{2}\\downarrow$' },
{ raw: 'Chemical block:\n$$\\mathrm{Cu}^{2+} + 2\\mathrm{OH}^{-} \\rightarrow \\mathrm{Cu(OH)_2}\\downarrow$$' },
{ raw: 'Summation: $\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}$' },
{ raw: 'Matrix:\n$$\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}$$' },
{ raw: 'Escaped delimiters: \\(\\alpha + \\beta = \\gamma\\) and \\[\\int_a^b f(x)\\,dx\\]' },
];
const codeBlockSample = `Here is some Python:
\`\`\`python
def fibonacci(n: int) -> int:
"""Calculate the nth Fibonacci number."""
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(10)) # 55
\`\`\`
And inline code: \`const x = 42;\`
`;
const tableSample = `| Element | Symbol | Atomic Number |
|---------|--------|:-------------:|
| Hydrogen | H | 1 |
| Helium | He | 2 |
| Lithium | Li | 3 |
| Carbon | C | 6 |
`;
const listsSample = `### Ordered List
1. First item
2. Second item
1. Nested item A
2. Nested item B
3. Third item
### Unordered List
- Alpha
- Beta
- Sub-item
- Another sub-item
- Gamma
### Blockquote
> This is a blockquote with **bold** and *italic* text.
>
> It can span multiple paragraphs.
`;
const mixedSample = `## Quadratic Formula
The solutions to $ax^2 + bx + c = 0$ are given by:
$$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$$
### Example in Python
\`\`\`python
import math
def solve_quadratic(a, b, c):
discriminant = b**2 - 4*a*c
if discriminant < 0:
return None
x1 = (-b + math.sqrt(discriminant)) / (2*a)
x2 = (-b - math.sqrt(discriminant)) / (2*a)
return x1, x2
\`\`\`
| a | b | c | Solutions |
|---|---|---|-----------|
| 1 | -3 | 2 | $x = 1, 2$ |
| 1 | 0 | -4 | $x = \\pm 2$ |
| 1 | 2 | 5 | No real solutions |
> **Note:** The discriminant $\\Delta = b^2 - 4ac$ determines the nature of the roots.
`;
const mermaidSample = `\`\`\`mermaid
graph TD
A[Start] --> B{Decision}
B -->|Yes| C[Process A]
B -->|No| D[Process B]
C --> E[End]
D --> E
\`\`\`
`;
// --- Streaming Simulation ---
const fullStreamText = `The energy-mass equivalence is $E = mc^2$.
In chemistry, the neutralization reaction:
$$\\mathrm{Mg}^{2+} + 2\\mathrm{OH}^{-} = \\mathrm{Mg(OH)}_2\\downarrow$$
Here is a code example:
\`\`\`python
def greet(name):
print(f"Hello, {name}!")
\`\`\`
And the derivative rule: $\\frac{d}{dx}\\sin x = \\cos x$.
Done.`;
const streamBuffer = ref('');
const isStreaming = ref(false);
const streamSpeed = ref(30);
let streamTimer: ReturnType<typeof setInterval> | null = null;
const startStream = () => {
resetStream();
isStreaming.value = true;
let idx = 0;
const tick = () => {
if (idx >= fullStreamText.length) {
if (streamTimer) clearInterval(streamTimer);
isStreaming.value = false;
return;
}
streamBuffer.value += fullStreamText[idx++];
};
streamTimer = setInterval(tick, streamSpeed.value);
};
const resetStream = () => {
if (streamTimer) clearInterval(streamTimer);
streamBuffer.value = '';
isStreaming.value = false;
};
// Custom input
const customInput = ref('');
// Render mermaid after mount and content changes
const doMermaid = async () => {
await nextTick();
if (mermaidContainer.value) {
await renderMermaidInContainer(mermaidContainer.value);
}
};
onMounted(doMermaid);
watch(mermaidSample, doMermaid);
</script>
<style lang="less" scoped>
@import '../../components/css/markdown.less';
.markdown-test-page {
max-width: 860px;
margin: 0 auto;
padding: 32px 24px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.page-title {
font-size: 24px;
font-weight: 700;
margin-bottom: 4px;
}
.page-desc {
color: var(--td-text-color-secondary, #666);
font-size: 14px;
margin-bottom: 32px;
}
.test-section {
margin-bottom: 36px;
border-bottom: 1px solid var(--td-component-stroke, #e5e5e5);
padding-bottom: 24px;
h2 {
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
}
}
.test-hint {
font-size: 13px;
color: var(--td-text-color-secondary, #999);
margin-bottom: 8px;
}
.test-case {
margin: 12px 0;
}
.test-raw {
background: var(--td-bg-color-secondarycontainer, #f5f5f5);
padding: 6px 10px;
border-radius: 4px;
margin-bottom: 6px;
font-size: 13px;
overflow-x: auto;
code {
white-space: pre-wrap;
word-break: break-all;
}
}
.test-rendered {
padding: 8px 12px;
border: 1px solid var(--td-component-stroke, #e5e5e5);
border-radius: 6px;
background: var(--td-bg-color-container, #fff);
}
.stream-controls {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.btn {
padding: 4px 16px;
border: 1px solid var(--td-component-stroke, #ccc);
border-radius: 4px;
background: var(--td-bg-color-container, #fff);
cursor: pointer;
font-size: 13px;
&:hover {
background: var(--td-bg-color-container-hover, #f0f0f0);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.speed-label {
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
input[type="range"] {
width: 120px;
}
}
.custom-textarea {
width: 100%;
padding: 10px;
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
font-size: 13px;
border: 1px solid var(--td-component-stroke, #ccc);
border-radius: 6px;
resize: vertical;
box-sizing: border-box;
margin-bottom: 12px;
}
.loading-typing {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 0;
span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--td-brand-color, #0052d9);
animation: typingBounce 1.4s ease-in-out infinite;
&:nth-child(1) { animation-delay: 0s; }
&:nth-child(2) { animation-delay: 0.2s; }
&:nth-child(3) { animation-delay: 0.4s; }
}
}
@keyframes typingBounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-8px); }
}
// Markdown content styles (same as chat)
.markdown-content {
font-size: 15px;
color: var(--td-text-color-primary);
line-height: 1.6;
:deep(p) { margin: 6px 0; }
:deep(code) {
background: var(--td-bg-color-secondarycontainer, #f5f5f5);
padding: 2px 5px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
font-size: 13px;
}
:deep(pre) {
background: var(--td-bg-color-secondarycontainer, #f5f5f5);
padding: 12px;
border-radius: 4px;
overflow-x: auto;
margin: 8px 0;
code { background: none; padding: 0; }
}
:deep(table) {
border-collapse: collapse;
margin: 8px 0;
width: 100%;
th, td {
border: 1px solid var(--td-component-stroke, #e5e5e5);
padding: 6px 10px;
text-align: left;
}
th {
background: var(--td-bg-color-secondarycontainer, #f5f5f5);
font-weight: 600;
}
}
:deep(blockquote) {
border-left: 3px solid var(--td-brand-color, #0052d9);
padding-left: 12px;
margin: 8px 0;
color: var(--td-text-color-secondary);
}
:deep(img) {
max-width: 80%;
border-radius: 8px;
margin: 8px 0;
}
:deep(.mermaid) {
margin: 16px 0;
padding: 16px;
background: var(--td-bg-color-secondarycontainer, #f5f5f5);
border-radius: 8px;
text-align: center;
svg { max-width: 100%; height: auto; }
}
}
</style>