mateclaw/mateclaw-ui/src/composables/useStreamingMarkdown.ts
matevip 6a13cc2f50 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)
2026-06-15 11:39:24 +08:00

88 lines
2.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 }
}