mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🧠 refactor: Improve Reasoning Component Structure and UX (#10320)
* refactor: Reasoning components with independent toggle buttons - Refactored ThinkingButton to remove unnecessary state and props. - Updated ContentParts to simplify content rendering and remove hover handling. - Improved Reasoning component to include independent toggle functionality for each THINK part. - Adjusted styles for better layout consistency and user experience. * refactor: isolate hover effects for Reasoning - Updated ThinkingButton to improve hover effects and layout consistency. - Refactored Reasoning component to include a new wrapper class for better styling. - Adjusted icon visibility and transitions for a smoother user experience. * fix: Prevent rendering of empty messages in Chat component - Added a check to skip rendering if the message text is only whitespace, improving the user interface by avoiding empty containers. * chore: Replace div with fragment in Thinking component for cleaner markup * chore: move Thinking component to Content Parts directory * refactor: prevent rendering of whitespace-only text in Part component only for edge cases
This commit is contained in:
parent
c0f1cfcaba
commit
9b4c4cafb6
5 changed files with 95 additions and 176 deletions
|
|
@ -1,5 +1,4 @@
|
|||
import { memo, useMemo, useState, useCallback } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type {
|
||||
TMessageContentParts,
|
||||
|
|
@ -7,14 +6,11 @@ import type {
|
|||
TAttachment,
|
||||
Agents,
|
||||
} from 'librechat-data-provider';
|
||||
import { ThinkingButton } from '~/components/Artifacts/Thinking';
|
||||
import { MessageContext, SearchContext } from '~/Providers';
|
||||
import { showThinkingAtom } from '~/store/showThinking';
|
||||
import MemoryArtifacts from './MemoryArtifacts';
|
||||
import Sources from '~/components/Web/Sources';
|
||||
import { mapAttachments } from '~/utils/map';
|
||||
import { EditTextPart } from './Parts';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import Part from './Part';
|
||||
|
||||
type ContentPartsProps = {
|
||||
|
|
@ -52,53 +48,10 @@ const ContentParts = memo(
|
|||
siblingIdx,
|
||||
setSiblingIdx,
|
||||
}: ContentPartsProps) => {
|
||||
const localize = useLocalize();
|
||||
const [showThinking, setShowThinking] = useAtom(showThinkingAtom);
|
||||
const [isExpanded, setIsExpanded] = useState(showThinking);
|
||||
const [isContentHovered, setIsContentHovered] = useState(false);
|
||||
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
|
||||
|
||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||
|
||||
const handleContentEnter = useCallback(() => setIsContentHovered(true), []);
|
||||
const handleContentLeave = useCallback(() => setIsContentHovered(false), []);
|
||||
|
||||
const hasReasoningParts = useMemo(() => {
|
||||
const hasThinkPart = content?.some((part) => part?.type === ContentTypes.THINK) ?? false;
|
||||
const allThinkPartsHaveContent =
|
||||
content?.every((part) => {
|
||||
if (part?.type !== ContentTypes.THINK) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof part.think === 'string') {
|
||||
const cleanedContent = part.think.replace(/<\/?think>/g, '').trim();
|
||||
return cleanedContent.length > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}) ?? false;
|
||||
|
||||
return hasThinkPart && allThinkPartsHaveContent;
|
||||
}, [content]);
|
||||
|
||||
// Extract all reasoning text for copy functionality
|
||||
const reasoningContent = useMemo(() => {
|
||||
if (!content) {
|
||||
return '';
|
||||
}
|
||||
return content
|
||||
.filter((part) => part?.type === ContentTypes.THINK)
|
||||
.map((part) => {
|
||||
if (typeof part?.think === 'string') {
|
||||
return part.think.replace(/<\/?think>/g, '').trim();
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}, [content]);
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -147,91 +100,40 @@ const ContentParts = memo(
|
|||
<SearchContext.Provider value={{ searchResults }}>
|
||||
<MemoryArtifacts attachments={attachments} />
|
||||
<Sources messageId={messageId} conversationId={conversationId || undefined} />
|
||||
{hasReasoningParts && (
|
||||
<div onMouseEnter={handleContentEnter} onMouseLeave={handleContentLeave}>
|
||||
<div className="sticky top-0 z-10 mb-2 bg-surface-secondary pb-2 pt-2">
|
||||
<ThinkingButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={() =>
|
||||
setIsExpanded((prev) => {
|
||||
const val = !prev;
|
||||
setShowThinking(val);
|
||||
return val;
|
||||
})
|
||||
}
|
||||
label={
|
||||
effectiveIsSubmitting && isLast
|
||||
? localize('com_ui_thinking')
|
||||
: localize('com_ui_thoughts')
|
||||
}
|
||||
content={reasoningContent}
|
||||
isContentHovered={isContentHovered}
|
||||
/>
|
||||
</div>
|
||||
{content
|
||||
.filter((part) => part?.type === ContentTypes.THINK)
|
||||
.map((part) => {
|
||||
const originalIdx = content.indexOf(part);
|
||||
return (
|
||||
<MessageContext.Provider
|
||||
key={`provider-${messageId}-${originalIdx}`}
|
||||
value={{
|
||||
messageId,
|
||||
isExpanded,
|
||||
conversationId,
|
||||
partIndex: originalIdx,
|
||||
nextType: content[originalIdx + 1]?.type,
|
||||
isSubmitting: effectiveIsSubmitting,
|
||||
isLatestMessage,
|
||||
}}
|
||||
>
|
||||
<Part
|
||||
part={part}
|
||||
attachments={undefined}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
key={`part-${messageId}-${originalIdx}`}
|
||||
isCreatedByUser={isCreatedByUser}
|
||||
isLast={originalIdx === content.length - 1}
|
||||
showCursor={false}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{content
|
||||
.filter((part) => part && part.type !== ContentTypes.THINK)
|
||||
.map((part) => {
|
||||
const originalIdx = content.indexOf(part);
|
||||
const toolCallId =
|
||||
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
|
||||
const attachments = attachmentMap[toolCallId];
|
||||
{content.map((part, idx) => {
|
||||
if (!part) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageContext.Provider
|
||||
key={`provider-${messageId}-${originalIdx}`}
|
||||
value={{
|
||||
messageId,
|
||||
isExpanded,
|
||||
conversationId,
|
||||
partIndex: originalIdx,
|
||||
nextType: content[originalIdx + 1]?.type,
|
||||
isSubmitting: effectiveIsSubmitting,
|
||||
isLatestMessage,
|
||||
}}
|
||||
>
|
||||
<Part
|
||||
part={part}
|
||||
attachments={attachments}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
key={`part-${messageId}-${originalIdx}`}
|
||||
isCreatedByUser={isCreatedByUser}
|
||||
isLast={originalIdx === content.length - 1}
|
||||
showCursor={originalIdx === content.length - 1 && isLast}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
})}
|
||||
const toolCallId =
|
||||
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
|
||||
const partAttachments = attachmentMap[toolCallId];
|
||||
|
||||
return (
|
||||
<MessageContext.Provider
|
||||
key={`provider-${messageId}-${idx}`}
|
||||
value={{
|
||||
messageId,
|
||||
isExpanded: true,
|
||||
conversationId,
|
||||
partIndex: idx,
|
||||
nextType: content[idx + 1]?.type,
|
||||
isSubmitting: effectiveIsSubmitting,
|
||||
isLatestMessage,
|
||||
}}
|
||||
>
|
||||
<Part
|
||||
part={part}
|
||||
attachments={partAttachments}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
key={`part-${messageId}-${idx}`}
|
||||
isCreatedByUser={isCreatedByUser}
|
||||
isLast={idx === content.length - 1}
|
||||
showCursor={idx === content.length - 1 && isLast}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
})}
|
||||
</SearchContext.Provider>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import { DelayedRender } from '@librechat/client';
|
|||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { TMessageContentProps, TDisplayProps } from '~/common';
|
||||
import Error from '~/components/Messages/Content/Error';
|
||||
import Thinking from '~/components/Artifacts/Thinking';
|
||||
import { useMessageContext } from '~/Providers';
|
||||
import MarkdownLite from './MarkdownLite';
|
||||
import EditMessage from './EditMessage';
|
||||
import Thinking from './Parts/Thinking';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import Container from './Container';
|
||||
import Markdown from './Markdown';
|
||||
|
|
|
|||
|
|
@ -65,6 +65,10 @@ const Part = memo(
|
|||
if (part.tool_call_ids != null && !text) {
|
||||
return null;
|
||||
}
|
||||
/** Skip rendering if text is only whitespace to avoid empty Container */
|
||||
if (!isLast && text.length > 0 && /^\s*$/.test(text)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Container>
|
||||
<Text text={text} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
|
||||
|
|
@ -75,7 +79,7 @@ const Part = memo(
|
|||
if (typeof reasoning !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return <Reasoning reasoning={reasoning} />;
|
||||
return <Reasoning reasoning={reasoning} isLast={isLast ?? false} />;
|
||||
} else if (part.type === ContentTypes.TOOL_CALL) {
|
||||
const toolCall = part[ContentTypes.TOOL_CALL];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import { memo, useMemo } from 'react';
|
||||
import { memo, useMemo, useState, useCallback } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import { ThinkingContent } from '~/components/Artifacts/Thinking';
|
||||
import { ThinkingContent, ThinkingButton } from './Thinking';
|
||||
import { showThinkingAtom } from '~/store/showThinking';
|
||||
import { useMessageContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type ReasoningProps = {
|
||||
reasoning: string;
|
||||
isLast: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -25,13 +30,16 @@ type ReasoningProps = {
|
|||
* Key differences from legacy Thinking.tsx:
|
||||
* - Works with content parts array instead of plain text
|
||||
* - Strips `<think>` tags instead of `:::thinking:::` markers
|
||||
* - Uses shared ThinkingButton via ContentParts.tsx
|
||||
* - Controlled by MessageContext isExpanded state
|
||||
* - Each THINK part has its own independent toggle button
|
||||
* - Can be interleaved with other content types
|
||||
*
|
||||
* For legacy text-based messages, see Thinking.tsx component.
|
||||
*/
|
||||
const Reasoning = memo(({ reasoning }: ReasoningProps) => {
|
||||
const { isExpanded, nextType } = useMessageContext();
|
||||
const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
|
||||
const localize = useLocalize();
|
||||
const [showThinking] = useAtom(showThinkingAtom);
|
||||
const [isExpanded, setIsExpanded] = useState(showThinking);
|
||||
const { isSubmitting, isLatestMessage, nextType } = useMessageContext();
|
||||
|
||||
// Strip <think> tags from the reasoning content (modern format)
|
||||
const reasoningText = useMemo(() => {
|
||||
|
|
@ -41,24 +49,45 @@ const Reasoning = memo(({ reasoning }: ReasoningProps) => {
|
|||
.trim();
|
||||
}, [reasoning]);
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||
|
||||
const label = useMemo(
|
||||
() =>
|
||||
effectiveIsSubmitting && isLast ? localize('com_ui_thinking') : localize('com_ui_thoughts'),
|
||||
[effectiveIsSubmitting, localize, isLast],
|
||||
);
|
||||
|
||||
if (!reasoningText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Note: The toggle button is rendered separately in ContentParts.tsx
|
||||
// This component only handles the collapsible content area
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-all duration-300 ease-out',
|
||||
nextType !== ContentTypes.THINK && isExpanded && 'mb-4',
|
||||
)}
|
||||
style={{
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<ThinkingContent>{reasoningText}</ThinkingContent>
|
||||
<div className="group/reasoning">
|
||||
<div className="sticky top-0 z-10 mb-2 bg-surface-secondary pb-2 pt-2">
|
||||
<ThinkingButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={handleClick}
|
||||
label={label}
|
||||
content={reasoningText}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-all duration-300 ease-out',
|
||||
nextType !== ContentTypes.THINK && isExpanded && 'mb-4',
|
||||
)}
|
||||
style={{
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<ThinkingContent>{reasoningText}</ThinkingContent>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -35,25 +35,17 @@ export const ThinkingButton = memo(
|
|||
onClick,
|
||||
label,
|
||||
content,
|
||||
isContentHovered = false,
|
||||
}: {
|
||||
isExpanded: boolean;
|
||||
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
label: string;
|
||||
content?: string;
|
||||
isContentHovered?: boolean;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const fontSize = useAtomValue(fontSizeAtom);
|
||||
|
||||
const [isButtonHovered, setIsButtonHovered] = useState(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const isHovered = useMemo(
|
||||
() => isButtonHovered || isContentHovered,
|
||||
[isButtonHovered, isContentHovered],
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -71,23 +63,20 @@ export const ThinkingButton = memo(
|
|||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setIsButtonHovered(true)}
|
||||
onMouseLeave={() => setIsButtonHovered(false)}
|
||||
className={cn(
|
||||
'group flex flex-1 items-center justify-start rounded-lg leading-[18px]',
|
||||
'group/button flex flex-1 items-center justify-start rounded-lg leading-[18px]',
|
||||
fontSize,
|
||||
)}
|
||||
>
|
||||
{isHovered ? (
|
||||
<span className="relative mr-1.5 inline-flex h-[18px] w-[18px] items-center justify-center">
|
||||
<Lightbulb className="icon-sm absolute text-text-secondary opacity-100 transition-opacity group-hover/button:opacity-0" />
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'icon-sm mr-1.5 transform-gpu text-text-primary transition-transform duration-300',
|
||||
'icon-sm absolute transform-gpu text-text-primary opacity-0 transition-all duration-300 group-hover/button:opacity-100',
|
||||
isExpanded && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Lightbulb className="icon-sm mr-1.5 text-text-secondary" />
|
||||
)}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
{content && (
|
||||
|
|
@ -132,16 +121,12 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
|
|||
const localize = useLocalize();
|
||||
const showThinking = useAtomValue(showThinkingAtom);
|
||||
const [isExpanded, setIsExpanded] = useState(showThinking);
|
||||
const [isContentHovered, setIsContentHovered] = useState(false);
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleContentEnter = useCallback(() => setIsContentHovered(true), []);
|
||||
const handleContentLeave = useCallback(() => setIsContentHovered(false), []);
|
||||
|
||||
const label = useMemo(() => localize('com_ui_thoughts'), [localize]);
|
||||
|
||||
// Extract text content for copy functionality
|
||||
|
|
@ -157,14 +142,13 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
|
|||
}
|
||||
|
||||
return (
|
||||
<div onMouseEnter={handleContentEnter} onMouseLeave={handleContentLeave}>
|
||||
<>
|
||||
<div className="sticky top-0 z-10 mb-4 bg-surface-primary pb-2 pt-2">
|
||||
<ThinkingButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={handleClick}
|
||||
label={label}
|
||||
content={textContent}
|
||||
isContentHovered={isContentHovered}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
|
@ -177,7 +161,7 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
|
|||
<ThinkingContent>{children}</ThinkingContent>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue