perf(chat): throttle streaming markdown render and defer chart mounts

- add useStreamingMarkdown: cap mid-stream markdown re-render to ~140ms,
  full-fidelity render once the segment completes
- skip code-block language auto-detection while streaming (escaped plain
  text), restore full highlighting on the final render
- defer echarts/mermaid blocks to a lightweight loading placeholder while
  streaming so their parsers never run on truncated source
- bypass the render cache for streaming-mode output
- wire into ContentSegment and MessageBubble (content + thinking)
This commit is contained in:
matevip 2026-06-15 11:39:24 +08:00
parent 4cbd2b50f3
commit 6a13cc2f50
6 changed files with 273 additions and 24 deletions

View File

@ -616,6 +616,29 @@ html.dark body::before {
font-size: 13px;
}
/* Streaming placeholder for echarts/mermaid blocks (shown until the fenced
source finishes streaming, then replaced by the real chart). */
.markdown-body .chart-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: 14px 0;
min-height: 120px;
border-radius: 12px;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border-light);
}
.markdown-body .chart-loading__dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--mc-border, #cbd5e1);
animation: mc-product-pulse 1.4s ease-in-out infinite;
}
.markdown-body .chart-loading__dot:nth-child(2) { animation-delay: 0.2s; }
.markdown-body .chart-loading__dot:nth-child(3) { animation-delay: 0.4s; }
/* ================================================================
Product cards (from ```product-cards fenced blocks shopping /
price-comparison results rendered as a clickable card grid)

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
import { useStreamingMarkdown } from '@/composables/useStreamingMarkdown'
import TypingCursor from './TypingCursor.vue'
import type { MessageSegment } from '@/types'
@ -11,10 +11,14 @@ const props = withDefaults(defineProps<{
showCursor: false,
})
const { renderMarkdown } = useMarkdownRenderer()
const renderedContent = computed(() => renderMarkdown(props.segment.text || ''))
const isRunning = computed(() => props.segment.status === 'running')
// Throttle markdown rendering while the segment streams; render once at full
// fidelity the moment it completes.
const { html: renderedContent } = useStreamingMarkdown(
() => props.segment.text || '',
() => isRunning.value,
)
</script>
<template>

View File

@ -440,7 +440,7 @@ import {
VideoPause,
WarningFilled,
} from '@element-plus/icons-vue'
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
import { useStreamingMarkdown } from '@/composables/useStreamingMarkdown'
import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttachment'
import { useToolLabel } from '@/composables/useToolLabel'
import { http } from '@/api'
@ -460,7 +460,6 @@ import type { BrowserAction } from './BrowserTimeline.vue'
import type { Message, MessageSegment, ChatAttachment, ToolCallMeta, PlanMeta } from '@/types'
import type { ChatErrorInfo } from '@/types/chatError'
const { renderMarkdown } = useMarkdownRenderer()
const { t, locale } = useI18n()
const { getToolLabel } = useToolLabel()
const { blobUrls, loadAllImages, loadAllVideos, loadAllAudios, loadAllModels, downloadFile, openImage, getDisplayUrl, revokeAll } = useAuthenticatedAttachment()
@ -614,10 +613,12 @@ const toggleThinking = () => {
emit('toggle-thinking', localThinkingExpanded.value)
}
const renderedThinkingContent = computed(() => {
if (!thinkingContent.value) return ''
return renderMarkdown(thinkingContent.value)
})
// Throttle thinking + main-content markdown while the turn streams; both render
// once at full fidelity when generation stops.
const { html: renderedThinkingContent } = useStreamingMarkdown(
() => thinkingContent.value,
() => isGenerating.value,
)
// --- ---
const isApprovalPlaceholder = (text: string) => {
@ -643,10 +644,10 @@ const parseErrorText = computed(() => {
return errorPart?.text || ''
})
const renderedContent = computed(() => {
if (!displayContent.value) return ''
return renderMarkdown(displayContent.value)
})
const { html: renderedContent } = useStreamingMarkdown(
() => displayContent.value,
() => isGenerating.value,
)
const showLoadingIndicator = computed(() => {
return isGenerating.value && !displayContent.value

View File

@ -0,0 +1,72 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import { useMarkdownRenderer } from '../useMarkdownRenderer'
const { renderMarkdown } = useMarkdownRenderer()
// An unlabeled fenced code block. The default (final) render auto-detects the
// language via hljs.highlightAuto — the single most expensive step. The
// streaming render must skip it and emit escaped plain text instead.
const UNLABELED_CODE = `\`\`\`
function add(a, b) {
return a + b
}
\`\`\``
describe('streaming markdown render mode', () => {
it('skips code auto-highlight while streaming', () => {
const streamed = renderMarkdown(UNLABELED_CODE, { streaming: true })
// No highlight.js token spans in the streamed (cheap) render. The
// structural `hljs-lines` gutter wrapper is always present, so we assert on
// the token spans (hljs-keyword / hljs-string / …) specifically.
expect(streamed).not.toContain('<span class="hljs-')
// The code still shows, just as escaped plain text inside the gutter list.
expect(streamed).toContain('function add')
})
it('auto-highlights the same block on the final (non-streaming) render', () => {
const final = renderMarkdown(UNLABELED_CODE, { streaming: false })
// highlight.js emits token classes once auto-detection runs.
expect(final).toContain('<span class="hljs-')
})
it('respects an explicit language even while streaming', () => {
const labeled = '```js\nconst x = 1\n```'
const streamed = renderMarkdown(labeled, { streaming: true })
// Explicit language uses single-grammar hljs.highlight (cheap), kept on.
expect(streamed).toContain('<span class="hljs-')
})
it('does not serve a streaming render back to a later final render', () => {
// Same source text rendered streaming-first then final: the final must not
// be the cached low-fidelity streaming output.
const md = '```\nlet y = 2\n```'
const streamed = renderMarkdown(md, { streaming: true })
const final = renderMarkdown(md, { streaming: false })
expect(streamed).not.toContain('<span class="hljs-')
expect(final).toContain('<span class="hljs-')
})
it('defers echarts to a placeholder while streaming, real block on final', () => {
const md = '```echarts\n{"series":[{"type":"bar","data":[1,2,3]}]}\n```'
const streamed = renderMarkdown(md, { streaming: true })
expect(streamed).toContain('chart-loading')
expect(streamed).not.toContain('echarts-block')
expect(streamed).not.toContain('data-echarts-option')
const final = renderMarkdown(md, { streaming: false })
expect(final).toContain('class="echarts-block"')
expect(final).toContain('data-echarts-option')
})
it('defers mermaid to a placeholder while streaming, real block on final', () => {
const md = '```mermaid\ngraph TD; A-->B;\n```'
const streamed = renderMarkdown(md, { streaming: true })
expect(streamed).toContain('chart-loading')
expect(streamed).not.toContain('mermaid-block')
const final = renderMarkdown(md, { streaming: false })
expect(final).toContain('class="mermaid-block"')
expect(final).toContain('data-mermaid')
})
})

View File

@ -268,6 +268,23 @@ function renderProductCards(rawCode: string): string {
return `<div class="product-cards">${cards}</div>`
}
/**
* Lightweight loading placeholder shown in place of an echarts/mermaid block
* during throttled mid-stream renders. These post-mount renderers parse the
* fenced source (echarts: JSON.parse, mermaid: diagram grammar), which fails
* loudly on the half-emitted source of a still-streaming block. Emitting a
* neutral placeholder (no `.echarts-block` / `.mermaid-block` class, so the
* observers ignore it) avoids the parse churn and the mount/dispose flicker;
* the final non-streaming render emits the real block and mounts once.
*/
function chartLoadingPlaceholder(): string {
return '<div class="chart-loading" aria-label="Loading chart">'
+ '<span class="chart-loading__dot"></span>'
+ '<span class="chart-loading__dot"></span>'
+ '<span class="chart-loading__dot"></span>'
+ '</div>'
}
// ---------------------------------------------------------------------------
// Custom renderer (marked v15 requires a plain object — class instances are
// NOT dispatched).
@ -283,6 +300,8 @@ const customRenderer = {
// so users can copy the diagram source even before render completes; the
// composable paints the SVG into `.mermaid-block__body`.
if (infoStr === 'mermaid') {
// Mid-stream: defer to a placeholder; mermaid can't parse a partial diagram.
if (streamingRenderMode) return chartLoadingPlaceholder()
const encoded = encodeURIComponent(rawCode)
const copySvg = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`
const downloadSvg = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`
@ -306,6 +325,8 @@ const customRenderer = {
// ECharts: same pattern, mounted by useEChartsRenderer.
if (infoStr === 'echarts') {
// Mid-stream: defer to a placeholder; the option JSON is still truncated.
if (streamingRenderMode) return chartLoadingPlaceholder()
return `<div class="echarts-block" data-echarts-option="${encodeURIComponent(rawCode)}"></div>`
}
@ -323,9 +344,18 @@ const customRenderer = {
let highlighted: string
try {
highlighted = hasLanguage
? hljs.highlight(rawCode, { language: detectedLang }).value
: hljs.highlightAuto(rawCode).value
if (hasLanguage) {
highlighted = hljs.highlight(rawCode, { language: detectedLang }).value
} else if (streamingRenderMode) {
// Mid-stream throttled render: skip language auto-detection. hljs
// probes every registered grammar, which is the single most expensive
// step in the pipeline and would re-run on each throttled pass over a
// still-growing block. Show escaped plain text now; the final
// (non-streaming) render does the real auto-highlight once.
highlighted = escapeHtml(rawCode)
} else {
highlighted = hljs.highlightAuto(rawCode).value
}
} catch {
highlighted = escapeHtml(rawCode)
}
@ -511,19 +541,40 @@ export type WikilinkMode = 'legacy' | 'none'
export interface RenderMarkdownOptions {
/** How to handle `[[...]]` syntax. Defaults to `'legacy'`. */
wikilink?: WikilinkMode
/**
* Streaming-friendly render. When `true`, the renderer skips code-block
* language auto-detection (the most expensive step) and bypasses the LRU
* cache. Use it for the throttled mid-stream renders driven by
* {@link useStreamingMarkdown}; the final render must run with this off so
* the completed message gets full-fidelity highlighting.
*/
streaming?: boolean
}
// Module-level flag read by the custom code renderer. Safe because
// `markedInstance.parse()` runs fully synchronously (JS single-threaded) — the
// flag is set immediately before the parse and cleared in a `finally`, so it
// can never leak across renders.
let streamingRenderMode = false
export function useMarkdownRenderer() {
function renderMarkdown(content: string, opts?: RenderMarkdownOptions): string {
if (!content) return ''
const wikilink: WikilinkMode = opts?.wikilink ?? 'legacy'
const streaming = opts?.streaming ?? false
const k = cacheKey(content, wikilink)
const cached = RENDER_CACHE.get(k)
if (cached !== undefined) {
// Refresh LRU position — re-insert at the tail.
RENDER_CACHE.delete(k)
RENDER_CACHE.set(k, cached)
return cached
// Streaming renders bypass the cache entirely: their length-based keys
// collide with the final full-fidelity render of the same text, and a
// streaming entry (no auto-highlight) must never be served as the final
// result.
if (!streaming) {
const cached = RENDER_CACHE.get(k)
if (cached !== undefined) {
// Refresh LRU position — re-insert at the tail.
RENDER_CACHE.delete(k)
RENDER_CACHE.set(k, cached)
return cached
}
}
// 1. LaTeX placeholders (skips fenced/inline code).
@ -554,9 +605,20 @@ export function useMarkdownRenderer() {
},
)
// 3. Marked → 4. DOMPurify.
const rawHtml = markedInstance.parse(withWikiLinks) as string
let rawHtml: string
streamingRenderMode = streaming
try {
rawHtml = markedInstance.parse(withWikiLinks) as string
} finally {
streamingRenderMode = false
}
const result = DOMPurify.sanitize(rawHtml, purifyConfig)
if (streaming) {
// Throwaway render — don't pollute the LRU with low-fidelity entries.
return result
}
// Evict oldest entry when at capacity (Map preserves insertion order).
if (RENDER_CACHE.size >= RENDER_CACHE_CAP) {
const oldestKey = RENDER_CACHE.keys().next().value

View File

@ -0,0 +1,87 @@
import { ref, watch, onUnmounted, type Ref } from 'vue'
import { useMarkdownRenderer, type RenderMarkdownOptions } from '@/composables/useMarkdownRenderer'
/**
* Throttle interval (ms) for mid-stream markdown re-renders.
*
* Streaming emits one `content_delta` per token; rendering the full accumulated
* markdown on every delta is O(n²) over the message and re-runs marked +
* highlight.js + DOMPurify each time the dominant source of streaming jank.
* Throttling to ~7 renders/sec keeps the text feeling live while cutting the
* render count ~10× on a fast model. The final render (when streaming stops)
* always runs immediately at full fidelity.
*/
const STREAM_RENDER_INTERVAL_MS = 140
/**
* Reactive, throttled markdown rendering for streaming message content.
*
* While `streaming()` is true the rendered HTML is refreshed at most once per
* {@link STREAM_RENDER_INTERVAL_MS} and uses the renderer's `streaming` mode
* (skips code auto-highlight, bypasses the cache). The moment `streaming()`
* flips to false or the source changes while already idle it renders once
* immediately with full fidelity (auto-highlight + cache).
*
* @param source getter for the raw markdown text
* @param streaming getter that is true while the text is still being streamed
* @param opts passed through to `renderMarkdown` (e.g. `wikilink`)
*/
export function useStreamingMarkdown(
source: () => string,
streaming: () => boolean,
opts?: RenderMarkdownOptions,
): { html: Ref<string> } {
const { renderMarkdown } = useMarkdownRenderer()
const html = ref('')
let throttleTimer: ReturnType<typeof setTimeout> | null = null
let pendingText: string | null = null
let lastRenderMs = 0
const clearTimer = () => {
if (throttleTimer) {
clearTimeout(throttleTimer)
throttleTimer = null
}
}
const renderNow = (text: string, stream: boolean) => {
html.value = text ? renderMarkdown(text, { ...opts, streaming: stream }) : ''
lastRenderMs = Date.now()
}
watch(
[source, streaming],
([text, isStreaming]) => {
if (!isStreaming) {
// Idle / completed: full-fidelity render now, drop any pending throttle.
clearTimer()
pendingText = null
renderNow(text, false)
return
}
// Streaming: leading-edge render if we're past the interval, otherwise
// coalesce into a single trailing render.
pendingText = text
const elapsed = Date.now() - lastRenderMs
if (elapsed >= STREAM_RENDER_INTERVAL_MS) {
clearTimer()
renderNow(text, true)
pendingText = null
} else if (!throttleTimer) {
throttleTimer = setTimeout(() => {
throttleTimer = null
if (pendingText !== null) {
renderNow(pendingText, true)
pendingText = null
}
}, STREAM_RENDER_INTERVAL_MS - elapsed)
}
},
{ immediate: true },
)
onUnmounted(clearTimer)
return { html }
}