mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
4cbd2b50f3
commit
6a13cc2f50
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
87
mateclaw-ui/src/composables/useStreamingMarkdown.ts
Normal file
87
mateclaw-ui/src/composables/useStreamingMarkdown.ts
Normal 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 }
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user