🏄‍♂️ 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

@ -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}