mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-23 20:00:15 +01:00
🏄♂️ 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:
parent
d60a149ad9
commit
591a019766
48 changed files with 1791 additions and 726 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue