diff --git a/client/src/components/Chat/Messages/Content/Markdown.tsx b/client/src/components/Chat/Messages/Content/Markdown.tsx index 54e01e1145..d0dffc7238 100644 --- a/client/src/components/Chat/Messages/Content/Markdown.tsx +++ b/client/src/components/Chat/Messages/Content/Markdown.tsx @@ -1,9 +1,12 @@ -import React, { memo, useMemo } from 'react'; +import React, { memo, useMemo, useRef, useEffect } from 'react'; import { useRecoilValue } from 'recoil'; import { getRemarkPlugins, getRehypePlugins, getMarkdownComponents } from './markdownConfig'; +import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming'; import MarkdownErrorBoundary from './MarkdownErrorBoundary'; +import { FADE_HYDRATION_THRESHOLD } from './animate'; +import { useMessageContext } from '~/Providers'; import MarkdownBlocks from './MarkdownBlocks'; -import { preprocessLaTeX } from '~/utils'; +import { preprocessLaTeX, cn } from '~/utils'; import store from '~/store'; type TContentProps = { @@ -12,9 +15,30 @@ type TContentProps = { }; const Markdown = memo(function Markdown({ content = '', isLatestMessage }: TContentProps) { + const { isSubmitting = false } = useMessageContext(); + const smoothStreaming = useSmoothStreaming(); const LaTeXParsing = useRecoilValue(store.LaTeXParsing); const isInitializing = content === ''; + const animate = smoothStreaming && isLatestMessage && isSubmitting; + + // Hydration signal for the fade: substantial content already present at the + // render where `animate` flips on means resumed/switched-to/follow-up + // content, which becomes the fade baseline instead of re-animating. The flag + // is cleared after that render commits, so blocks mounting later in the same + // stream (new paragraphs, however large) always animate. + const prevAnimateRef = useRef(false); + const hydratedRef = useRef(false); + if (animate && !prevAnimateRef.current) { + hydratedRef.current = content.length > FADE_HYDRATION_THRESHOLD; + } + prevAnimateRef.current = animate; + useEffect(() => { + if (animate) { + hydratedRef.current = false; + } + }, [animate]); + const currentContent = useMemo(() => { if (isInitializing) { return ''; @@ -26,7 +50,12 @@ const Markdown = memo(function Markdown({ content = '', isLatestMessage }: TCont return (

- +

); @@ -39,6 +68,8 @@ const Markdown = memo(function Markdown({ content = '', isLatestMessage }: TCont remarkPlugins={getRemarkPlugins()} rehypePlugins={getRehypePlugins()} components={getMarkdownComponents()} + animate={animate} + hydrated={hydratedRef.current} /> ); diff --git a/client/src/components/Chat/Messages/Content/MarkdownBlocks.bench.tsx b/client/src/components/Chat/Messages/Content/MarkdownBlocks.bench.tsx index 3c664f4b0c..6dc10e8dd6 100644 --- a/client/src/components/Chat/Messages/Content/MarkdownBlocks.bench.tsx +++ b/client/src/components/Chat/Messages/Content/MarkdownBlocks.bench.tsx @@ -3,7 +3,7 @@ import { RecoilRoot } from 'recoil'; import ReactMarkdown from 'react-markdown'; import { render } from '@testing-library/react'; import { getRemarkPlugins, getRehypePlugins, getMarkdownComponents } from './markdownConfig'; -import { ArtifactProvider, CodeBlockProvider } from '~/Providers'; +import { ArtifactProvider, CodeBlockProvider, MessageContext } from '~/Providers'; import CodeBlock from '~/components/Messages/Content/CodeBlock'; import Markdown from './Markdown'; @@ -87,6 +87,19 @@ const NewMarkdown = ({ content }: { content: string }) => ( ); +const streamingContext = { + messageId: 'bench', + isExpanded: false, + isSubmitting: true, + isLatestMessage: true, +}; + +const FadeMarkdown = ({ content }: { content: string }) => ( + + + +); + const measure = ( Component: React.ComponentType<{ content: string }>, prefixes: string[], @@ -122,19 +135,24 @@ describe('Markdown streaming benchmark (OLD whole-message vs NEW per-block)', () // Warm up module/highlight caches so the first measured run isn't skewed. measure(OldMarkdown, prefixes); measure(NewMarkdown, prefixes); + measure(FadeMarkdown, prefixes); const old: Array<{ totalMs: number; codeBlockRenders: number }> = []; const neu: Array<{ totalMs: number; codeBlockRenders: number }> = []; + const fade: Array<{ totalMs: number; codeBlockRenders: number }> = []; for (let i = 0; i < iterations; i += 1) { old.push(measure(OldMarkdown, prefixes)); neu.push(measure(NewMarkdown, prefixes)); + fade.push(measure(FadeMarkdown, prefixes)); } const minMs = (rs: Array<{ totalMs: number }>) => Math.min(...rs.map((r) => r.totalMs)); const oldMs = minMs(old); const newMs = minMs(neu); + const fadeMs = minMs(fade); const oldRenders = old[0].codeBlockRenders; const newRenders = neu[0].codeBlockRenders; + const fadeRenders = fade[0].codeBlockRenders; console.log( [ @@ -145,17 +163,23 @@ describe('Markdown streaming benchmark (OLD whole-message vs NEW per-block)', () `code-block renders over the stream (structural, noise-free):`, ` OLD (whole-message): ${oldRenders}`, ` NEW (per-block) : ${newRenders}`, + ` NEW + fade : ${fadeRenders}`, ` reduction : ${(100 * (1 - newRenders / oldRenders)).toFixed(1)}%`, '', `total render time (min of ${iterations}, summed Profiler actualDuration; jsdom):`, - ` OLD: ${oldMs.toFixed(1)} ms`, - ` NEW: ${newMs.toFixed(1)} ms`, - ` speedup: ${(oldMs / newMs).toFixed(2)}x`, + ` OLD : ${oldMs.toFixed(1)} ms`, + ` NEW : ${newMs.toFixed(1)} ms`, + ` NEW + fade: ${fadeMs.toFixed(1)} ms (${(100 * (fadeMs / newMs - 1)).toFixed(1)}% vs NEW)`, + ` speedup vs OLD: ${(oldMs / newMs).toFixed(2)}x (fade: ${(oldMs / fadeMs).toFixed(2)}x)`, '=============================================================', '', ].join('\n'), ); + // The fade plugin must not disturb block memoization: code blocks render + // exactly as often as without it. + expect(fadeRenders).toBe(newRenders); + // Sanity: the per-block renderer must not render code blocks MORE than the // whole-message renderer. The real win is asserted separately below. expect(newRenders).toBeLessThanOrEqual(oldRenders); diff --git a/client/src/components/Chat/Messages/Content/MarkdownBlocks.tsx b/client/src/components/Chat/Messages/Content/MarkdownBlocks.tsx index 469159fc3e..d5b76f3ae6 100644 --- a/client/src/components/Chat/Messages/Content/MarkdownBlocks.tsx +++ b/client/src/components/Chat/Messages/Content/MarkdownBlocks.tsx @@ -1,14 +1,17 @@ -import React, { memo, useMemo } from 'react'; +import React, { memo, useMemo, useLayoutEffect } from 'react'; import ReactMarkdown from 'react-markdown'; import type { PluggableList } from 'unified'; import type { ElementType } from 'react'; import { ArtifactProvider, CodeBlockProvider } from '~/Providers'; import { splitMarkdownIntoBlocks } from './splitMarkdown'; +import { createFadePlugin } from './animate'; type SharedProps = { remarkPlugins: PluggableList; rehypePlugins: PluggableList; components: { [nodeType: string]: ElementType }; + animate?: boolean; + hydrated?: boolean; }; type MarkdownBlockProps = SharedProps & { @@ -34,7 +37,28 @@ const MarkdownBlock = memo( remarkPlugins, rehypePlugins, components, + animate = false, + hydrated = false, }: MarkdownBlockProps) { + // One fade-plugin instance per block: its closure tracks this block's + // character offsets so only newly streamed words animate. When `animate` + // flips off at stream end, the plain plugin array renders the settled + // block without wrapper spans. Classification is staged during render and + // published after React commits, so abandoned renders leave no trace. + // `hydrated` only matters at plugin creation (its first run), so it is + // deliberately absent from the memo comparator and the useMemo deps. + const fade = useMemo( + () => (animate ? createFadePlugin(hydrated) : null), + // eslint-disable-next-line react-hooks/exhaustive-deps + [animate], + ); + const blockRehypePlugins = useMemo( + () => (fade == null ? rehypePlugins : [...rehypePlugins, fade.plugin]), + [fade, rehypePlugins], + ); + useLayoutEffect(() => { + fade?.commit(); + }); return ( @@ -42,7 +66,7 @@ const MarkdownBlock = memo( /** @ts-ignore */ remarkPlugins={remarkPlugins} /** @ts-ignore */ - rehypePlugins={rehypePlugins} + rehypePlugins={blockRehypePlugins} components={components} > {content} @@ -55,7 +79,8 @@ const MarkdownBlock = memo( prev.content === next.content && prev.codeBaseIndex === next.codeBaseIndex && prev.artifactBaseIndex === next.artifactBaseIndex && - prev.mermaidBaseIndex === next.mermaidBaseIndex, + prev.mermaidBaseIndex === next.mermaidBaseIndex && + prev.animate === next.animate, ); MarkdownBlock.displayName = 'MarkdownBlock'; @@ -75,6 +100,8 @@ const MarkdownBlocks = memo(function MarkdownBlocks({ remarkPlugins, rehypePlugins, components, + animate, + hydrated, }: MarkdownBlocksProps) { const blocks = useMemo(() => { let codeBaseIndex = 0; @@ -106,6 +133,8 @@ const MarkdownBlocks = memo(function MarkdownBlocks({ remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={components} + animate={animate} + hydrated={hydrated} /> ))} diff --git a/client/src/components/Chat/Messages/Content/MessageContent.tsx b/client/src/components/Chat/Messages/Content/MessageContent.tsx index b4c09f1ab3..d20c5f71e7 100644 --- a/client/src/components/Chat/Messages/Content/MessageContent.tsx +++ b/client/src/components/Chat/Messages/Content/MessageContent.tsx @@ -3,6 +3,7 @@ import { useRecoilValue } from 'recoil'; import { Alert, DelayedRender } from '@librechat/client'; import type { TMessage } from 'librechat-data-provider'; import type { TMessageContentProps, TDisplayProps } from '~/common'; +import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming'; import Error from '~/components/Messages/Content/Error'; import { useMessageContext } from '~/Providers'; import MarkdownLite from './MarkdownLite'; @@ -26,17 +27,21 @@ const parseThinkingContent = (text: string) => { }; }; -const LoadingFallback = () => ( -
-
-
-

- -

+const LoadingFallback = () => { + const smoothStreaming = useSmoothStreaming(); + + return ( +
+
+
+

+ +

+
-
-); + ); +}; const ErrorBox = ({ children, @@ -94,10 +99,13 @@ export const ErrorMessage = ({ const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => { const { isSubmitting = false, isLatestMessage = false } = useMessageContext(); const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown); + const smoothStreaming = useSmoothStreaming(); + // The word fade itself indicates streaming, so the trailing block cursor + // only shows when the fade is unavailable (setting off or reduced motion). const showCursorState = useMemo( - () => showCursor === true && isSubmitting, - [showCursor, isSubmitting], + () => showCursor === true && isSubmitting && !(smoothStreaming && !isCreatedByUser), + [showCursor, isSubmitting, smoothStreaming, isCreatedByUser], ); const content = useMemo(() => { diff --git a/client/src/components/Chat/Messages/Content/Parts/EmptyText.tsx b/client/src/components/Chat/Messages/Content/Parts/EmptyText.tsx index 6f51951f9b..a5487aa0f6 100644 --- a/client/src/components/Chat/Messages/Content/Parts/EmptyText.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/EmptyText.tsx @@ -1,13 +1,17 @@ import { memo } from 'react'; +import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming'; +import { cn } from '~/utils'; /** Streaming cursor placeholder — no bottom margin to match Container's structure and prevent CLS */ const EmptyTextPart = memo(() => { + const smoothStreaming = useSmoothStreaming(); + return (

- +

diff --git a/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx index 6c059af06c..486a7daa9b 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx @@ -3,6 +3,7 @@ import { useAtomValue } from 'jotai'; import { ContentTypes } from 'librechat-data-provider'; import type { MouseEvent, FocusEvent } from 'react'; import { ThinkingContent, ThinkingButton, FloatingThinkingBar } from './Thinking'; +import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming'; import { useLocalize, useExpandCollapse } from '~/hooks'; import { showThinkingAtom } from '~/store/showThinking'; import { useMessageContext } from '~/Providers'; @@ -39,6 +40,7 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => { const contentId = useId(); const localize = useLocalize(); const showThinking = useAtomValue(showThinkingAtom); + const smoothStreaming = useSmoothStreaming(); const [isExpanded, setIsExpanded] = useState(showThinking); const [isBarVisible, setIsBarVisible] = useState(false); const containerRef = useRef(null); @@ -118,7 +120,11 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => { style={expandStyle} >
- {reasoningText} + + {reasoningText} + showCursor && isSubmitting, [showCursor, isSubmitting]); + const smoothStreaming = useSmoothStreaming(); + // The word fade itself indicates streaming, so the trailing block cursor + // only shows when the fade is unavailable (setting off or reduced motion). + const showCursorState = useMemo( + () => showCursor && isSubmitting && !(smoothStreaming && !isCreatedByUser), + [showCursor, isSubmitting, smoothStreaming, isCreatedByUser], + ); const content: ContentType = useMemo(() => { if (!isCreatedByUser) { diff --git a/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx b/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx index 2466828c28..ff556ac03b 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx @@ -6,6 +6,7 @@ import type { FocusEvent, FC } from 'react'; import { useLocalize, useExpandCollapse } from '~/hooks'; import { showThinkingAtom } from '~/store/showThinking'; import { fontSizeAtom } from '~/store/fontSize'; +import { AnimatedText } from '../animate'; import { cn } from '~/utils'; /** @@ -14,12 +15,15 @@ import { cn } from '~/utils'; */ export const ThinkingContent: FC<{ children: React.ReactNode; -}> = memo(({ children }) => { + animate?: boolean; +}> = memo(({ children, animate = false }) => { const fontSize = useAtomValue(fontSizeAtom); + const content = + animate && typeof children === 'string' ? : children; return (
-

{children}

+

{content}

); }); diff --git a/client/src/components/Chat/Messages/Content/__tests__/animate.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/animate.test.tsx new file mode 100644 index 0000000000..da4382753d --- /dev/null +++ b/client/src/components/Chat/Messages/Content/__tests__/animate.test.tsx @@ -0,0 +1,264 @@ +import React from 'react'; +import ReactMarkdown from 'react-markdown'; +import { render } from '@testing-library/react'; +import type { FadePlugin } from '../animate'; +import { + splitWords, + beginRun, + endRun, + classifyValue, + createFadeState, + createFadePlugin, + AnimatedText, + FADE_DURATION_MS, + FADE_STAGGER_MS, + FADE_STAGGER_MAX_MS, + FADE_HYDRATION_THRESHOLD, +} from '../animate'; + +let nowSpy: jest.SpyInstance; +let mockedNow = 0; + +const setTime = (value: number) => { + mockedNow = value; +}; + +beforeEach(() => { + mockedNow = 0; + nowSpy = jest.spyOn(performance, 'now').mockImplementation(() => mockedNow); +}); + +afterEach(() => { + nowSpy.mockRestore(); +}); + +describe('splitWords', () => { + it('splits into word parts with trailing whitespace and round-trips exactly', () => { + const value = ' Hello world,\nthis is streamed '; + const parts = splitWords(value); + expect(parts.join('')).toBe(value); + expect(parts[0]).toBe(' '); + expect(parts[1]).toBe('Hello '); + expect(parts[2]).toBe('world,\n'); + }); + + it('splits CJK runs into smaller segments', () => { + const parts = splitWords('これは日本語のテストです'); + expect(parts.join('')).toBe('これは日本語のテストです'); + expect(parts.length).toBeGreaterThan(1); + }); + + it('segments kana-only Japanese text without any Han characters', () => { + const hiragana = splitWords('こんにちはせかい'); + expect(hiragana.join('')).toBe('こんにちはせかい'); + expect(hiragana.length).toBeGreaterThan(1); + + const katakana = splitWords('カタカナテキスト'); + expect(katakana.join('')).toBe('カタカナテキスト'); + expect(katakana.length).toBeGreaterThan(1); + }); + + it('segments spaceless Southeast Asian scripts', () => { + const thai = splitWords('สวัสดีครับผมชื่อจอห์นและนี่คือการทดสอบ'); + expect(thai.join('')).toBe('สวัสดีครับผมชื่อจอห์นและนี่คือการทดสอบ'); + expect(thai.length).toBeGreaterThan(1); + + const burmese = splitWords('မင်္ဂလာပါကမ္ဘာကြီး'); + expect(burmese.join('')).toBe('မင်္ဂလာပါကမ္ဘာကြီး'); + expect(burmese.length).toBeGreaterThan(1); + }); +}); + +describe('classifyValue', () => { + it('animates every word on the first run with capped stagger', () => { + const state = createFadeState(); + const run = beginRun(state); + const words = 'a b c d e f g h i j k l m'.split(' ').join(' '); + const segments = classifyValue(run, words).filter((segment) => /\S/.test(segment.value)); + endRun(run); + + expect(segments.every((segment) => segment.animated)).toBe(true); + expect(segments[0].delay).toBe(0); + expect(segments[1].delay).toBe(FADE_STAGGER_MS); + const maxDelay = Math.max(...segments.map((segment) => segment.delay)); + expect(maxDelay).toBe(FADE_STAGGER_MAX_MS); + }); + + it('does not animate whitespace parts', () => { + const state = createFadeState(); + const run = beginRun(state); + const segments = classifyValue(run, ' hello '); + endRun(run); + const whitespace = segments.filter((segment) => !/\S/.test(segment.value)); + expect(whitespace.length).toBeGreaterThan(0); + expect(whitespace.every((segment) => !segment.animated)).toBe(true); + }); + + it('only animates newly appended words on later runs', () => { + const state = createFadeState(); + const first = beginRun(state); + classifyValue(first, 'hello world '); + endRun(first); + + setTime(FADE_DURATION_MS + FADE_STAGGER_MAX_MS + 1); + const second = beginRun(state); + const segments = classifyValue(second, 'hello world and more'); + endRun(second); + + const byValue = new Map(segments.map((segment) => [segment.value.trim(), segment])); + expect(byValue.get('hello')?.animated).toBe(false); + expect(byValue.get('world')?.animated).toBe(false); + expect(byValue.get('and')?.animated).toBe(true); + expect(byValue.get('more')?.animated).toBe(true); + }); + + it('replays identical animation props for words still inside their window', () => { + const state = createFadeState(); + const first = beginRun(state); + const firstSegments = classifyValue(first, 'hello world'); + endRun(first); + const worldDelay = firstSegments.find((s) => s.value === 'world')?.delay; + + setTime(FADE_DURATION_MS / 2); + const second = beginRun(state); + const segments = classifyValue(second, 'hello world again'); + endRun(second); + + const world = segments.find((segment) => segment.value.trim() === 'world'); + expect(world?.animated).toBe(true); + expect(world?.delay).toBe(worldDelay); + }); + + it('keeps animating a word that grows at the stream head', () => { + const state = createFadeState(); + const first = beginRun(state); + classifyValue(first, 'hel'); + endRun(first); + + setTime(FADE_DURATION_MS / 2); + const second = beginRun(state); + const segments = classifyValue(second, 'hello'); + endRun(second); + expect(segments[0].animated).toBe(true); + expect(segments[0].delay).toBe(0); + }); +}); + +describe('createFadePlugin', () => { + const renderMarkdown = (fade: FadePlugin, content: string) => ( + /** @ts-ignore */ + {content} + ); + + it('wraps words in fade spans on first render, including inline formatting', () => { + const fade = createFadePlugin(); + const { container } = render(renderMarkdown(fade, 'Hello **bold** world')); + const spans = container.querySelectorAll('span[data-lc-fade]'); + expect(spans.length).toBe(3); + expect(container.textContent).toBe('Hello bold world'); + }); + + it('does not wrap text inside code blocks or inline code', () => { + const fade = createFadePlugin(); + const { container } = render( + renderMarkdown(fade, 'text `inline code` more\n\n```\nconst x = 1;\n```'), + ); + expect(container.querySelector('code span[data-lc-fade]')).toBeNull(); + expect(container.querySelector('pre span[data-lc-fade]')).toBeNull(); + expect(container.querySelectorAll('p span[data-lc-fade]').length).toBe(2); + }); + + it('only animates appended words across streamed re-renders', () => { + const fade = createFadePlugin(); + const { container, rerender } = render(renderMarkdown(fade, 'Hello world')); + fade.commit(); + + setTime(FADE_DURATION_MS + FADE_STAGGER_MAX_MS + 1); + rerender(renderMarkdown(fade, 'Hello world and more text')); + + const animated = Array.from(container.querySelectorAll('span[data-lc-fade]')).map((span) => + span.textContent?.trim(), + ); + expect(animated).toEqual(['and', 'more', 'text']); + const bare = Array.from(container.querySelectorAll('p > span:not([data-lc-fade])')).map( + (span) => span.textContent?.trim(), + ); + expect(bare).toEqual(['Hello', 'world']); + }); + + it('does not re-animate words when markdown restructures around them', () => { + const fade = createFadePlugin(); + const { container, rerender } = render(renderMarkdown(fade, 'Result is done and')); + fade.commit(); + + setTime(FADE_DURATION_MS + FADE_STAGGER_MAX_MS + 1); + rerender(renderMarkdown(fade, 'Result is done and **final**')); + + const animated = Array.from(container.querySelectorAll('span[data-lc-fade]')).map((span) => + span.textContent?.trim(), + ); + expect(animated).toEqual(['final']); + }); + + it('treats the first render of a hydrated plugin as baseline without animating', () => { + const fade = createFadePlugin(true); + const hydrated = `word${' word'.repeat(Math.ceil(FADE_HYDRATION_THRESHOLD / 5) + 4)}`; + const { container, rerender } = render(renderMarkdown(fade, hydrated)); + expect(container.querySelectorAll('span[data-lc-fade]').length).toBe(0); + fade.commit(); + + rerender(renderMarkdown(fade, `${hydrated} appended tail`)); + const animated = Array.from(container.querySelectorAll('span[data-lc-fade]')).map((span) => + span.textContent?.trim(), + ); + expect(animated).toEqual(['appended', 'tail']); + }); + + it('animates a large first render on a non-hydrated plugin (new block in one chunk)', () => { + const fade = createFadePlugin(); + const large = `word${' word'.repeat(Math.ceil(FADE_HYDRATION_THRESHOLD / 5) + 4)}`; + const { container } = render(renderMarkdown(fade, large)); + expect(container.querySelectorAll('span[data-lc-fade]').length).toBeGreaterThan(0); + }); + + it('re-classifies identically when a render is never committed', () => { + const fade = createFadePlugin(); + const { container, rerender } = render(renderMarkdown(fade, 'Hello world')); + + setTime(FADE_DURATION_MS + FADE_STAGGER_MAX_MS + 1); + rerender(renderMarkdown(fade, 'Hello world')); + + const animated = Array.from(container.querySelectorAll('span[data-lc-fade]')).map((span) => + span.textContent?.trim(), + ); + expect(animated).toEqual(['Hello', 'world']); + }); +}); + +describe('AnimatedText', () => { + it('renders new words in fade spans and settles old words to plain text', () => { + const { container, rerender } = render(); + expect(container.querySelectorAll('span[data-lc-fade]').length).toBe(2); + + setTime(FADE_DURATION_MS + FADE_STAGGER_MAX_MS + 1); + rerender(); + + const animated = Array.from(container.querySelectorAll('span[data-lc-fade]')).map((span) => + span.textContent?.trim(), + ); + expect(animated).toEqual(['the', 'answer']); + expect(container.textContent).toBe('thinking about the answer'); + }); + + it('treats large hydrated text as baseline without animating', () => { + const hydrated = `word${' word'.repeat(Math.ceil(FADE_HYDRATION_THRESHOLD / 5) + 4)}`; + const { container, rerender } = render(); + expect(container.querySelectorAll('span[data-lc-fade]').length).toBe(0); + + rerender(); + const animated = Array.from(container.querySelectorAll('span[data-lc-fade]')).map((span) => + span.textContent?.trim(), + ); + expect(animated).toEqual(['appended', 'tail']); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/animate.tsx b/client/src/components/Chat/Messages/Content/animate.tsx new file mode 100644 index 0000000000..c295cf39d9 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/animate.tsx @@ -0,0 +1,349 @@ +import { memo, useRef, Fragment, useLayoutEffect } from 'react'; +import type { Root, Element, ElementContent } from 'hast'; +import type { CSSProperties } from 'react'; +import type { Plugin } from 'unified'; + +/** Must match the animation duration on `[data-lc-fade]` in style.css */ +export const FADE_DURATION_MS = 250; +export const FADE_STAGGER_MS = 25; +export const FADE_STAGGER_MAX_MS = 250; +/** + * Text already longer than this when animation starts is hydrated/resumed + * content (reconnected stream, conversation switch, follow-up turn), not a + * fresh delta — that content becomes the baseline instead of re-fading. The + * markdown path evaluates this against the whole message when its `animate` + * gate flips on; `AnimatedText` evaluates it against its own first text. + */ +export const FADE_HYDRATION_THRESHOLD = 120; + +type FadeEntry = { at: number; delay: number }; + +type PendingRun = { + prevCount: number; + additions: Map; + now: number; +}; + +export type FadeState = { + /** Total characters classified during the last committed run; parts below this offset are not new */ + prevCount: number; + /** Parts still mid-animation, keyed by start offset, so re-renders replay identical props */ + active: Map; + /** True until the first run commits; used for the hydration baseline decision */ + firstRun: boolean; + /** Staged result of the latest render, published to the fields above on commit */ + pending: PendingRun | null; +}; + +type FadeRun = { + state: FadeState; + now: number; + count: number; + newIndex: number; + /** Baseline mode: classify everything as already seen (hydrated first run) */ + suppress: boolean; + additions: Map; +}; + +export type FadeSegment = { + start: number; + value: string; + animated: boolean; + delay: number; +}; + +const WORD_REGEX = /\S+\s*/g; +const NON_WHITESPACE_REGEX = /\S/; +/** + * Scripts written without word-delimiting spaces: Thai, Lao, Myanmar, Khmer, + * Tibetan, CJK ideographs/kana, Hangul, and CJK compatibility ideographs. + * Whitespace splitting yields one ever-growing part for these, which would + * stop fading once its window expires, so they go through Intl.Segmenter. + */ +const SPACELESS_REGEX = + /[\u0E00-\u0EFF\u0F00-\u0FFF\u1000-\u109F\u1780-\u17FF\u2E80-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]/; + +let wordSegmenter: Intl.Segmenter | null | undefined; + +function getWordSegmenter(): Intl.Segmenter | null { + if (wordSegmenter === undefined) { + wordSegmenter = + typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function' + ? new Intl.Segmenter(undefined, { granularity: 'word' }) + : null; + } + return wordSegmenter; +} + +function pushSegmentedParts(parts: string[], token: string): void { + const segmenter = getWordSegmenter(); + if (segmenter == null) { + parts.push(token); + return; + } + const trailing = /\s+$/.exec(token); + const word = trailing == null ? token : token.slice(0, trailing.index); + for (const segment of segmenter.segment(word)) { + parts.push(segment.segment); + } + if (trailing != null) { + parts.push(trailing[0]); + } +} + +/** + * Splits text into word parts, each a non-whitespace run plus its trailing + * whitespace, with whitespace-only runs kept as separate parts. Scripts + * without word-delimiting spaces (CJK) are further split via Intl.Segmenter. + * Concatenating the result always reproduces the input exactly. + */ +export function splitWords(value: string): string[] { + const parts: string[] = []; + WORD_REGEX.lastIndex = 0; + let index = 0; + let match: RegExpExecArray | null; + while ((match = WORD_REGEX.exec(value)) !== null) { + if (match.index > index) { + parts.push(value.slice(index, match.index)); + } + const token = match[0]; + if (SPACELESS_REGEX.test(token)) { + pushSegmentedParts(parts, token); + } else { + parts.push(token); + } + index = match.index + token.length; + } + if (index < value.length) { + parts.push(value.slice(index)); + } + return parts; +} + +export function createFadeState(): FadeState { + return { prevCount: 0, active: new Map(), firstRun: true, pending: null }; +} + +export function beginRun(state: FadeState, suppress = false): FadeRun { + return { + state, + now: performance.now(), + count: 0, + newIndex: 0, + suppress, + additions: new Map(), + }; +} + +/** + * Stages the run's result on the state without publishing it. Classification + * never mutates committed state during render, so a render that React + * abandons (concurrent interruption, StrictMode double-render) leaves no + * trace; only {@link commitRun} — called after the render commits — publishes. + */ +export function stageRun(run: FadeRun): void { + run.state.pending = { prevCount: run.count, additions: run.additions, now: run.now }; +} + +/** Publishes the staged run: baseline offset, new animations, pruned entries. */ +export function commitRun(state: FadeState): void { + const pending = state.pending; + if (pending == null) { + return; + } + state.pending = null; + state.firstRun = false; + state.prevCount = pending.prevCount; + for (const [start, entry] of pending.additions) { + state.active.set(start, entry); + } + for (const [start, entry] of state.active) { + if (pending.now - entry.at >= entry.delay + FADE_DURATION_MS) { + state.active.delete(start); + } + } +} + +/** Stages and immediately commits — for callers without a commit phase. */ +export function endRun(run: FadeRun): void { + stageRun(run); + commitRun(run.state); +} + +/** + * Classifies one text value into fade segments, advancing the run's + * document-order character offset. A part is animated when it starts past the + * last committed run's total offset (newly streamed) or when it is still + * within its animation window from an earlier run — in which case it replays + * identical animation props so React leaves the in-flight CSS animation + * untouched. Committed state is only read here; new animations are recorded + * on the run and published by {@link commitRun}. + */ +export function classifyValue(run: FadeRun, value: string): FadeSegment[] { + const { state, now } = run; + const segments: FadeSegment[] = []; + for (const part of splitWords(value)) { + const start = run.count; + run.count += part.length; + if (run.suppress || !NON_WHITESPACE_REGEX.test(part)) { + segments.push({ start, value: part, animated: false, delay: 0 }); + continue; + } + if (start >= state.prevCount) { + const staged = run.additions.get(start); + const delay = staged?.delay ?? Math.min(run.newIndex * FADE_STAGGER_MS, FADE_STAGGER_MAX_MS); + run.newIndex += 1; + run.additions.set(start, staged ?? { at: now, delay }); + segments.push({ start, value: part, animated: true, delay }); + continue; + } + const entry = state.active.get(start); + if (entry != null && now - entry.at < entry.delay + FADE_DURATION_MS) { + segments.push({ start, value: part, animated: true, delay: entry.delay }); + continue; + } + segments.push({ start, value: part, animated: false, delay: 0 }); + } + return segments; +} + +const SKIP_TAGS = new Set([ + 'code', + 'pre', + 'svg', + 'math', + 'annotation', + 'script', + 'style', + 'artifact', + 'citation', + 'composite-citation', + 'highlighted-text', + 'mcp-ui-resource', + 'mcp-ui-carousel', +]); + +function isSkippedElement(node: Element): boolean { + if (SKIP_TAGS.has(node.tagName)) { + return true; + } + const className = node.properties?.className; + if (Array.isArray(className)) { + return className.some((name) => typeof name === 'string' && name.startsWith('katex')); + } + return typeof className === 'string' && className.startsWith('katex'); +} + +function toContent(segment: FadeSegment): ElementContent { + if (!NON_WHITESPACE_REGEX.test(segment.value)) { + return { type: 'text', value: segment.value }; + } + const properties: Element['properties'] = {}; + if (segment.animated) { + properties.dataLcFade = ''; + if (segment.delay > 0) { + properties.style = `--lc-delay:${segment.delay}ms`; + } + } + return { + type: 'element', + tagName: 'span', + properties, + children: [{ type: 'text', value: segment.value }], + }; +} + +function transformElement(run: FadeRun, element: Element): void { + const next: ElementContent[] = []; + for (const child of element.children) { + if (child.type === 'text') { + for (const segment of classifyValue(run, child.value)) { + next.push(toContent(segment)); + } + continue; + } + if (child.type === 'element' && !isSkippedElement(child)) { + transformElement(run, child); + } + next.push(child); + } + element.children = next; +} + +export type FadePlugin = { + plugin: Plugin<[], Root>; + /** Publish the latest render's staged classification; call after React commits. */ + commit: () => void; +}; + +/** + * Creates a per-renderer rehype plugin that wraps newly streamed words in + * one-shot CSS fade spans (`[data-lc-fade]`). New-text detection uses + * document-order character offsets held in the factory closure, so text that + * was already visible in a previous render mounts as a bare span and never + * re-animates, even when markdown re-parsing restructures the tree. Pass + * `hydrated: true` for renderers created while previously accumulated content + * is already showing (resumed stream, conversation switch, follow-up turn): + * their first run becomes the baseline without animating. Classification is + * staged during render and must be published via `commit()` after React + * commits (a layout effect), so abandoned renders leave no trace. Create one + * instance per streaming renderer and drop it (plain plugin array) once the + * stream ends so the settled message renders without wrapper spans. + */ +export function createFadePlugin(hydrated = false): FadePlugin { + const state = createFadeState(); + const plugin: Plugin<[], Root> = function rehypeFade() { + return (tree: Root) => { + const run = beginRun(state, hydrated && state.firstRun); + for (const child of tree.children) { + if (child.type === 'element' && !isSkippedElement(child)) { + transformElement(run, child); + } + } + stageRun(run); + }; + }; + return { plugin, commit: () => commitRun(state) }; +} + +const DELAY_VAR = '--lc-delay'; + +/** + * Plain-text counterpart of the rehype plugin for non-markdown streamed text + * (reasoning). Renders words in fade spans keyed by character offset; only + * render this while the text is actively streaming and render the raw string + * once settled. + */ +export const AnimatedText = memo(function AnimatedText({ text }: { text: string }) { + const stateRef = useRef(null); + if (stateRef.current == null) { + stateRef.current = createFadeState(); + } + const state = stateRef.current; + const suppress = state.firstRun && text.length > FADE_HYDRATION_THRESHOLD; + const run = beginRun(state, suppress); + const segments = classifyValue(run, text); + stageRun(run); + + useLayoutEffect(() => { + commitRun(state); + }); + + return ( + <> + {segments.map((segment) => { + if (!segment.animated) { + return {segment.value}; + } + const style = + segment.delay > 0 ? ({ [DELAY_VAR]: `${segment.delay}ms` } as CSSProperties) : undefined; + return ( + + {segment.value} + + ); + })} + + ); +}); +AnimatedText.displayName = 'AnimatedText'; diff --git a/client/src/components/Nav/Settings/registry.tsx b/client/src/components/Nav/Settings/registry.tsx index 0d960c0fb6..c2406a804a 100644 --- a/client/src/components/Nav/Settings/registry.tsx +++ b/client/src/components/Nav/Settings/registry.tsx @@ -30,6 +30,7 @@ import DeleteAccount from '../SettingsTabs/Account/DeleteAccount'; import { ForkSettings } from '../SettingsTabs/Chat/ForkSettings'; import ChatDirection from '../SettingsTabs/Chat/ChatDirection'; import { DeleteCache } from '../SettingsTabs/Data/DeleteCache'; +import { smoothStreamingAtom } from '~/store/smoothStreaming'; import { RevokeKeys } from '../SettingsTabs/Data/RevokeKeys'; import { ClearChats } from '../SettingsTabs/Data/ClearChats'; import { TokenCredits, AutoRefill } from './BillingControls'; @@ -275,6 +276,19 @@ export const registry: SettingEntry[] = [ switchId: 'showThinking', }), }, + { + id: 'smoothStreaming', + tab: CHAT, + section: 'messages', + labelKey: 'com_nav_smooth_streaming', + keywords: ['smooth', 'streaming', 'fade', 'animation', 'animate'], + Component: toggleControl({ + stateAtom: smoothStreamingAtom, + localizationKey: 'com_nav_smooth_streaming', + switchId: 'smoothStreaming', + hoverCardText: 'com_nav_info_smooth_streaming', + }), + }, { id: 'autoExpandTools', tab: CHAT, diff --git a/client/src/hooks/Messages/index.ts b/client/src/hooks/Messages/index.ts index ad93d910a1..c3e562c1d4 100644 --- a/client/src/hooks/Messages/index.ts +++ b/client/src/hooks/Messages/index.ts @@ -19,3 +19,4 @@ export { default as useMessageHelpers } from './useMessageHelpers'; export { default as useCopyToClipboard } from './useCopyToClipboard'; export { default as useContentMetadata } from './useContentMetadata'; export { default as useMessageScrolling } from './useMessageScrolling'; +export { default as useSmoothStreaming } from './useSmoothStreaming'; diff --git a/client/src/hooks/Messages/useSmoothStreaming.ts b/client/src/hooks/Messages/useSmoothStreaming.ts new file mode 100644 index 0000000000..3230e867e7 --- /dev/null +++ b/client/src/hooks/Messages/useSmoothStreaming.ts @@ -0,0 +1,16 @@ +import { useAtomValue } from 'jotai'; +import { useMediaQuery } from '@librechat/client'; +import { smoothStreamingAtom } from '~/store/smoothStreaming'; + +/** + * Whether the smooth streaming fade is enabled for this client: the user + * setting is on and the device does not prefer reduced motion. Streaming + * renderers combine this with per-message state (latest message, submitting) + * to decide whether to animate; the trailing block cursor is hidden while the + * fade is enabled since the fade itself indicates streaming. + */ +export default function useSmoothStreaming(): boolean { + const smoothStreaming = useAtomValue(smoothStreamingAtom); + const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); + return smoothStreaming && !reducedMotion; +} diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 611aa69e0d..8db48f731a 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -533,6 +533,7 @@ "com_nav_info_save_badges_state": "When enabled, the state of the chat badges will be saved. This means that if you create a new chat, the badges will remain in the same state as the previous chat. If you disable this option, the badges will reset to their default state every time you create a new chat", "com_nav_info_save_draft": "When enabled, the text and attachments you enter in the chat form will be automatically saved locally as drafts. These drafts will be available even if you reload the page or switch to a different conversation. Drafts are stored locally on your device and are deleted once the message is sent.", "com_nav_info_show_thinking": "When enabled, the chat will display the thinking dropdowns open by default, allowing you to view the AI's reasoning in real-time. When disabled, the thinking dropdowns will remain closed by default for a cleaner and more streamlined interface", + "com_nav_info_smooth_streaming": "When enabled, newly streamed words fade in smoothly for the latest response. This is purely visual — it does not delay token delivery — and is disabled automatically when your device prefers reduced motion.", "com_nav_info_stateful_sessions": "When enabled, this agent's code executions reuse one persistent sandbox workspace per conversation: files, installed packages, and working state usually carry over between runs. The workspace may occasionally reset, so anything important should be saved under /mnt/data. Requires Code Interpreter and the app-level stateful sessions capability.", "com_nav_info_user_name_display": "When enabled, the username of the sender will be shown above each message you send. When disabled, you will only see \"You\" above your messages.", "com_nav_keep_screen_awake": "Keep screen awake during response generation", @@ -624,6 +625,7 @@ "com_nav_show_thinking": "Open Thinking Dropdowns by Default", "com_nav_slash_command": "/-Command", "com_nav_slash_command_description": "Toggle command \"/\" for selecting a prompt via keyboard", + "com_nav_smooth_streaming": "Smooth streaming text (fade in new words)", "com_nav_speech_to_text": "Speech to Text", "com_nav_stop_generating": "Stop generating", "com_nav_text_to_speech": "Text to Speech", diff --git a/client/src/store/smoothStreaming.ts b/client/src/store/smoothStreaming.ts new file mode 100644 index 0000000000..21728c6758 --- /dev/null +++ b/client/src/store/smoothStreaming.ts @@ -0,0 +1,13 @@ +import { createStorageAtom } from './jotai-utils'; + +const DEFAULT_SMOOTH_STREAMING = true; + +/** + * Controls whether newly streamed message text fades in smoothly. Purely + * visual: token delivery and state updates are unaffected, and the CSS + * animation is disabled under `prefers-reduced-motion`. + */ +export const smoothStreamingAtom = createStorageAtom( + 'smoothStreaming', + DEFAULT_SMOOTH_STREAMING, +); diff --git a/client/src/style.css b/client/src/style.css index 1128e6e90d..e465ebb7ad 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -1908,6 +1908,52 @@ html { display: none !important; } +/* Smooth streaming: one-shot fade-in on newly streamed words. + Duration must match FADE_DURATION_MS in Chat/Messages/Content/animate.tsx */ +@keyframes lc-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +[data-lc-fade] { + animation: lc-fade-in 250ms ease-out both; + animation-delay: var(--lc-delay, 0ms); +} + +@media (prefers-reduced-motion: reduce) { + [data-lc-fade] { + animation: none; + } +} + +/* Pre-first-token dot, restyled to match the word fade: it fades in on the + same curve rather than popping, then breathes on opacity instead of the + classic size throb, so "run starting" and "text arriving" read as one + system. Only applied while the fade is active; without it the dot keeps + its original pulseSize behavior. */ +@keyframes lc-dot-breathe { + 0%, + 100% { + opacity: 0.35; + transform: translateZ(0) scale(0.85); + } + 50% { + opacity: 1; + transform: translateZ(0) scale(1); + } +} + +.submitting .result-thinking.result-thinking-fade:empty:last-child:after { + animation: + lc-fade-in 250ms ease-out both, + lc-dot-breathe 1.6s ease-in-out 250ms infinite; + will-change: opacity, transform; +} + .webkit-dark-styles, .webkit-dark-styles:focus { background-clip: content-box;