🏄‍♂️ refactor: Optimize Reasoning UI & Token Streaming (#5546)

*  feat: Implement Show Thinking feature; refactor: testing thinking render optimizations

*  feat: Refactor Thinking component styles and enhance Markdown rendering

* chore: add back removed code, revert type changes

* chore: Add back resetCounter effect to Markdown component for improved code block indexing

* chore: bump @librechat/agents and google langchain packages

* WIP: reasoning type updates

* WIP: first pass, reasoning content blocks

* chore: revert code

* chore: bump @librechat/agents

* refactor: optimize reasoning tag handling

* style: ul indent padding

* feat: add Reasoning component to handle reasoning display

* feat: first pass, content reasoning part styling

* refactor: add content placeholder for endpoints using new stream handler

* refactor: only cache messages when requesting stream audio

* fix: circular dep.

* fix: add default param

* refactor: tts, only request after message stream, fix chrome autoplay

* style: update label for submitting state and add localization for 'Thinking...'

* fix: improve global audio pause logic and reset active run ID

* fix: handle artifact edge cases

* fix: remove unnecessary console log from artifact update test

* feat: add support for continued message handling with new streaming method

---------

Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
This commit is contained in:
Danny Avila 2025-01-29 19:46:58 -05:00 committed by GitHub
parent d60a149ad9
commit 591a019766
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 1791 additions and 726 deletions

View file

@ -62,10 +62,6 @@ export function Artifact({
const content = extractContent(props.children);
logger.log('artifacts', 'updateArtifact: content.length', content.length);
if (!content || content.trim() === '') {
return;
}
const title = props.title ?? 'Untitled Artifact';
const type = props.type ?? 'unknown';
const identifier = props.identifier ?? 'no-identifier';

View file

@ -43,7 +43,6 @@ const CodeEditor = ({
},
onError: () => {
setIsMutating(false);
setCurrentCode(artifact.content);
},
});

View file

@ -1,20 +1,62 @@
import { useState } from 'react';
import { useState, useMemo, memo, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { Atom, ChevronDown } from 'lucide-react';
import type { MouseEvent } from 'react';
import useLocalize from '~/hooks/useLocalize';
import type { MouseEvent, FC } from 'react';
import { useLocalize } from '~/hooks';
import store from '~/store';
interface ThinkingProps {
children: React.ReactNode;
}
const BUTTON_STYLES = {
base: 'group mt-3 flex w-fit items-center justify-center rounded-xl bg-surface-tertiary px-3 py-2 text-xs leading-[18px] animate-thinking-appear',
icon: 'icon-sm ml-1.5 transform-gpu text-text-primary transition-transform duration-200',
} as const;
const Thinking = ({ children }: ThinkingProps) => {
const CONTENT_STYLES = {
wrapper: 'relative pl-3 text-text-secondary',
border:
'absolute left-0 h-[calc(100%-10px)] border-l-2 border-border-medium dark:border-border-heavy',
partBorder:
'absolute left-0 h-[calc(100%)] border-l-2 border-border-medium dark:border-border-heavy',
text: 'whitespace-pre-wrap leading-[26px]',
} as const;
export const ThinkingContent: FC<{ children: React.ReactNode; isPart?: boolean }> = memo(
({ isPart, children }) => (
<div className={CONTENT_STYLES.wrapper}>
<div className={isPart === true ? CONTENT_STYLES.partBorder : CONTENT_STYLES.border} />
<p className={CONTENT_STYLES.text}>{children}</p>
</div>
),
);
export const ThinkingButton = memo(
({
isExpanded,
onClick,
label,
}: {
isExpanded: boolean;
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
label: string;
}) => (
<button type="button" onClick={onClick} className={BUTTON_STYLES.base}>
<Atom size={14} className="mr-1.5 text-text-secondary" />
{label}
<ChevronDown className={`${BUTTON_STYLES.icon} ${isExpanded ? 'rotate-180' : ''}`} />
</button>
),
);
const Thinking: React.ElementType = memo(({ children }: { children: React.ReactNode }) => {
const localize = useLocalize();
const [isExpanded, setIsExpanded] = useState(true);
const showThinking = useRecoilValue<boolean>(store.showThinking);
const [isExpanded, setIsExpanded] = useState(showThinking);
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setIsExpanded(!isExpanded);
};
setIsExpanded((prev) => !prev);
}, []);
const label = useMemo(() => localize('com_ui_thoughts'), [localize]);
if (children == null) {
return null;
@ -22,28 +64,23 @@ const Thinking = ({ children }: ThinkingProps) => {
return (
<div className="mb-3">
<button
type="button"
onClick={handleClick}
className="group mb-3 flex w-fit items-center justify-center rounded-xl bg-surface-tertiary px-3.5 py-2 text-xs leading-[18px] text-text-primary transition-colors hover:bg-surface-secondary"
<ThinkingButton isExpanded={isExpanded} onClick={handleClick} label={label} />
<div
className="grid transition-all duration-300 ease-out"
style={{
gridTemplateRows: isExpanded ? '1fr' : '0fr',
}}
>
<Atom size={14} className="mr-1.5 text-text-secondary" />
{localize('com_ui_thoughts')}
<ChevronDown
className="icon-sm ml-1.5 text-text-primary transition-transform duration-200"
style={{
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
</button>
{isExpanded && (
<div className="relative pl-3 text-text-secondary">
<div className="absolute left-0 top-[5px] h-[calc(100%-10px)] border-l-2 border-border-medium dark:border-border-heavy" />
<p className="my-4 whitespace-pre-wrap leading-[26px]">{children}</p>
<div className="overflow-hidden">
<ThinkingContent>{children}</ThinkingContent>
</div>
)}
</div>
</div>
);
};
});
export default Thinking;
ThinkingButton.displayName = 'ThinkingButton';
ThinkingContent.displayName = 'ThinkingContent';
Thinking.displayName = 'Thinking';
export default memo(Thinking);

View file

@ -1,3 +1,4 @@
/* eslint-disable jsx-a11y/media-has-caption */
import { useEffect, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import type { TMessageAudio } from '~/common';
@ -78,7 +79,6 @@ export function BrowserTTS({ isLast, index, messageId, content, className }: TMe
logger.error('Error fetching audio:', error);
}}
id={`audio-${messageId}`}
muted
autoPlay
/>
</>
@ -169,7 +169,6 @@ export function EdgeTTS({ isLast, index, messageId, content, className }: TMessa
logger.error('Error fetching audio:', error);
}}
id={`audio-${messageId}`}
muted
autoPlay
/>
) : null}
@ -248,7 +247,6 @@ export function ExternalTTS({ isLast, index, messageId, content, className }: TM
logger.error('Error fetching audio:', error);
}}
id={`audio-${messageId}`}
muted
autoPlay
/>
</>

View file

@ -53,7 +53,7 @@ export default function StreamAudio({ index = 0 }) {
const shouldFetch = !!(
token != null &&
automaticPlayback &&
isSubmitting &&
!isSubmitting &&
latestMessage &&
!latestMessage.isCreatedByUser &&
latestText &&
@ -118,14 +118,14 @@ export default function StreamAudio({ index = 0 }) {
}
let done = false;
const chunks: Uint8Array[] = [];
const chunks: ArrayBuffer[] = [];
while (!done) {
const readPromise = reader.read();
const { value, done: readerDone } = (await Promise.race([
readPromise,
timeoutPromise(maxPromiseTime, promiseTimeoutMessage),
])) as ReadableStreamReadResult<Uint8Array>;
])) as ReadableStreamReadResult<ArrayBuffer>;
if (cacheTTS && value) {
chunks.push(value);
@ -195,8 +195,8 @@ export default function StreamAudio({ index = 0 }) {
useEffect(() => {
if (
playbackRate &&
globalAudioURL &&
playbackRate != null &&
globalAudioURL != null &&
playbackRate > 0 &&
audioRef.current &&
audioRef.current.playbackRate !== playbackRate
@ -213,6 +213,7 @@ export default function StreamAudio({ index = 0 }) {
logger.log('StreamAudio.tsx - globalAudioURL:', globalAudioURL);
return (
// eslint-disable-next-line jsx-a11y/media-has-caption
<audio
ref={audioRef}
controls
@ -226,7 +227,6 @@ export default function StreamAudio({ index = 0 }) {
}}
src={globalAudioURL ?? undefined}
id={globalAudioId}
muted
autoPlay
/>
);

View file

@ -1,8 +1,10 @@
import { memo, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { memo, useMemo, useState } from 'react';
import { useRecoilValue, useRecoilState } from 'recoil';
import { ContentTypes } from 'librechat-data-provider';
import type { TMessageContentParts, TAttachment, Agents } from 'librechat-data-provider';
import { ThinkingButton } from '~/components/Artifacts/Thinking';
import EditTextPart from './Parts/EditTextPart';
import useLocalize from '~/hooks/useLocalize';
import { mapAttachments } from '~/utils/map';
import { MessageContext } from '~/Providers';
import store from '~/store';
@ -39,11 +41,20 @@ const ContentParts = memo(
siblingIdx,
setSiblingIdx,
}: ContentPartsProps) => {
const localize = useLocalize();
const [showThinking, setShowThinking] = useRecoilState<boolean>(store.showThinking);
const [isExpanded, setIsExpanded] = useState(showThinking);
const messageAttachmentsMap = useRecoilValue(store.messageAttachmentsMap);
const attachmentMap = useMemo(
() => mapAttachments(attachments ?? messageAttachmentsMap[messageId] ?? []),
[attachments, messageAttachmentsMap, messageId],
);
const hasReasoningParts = useMemo(
() => content?.some((part) => part?.type === ContentTypes.THINK && part.think) ?? false,
[content],
);
if (!content) {
return null;
}
@ -74,6 +85,21 @@ const ContentParts = memo(
return (
<>
{hasReasoningParts && (
<div className="mb-5">
<ThinkingButton
isExpanded={isExpanded}
onClick={() =>
setIsExpanded((prev) => {
const val = !prev;
setShowThinking(val);
return val;
})
}
label={isSubmitting ? localize('com_ui_thinking') : localize('com_ui_thoughts')}
/>
</div>
)}
{content
.filter((part) => part)
.map((part, idx) => {
@ -88,6 +114,8 @@ const ContentParts = memo(
messageId,
conversationId,
partIndex: idx,
isExpanded,
nextType: content[idx + 1]?.type,
}}
>
<Part

View file

@ -23,7 +23,7 @@ import useLocalize from '~/hooks/useLocalize';
import store from '~/store';
type TCodeProps = {
inline: boolean;
inline?: boolean;
className?: string;
children: React.ReactNode;
};
@ -42,7 +42,7 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps
}, [children, resetCounter]);
if (isMath) {
return children;
return <>{children}</>;
} else if (isSingleLine) {
return (
<code onDoubleClick={handleDoubleClick} className={className}>
@ -71,79 +71,86 @@ export const codeNoExecution: React.ElementType = memo(({ className, children }:
}
});
export const a: React.ElementType = memo(
({ href, children }: { href: string; children: React.ReactNode }) => {
const user = useRecoilValue(store.user);
const { showToast } = useToastContext();
const localize = useLocalize();
type TAnchorProps = {
href: string;
children: React.ReactNode;
};
const {
file_id = '',
filename = '',
filepath,
} = useMemo(() => {
const pattern = new RegExp(`(?:files|outputs)/${user?.id}/([^\\s]+)`);
const match = href.match(pattern);
if (match && match[0]) {
const path = match[0];
const parts = path.split('/');
const name = parts.pop();
const file_id = parts.pop();
return { file_id, filename: name, filepath: path };
}
return { file_id: '', filename: '', filepath: '' };
}, [user?.id, href]);
export const a: React.ElementType = memo(({ href, children }: TAnchorProps) => {
const user = useRecoilValue(store.user);
const { showToast } = useToastContext();
const localize = useLocalize();
const { refetch: downloadFile } = useFileDownload(user?.id ?? '', file_id);
const props: { target?: string; onClick?: React.MouseEventHandler } = { target: '_new' };
if (!file_id || !filename) {
return (
<a href={href} {...props}>
{children}
</a>
);
const {
file_id = '',
filename = '',
filepath,
} = useMemo(() => {
const pattern = new RegExp(`(?:files|outputs)/${user?.id}/([^\\s]+)`);
const match = href.match(pattern);
if (match && match[0]) {
const path = match[0];
const parts = path.split('/');
const name = parts.pop();
const file_id = parts.pop();
return { file_id, filename: name, filepath: path };
}
return { file_id: '', filename: '', filepath: '' };
}, [user?.id, href]);
const handleDownload = async (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
try {
const stream = await downloadFile();
if (stream.data == null || stream.data === '') {
console.error('Error downloading file: No data found');
showToast({
status: 'error',
message: localize('com_ui_download_error'),
});
return;
}
const link = document.createElement('a');
link.href = stream.data;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(stream.data);
} catch (error) {
console.error('Error downloading file:', error);
}
};
props.onClick = handleDownload;
props.target = '_blank';
const { refetch: downloadFile } = useFileDownload(user?.id ?? '', file_id);
const props: { target?: string; onClick?: React.MouseEventHandler } = { target: '_new' };
if (!file_id || !filename) {
return (
<a
href={filepath.startsWith('files/') ? `/api/${filepath}` : `/api/files/${filepath}`}
{...props}
>
<a href={href} {...props}>
{children}
</a>
);
},
);
}
export const p: React.ElementType = memo(({ children }: { children: React.ReactNode }) => {
const handleDownload = async (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
try {
const stream = await downloadFile();
if (stream.data == null || stream.data === '') {
console.error('Error downloading file: No data found');
showToast({
status: 'error',
message: localize('com_ui_download_error'),
});
return;
}
const link = document.createElement('a');
link.href = stream.data;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(stream.data);
} catch (error) {
console.error('Error downloading file:', error);
}
};
props.onClick = handleDownload;
props.target = '_blank';
return (
<a
href={filepath.startsWith('files/') ? `/api/${filepath}` : `/api/files/${filepath}`}
{...props}
>
{children}
</a>
);
});
type TParagraphProps = {
children: React.ReactNode;
};
export const p: React.ElementType = memo(({ children }: TParagraphProps) => {
return <p className="mb-2 whitespace-pre-wrap">{children}</p>;
});
@ -157,27 +164,40 @@ type TContentProps = {
const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentProps) => {
const LaTeXParsing = useRecoilValue<boolean>(store.LaTeXParsing);
const isInitializing = content === '';
let currentContent = content;
if (!isInitializing) {
currentContent = currentContent.replace('<think>', ':::thinking') || '';
currentContent = currentContent.replace('</think>', ':::') || '';
currentContent = LaTeXParsing ? preprocessLaTeX(currentContent) : currentContent;
}
const currentContent = useMemo(() => {
if (isInitializing) {
return '';
}
return LaTeXParsing ? preprocessLaTeX(content) : content;
}, [content, LaTeXParsing, isInitializing]);
const rehypePlugins = [
[rehypeKatex, { output: 'mathml' }],
[
rehypeHighlight,
{
detect: true,
ignoreMissing: true,
subset: langSubset,
},
const rehypePlugins = useMemo(
() => [
[rehypeKatex, { output: 'mathml' }],
[
rehypeHighlight,
{
detect: true,
ignoreMissing: true,
subset: langSubset,
},
],
],
];
[],
);
const remarkPlugins: Pluggable[] = useMemo(
() => [
supersub,
remarkGfm,
remarkDirective,
artifactPlugin,
[remarkMath, { singleDollarTextMath: true }],
],
[],
);
if (isInitializing) {
return (
@ -189,14 +209,6 @@ const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentPr
);
}
const remarkPlugins: Pluggable[] = [
supersub,
remarkGfm,
remarkDirective,
artifactPlugin,
[remarkMath, { singleDollarTextMath: true }],
];
return (
<ArtifactProvider>
<CodeBlockProvider>
@ -205,7 +217,6 @@ const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentPr
remarkPlugins={remarkPlugins}
/* @ts-ignore */
rehypePlugins={rehypePlugins}
// linkTarget="_new"
components={
{
code,
@ -218,7 +229,7 @@ const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentPr
}
}
>
{isLatestMessage && showCursor === true ? currentContent + cursor : currentContent}
{isLatestMessage && (showCursor ?? false) ? currentContent + cursor : currentContent}
</ReactMarkdown>
</CodeBlockProvider>
</ArtifactProvider>

View file

@ -10,6 +10,7 @@ import type { TMessageContentParts, TAttachment } from 'librechat-data-provider'
import { ErrorMessage } from './MessageContent';
import ExecuteCode from './Parts/ExecuteCode';
import RetrievalCall from './RetrievalCall';
import Reasoning from './Parts/Reasoning';
import CodeAnalyze from './CodeAnalyze';
import Container from './Container';
import ToolCall from './ToolCall';
@ -46,6 +47,12 @@ const Part = memo(({ part, isSubmitting, attachments, showCursor, isCreatedByUse
<Text text={text} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
</Container>
);
} else if (part.type === ContentTypes.THINK) {
const reasoning = typeof part.think === 'string' ? part.think : part.think.value;
if (typeof reasoning !== 'string') {
return null;
}
return <Reasoning reasoning={reasoning} />;
} else if (part.type === ContentTypes.TOOL_CALL) {
const toolCall = part[ContentTypes.TOOL_CALL];

View file

@ -0,0 +1,34 @@
import { memo, useMemo } from 'react';
import { ContentTypes } from 'librechat-data-provider';
import { ThinkingContent } from '~/components/Artifacts/Thinking';
import { useMessageContext } from '~/Providers';
import { cn } from '~/utils';
type ReasoningProps = {
reasoning: string;
};
const Reasoning = memo(({ reasoning }: ReasoningProps) => {
const { isExpanded, nextType } = useMessageContext();
const reasoningText = useMemo(() => {
return reasoning.replace(/^<think>\s*/, '').replace(/\s*<\/think>$/, '');
}, [reasoning]);
return (
<div
className={cn(
'grid transition-all duration-300 ease-out',
nextType !== ContentTypes.THINK && isExpanded && 'mb-10',
)}
style={{
gridTemplateRows: isExpanded ? '1fr' : '0fr',
}}
>
<div className="overflow-hidden">
<ThinkingContent isPart={true}>{reasoningText}</ThinkingContent>
</div>
</div>
);
});
export default Reasoning;

View file

@ -46,7 +46,7 @@ const TextPart = memo(({ text, isCreatedByUser, showCursor }: TextPartProps) =>
showCursorState && !!text.length ? 'result-streaming' : '',
'markdown prose message-content dark:prose-invert light w-full break-words',
isCreatedByUser && !enableUserMsgMarkdown && 'whitespace-pre-wrap',
isCreatedByUser ? 'dark:text-gray-20' : 'dark:text-gray-70',
isCreatedByUser ? 'dark:text-gray-20' : 'dark:text-gray-100',
)}
>
{content}

View file

@ -109,7 +109,9 @@ export default function HoverButtons({
messageId={message.messageId}
content={message.content ?? message.text}
isLast={isLast}
className="hover-button rounded-md p-1 pl-0 text-gray-500 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible"
className={cn(
'ml-0 flex items-center gap-1.5 rounded-md p-1 text-xs hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
)}
/>
)}
{isEditableEndpoint && (

View file

@ -1,4 +1,3 @@
// client/src/components/Chat/Messages/MessageAudio.tsx
import { memo } from 'react';
import { useRecoilValue } from 'recoil';
import type { TMessageAudio } from '~/common';

View file

@ -5,6 +5,7 @@ import SendMessageKeyEnter from './EnterToSend';
import ShowCodeSwitch from './ShowCodeSwitch';
import { ForkSettings } from './ForkSettings';
import ChatDirection from './ChatDirection';
import ShowThinking from './ShowThinking';
import LaTeXParsing from './LaTeXParsing';
import ModularChat from './ModularChat';
import SaveDraft from './SaveDraft';
@ -37,6 +38,9 @@ function Chat() {
<div className="pb-3">
<LaTeXParsing />
</div>
<div className="pb-3">
<ShowThinking />
</div>
</div>
);
}

View file

@ -0,0 +1,37 @@
import { useRecoilState } from 'recoil';
import HoverCardSettings from '../HoverCardSettings';
import { Switch } from '~/components/ui';
import useLocalize from '~/hooks/useLocalize';
import store from '~/store';
export default function SaveDraft({
onCheckedChange,
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const [showThinking, setSaveDrafts] = useRecoilState<boolean>(store.showThinking);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setSaveDrafts(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_nav_show_thinking')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_show_thinking" />
</div>
<Switch
id="showThinking"
checked={showThinking}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="showThinking"
/>
</div>
);
}