mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🚣♀️ feat: Smooth Streaming Text Fade-In (#14757)
* 🚣♀️ feat: Smooth Streaming Text Fade-In Adds a native FlowToken-style fade-in for streamed message text, with no changes to the streaming data path: a per-block rehype plugin wraps newly arrived words in one-shot CSS fade spans, using document-order character offsets so already-visible text never re-animates, even when markdown re-parsing restructures the tree. Words still inside their animation window replay identical props so in-flight fades are never cut short. - New Content/animate.tsx: word splitting (Intl.Segmenter for CJK), offset-based new-text classification, rehype plugin factory, and an AnimatedText component for plain-text reasoning content - MarkdownBlocks: per-block plugin instance appended to the cached rehype plugins only while animating; block memoization untouched (bench asserts identical code-block render counts) - Animation gated on isSubmitting && isLatestMessage and dropped at stream end, so settled messages render without wrapper spans - Skips code, pre, math/KaTeX, artifacts, citations, and MCP UI subtrees - Smooth streaming toggle (default on) under Settings → Chat → Messages; animation disabled under prefers-reduced-motion - Extends the streaming bench with a fade variant (+9% render time in jsdom, 62/62 code-block renders) and adds unit tests for the plugin, classification, and AnimatedText Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB * 🔧 chore: Fix Import Order in Markdown and Settings Registry Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB * ✅ test: Cover Kana-Only CJK Word Segmentation Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB * 🛡️ fix: Harden Streaming Fade for Resume, Concurrency, and Reduced Motion Addresses Codex review on the smooth streaming fade: - Hydrated/resumed content becomes the animation baseline: a renderer whose first run already exceeds FADE_HYDRATION_THRESHOLD (reconnected stream, conversation switch) no longer re-fades the entire accumulated response - Classification is now transactional under React 18 concurrency: runs are staged during render and published via commit() from a layout effect, so abandoned renders (interruption, StrictMode double-render) leave no trace - prefers-reduced-motion now disables the rehype transform and AnimatedText in the render gate, not just the CSS animation, so reduced-motion users skip the span-wrapping work entirely Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB * 🌏 fix: Refine Fade Hydration Signal, Spaceless Scripts, Collapsed Reasoning Second Codex review round on the smooth streaming fade: - Hydration is now an explicit signal instead of a per-block length guess: Markdown captures whether substantial content already exists at the render where its animate gate flips on, and passes it to each block's plugin. New blocks mounting later in the stream always animate regardless of size, and resumed content becomes the baseline regardless of block sizes - Word segmentation now covers all spaceless scripts (Thai, Lao, Tibetan, Myanmar, Khmer) in addition to CJK/Hangul, so continuously streamed text in those scripts keeps fading instead of freezing after the first window - Reasoning text no longer runs the word transform while the thinking panel is collapsed; expanding mid-stream starts from a hydrated baseline Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB * 🖱️ feat: Hide Streaming Cursors While Smooth Fade Is Active The word fade itself signals streaming, so the trailing block cursor (result-streaming) and the pulsing thinking dot (result-thinking) are now suppressed whenever the smooth streaming fade is enabled. Both return when the setting is off or the device prefers reduced motion. Extracts the shared gate into a useSmoothStreaming hook consumed by Markdown, Reasoning, TextPart, DisplayMessage, EmptyText, and the legacy loading fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB * ✅ test: Pin Cursor Assertions to Fade-Off State The pulsing thinking cursor now only renders while the smooth streaming fade is off, so the tests asserting it set the toggle off first (at file level where earlier renders would cache the atom's first read). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB * 💫 feat: Restore Start-of-Run Dot With Fade-Matched Treatment The trailing streaming cursor stays hidden while the fade is active (the fade itself signals text arriving), but the pre-first-token dot returns: nothing else tells the user the run started before any text exists. Restyled to match the word fade rather than the classic size throb — it fades in on the same 250ms ease-out curve, then breathes on opacity, so "run starting" and "text arriving" read as one system. Applied only when the fade is active; with the setting off or reduced motion preferred the dot keeps its original pulseSize behavior, so the cursor assertions in the existing suites hold unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ee8c0abe2d
commit
ae24461146
16 changed files with 844 additions and 26 deletions
|
|
@ -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<boolean>(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 (
|
||||
<div className="absolute">
|
||||
<p className="relative">
|
||||
<span className={isLatestMessage ? 'result-thinking' : ''} />
|
||||
<span
|
||||
className={cn(
|
||||
isLatestMessage && 'result-thinking',
|
||||
isLatestMessage && smoothStreaming && 'result-thinking-fade',
|
||||
)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -39,6 +68,8 @@ const Markdown = memo(function Markdown({ content = '', isLatestMessage }: TCont
|
|||
remarkPlugins={getRemarkPlugins()}
|
||||
rehypePlugins={getRehypePlugins()}
|
||||
components={getMarkdownComponents()}
|
||||
animate={animate}
|
||||
hydrated={hydratedRef.current}
|
||||
/>
|
||||
</MarkdownErrorBoundary>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
|||
<Markdown content={content} isLatestMessage={true} />
|
||||
);
|
||||
|
||||
const streamingContext = {
|
||||
messageId: 'bench',
|
||||
isExpanded: false,
|
||||
isSubmitting: true,
|
||||
isLatestMessage: true,
|
||||
};
|
||||
|
||||
const FadeMarkdown = ({ content }: { content: string }) => (
|
||||
<MessageContext.Provider value={streamingContext}>
|
||||
<Markdown content={content} isLatestMessage={true} />
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<ArtifactProvider baseIndex={artifactBaseIndex}>
|
||||
<CodeBlockProvider baseIndex={codeBaseIndex} mermaidBaseIndex={mermaidBaseIndex}>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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 = () => (
|
||||
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
|
||||
<div className="markdown prose dark:prose-invert light w-full break-words">
|
||||
<div className="absolute">
|
||||
<p className="submitting relative">
|
||||
<span className="result-thinking" />
|
||||
</p>
|
||||
const LoadingFallback = () => {
|
||||
const smoothStreaming = useSmoothStreaming();
|
||||
|
||||
return (
|
||||
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
|
||||
<div className="markdown prose dark:prose-invert light w-full break-words">
|
||||
<div className="absolute">
|
||||
<p className="submitting relative">
|
||||
<span className={cn('result-thinking', smoothStreaming && 'result-thinking-fade')} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
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(() => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="text-message flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
|
||||
<div className="markdown prose dark:prose-invert light w-full break-words">
|
||||
<div className="absolute">
|
||||
<p className="submitting relative">
|
||||
<span className="result-thinking" />
|
||||
<span className={cn('result-thinking', smoothStreaming && 'result-thinking-fade')} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null);
|
||||
|
|
@ -118,7 +120,11 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
|
|||
style={expandStyle}
|
||||
>
|
||||
<div className="relative overflow-hidden" ref={expandRef}>
|
||||
<ThinkingContent>{reasoningText}</ThinkingContent>
|
||||
<ThinkingContent
|
||||
animate={smoothStreaming && effectiveIsSubmitting && isLast && isExpanded}
|
||||
>
|
||||
{reasoningText}
|
||||
</ThinkingContent>
|
||||
<FloatingThinkingBar
|
||||
isVisible={isBarVisible && isExpanded}
|
||||
isExpanded={isExpanded}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { memo, useMemo, ReactElement } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
|
||||
import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming';
|
||||
import Markdown from '~/components/Chat/Messages/Content/Markdown';
|
||||
import { useMessageContext } from '~/Providers';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -20,7 +21,13 @@ type ContentType =
|
|||
const TextPart = memo(function TextPart({ text, isCreatedByUser, showCursor }: TextPartProps) {
|
||||
const { isSubmitting = false, isLatestMessage = false } = useMessageContext();
|
||||
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
|
||||
const showCursorState = useMemo(() => 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) {
|
||||
|
|
|
|||
|
|
@ -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' ? <AnimatedText text={children} /> : children;
|
||||
|
||||
return (
|
||||
<div className="relative rounded-lg border border-border-light bg-surface-secondary p-3 pb-8 text-text-secondary">
|
||||
<p className={cn('whitespace-pre-wrap leading-[26px]', fontSize)}>{children}</p>
|
||||
<p className={cn('whitespace-pre-wrap leading-[26px]', fontSize)}>{content}</p>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<number, []>;
|
||||
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 */
|
||||
<ReactMarkdown rehypePlugins={[fade.plugin]}>{content}</ReactMarkdown>
|
||||
);
|
||||
|
||||
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(<AnimatedText text="thinking about" />);
|
||||
expect(container.querySelectorAll('span[data-lc-fade]').length).toBe(2);
|
||||
|
||||
setTime(FADE_DURATION_MS + FADE_STAGGER_MAX_MS + 1);
|
||||
rerender(<AnimatedText text="thinking about the answer" />);
|
||||
|
||||
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(<AnimatedText text={hydrated} />);
|
||||
expect(container.querySelectorAll('span[data-lc-fade]').length).toBe(0);
|
||||
|
||||
rerender(<AnimatedText text={`${hydrated} appended tail`} />);
|
||||
const animated = Array.from(container.querySelectorAll('span[data-lc-fade]')).map((span) =>
|
||||
span.textContent?.trim(),
|
||||
);
|
||||
expect(animated).toEqual(['appended', 'tail']);
|
||||
});
|
||||
});
|
||||
349
client/src/components/Chat/Messages/Content/animate.tsx
Normal file
349
client/src/components/Chat/Messages/Content/animate.tsx
Normal file
|
|
@ -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<number, FadeEntry>;
|
||||
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<number, FadeEntry>;
|
||||
/** 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<number, FadeEntry>;
|
||||
};
|
||||
|
||||
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<FadeState | null>(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 <Fragment key={segment.start}>{segment.value}</Fragment>;
|
||||
}
|
||||
const style =
|
||||
segment.delay > 0 ? ({ [DELAY_VAR]: `${segment.delay}ms` } as CSSProperties) : undefined;
|
||||
return (
|
||||
<span key={segment.start} data-lc-fade="" style={style}>
|
||||
{segment.value}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
AnimatedText.displayName = 'AnimatedText';
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
16
client/src/hooks/Messages/useSmoothStreaming.ts
Normal file
16
client/src/hooks/Messages/useSmoothStreaming.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
13
client/src/store/smoothStreaming.ts
Normal file
13
client/src/store/smoothStreaming.ts
Normal file
|
|
@ -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<boolean>(
|
||||
'smoothStreaming',
|
||||
DEFAULT_SMOOTH_STREAMING,
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue