mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +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;
|
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 /
|
Product cards (from ```product-cards fenced blocks — shopping /
|
||||||
price-comparison results rendered as a clickable card grid)
|
price-comparison results rendered as a clickable card grid)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
|
import { useStreamingMarkdown } from '@/composables/useStreamingMarkdown'
|
||||||
import TypingCursor from './TypingCursor.vue'
|
import TypingCursor from './TypingCursor.vue'
|
||||||
import type { MessageSegment } from '@/types'
|
import type { MessageSegment } from '@/types'
|
||||||
|
|
||||||
@ -11,10 +11,14 @@ const props = withDefaults(defineProps<{
|
|||||||
showCursor: false,
|
showCursor: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { renderMarkdown } = useMarkdownRenderer()
|
|
||||||
|
|
||||||
const renderedContent = computed(() => renderMarkdown(props.segment.text || ''))
|
|
||||||
const isRunning = computed(() => props.segment.status === 'running')
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@ -440,7 +440,7 @@ import {
|
|||||||
VideoPause,
|
VideoPause,
|
||||||
WarningFilled,
|
WarningFilled,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
|
import { useStreamingMarkdown } from '@/composables/useStreamingMarkdown'
|
||||||
import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttachment'
|
import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttachment'
|
||||||
import { useToolLabel } from '@/composables/useToolLabel'
|
import { useToolLabel } from '@/composables/useToolLabel'
|
||||||
import { http } from '@/api'
|
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 { Message, MessageSegment, ChatAttachment, ToolCallMeta, PlanMeta } from '@/types'
|
||||||
import type { ChatErrorInfo } from '@/types/chatError'
|
import type { ChatErrorInfo } from '@/types/chatError'
|
||||||
|
|
||||||
const { renderMarkdown } = useMarkdownRenderer()
|
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const { getToolLabel } = useToolLabel()
|
const { getToolLabel } = useToolLabel()
|
||||||
const { blobUrls, loadAllImages, loadAllVideos, loadAllAudios, loadAllModels, downloadFile, openImage, getDisplayUrl, revokeAll } = useAuthenticatedAttachment()
|
const { blobUrls, loadAllImages, loadAllVideos, loadAllAudios, loadAllModels, downloadFile, openImage, getDisplayUrl, revokeAll } = useAuthenticatedAttachment()
|
||||||
@ -614,10 +613,12 @@ const toggleThinking = () => {
|
|||||||
emit('toggle-thinking', localThinkingExpanded.value)
|
emit('toggle-thinking', localThinkingExpanded.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderedThinkingContent = computed(() => {
|
// Throttle thinking + main-content markdown while the turn streams; both render
|
||||||
if (!thinkingContent.value) return ''
|
// once at full fidelity when generation stops.
|
||||||
return renderMarkdown(thinkingContent.value)
|
const { html: renderedThinkingContent } = useStreamingMarkdown(
|
||||||
})
|
() => thinkingContent.value,
|
||||||
|
() => isGenerating.value,
|
||||||
|
)
|
||||||
|
|
||||||
// --- 主内容 ---
|
// --- 主内容 ---
|
||||||
const isApprovalPlaceholder = (text: string) => {
|
const isApprovalPlaceholder = (text: string) => {
|
||||||
@ -643,10 +644,10 @@ const parseErrorText = computed(() => {
|
|||||||
return errorPart?.text || ''
|
return errorPart?.text || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderedContent = computed(() => {
|
const { html: renderedContent } = useStreamingMarkdown(
|
||||||
if (!displayContent.value) return ''
|
() => displayContent.value,
|
||||||
return renderMarkdown(displayContent.value)
|
() => isGenerating.value,
|
||||||
})
|
)
|
||||||
|
|
||||||
const showLoadingIndicator = computed(() => {
|
const showLoadingIndicator = computed(() => {
|
||||||
return isGenerating.value && !displayContent.value
|
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>`
|
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
|
// Custom renderer (marked v15 requires a plain object — class instances are
|
||||||
// NOT dispatched).
|
// NOT dispatched).
|
||||||
@ -283,6 +300,8 @@ const customRenderer = {
|
|||||||
// so users can copy the diagram source even before render completes; the
|
// so users can copy the diagram source even before render completes; the
|
||||||
// composable paints the SVG into `.mermaid-block__body`.
|
// composable paints the SVG into `.mermaid-block__body`.
|
||||||
if (infoStr === 'mermaid') {
|
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 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 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>`
|
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.
|
// ECharts: same pattern, mounted by useEChartsRenderer.
|
||||||
if (infoStr === 'echarts') {
|
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>`
|
return `<div class="echarts-block" data-echarts-option="${encodeURIComponent(rawCode)}"></div>`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -323,9 +344,18 @@ const customRenderer = {
|
|||||||
|
|
||||||
let highlighted: string
|
let highlighted: string
|
||||||
try {
|
try {
|
||||||
highlighted = hasLanguage
|
if (hasLanguage) {
|
||||||
? hljs.highlight(rawCode, { language: detectedLang }).value
|
highlighted = hljs.highlight(rawCode, { language: detectedLang }).value
|
||||||
: hljs.highlightAuto(rawCode).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 {
|
} catch {
|
||||||
highlighted = escapeHtml(rawCode)
|
highlighted = escapeHtml(rawCode)
|
||||||
}
|
}
|
||||||
@ -511,19 +541,40 @@ export type WikilinkMode = 'legacy' | 'none'
|
|||||||
export interface RenderMarkdownOptions {
|
export interface RenderMarkdownOptions {
|
||||||
/** How to handle `[[...]]` syntax. Defaults to `'legacy'`. */
|
/** How to handle `[[...]]` syntax. Defaults to `'legacy'`. */
|
||||||
wikilink?: WikilinkMode
|
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() {
|
export function useMarkdownRenderer() {
|
||||||
function renderMarkdown(content: string, opts?: RenderMarkdownOptions): string {
|
function renderMarkdown(content: string, opts?: RenderMarkdownOptions): string {
|
||||||
if (!content) return ''
|
if (!content) return ''
|
||||||
const wikilink: WikilinkMode = opts?.wikilink ?? 'legacy'
|
const wikilink: WikilinkMode = opts?.wikilink ?? 'legacy'
|
||||||
|
const streaming = opts?.streaming ?? false
|
||||||
const k = cacheKey(content, wikilink)
|
const k = cacheKey(content, wikilink)
|
||||||
const cached = RENDER_CACHE.get(k)
|
// Streaming renders bypass the cache entirely: their length-based keys
|
||||||
if (cached !== undefined) {
|
// collide with the final full-fidelity render of the same text, and a
|
||||||
// Refresh LRU position — re-insert at the tail.
|
// streaming entry (no auto-highlight) must never be served as the final
|
||||||
RENDER_CACHE.delete(k)
|
// result.
|
||||||
RENDER_CACHE.set(k, cached)
|
if (!streaming) {
|
||||||
return cached
|
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).
|
// 1. LaTeX placeholders (skips fenced/inline code).
|
||||||
@ -554,9 +605,20 @@ export function useMarkdownRenderer() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
// 3. Marked → 4. DOMPurify.
|
// 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)
|
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).
|
// Evict oldest entry when at capacity (Map preserves insertion order).
|
||||||
if (RENDER_CACHE.size >= RENDER_CACHE_CAP) {
|
if (RENDER_CACHE.size >= RENDER_CACHE_CAP) {
|
||||||
const oldestKey = RENDER_CACHE.keys().next().value
|
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