diff --git a/src/renderer/src/components/markdown/MarkdownRenderer.vue b/src/renderer/src/components/markdown/MarkdownRenderer.vue index 3ab4a44dd..82e1a5b03 100644 --- a/src/renderer/src/components/markdown/MarkdownRenderer.vue +++ b/src/renderer/src/components/markdown/MarkdownRenderer.vue @@ -4,32 +4,34 @@ @keydown="handleRendererKeydown" > + CONTENT_UPDATE_COALESCE_LEVELS.find((level) => length <= level.maxLength) ?? + CONTENT_UPDATE_COALESCE_LEVELS[CONTENT_UPDATE_COALESCE_LEVELS.length - 1] +// Streaming render is split into a static committed prefix + a small live tail +// (see findSafeSplit / renderSegments below). The tail must stay small so each +// token only re-renders a bounded chunk instead of the whole document. +const STREAM_TAIL_CAP_CHARS = 6000 +const STREAM_MIN_TAIL_CHARS = 2000 const STATIC_INITIAL_RENDER_BATCH_SIZE = 96 const STATIC_RENDER_BATCH_SIZE = 80 const STATIC_RENDER_BATCH_DELAY_MS = 0 @@ -220,7 +246,6 @@ const STATIC_MAX_LIVE_NODES = 260 const STATIC_LIVE_NODE_BUFFER = 80 const shouldVirtualizeNodes = computed(() => props.virtualizeNodes && !isStreaming.value) -const shouldUseViewportPriority = computed(() => props.virtualizeNodes) const resolvedNodeVirtual = computed(() => shouldVirtualizeNodes.value ? ('auto' as const) : false ) @@ -241,9 +266,184 @@ const renderBatchBudgetMs = computed(() => const renderBatchIdleTimeoutMs = computed(() => isStreaming.value ? STREAM_RENDER_BATCH_IDLE_TIMEOUT_MS : STATIC_RENDER_BATCH_IDLE_TIMEOUT_MS ) -const parseCoalesceMs = computed(() => - isStreaming.value ? STREAM_PARSE_COALESCE_MS : STATIC_PARSE_COALESCE_MS +const parseCoalesceMs = computed(() => { + if (!isStreaming.value) return STATIC_PARSE_COALESCE_MS + return contentUpdateCoalesceLevel(renderContent.value.length).parseCoalesceMs +}) + +// --- Streaming split-render (committed prefix + live tail) --- +// Each token currently makes Markstream re-render the whole document, so long +// streams stall the renderer. Instead, once the stream passes the tail cap we +// render a static committed prefix (only re-rendered when the split advances, +// with node virtualization limiting the cost) plus a small streaming tail that +// is cheap to re-render per token. On completion everything re-renders once as +// a single static document. +const committedLength = ref(0) + +const fenceLineStarts = (s: string): { backtick: number[]; tilde: number[] } => { + const backtick: number[] = [] + const tilde: number[] = [] + const re = /^(`{3,}|~{3,})/gm + let match: RegExpExecArray | null + while ((match = re.exec(s)) !== null) { + if (match[1].startsWith('`')) { + backtick.push(match.index) + } else { + tilde.push(match.index) + } + } + return { backtick, tilde } +} + +const findSafeSplit = (content: string, preferred: number, current: number): number => { + const minSplit = Math.max(0, content.length - STREAM_TAIL_CAP_CHARS) + const maxSplit = Math.min(content.length, Math.max(0, preferred)) + // Prefer a blank-line boundary so blocks/paragraphs aren't chopped mid-way. + const blankLine = content.lastIndexOf('\n\n', maxSplit) + let split = blankLine >= minSplit ? Math.min(maxSplit, blankLine + 2) : maxSplit + // No blank-line boundary in the window: advancing would chop a long + // paragraph/list mid-way. Keep the current split (no advance) until a safe + // boundary appears instead of rendering a half-paragraph in the prefix. + if (blankLine < minSplit) return current + // Fences must be balanced per marker type (` vs ~): an odd count of either + // means the committed prefix ends inside an unclosed fence. + const fenceStarts = fenceLineStarts(content.slice(0, split)) + const unbalanced = + fenceStarts.backtick.length % 2 === 1 + ? fenceStarts.backtick + : fenceStarts.tilde.length % 2 === 1 + ? fenceStarts.tilde + : null + if (unbalanced) { + const opener = unbalanced[unbalanced.length - 1] + const beforeFence = opener >= 0 ? content.lastIndexOf('\n\n', opener) : -1 + // A single fenced block can be larger than the tail cap; committing a piece + // of it would leave an unterminated code block in the static prefix. Keep + // the current split (no advance) until the fence closes. + if (beforeFence < minSplit || beforeFence < 0) return current + split = Math.min(maxSplit, beforeFence + 2) + } + return split +} + +// Split only once a committed prefix actually exists: a stream that cannot be +// split safely (e.g. a single fenced block larger than the tail cap) keeps +// `committedLength` at 0 and renders as a single streaming document. +const usingSplitRender = computed(() => isStreaming.value && committedLength.value > 0) +const committedContent = computed(() => + usingSplitRender.value ? renderContent.value.slice(0, committedLength.value) : '' +) +const tailContent = computed(() => + usingSplitRender.value ? renderContent.value.slice(committedLength.value) : renderContent.value ) + +watch( + renderContent, + (content) => { + if (!isStreaming.value) return + // A regenerated/interrupted stream can shrink below an already-advanced + // split; reset so the split restarts from scratch instead of keeping a + // stale prefix (which would eat the whole content into the prefix). + if (content.length < committedLength.value) { + committedLength.value = 0 + } + if (content.length - committedLength.value > STREAM_TAIL_CAP_CHARS) { + committedLength.value = findSafeSplit( + content, + content.length - STREAM_MIN_TAIL_CHARS, + committedLength.value + ) + } + }, + { immediate: true } +) + +watch(isStreaming, (streaming, wasStreaming) => { + if (wasStreaming && !streaming) committedLength.value = 0 +}) + +type RenderSegment = { + key: string + content: string + final: boolean + codeBlockStream: boolean + smoothStreaming: boolean | 'auto' + typewriter: boolean | 'simple' + nodeVirtual: boolean | 'auto' + maxLiveNodes: number + liveNodeBuffer: number + initialBatch: number + batchSize: number + batchDelay: number + batchBudget: number + batchIdle: number + parseCoalesce: number + customId: string +} + +const renderSegments = computed(() => { + if (!usingSplitRender.value) { + return [ + { + key: 'full', + content: renderContent.value, + final: resolvedFinal.value, + codeBlockStream: isStreaming.value, + smoothStreaming: resolvedSmoothStreaming.value, + typewriter: resolvedTypewriter.value, + nodeVirtual: resolvedNodeVirtual.value, + maxLiveNodes: maxLiveNodes.value, + liveNodeBuffer: liveNodeBuffer.value, + initialBatch: initialRenderBatchSize.value, + batchSize: renderBatchSize.value, + batchDelay: renderBatchDelay.value, + batchBudget: renderBatchBudgetMs.value, + batchIdle: renderBatchIdleTimeoutMs.value, + parseCoalesce: parseCoalesceMs.value, + customId: customRendererId.value + } + ] + } + return [ + { + key: 'prefix', + content: committedContent.value, + final: true, + codeBlockStream: false, + smoothStreaming: false, + typewriter: false, + nodeVirtual: 'auto', + maxLiveNodes: STATIC_MAX_LIVE_NODES, + liveNodeBuffer: STATIC_LIVE_NODE_BUFFER, + initialBatch: STATIC_INITIAL_RENDER_BATCH_SIZE, + batchSize: STATIC_RENDER_BATCH_SIZE, + batchDelay: STATIC_RENDER_BATCH_DELAY_MS, + batchBudget: STATIC_RENDER_BATCH_BUDGET_MS, + batchIdle: STATIC_RENDER_BATCH_IDLE_TIMEOUT_MS, + parseCoalesce: STATIC_PARSE_COALESCE_MS, + customId: `${customRendererId.value}::prefix` + }, + { + key: 'tail', + content: tailContent.value, + final: false, + codeBlockStream: true, + smoothStreaming: resolvedSmoothStreaming.value, + typewriter: resolvedTypewriter.value, + nodeVirtual: false, + maxLiveNodes: 0, + liveNodeBuffer: 0, + initialBatch: STREAM_INITIAL_RENDER_BATCH_SIZE, + batchSize: STREAM_RENDER_BATCH_SIZE, + batchDelay: STREAM_RENDER_BATCH_DELAY_MS, + batchBudget: STREAM_RENDER_BATCH_BUDGET_MS, + batchIdle: STREAM_RENDER_BATCH_IDLE_TIMEOUT_MS, + parseCoalesce: parseCoalesceMs.value, + customId: `${customRendererId.value}::tail` + } + ] +}) + const { navigateLink } = useMarkdownLinkNavigation({ linkContext: effectiveLinkContext }) @@ -358,23 +558,16 @@ function handleRendererMouseout(event: MouseEvent): void { // which would repaint stale markdown and reintroduce the completion flash. let contentRevision = 0 -const updateContentFast = useDebounceFn( - (revision: number, value: string) => { - if (revision === contentRevision) { - renderContent.value = value - } - }, - 32, - { maxWait: 64 } -) -const updateContentSlow = useDebounceFn( - (revision: number, value: string) => { - if (revision === contentRevision) { - renderContent.value = value - } - }, - 96, - { maxWait: 180 } +const contentUpdateDebouncers = CONTENT_UPDATE_COALESCE_LEVELS.map((level) => + useDebounceFn( + (revision: number, value: string) => { + if (revision === contentRevision) { + renderContent.value = value + } + }, + level.debounceMs, + { maxWait: level.maxWaitMs } + ) ) const updateContent = (value: string, commitImmediately: boolean) => { @@ -388,12 +581,10 @@ const updateContent = (value: string, commitImmediately: boolean) => { return } - if (props.smoothStreaming && value.length > 12_000) { - updateContentSlow(revision, normalizedValue) - return - } - - updateContentFast(revision, normalizedValue) + const levelIndex = isStreaming.value + ? CONTENT_UPDATE_COALESCE_LEVELS.indexOf(contentUpdateCoalesceLevel(value.length)) + : 0 + contentUpdateDebouncers[levelIndex](revision, normalizedValue) } watch([() => props.content, isStreaming], ([value, streaming], [, wasStreaming]) => { diff --git a/test/renderer/components/MarkdownRenderer.test.ts b/test/renderer/components/MarkdownRenderer.test.ts index fe9a07f0b..af36df533 100644 --- a/test/renderer/components/MarkdownRenderer.test.ts +++ b/test/renderer/components/MarkdownRenderer.test.ts @@ -559,4 +559,77 @@ describe('MarkdownRenderer', () => { referenceElement.dispatchEvent(new MouseEvent('mouseout')) expect(hideReferenceMock).toHaveBeenCalled() }) + + it('splits long streaming content into a static prefix and a live tail', async () => { + const longContent = Array.from( + { length: 400 }, + (_, index) => `paragraph ${index} of text` + ).join('\n\n') + const { wrapper } = await setup({ streaming: true, content: longContent }) + await flushPromises() + + const renderers = wrapper.findAll('[data-testid="node-renderer"]') + expect(renderers).toHaveLength(2) + + const prefix = renderers[0] + const tail = renderers[1] + expect(prefix.attributes('data-final')).toBe('true') + expect(prefix.attributes('data-custom-id')).toMatch(/::prefix$/) + expect(tail.attributes('data-final')).toBe('false') + expect(tail.attributes('data-custom-id')).toMatch(/::tail$/) + // Prefix + tail together reconstruct the whole document. + expect(prefix.attributes('data-content') + tail.attributes('data-content')).toBe(longContent) + }) + + it('does not split inside a single oversized code fence', async () => { + const fencedContent = '```\n' + 'a'.repeat(6500) + '\n```' + const { wrapper } = await setup({ streaming: true, content: fencedContent }) + await flushPromises() + + const renderers = wrapper.findAll('[data-testid="node-renderer"]') + expect(renderers).toHaveLength(1) + // The whole fence stays in the single streaming renderer, never split. + expect(renderers[0].attributes('data-content')).toBe(fencedContent) + }) + + it('does not split when mixed fence markers are unbalanced', async () => { + // One unclosed ``` fence and one unclosed ~~~ fence: combined parity looks + // even, but each marker type must be balanced independently. + const mixedContent = + '```\n' + 'a'.repeat(2000) + '\n\n~~~\n' + 'b'.repeat(2000) + '\n\n' + 'c'.repeat(2000) + const { wrapper } = await setup({ streaming: true, content: mixedContent }) + await flushPromises() + + const renderers = wrapper.findAll('[data-testid="node-renderer"]') + expect(renderers).toHaveLength(1) + expect(renderers[0].attributes('data-content')).toBe(mixedContent) + }) + + it('does not split mid-paragraph when no blank-line boundary exists', async () => { + const noBlankContent = 'word '.repeat(2500) + const { wrapper } = await setup({ streaming: true, content: noBlankContent }) + await flushPromises() + + const renderers = wrapper.findAll('[data-testid="node-renderer"]') + expect(renderers).toHaveLength(1) + expect(renderers[0].attributes('data-content')).toBe(noBlankContent) + }) + + it('resets the split when a stream shrinks and restarts', async () => { + const longContent = Array.from( + { length: 400 }, + (_, index) => `paragraph ${index} of text` + ).join('\n\n') + const { wrapper } = await setup({ streaming: true, content: longContent }) + await flushPromises() + expect(wrapper.findAll('[data-testid="node-renderer"]')).toHaveLength(2) + + const restartedContent = 'short regenerated answer' + await wrapper.setProps({ content: restartedContent }) + await flushPromises() + + const renderers = wrapper.findAll('[data-testid="node-renderer"]') + expect(renderers).toHaveLength(1) + expect(renderers[0].attributes('data-content')).toBe(restartedContent) + }) })