mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-21 02:40:14 +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,5 +1,5 @@
|
|||
import { memo, useMemo, useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { memo, useMemo, useState, useCallback } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type {
|
||||
TMessageContentParts,
|
||||
|
|
@ -9,12 +9,12 @@ import type {
|
|||
} 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 store from '~/store';
|
||||
import Part from './Part';
|
||||
|
||||
type ContentPartsProps = {
|
||||
|
|
@ -53,12 +53,16 @@ const ContentParts = memo(
|
|||
setSiblingIdx,
|
||||
}: ContentPartsProps) => {
|
||||
const localize = useLocalize();
|
||||
const [showThinking, setShowThinking] = useRecoilState<boolean>(store.showThinking);
|
||||
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 =
|
||||
|
|
@ -78,6 +82,23 @@ const ContentParts = memo(
|
|||
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;
|
||||
}
|
||||
|
|
@ -127,40 +148,74 @@ const ContentParts = memo(
|
|||
<MemoryArtifacts attachments={attachments} />
|
||||
<Sources messageId={messageId} conversationId={conversationId || undefined} />
|
||||
{hasReasoningParts && (
|
||||
<div className="mb-5">
|
||||
<ThinkingButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={() =>
|
||||
setIsExpanded((prev) => {
|
||||
const val = !prev;
|
||||
setShowThinking(val);
|
||||
return val;
|
||||
})
|
||||
}
|
||||
label={
|
||||
effectiveIsSubmitting && isLast
|
||||
? localize('com_ui_thinking')
|
||||
: localize('com_ui_thoughts')
|
||||
}
|
||||
/>
|
||||
<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)
|
||||
.map((part, idx) => {
|
||||
.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];
|
||||
|
||||
return (
|
||||
<MessageContext.Provider
|
||||
key={`provider-${messageId}-${idx}`}
|
||||
key={`provider-${messageId}-${originalIdx}`}
|
||||
value={{
|
||||
messageId,
|
||||
isExpanded,
|
||||
conversationId,
|
||||
partIndex: idx,
|
||||
nextType: content[idx + 1]?.type,
|
||||
partIndex: originalIdx,
|
||||
nextType: content[originalIdx + 1]?.type,
|
||||
isSubmitting: effectiveIsSubmitting,
|
||||
isLatestMessage,
|
||||
}}
|
||||
|
|
@ -169,10 +224,10 @@ const ContentParts = memo(
|
|||
part={part}
|
||||
attachments={attachments}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
key={`part-${messageId}-${idx}`}
|
||||
key={`part-${messageId}-${originalIdx}`}
|
||||
isCreatedByUser={isCreatedByUser}
|
||||
isLast={idx === content.length - 1}
|
||||
showCursor={idx === content.length - 1 && isLast}
|
||||
isLast={originalIdx === content.length - 1}
|
||||
showCursor={originalIdx === content.length - 1 && isLast}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ const EditMessage = ({
|
|||
|
||||
return (
|
||||
<Container message={message}>
|
||||
<div className="bg-token-main-surface-primary relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border border-border-medium text-text-primary [&:has(textarea:focus)]:border-border-heavy [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]">
|
||||
<div className="bg-token-main-surface-primary relative mt-2 flex w-full flex-grow flex-col overflow-hidden rounded-2xl border border-border-medium text-text-primary [&:has(textarea:focus)]:border-border-heavy [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]">
|
||||
<TextareaAutosize
|
||||
{...registerProps}
|
||||
ref={(e) => {
|
||||
|
|
|
|||
|
|
@ -14,57 +14,79 @@ import Markdown from './Markdown';
|
|||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const ERROR_CONNECTION_TEXT = 'Error connecting to server, try refreshing the page.';
|
||||
const DELAYED_ERROR_TIMEOUT = 5500;
|
||||
const UNFINISHED_DELAY = 250;
|
||||
|
||||
const parseThinkingContent = (text: string) => {
|
||||
const thinkingMatch = text.match(/:::thinking([\s\S]*?):::/);
|
||||
return {
|
||||
thinkingContent: thinkingMatch ? thinkingMatch[1].trim() : '',
|
||||
regularContent: thinkingMatch ? text.replace(/:::thinking[\s\S]*?:::/, '').trim() : text,
|
||||
};
|
||||
};
|
||||
|
||||
const LoadingFallback = () => (
|
||||
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
|
||||
<div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
|
||||
<div className="absolute">
|
||||
<p className="submitting relative">
|
||||
<span className="result-thinking" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ErrorBox = ({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className={cn(
|
||||
'rounded-xl border border-red-500/20 bg-red-500/5 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ConnectionError = ({ message }: { message?: TMessage }) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<DelayedRender delay={DELAYED_ERROR_TIMEOUT}>
|
||||
<Container message={message}>
|
||||
<div className="mt-2 rounded-xl border border-red-500/20 bg-red-50/50 px-4 py-3 text-sm text-red-700 shadow-sm transition-all dark:bg-red-950/30 dark:text-red-100">
|
||||
{localize('com_ui_error_connection')}
|
||||
</div>
|
||||
</Container>
|
||||
</DelayedRender>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export const ErrorMessage = ({
|
||||
text,
|
||||
message,
|
||||
className = '',
|
||||
}: Pick<TDisplayProps, 'text' | 'className'> & {
|
||||
message?: TMessage;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
if (text === 'Error connecting to server, try refreshing the page.') {
|
||||
console.log('error message', message);
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
|
||||
<div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
|
||||
<div className="absolute">
|
||||
<p className="submitting relative">
|
||||
<span className="result-thinking" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DelayedRender delay={5500}>
|
||||
<Container message={message}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{localize('com_ui_error_connection')}
|
||||
</div>
|
||||
</Container>
|
||||
</DelayedRender>
|
||||
</Suspense>
|
||||
);
|
||||
}: Pick<TDisplayProps, 'text' | 'className'> & { message?: TMessage }) => {
|
||||
if (text === ERROR_CONNECTION_TEXT) {
|
||||
return <ConnectionError message={message} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container message={message}>
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className={cn(
|
||||
'rounded-xl border border-red-500/20 bg-red-500/5 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ErrorBox className={className}>
|
||||
<Error text={text} />
|
||||
</div>
|
||||
</ErrorBox>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
|
@ -72,27 +94,29 @@ export const ErrorMessage = ({
|
|||
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => {
|
||||
const { isSubmitting = false, isLatestMessage = false } = useMessageContext();
|
||||
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
|
||||
|
||||
const showCursorState = useMemo(
|
||||
() => showCursor === true && isSubmitting,
|
||||
[showCursor, isSubmitting],
|
||||
);
|
||||
|
||||
let content: React.ReactElement;
|
||||
if (!isCreatedByUser) {
|
||||
content = <Markdown content={text} isLatestMessage={isLatestMessage} />;
|
||||
} else if (enableUserMsgMarkdown) {
|
||||
content = <MarkdownLite content={text} />;
|
||||
} else {
|
||||
content = <>{text}</>;
|
||||
}
|
||||
const content = useMemo(() => {
|
||||
if (!isCreatedByUser) {
|
||||
return <Markdown content={text} isLatestMessage={isLatestMessage} />;
|
||||
}
|
||||
if (enableUserMsgMarkdown) {
|
||||
return <MarkdownLite content={text} />;
|
||||
}
|
||||
return <>{text}</>;
|
||||
}, [isCreatedByUser, enableUserMsgMarkdown, text, isLatestMessage]);
|
||||
|
||||
return (
|
||||
<Container message={message}>
|
||||
<div
|
||||
className={cn(
|
||||
isSubmitting ? 'submitting' : '',
|
||||
showCursorState && !!text.length ? 'result-streaming' : '',
|
||||
'markdown prose message-content dark:prose-invert light w-full break-words',
|
||||
isSubmitting && 'submitting',
|
||||
showCursorState && text.length > 0 && 'result-streaming',
|
||||
isCreatedByUser && !enableUserMsgMarkdown && 'whitespace-pre-wrap',
|
||||
isCreatedByUser ? 'dark:text-gray-20' : 'dark:text-gray-100',
|
||||
)}
|
||||
|
|
@ -103,7 +127,6 @@ const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplay
|
|||
);
|
||||
};
|
||||
|
||||
// Unfinished Message Component
|
||||
export const UnfinishedMessage = ({ message }: { message: TMessage }) => (
|
||||
<ErrorMessage
|
||||
message={message}
|
||||
|
|
@ -123,21 +146,14 @@ const MessageContent = ({
|
|||
const { message } = props;
|
||||
const { messageId } = message;
|
||||
|
||||
const { thinkingContent, regularContent } = useMemo(() => {
|
||||
const thinkingMatch = text.match(/:::thinking([\s\S]*?):::/);
|
||||
return {
|
||||
thinkingContent: thinkingMatch ? thinkingMatch[1].trim() : '',
|
||||
regularContent: thinkingMatch ? text.replace(/:::thinking[\s\S]*?:::/, '').trim() : text,
|
||||
};
|
||||
}, [text]);
|
||||
|
||||
const { thinkingContent, regularContent } = useMemo(() => parseThinkingContent(text), [text]);
|
||||
const showRegularCursor = useMemo(() => isLast && isSubmitting, [isLast, isSubmitting]);
|
||||
|
||||
const unfinishedMessage = useMemo(
|
||||
() =>
|
||||
!isSubmitting && unfinished ? (
|
||||
<Suspense>
|
||||
<DelayedRender delay={250}>
|
||||
<DelayedRender delay={UNFINISHED_DELAY}>
|
||||
<UnfinishedMessage message={message} />
|
||||
</DelayedRender>
|
||||
</Suspense>
|
||||
|
|
@ -146,8 +162,10 @@ const MessageContent = ({
|
|||
);
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage message={props.message} text={text} />;
|
||||
} else if (edit) {
|
||||
return <ErrorMessage message={message} text={text} />;
|
||||
}
|
||||
|
||||
if (edit) {
|
||||
return <EditMessage text={text} isSubmitting={isSubmitting} {...props} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useForm } from 'react-hook-form';
|
|||
import { TextareaAutosize } from '@librechat/client';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { Lightbulb, MessageSquare } from 'lucide-react';
|
||||
import { useUpdateMessageContentMutation } from 'librechat-data-provider/react-query';
|
||||
import type { Agents } from 'librechat-data-provider';
|
||||
import type { TEditProps } from '~/common';
|
||||
|
|
@ -153,6 +154,22 @@ const EditTextPart = ({
|
|||
|
||||
return (
|
||||
<Container message={message}>
|
||||
{part.type === ContentTypes.THINK && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-text-secondary">
|
||||
<span className="flex gap-2 rounded-lg bg-surface-tertiary px-1.5 py-1 font-medium">
|
||||
<Lightbulb className="size-3.5" />
|
||||
{localize('com_ui_thoughts')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{part.type !== ContentTypes.THINK && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-text-secondary">
|
||||
<span className="flex gap-2 rounded-lg bg-surface-tertiary px-1.5 py-1 font-medium">
|
||||
<MessageSquare className="size-3.5" />
|
||||
{localize('com_ui_response')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-token-main-surface-primary relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border border-border-medium text-text-primary [&:has(textarea:focus)]:border-border-heavy [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]">
|
||||
<TextareaAutosize
|
||||
{...registerProps}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,32 @@ type ReasoningProps = {
|
|||
reasoning: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reasoning Component (MODERN SYSTEM)
|
||||
*
|
||||
* Used for structured content parts with ContentTypes.THINK type.
|
||||
* This handles modern message format where content is an array of typed parts.
|
||||
*
|
||||
* Pattern: `{ content: [{ type: "think", think: "<think>content</think>" }, ...] }`
|
||||
*
|
||||
* Used by:
|
||||
* - ContentParts.tsx → Part.tsx for structured messages
|
||||
* - Agent/Assistant responses (OpenAI Assistants, custom agents)
|
||||
* - O-series models (o1, o3) with reasoning capabilities
|
||||
* - Modern Claude responses with thinking blocks
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* For legacy text-based messages, see Thinking.tsx component.
|
||||
*/
|
||||
const Reasoning = memo(({ reasoning }: ReasoningProps) => {
|
||||
const { isExpanded, nextType } = useMessageContext();
|
||||
|
||||
// Strip <think> tags from the reasoning content (modern format)
|
||||
const reasoningText = useMemo(() => {
|
||||
return reasoning
|
||||
.replace(/^<think>\s*/, '')
|
||||
|
|
@ -21,18 +45,20 @@ const Reasoning = memo(({ reasoning }: ReasoningProps) => {
|
|||
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-8',
|
||||
nextType !== ContentTypes.THINK && isExpanded && 'mb-4',
|
||||
)}
|
||||
style={{
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<ThinkingContent isPart={true}>{reasoningText}</ThinkingContent>
|
||||
<ThinkingContent>{reasoningText}</ThinkingContent>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue