mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-09 02:54:23 +01:00
💡 feat: Improve Reasoning Content UI, copy-to-clipboard, and error handling (#10278)
* ✨ feat: Refactor error handling and improve loading states in MessageContent component * ✨ feat: Enhance Thinking and ContentParts components with improved hover functionality and clipboard support * fix: Adjust padding in Thinking and ContentParts components for consistent layout * ✨ feat: Add response label and improve message editing UI with contextual indicators * ✨ feat: Add isEditing prop to Feedback and Fork components for improved editing state handling * refactor: Remove isEditing prop from Feedback and Fork components for cleaner state management * refactor: Migrate state management from Recoil to Jotai for font size and show thinking features * refactor: Separate ToggleSwitch into RecoilToggle and JotaiToggle components for improved clarity and state management * refactor: Remove unnecessary comments in ToggleSwitch and MessageContent components for cleaner code * chore: reorder import statements in Thinking.tsx * chore: reorder import statement in EditTextPart.tsx * chore: reorder import statement * chore: Reorganize imports in ToggleSwitch.tsx --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
ea45d0b9c6
commit
c0f1cfcaba
13 changed files with 528 additions and 186 deletions
|
|
@ -1,72 +1,171 @@
|
|||
import { useState, useMemo, memo, useCallback } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Atom, ChevronDown } from 'lucide-react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Lightbulb, ChevronDown } from 'lucide-react';
|
||||
import { Clipboard, CheckMark } from '@librechat/client';
|
||||
import type { MouseEvent, FC } from 'react';
|
||||
import { showThinkingAtom } from '~/store/showThinking';
|
||||
import { fontSizeAtom } from '~/store/fontSize';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
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;
|
||||
/**
|
||||
* ThinkingContent - Displays the actual thinking/reasoning content
|
||||
* Used by both legacy text-based messages and modern content parts
|
||||
*/
|
||||
export const ThinkingContent: FC<{
|
||||
children: React.ReactNode;
|
||||
}> = memo(({ children }) => {
|
||||
const fontSize = useAtomValue(fontSizeAtom);
|
||||
|
||||
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>
|
||||
return (
|
||||
<div className="relative rounded-3xl border border-border-medium bg-surface-tertiary p-4 text-text-secondary">
|
||||
<p className={cn('whitespace-pre-wrap leading-[26px]', fontSize)}>{children}</p>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* ThinkingButton - Toggle button for expanding/collapsing thinking content
|
||||
* Shows lightbulb icon by default, chevron on hover
|
||||
* Shared between legacy Thinking component and modern ContentParts
|
||||
*/
|
||||
export const ThinkingButton = memo(
|
||||
({
|
||||
isExpanded,
|
||||
onClick,
|
||||
label,
|
||||
content,
|
||||
isContentHovered = false,
|
||||
}: {
|
||||
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>
|
||||
),
|
||||
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();
|
||||
if (content) {
|
||||
navigator.clipboard.writeText(content);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
}
|
||||
},
|
||||
[content],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<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]',
|
||||
fontSize,
|
||||
)}
|
||||
>
|
||||
{isHovered ? (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'icon-sm mr-1.5 transform-gpu text-text-primary transition-transform duration-300',
|
||||
isExpanded && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Lightbulb className="icon-sm mr-1.5 text-text-secondary" />
|
||||
)}
|
||||
{label}
|
||||
</button>
|
||||
{content && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
title={
|
||||
isCopied
|
||||
? localize('com_ui_copied_to_clipboard')
|
||||
: localize('com_ui_copy_thoughts_to_clipboard')
|
||||
}
|
||||
className={cn(
|
||||
'rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
|
||||
'hover:bg-surface-hover hover:text-text-primary',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white',
|
||||
)}
|
||||
>
|
||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Thinking Component (LEGACY SYSTEM)
|
||||
*
|
||||
* Used for simple text-based messages with `:::thinking:::` markers.
|
||||
* This handles the old message format where text contains embedded thinking blocks.
|
||||
*
|
||||
* Pattern: `:::thinking\n{content}\n:::\n{response}`
|
||||
*
|
||||
* Used by:
|
||||
* - MessageContent.tsx for plain text messages
|
||||
* - Legacy message format compatibility
|
||||
* - User messages when manually adding thinking content
|
||||
*
|
||||
* For modern structured content (agents/assistants), see Reasoning.tsx component.
|
||||
*/
|
||||
const Thinking: React.ElementType = memo(({ children }: { children: React.ReactNode }) => {
|
||||
const localize = useLocalize();
|
||||
const showThinking = useRecoilValue<boolean>(store.showThinking);
|
||||
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
|
||||
const textContent = useMemo(() => {
|
||||
if (typeof children === 'string') {
|
||||
return children;
|
||||
}
|
||||
return '';
|
||||
}, [children]);
|
||||
|
||||
if (children == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<ThinkingButton isExpanded={isExpanded} onClick={handleClick} label={label} />
|
||||
<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
|
||||
className={cn('grid transition-all duration-300 ease-out', isExpanded && 'mb-8')}
|
||||
|
|
@ -75,10 +174,10 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
|
|||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<ThinkingContent isPart={true}>{children}</ThinkingContent>
|
||||
<ThinkingContent>{children}</ThinkingContent>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue