diff --git a/mateclaw-ui/src/assets/main.css b/mateclaw-ui/src/assets/main.css
index 1acc63c9..072bb1d6 100644
--- a/mateclaw-ui/src/assets/main.css
+++ b/mateclaw-ui/src/assets/main.css
@@ -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)
diff --git a/mateclaw-ui/src/components/chat/ContentSegment.vue b/mateclaw-ui/src/components/chat/ContentSegment.vue
index 1d6fef1d..0e3c3906 100644
--- a/mateclaw-ui/src/components/chat/ContentSegment.vue
+++ b/mateclaw-ui/src/components/chat/ContentSegment.vue
@@ -1,6 +1,6 @@
diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue
index 643be22c..5afa53e5 100644
--- a/mateclaw-ui/src/components/chat/MessageBubble.vue
+++ b/mateclaw-ui/src/components/chat/MessageBubble.vue
@@ -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
diff --git a/mateclaw-ui/src/composables/__tests__/streaming-render.test.ts b/mateclaw-ui/src/composables/__tests__/streaming-render.test.ts
new file mode 100644
index 00000000..6d7b09ce
--- /dev/null
+++ b/mateclaw-ui/src/composables/__tests__/streaming-render.test.ts
@@ -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(' {
+ 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(' {
+ 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')
+ })
+})
diff --git a/mateclaw-ui/src/composables/useMarkdownRenderer.ts b/mateclaw-ui/src/composables/useMarkdownRenderer.ts
index d632d384..0aeb12bb 100644
--- a/mateclaw-ui/src/composables/useMarkdownRenderer.ts
+++ b/mateclaw-ui/src/composables/useMarkdownRenderer.ts
@@ -268,6 +268,23 @@ function renderProductCards(rawCode: string): string {
return `
${cards}
`
}
+/**
+ * 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 '
'
+ + ''
+ + ''
+ + ''
+ + '
'
+}
+
// ---------------------------------------------------------------------------
// 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 = ``
const downloadSvg = ``
@@ -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 ``
}
@@ -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
diff --git a/mateclaw-ui/src/composables/useStreamingMarkdown.ts b/mateclaw-ui/src/composables/useStreamingMarkdown.ts
new file mode 100644
index 00000000..b7c9f72c
--- /dev/null
+++ b/mateclaw-ui/src/composables/useStreamingMarkdown.ts
@@ -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 } {
+ const { renderMarkdown } = useMarkdownRenderer()
+ const html = ref('')
+
+ let throttleTimer: ReturnType | 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 }
+}