diff --git a/src/lib/components/chat/Messages/Markdown.svelte b/src/lib/components/chat/Messages/Markdown.svelte index 599c1aef74..09536a2d06 100644 --- a/src/lib/components/chat/Messages/Markdown.svelte +++ b/src/lib/components/chat/Messages/Markdown.svelte @@ -35,6 +35,7 @@ import { user } from '$lib/stores'; import MarkdownTokens from './Markdown/MarkdownTokens.svelte'; + import { createMarkdownUpdateScheduler } from './markdown-update-scheduler'; export let id = ''; export let content; @@ -59,7 +60,6 @@ export let onTaskClick = () => {}; let tokens = []; - let pendingUpdate = null; let lastContent = ''; let lastParsedContent = ''; @@ -74,21 +74,12 @@ tokens = marked.lexer(processed); }; - const updateHandler = (content) => { - if (content && !pendingUpdate) { - pendingUpdate = requestAnimationFrame(() => { - pendingUpdate = null; - parseTokens(); - }); - } - }; + const updateScheduler = createMarkdownUpdateScheduler(parseTokens); - $: updateHandler(content); + $: updateScheduler.update(content); // Throttle parsing to once per animation frame while streaming - onDestroy(() => { - cancelAnimationFrame(pendingUpdate); - }); + onDestroy(updateScheduler.cancel); {#key id} diff --git a/src/lib/components/chat/Messages/ResponseMessage.svelte b/src/lib/components/chat/Messages/ResponseMessage.svelte index 1f03e56c7a..b19cf2f843 100644 --- a/src/lib/components/chat/Messages/ResponseMessage.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage.svelte @@ -65,6 +65,7 @@ import FullHeightIframe from '$lib/components/common/FullHeightIframe.svelte'; import OutputEditView from './OutputEditView.svelte'; import { getOutputText, replaceOutputMessageText, type OutputItem } from './structuredOutput'; + import { resolveMarkdownDone } from './markdown-update-scheduler'; interface MessageType { id: string; @@ -839,9 +840,7 @@ preview={!readOnly} {editCodeBlock} {topPadding} - done={($settings?.chatFadeStreamingText ?? true) - ? (message?.done ?? false) - : true} + done={resolveMarkdownDone($settings?.chatFadeStreamingText, message?.done)} {model} onTaskClick={async (e) => { console.log(e); diff --git a/src/lib/components/chat/Messages/markdown-update-scheduler.test.ts b/src/lib/components/chat/Messages/markdown-update-scheduler.test.ts new file mode 100644 index 0000000000..ba657d0c40 --- /dev/null +++ b/src/lib/components/chat/Messages/markdown-update-scheduler.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createMarkdownUpdateScheduler, resolveMarkdownDone } from './markdown-update-scheduler'; + +describe('createMarkdownUpdateScheduler', () => { + it('coalesces the fade-disabled done path onto the pending frame', () => { + let content = ''; + let done = resolveMarkdownDone(false, false); + const parsed: Array<{ content: string; done: boolean }> = []; + const frames: FrameRequestCallback[] = []; + const requestFrame = vi.fn((callback: FrameRequestCallback) => { + frames.push(callback); + return frames.length; + }); + const scheduler = createMarkdownUpdateScheduler( + () => parsed.push({ content, done }), + requestFrame, + vi.fn() + ); + + scheduler.update(content); + expect(requestFrame).not.toHaveBeenCalled(); + + content = 'partial'; + scheduler.update(content); + content = 'final'; + done = resolveMarkdownDone(false, true); + scheduler.update(content); + + expect(requestFrame).toHaveBeenCalledTimes(1); + expect(parsed).toEqual([]); + + frames[0](16); + expect(parsed).toEqual([{ content: 'final', done: true }]); + }); + + it('cancels a pending frame on teardown', () => { + const cancelFrame = vi.fn(); + const scheduler = createMarkdownUpdateScheduler( + vi.fn(), + vi.fn(() => 42), + cancelFrame + ); + + scheduler.update('streaming'); + scheduler.cancel(); + scheduler.cancel(); + + expect(cancelFrame).toHaveBeenCalledOnce(); + expect(cancelFrame).toHaveBeenCalledWith(42); + }); +}); + +describe('resolveMarkdownDone', () => { + it.each([ + { fadeStreamingText: false, messageDone: false, expected: true }, + { fadeStreamingText: false, messageDone: true, expected: true }, + { fadeStreamingText: true, messageDone: false, expected: false }, + { fadeStreamingText: true, messageDone: true, expected: true }, + { fadeStreamingText: undefined, messageDone: false, expected: false } + ])( + 'returns $expected when fadeStreamingText=$fadeStreamingText and messageDone=$messageDone', + ({ fadeStreamingText, messageDone, expected }) => { + expect(resolveMarkdownDone(fadeStreamingText, messageDone)).toBe(expected); + } + ); +}); diff --git a/src/lib/components/chat/Messages/markdown-update-scheduler.ts b/src/lib/components/chat/Messages/markdown-update-scheduler.ts new file mode 100644 index 0000000000..5dc9b3abb6 --- /dev/null +++ b/src/lib/components/chat/Messages/markdown-update-scheduler.ts @@ -0,0 +1,35 @@ +type RequestFrame = (callback: FrameRequestCallback) => number; +type CancelFrame = (handle: number) => void; + +const requestBrowserFrame: RequestFrame = (callback) => requestAnimationFrame(callback); +const cancelBrowserFrame: CancelFrame = (handle) => cancelAnimationFrame(handle); + +export const resolveMarkdownDone = ( + fadeStreamingText: boolean | null | undefined, + messageDone: boolean | null | undefined +) => ((fadeStreamingText ?? true) ? (messageDone ?? false) : true); + +export const createMarkdownUpdateScheduler = ( + parseTokens: () => void, + requestFrame: RequestFrame = requestBrowserFrame, + cancelFrame: CancelFrame = cancelBrowserFrame +) => { + let pendingUpdate: number | null = null; + + return { + update(content: unknown) { + if (!content || pendingUpdate !== null) return; + + pendingUpdate = requestFrame(() => { + pendingUpdate = null; + parseTokens(); + }); + }, + cancel() { + if (pendingUpdate === null) return; + + cancelFrame(pendingUpdate); + pendingUpdate = null; + } + }; +};