mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +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 { useState, useMemo, memo, useCallback } from 'react';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useAtomValue } from 'jotai';
|
||||||
import { Atom, ChevronDown } from 'lucide-react';
|
import { Lightbulb, ChevronDown } from 'lucide-react';
|
||||||
|
import { Clipboard, CheckMark } from '@librechat/client';
|
||||||
import type { MouseEvent, FC } from 'react';
|
import type { MouseEvent, FC } from 'react';
|
||||||
|
import { showThinkingAtom } from '~/store/showThinking';
|
||||||
|
import { fontSizeAtom } from '~/store/fontSize';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import { cn } from '~/utils';
|
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',
|
* ThinkingContent - Displays the actual thinking/reasoning content
|
||||||
icon: 'icon-sm ml-1.5 transform-gpu text-text-primary transition-transform duration-200',
|
* Used by both legacy text-based messages and modern content parts
|
||||||
} as const;
|
*/
|
||||||
|
export const ThinkingContent: FC<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}> = memo(({ children }) => {
|
||||||
|
const fontSize = useAtomValue(fontSizeAtom);
|
||||||
|
|
||||||
const CONTENT_STYLES = {
|
return (
|
||||||
wrapper: 'relative pl-3 text-text-secondary',
|
<div className="relative rounded-3xl border border-border-medium bg-surface-tertiary p-4 text-text-secondary">
|
||||||
border:
|
<p className={cn('whitespace-pre-wrap leading-[26px]', fontSize)}>{children}</p>
|
||||||
'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>
|
|
||||||
</div>
|
</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(
|
export const ThinkingButton = memo(
|
||||||
({
|
({
|
||||||
isExpanded,
|
isExpanded,
|
||||||
onClick,
|
onClick,
|
||||||
label,
|
label,
|
||||||
|
content,
|
||||||
|
isContentHovered = false,
|
||||||
}: {
|
}: {
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
|
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||||
label: string;
|
label: string;
|
||||||
}) => (
|
content?: string;
|
||||||
<button type="button" onClick={onClick} className={BUTTON_STYLES.base}>
|
isContentHovered?: boolean;
|
||||||
<Atom size={14} className="mr-1.5 text-text-secondary" />
|
}) => {
|
||||||
{label}
|
const localize = useLocalize();
|
||||||
<ChevronDown className={`${BUTTON_STYLES.icon} ${isExpanded ? 'rotate-180' : ''}`} />
|
const fontSize = useAtomValue(fontSizeAtom);
|
||||||
</button>
|
|
||||||
),
|
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 Thinking: React.ElementType = memo(({ children }: { children: React.ReactNode }) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const showThinking = useRecoilValue<boolean>(store.showThinking);
|
const showThinking = useAtomValue(showThinkingAtom);
|
||||||
const [isExpanded, setIsExpanded] = useState(showThinking);
|
const [isExpanded, setIsExpanded] = useState(showThinking);
|
||||||
|
const [isContentHovered, setIsContentHovered] = useState(false);
|
||||||
|
|
||||||
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
|
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsExpanded((prev) => !prev);
|
setIsExpanded((prev) => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleContentEnter = useCallback(() => setIsContentHovered(true), []);
|
||||||
|
const handleContentLeave = useCallback(() => setIsContentHovered(false), []);
|
||||||
|
|
||||||
const label = useMemo(() => localize('com_ui_thoughts'), [localize]);
|
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) {
|
if (children == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div onMouseEnter={handleContentEnter} onMouseLeave={handleContentLeave}>
|
||||||
<div className="mb-5">
|
<div className="sticky top-0 z-10 mb-4 bg-surface-primary pb-2 pt-2">
|
||||||
<ThinkingButton isExpanded={isExpanded} onClick={handleClick} label={label} />
|
<ThinkingButton
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
onClick={handleClick}
|
||||||
|
label={label}
|
||||||
|
content={textContent}
|
||||||
|
isContentHovered={isContentHovered}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn('grid transition-all duration-300 ease-out', isExpanded && 'mb-8')}
|
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">
|
<div className="overflow-hidden">
|
||||||
<ThinkingContent isPart={true}>{children}</ThinkingContent>
|
<ThinkingContent>{children}</ThinkingContent>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { memo, useMemo, useState } from 'react';
|
import { memo, useMemo, useState, useCallback } from 'react';
|
||||||
import { useRecoilState } from 'recoil';
|
import { useAtom } from 'jotai';
|
||||||
import { ContentTypes } from 'librechat-data-provider';
|
import { ContentTypes } from 'librechat-data-provider';
|
||||||
import type {
|
import type {
|
||||||
TMessageContentParts,
|
TMessageContentParts,
|
||||||
|
|
@ -9,12 +9,12 @@ import type {
|
||||||
} from 'librechat-data-provider';
|
} from 'librechat-data-provider';
|
||||||
import { ThinkingButton } from '~/components/Artifacts/Thinking';
|
import { ThinkingButton } from '~/components/Artifacts/Thinking';
|
||||||
import { MessageContext, SearchContext } from '~/Providers';
|
import { MessageContext, SearchContext } from '~/Providers';
|
||||||
|
import { showThinkingAtom } from '~/store/showThinking';
|
||||||
import MemoryArtifacts from './MemoryArtifacts';
|
import MemoryArtifacts from './MemoryArtifacts';
|
||||||
import Sources from '~/components/Web/Sources';
|
import Sources from '~/components/Web/Sources';
|
||||||
import { mapAttachments } from '~/utils/map';
|
import { mapAttachments } from '~/utils/map';
|
||||||
import { EditTextPart } from './Parts';
|
import { EditTextPart } from './Parts';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import store from '~/store';
|
|
||||||
import Part from './Part';
|
import Part from './Part';
|
||||||
|
|
||||||
type ContentPartsProps = {
|
type ContentPartsProps = {
|
||||||
|
|
@ -53,12 +53,16 @@ const ContentParts = memo(
|
||||||
setSiblingIdx,
|
setSiblingIdx,
|
||||||
}: ContentPartsProps) => {
|
}: ContentPartsProps) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const [showThinking, setShowThinking] = useRecoilState<boolean>(store.showThinking);
|
const [showThinking, setShowThinking] = useAtom(showThinkingAtom);
|
||||||
const [isExpanded, setIsExpanded] = useState(showThinking);
|
const [isExpanded, setIsExpanded] = useState(showThinking);
|
||||||
|
const [isContentHovered, setIsContentHovered] = useState(false);
|
||||||
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
|
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
|
||||||
|
|
||||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||||
|
|
||||||
|
const handleContentEnter = useCallback(() => setIsContentHovered(true), []);
|
||||||
|
const handleContentLeave = useCallback(() => setIsContentHovered(false), []);
|
||||||
|
|
||||||
const hasReasoningParts = useMemo(() => {
|
const hasReasoningParts = useMemo(() => {
|
||||||
const hasThinkPart = content?.some((part) => part?.type === ContentTypes.THINK) ?? false;
|
const hasThinkPart = content?.some((part) => part?.type === ContentTypes.THINK) ?? false;
|
||||||
const allThinkPartsHaveContent =
|
const allThinkPartsHaveContent =
|
||||||
|
|
@ -78,6 +82,23 @@ const ContentParts = memo(
|
||||||
return hasThinkPart && allThinkPartsHaveContent;
|
return hasThinkPart && allThinkPartsHaveContent;
|
||||||
}, [content]);
|
}, [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) {
|
if (!content) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -127,7 +148,8 @@ const ContentParts = memo(
|
||||||
<MemoryArtifacts attachments={attachments} />
|
<MemoryArtifacts attachments={attachments} />
|
||||||
<Sources messageId={messageId} conversationId={conversationId || undefined} />
|
<Sources messageId={messageId} conversationId={conversationId || undefined} />
|
||||||
{hasReasoningParts && (
|
{hasReasoningParts && (
|
||||||
<div className="mb-5">
|
<div onMouseEnter={handleContentEnter} onMouseLeave={handleContentLeave}>
|
||||||
|
<div className="sticky top-0 z-10 mb-2 bg-surface-secondary pb-2 pt-2">
|
||||||
<ThinkingButton
|
<ThinkingButton
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|
@ -142,25 +164,58 @@ const ContentParts = memo(
|
||||||
? localize('com_ui_thinking')
|
? localize('com_ui_thinking')
|
||||||
: localize('com_ui_thoughts')
|
: localize('com_ui_thoughts')
|
||||||
}
|
}
|
||||||
|
content={reasoningContent}
|
||||||
|
isContentHovered={isContentHovered}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
{content
|
||||||
.filter((part) => part)
|
.filter((part) => part && part.type !== ContentTypes.THINK)
|
||||||
.map((part, idx) => {
|
.map((part) => {
|
||||||
|
const originalIdx = content.indexOf(part);
|
||||||
const toolCallId =
|
const toolCallId =
|
||||||
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
|
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
|
||||||
const attachments = attachmentMap[toolCallId];
|
const attachments = attachmentMap[toolCallId];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageContext.Provider
|
<MessageContext.Provider
|
||||||
key={`provider-${messageId}-${idx}`}
|
key={`provider-${messageId}-${originalIdx}`}
|
||||||
value={{
|
value={{
|
||||||
messageId,
|
messageId,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
conversationId,
|
conversationId,
|
||||||
partIndex: idx,
|
partIndex: originalIdx,
|
||||||
nextType: content[idx + 1]?.type,
|
nextType: content[originalIdx + 1]?.type,
|
||||||
isSubmitting: effectiveIsSubmitting,
|
isSubmitting: effectiveIsSubmitting,
|
||||||
isLatestMessage,
|
isLatestMessage,
|
||||||
}}
|
}}
|
||||||
|
|
@ -169,10 +224,10 @@ const ContentParts = memo(
|
||||||
part={part}
|
part={part}
|
||||||
attachments={attachments}
|
attachments={attachments}
|
||||||
isSubmitting={effectiveIsSubmitting}
|
isSubmitting={effectiveIsSubmitting}
|
||||||
key={`part-${messageId}-${idx}`}
|
key={`part-${messageId}-${originalIdx}`}
|
||||||
isCreatedByUser={isCreatedByUser}
|
isCreatedByUser={isCreatedByUser}
|
||||||
isLast={idx === content.length - 1}
|
isLast={originalIdx === content.length - 1}
|
||||||
showCursor={idx === content.length - 1 && isLast}
|
showCursor={originalIdx === content.length - 1 && isLast}
|
||||||
/>
|
/>
|
||||||
</MessageContext.Provider>
|
</MessageContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ const EditMessage = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container message={message}>
|
<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
|
<TextareaAutosize
|
||||||
{...registerProps}
|
{...registerProps}
|
||||||
ref={(e) => {
|
ref={(e) => {
|
||||||
|
|
|
||||||
|
|
@ -14,19 +14,19 @@ import Markdown from './Markdown';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
export const ErrorMessage = ({
|
const ERROR_CONNECTION_TEXT = 'Error connecting to server, try refreshing the page.';
|
||||||
text,
|
const DELAYED_ERROR_TIMEOUT = 5500;
|
||||||
message,
|
const UNFINISHED_DELAY = 250;
|
||||||
className = '',
|
|
||||||
}: Pick<TDisplayProps, 'text' | 'className'> & {
|
const parseThinkingContent = (text: string) => {
|
||||||
message?: TMessage;
|
const thinkingMatch = text.match(/:::thinking([\s\S]*?):::/);
|
||||||
}) => {
|
return {
|
||||||
const localize = useLocalize();
|
thinkingContent: thinkingMatch ? thinkingMatch[1].trim() : '',
|
||||||
if (text === 'Error connecting to server, try refreshing the page.') {
|
regularContent: thinkingMatch ? text.replace(/:::thinking[\s\S]*?:::/, '').trim() : text,
|
||||||
console.log('error message', message);
|
};
|
||||||
return (
|
};
|
||||||
<Suspense
|
|
||||||
fallback={
|
const LoadingFallback = () => (
|
||||||
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
|
<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="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
|
||||||
<div className="absolute">
|
<div className="absolute">
|
||||||
|
|
@ -36,25 +36,15 @@ export const ErrorMessage = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
return (
|
const ErrorBox = ({
|
||||||
<Container message={message}>
|
children,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
|
|
@ -63,8 +53,40 @@ export const ErrorMessage = ({
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Error text={text} />
|
{children}
|
||||||
</div>
|
</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 }) => {
|
||||||
|
if (text === ERROR_CONNECTION_TEXT) {
|
||||||
|
return <ConnectionError message={message} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container message={message}>
|
||||||
|
<ErrorBox className={className}>
|
||||||
|
<Error text={text} />
|
||||||
|
</ErrorBox>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -72,27 +94,29 @@ export const ErrorMessage = ({
|
||||||
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => {
|
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => {
|
||||||
const { isSubmitting = false, isLatestMessage = false } = useMessageContext();
|
const { isSubmitting = false, isLatestMessage = false } = useMessageContext();
|
||||||
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
|
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
|
||||||
|
|
||||||
const showCursorState = useMemo(
|
const showCursorState = useMemo(
|
||||||
() => showCursor === true && isSubmitting,
|
() => showCursor === true && isSubmitting,
|
||||||
[showCursor, isSubmitting],
|
[showCursor, isSubmitting],
|
||||||
);
|
);
|
||||||
|
|
||||||
let content: React.ReactElement;
|
const content = useMemo(() => {
|
||||||
if (!isCreatedByUser) {
|
if (!isCreatedByUser) {
|
||||||
content = <Markdown content={text} isLatestMessage={isLatestMessage} />;
|
return <Markdown content={text} isLatestMessage={isLatestMessage} />;
|
||||||
} else if (enableUserMsgMarkdown) {
|
|
||||||
content = <MarkdownLite content={text} />;
|
|
||||||
} else {
|
|
||||||
content = <>{text}</>;
|
|
||||||
}
|
}
|
||||||
|
if (enableUserMsgMarkdown) {
|
||||||
|
return <MarkdownLite content={text} />;
|
||||||
|
}
|
||||||
|
return <>{text}</>;
|
||||||
|
}, [isCreatedByUser, enableUserMsgMarkdown, text, isLatestMessage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container message={message}>
|
<Container message={message}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
isSubmitting ? 'submitting' : '',
|
|
||||||
showCursorState && !!text.length ? 'result-streaming' : '',
|
|
||||||
'markdown prose message-content dark:prose-invert light w-full break-words',
|
'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 && !enableUserMsgMarkdown && 'whitespace-pre-wrap',
|
||||||
isCreatedByUser ? 'dark:text-gray-20' : 'dark:text-gray-100',
|
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 }) => (
|
export const UnfinishedMessage = ({ message }: { message: TMessage }) => (
|
||||||
<ErrorMessage
|
<ErrorMessage
|
||||||
message={message}
|
message={message}
|
||||||
|
|
@ -123,21 +146,14 @@ const MessageContent = ({
|
||||||
const { message } = props;
|
const { message } = props;
|
||||||
const { messageId } = message;
|
const { messageId } = message;
|
||||||
|
|
||||||
const { thinkingContent, regularContent } = useMemo(() => {
|
const { thinkingContent, regularContent } = useMemo(() => parseThinkingContent(text), [text]);
|
||||||
const thinkingMatch = text.match(/:::thinking([\s\S]*?):::/);
|
|
||||||
return {
|
|
||||||
thinkingContent: thinkingMatch ? thinkingMatch[1].trim() : '',
|
|
||||||
regularContent: thinkingMatch ? text.replace(/:::thinking[\s\S]*?:::/, '').trim() : text,
|
|
||||||
};
|
|
||||||
}, [text]);
|
|
||||||
|
|
||||||
const showRegularCursor = useMemo(() => isLast && isSubmitting, [isLast, isSubmitting]);
|
const showRegularCursor = useMemo(() => isLast && isSubmitting, [isLast, isSubmitting]);
|
||||||
|
|
||||||
const unfinishedMessage = useMemo(
|
const unfinishedMessage = useMemo(
|
||||||
() =>
|
() =>
|
||||||
!isSubmitting && unfinished ? (
|
!isSubmitting && unfinished ? (
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<DelayedRender delay={250}>
|
<DelayedRender delay={UNFINISHED_DELAY}>
|
||||||
<UnfinishedMessage message={message} />
|
<UnfinishedMessage message={message} />
|
||||||
</DelayedRender>
|
</DelayedRender>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
@ -146,8 +162,10 @@ const MessageContent = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <ErrorMessage message={props.message} text={text} />;
|
return <ErrorMessage message={message} text={text} />;
|
||||||
} else if (edit) {
|
}
|
||||||
|
|
||||||
|
if (edit) {
|
||||||
return <EditMessage text={text} isSubmitting={isSubmitting} {...props} />;
|
return <EditMessage text={text} isSubmitting={isSubmitting} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useForm } from 'react-hook-form';
|
||||||
import { TextareaAutosize } from '@librechat/client';
|
import { TextareaAutosize } from '@librechat/client';
|
||||||
import { ContentTypes } from 'librechat-data-provider';
|
import { ContentTypes } from 'librechat-data-provider';
|
||||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||||
|
import { Lightbulb, MessageSquare } from 'lucide-react';
|
||||||
import { useUpdateMessageContentMutation } from 'librechat-data-provider/react-query';
|
import { useUpdateMessageContentMutation } from 'librechat-data-provider/react-query';
|
||||||
import type { Agents } from 'librechat-data-provider';
|
import type { Agents } from 'librechat-data-provider';
|
||||||
import type { TEditProps } from '~/common';
|
import type { TEditProps } from '~/common';
|
||||||
|
|
@ -153,6 +154,22 @@ const EditTextPart = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container message={message}>
|
<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)]">
|
<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
|
<TextareaAutosize
|
||||||
{...registerProps}
|
{...registerProps}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,32 @@ type ReasoningProps = {
|
||||||
reasoning: string;
|
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 Reasoning = memo(({ reasoning }: ReasoningProps) => {
|
||||||
const { isExpanded, nextType } = useMessageContext();
|
const { isExpanded, nextType } = useMessageContext();
|
||||||
|
|
||||||
|
// Strip <think> tags from the reasoning content (modern format)
|
||||||
const reasoningText = useMemo(() => {
|
const reasoningText = useMemo(() => {
|
||||||
return reasoning
|
return reasoning
|
||||||
.replace(/^<think>\s*/, '')
|
.replace(/^<think>\s*/, '')
|
||||||
|
|
@ -21,18 +45,20 @@ const Reasoning = memo(({ reasoning }: ReasoningProps) => {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: The toggle button is rendered separately in ContentParts.tsx
|
||||||
|
// This component only handles the collapsible content area
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'grid transition-all duration-300 ease-out',
|
'grid transition-all duration-300 ease-out',
|
||||||
nextType !== ContentTypes.THINK && isExpanded && 'mb-8',
|
nextType !== ContentTypes.THINK && isExpanded && 'mb-4',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<ThinkingContent isPart={true}>{reasoningText}</ThinkingContent>
|
<ThinkingContent>{reasoningText}</ThinkingContent>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
import { showThinkingAtom } from '~/store/showThinking';
|
||||||
import FontSizeSelector from './FontSizeSelector';
|
import FontSizeSelector from './FontSizeSelector';
|
||||||
import { ForkSettings } from './ForkSettings';
|
import { ForkSettings } from './ForkSettings';
|
||||||
import ChatDirection from './ChatDirection';
|
import ChatDirection from './ChatDirection';
|
||||||
|
|
@ -28,7 +29,7 @@ const toggleSwitchConfigs = [
|
||||||
key: 'centerFormOnLanding',
|
key: 'centerFormOnLanding',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stateAtom: store.showThinking,
|
stateAtom: showThinkingAtom,
|
||||||
localizationKey: 'com_nav_show_thinking',
|
localizationKey: 'com_nav_show_thinking',
|
||||||
switchId: 'showThinking',
|
switchId: 'showThinking',
|
||||||
hoverCardText: undefined,
|
hoverCardText: undefined,
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { useRecoilState } from 'recoil';
|
import { useAtom } from 'jotai';
|
||||||
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
|
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
|
||||||
|
import { showThinkingAtom } from '~/store/showThinking';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import store from '~/store';
|
|
||||||
|
|
||||||
export default function SaveDraft({
|
export default function SaveDraft({
|
||||||
onCheckedChange,
|
onCheckedChange,
|
||||||
}: {
|
}: {
|
||||||
onCheckedChange?: (value: boolean) => void;
|
onCheckedChange?: (value: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const [showThinking, setSaveDrafts] = useRecoilState<boolean>(store.showThinking);
|
const [showThinking, setShowThinking] = useAtom(showThinkingAtom);
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
||||||
const handleCheckedChange = (value: boolean) => {
|
const handleCheckedChange = (value: boolean) => {
|
||||||
setSaveDrafts(value);
|
setShowThinking(value);
|
||||||
if (onCheckedChange) {
|
if (onCheckedChange) {
|
||||||
onCheckedChange(value);
|
onCheckedChange(value);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { WritableAtom, useAtom } from 'jotai';
|
||||||
import { RecoilState, useRecoilState } from 'recoil';
|
import { RecoilState, useRecoilState } from 'recoil';
|
||||||
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
|
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
@ -6,7 +7,7 @@ type LocalizeFn = ReturnType<typeof useLocalize>;
|
||||||
type LocalizeKey = Parameters<LocalizeFn>[0];
|
type LocalizeKey = Parameters<LocalizeFn>[0];
|
||||||
|
|
||||||
interface ToggleSwitchProps {
|
interface ToggleSwitchProps {
|
||||||
stateAtom: RecoilState<boolean>;
|
stateAtom: RecoilState<boolean> | WritableAtom<boolean, [boolean], void>;
|
||||||
localizationKey: LocalizeKey;
|
localizationKey: LocalizeKey;
|
||||||
hoverCardText?: LocalizeKey;
|
hoverCardText?: LocalizeKey;
|
||||||
switchId: string;
|
switchId: string;
|
||||||
|
|
@ -16,13 +17,18 @@ interface ToggleSwitchProps {
|
||||||
strongLabel?: boolean;
|
strongLabel?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
function isRecoilState<T>(atom: unknown): atom is RecoilState<T> {
|
||||||
|
return atom != null && typeof atom === 'object' && 'key' in atom;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RecoilToggle: React.FC<
|
||||||
|
Omit<ToggleSwitchProps, 'stateAtom'> & { stateAtom: RecoilState<boolean> }
|
||||||
|
> = ({
|
||||||
stateAtom,
|
stateAtom,
|
||||||
localizationKey,
|
localizationKey,
|
||||||
hoverCardText,
|
hoverCardText,
|
||||||
switchId,
|
switchId,
|
||||||
onCheckedChange,
|
onCheckedChange,
|
||||||
showSwitch = true,
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
strongLabel = false,
|
strongLabel = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -36,9 +42,47 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||||
|
|
||||||
const labelId = `${switchId}-label`;
|
const labelId = `${switchId}-label`;
|
||||||
|
|
||||||
if (!showSwitch) {
|
return (
|
||||||
return null;
|
<div className="flex items-center justify-between">
|
||||||
}
|
<div className="flex items-center space-x-2">
|
||||||
|
<div id={labelId}>
|
||||||
|
{strongLabel ? <strong>{localize(localizationKey)}</strong> : localize(localizationKey)}
|
||||||
|
</div>
|
||||||
|
{hoverCardText && <InfoHoverCard side={ESide.Bottom} text={localize(hoverCardText)} />}
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id={switchId}
|
||||||
|
checked={switchState}
|
||||||
|
onCheckedChange={handleCheckedChange}
|
||||||
|
disabled={disabled}
|
||||||
|
className="ml-4"
|
||||||
|
data-testid={switchId}
|
||||||
|
aria-labelledby={labelId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const JotaiToggle: React.FC<
|
||||||
|
Omit<ToggleSwitchProps, 'stateAtom'> & { stateAtom: WritableAtom<boolean, [boolean], void> }
|
||||||
|
> = ({
|
||||||
|
stateAtom,
|
||||||
|
localizationKey,
|
||||||
|
hoverCardText,
|
||||||
|
switchId,
|
||||||
|
onCheckedChange,
|
||||||
|
disabled = false,
|
||||||
|
strongLabel = false,
|
||||||
|
}) => {
|
||||||
|
const [switchState, setSwitchState] = useAtom(stateAtom);
|
||||||
|
const localize = useLocalize();
|
||||||
|
|
||||||
|
const handleCheckedChange = (value: boolean) => {
|
||||||
|
setSwitchState(value);
|
||||||
|
onCheckedChange?.(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelId = `${switchId}-label`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -52,13 +96,29 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||||
id={switchId}
|
id={switchId}
|
||||||
checked={switchState}
|
checked={switchState}
|
||||||
onCheckedChange={handleCheckedChange}
|
onCheckedChange={handleCheckedChange}
|
||||||
|
disabled={disabled}
|
||||||
className="ml-4"
|
className="ml-4"
|
||||||
data-testid={switchId}
|
data-testid={switchId}
|
||||||
aria-labelledby={labelId}
|
aria-labelledby={labelId}
|
||||||
disabled={disabled}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ToggleSwitch: React.FC<ToggleSwitchProps> = (props) => {
|
||||||
|
const { stateAtom, showSwitch = true } = props;
|
||||||
|
|
||||||
|
if (!showSwitch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRecoil = isRecoilState(stateAtom);
|
||||||
|
|
||||||
|
if (isRecoil) {
|
||||||
|
return <RecoilToggle {...props} stateAtom={stateAtom as RecoilState<boolean>} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <JotaiToggle {...props} stateAtom={stateAtom as WritableAtom<boolean, [boolean], void>} />;
|
||||||
|
};
|
||||||
|
|
||||||
export default ToggleSwitch;
|
export default ToggleSwitch;
|
||||||
|
|
|
||||||
|
|
@ -789,6 +789,8 @@
|
||||||
"com_ui_copy_stack_trace": "Copy stack trace",
|
"com_ui_copy_stack_trace": "Copy stack trace",
|
||||||
"com_ui_copy_to_clipboard": "Copy to clipboard",
|
"com_ui_copy_to_clipboard": "Copy to clipboard",
|
||||||
"com_ui_copy_url_to_clipboard": "Copy URL to clipboard",
|
"com_ui_copy_url_to_clipboard": "Copy URL to clipboard",
|
||||||
|
"com_ui_copy_stack_trace": "Copy stack trace",
|
||||||
|
"com_ui_copy_thoughts_to_clipboard": "Copy thoughts to clipboard",
|
||||||
"com_ui_create": "Create",
|
"com_ui_create": "Create",
|
||||||
"com_ui_create_link": "Create link",
|
"com_ui_create_link": "Create link",
|
||||||
"com_ui_create_memory": "Create Memory",
|
"com_ui_create_memory": "Create Memory",
|
||||||
|
|
@ -1222,6 +1224,7 @@
|
||||||
"com_ui_terms_of_service": "Terms of service",
|
"com_ui_terms_of_service": "Terms of service",
|
||||||
"com_ui_thinking": "Thinking...",
|
"com_ui_thinking": "Thinking...",
|
||||||
"com_ui_thoughts": "Thoughts",
|
"com_ui_thoughts": "Thoughts",
|
||||||
|
"com_ui_response": "Response",
|
||||||
"com_ui_token": "token",
|
"com_ui_token": "token",
|
||||||
"com_ui_token_exchange_method": "Token Exchange Method",
|
"com_ui_token_exchange_method": "Token Exchange Method",
|
||||||
"com_ui_token_url": "Token URL",
|
"com_ui_token_url": "Token URL",
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,21 @@
|
||||||
import { atom } from 'jotai';
|
|
||||||
import { atomWithStorage } from 'jotai/utils';
|
|
||||||
import { applyFontSize } from '@librechat/client';
|
import { applyFontSize } from '@librechat/client';
|
||||||
|
import { createStorageAtomWithEffect, initializeFromStorage } from './jotai-utils';
|
||||||
|
|
||||||
const DEFAULT_FONT_SIZE = 'text-base';
|
const DEFAULT_FONT_SIZE = 'text-base';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base storage atom for font size
|
* This atom stores the user's font size preference
|
||||||
*/
|
*/
|
||||||
const fontSizeStorageAtom = atomWithStorage<string>('fontSize', DEFAULT_FONT_SIZE, undefined, {
|
export const fontSizeAtom = createStorageAtomWithEffect<string>(
|
||||||
getOnInit: true,
|
'fontSize',
|
||||||
});
|
DEFAULT_FONT_SIZE,
|
||||||
|
applyFontSize,
|
||||||
/**
|
|
||||||
* Derived atom that applies font size changes to the DOM
|
|
||||||
* Read: returns the current font size
|
|
||||||
* Write: updates storage and applies the font size to the DOM
|
|
||||||
*/
|
|
||||||
export const fontSizeAtom = atom(
|
|
||||||
(get) => get(fontSizeStorageAtom),
|
|
||||||
(get, set, newValue: string) => {
|
|
||||||
set(fontSizeStorageAtom, newValue);
|
|
||||||
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
|
||||||
applyFontSize(newValue);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize font size on app load
|
* Initialize font size on app load
|
||||||
|
* This function applies the saved font size from localStorage to the DOM
|
||||||
*/
|
*/
|
||||||
export const initializeFontSize = () => {
|
export const initializeFontSize = (): void => {
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
initializeFromStorage('fontSize', DEFAULT_FONT_SIZE, applyFontSize);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedValue = localStorage.getItem('fontSize');
|
|
||||||
|
|
||||||
if (savedValue !== null) {
|
|
||||||
try {
|
|
||||||
const parsedValue = JSON.parse(savedValue);
|
|
||||||
applyFontSize(parsedValue);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
'Error parsing localStorage key "fontSize", resetting to default. Error:',
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
localStorage.setItem('fontSize', JSON.stringify(DEFAULT_FONT_SIZE));
|
|
||||||
applyFontSize(DEFAULT_FONT_SIZE);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
applyFontSize(DEFAULT_FONT_SIZE);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
88
client/src/store/jotai-utils.ts
Normal file
88
client/src/store/jotai-utils.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
import { atomWithStorage } from 'jotai/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a simple atom with localStorage persistence
|
||||||
|
* Uses Jotai's atomWithStorage with getOnInit for proper SSR support
|
||||||
|
*
|
||||||
|
* @param key - localStorage key
|
||||||
|
* @param defaultValue - default value if no saved value exists
|
||||||
|
* @returns Jotai atom with localStorage persistence
|
||||||
|
*/
|
||||||
|
export function createStorageAtom<T>(key: string, defaultValue: T) {
|
||||||
|
return atomWithStorage<T>(key, defaultValue, undefined, {
|
||||||
|
getOnInit: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an atom with localStorage persistence and side effects
|
||||||
|
* Useful when you need to apply changes to the DOM or trigger other actions
|
||||||
|
*
|
||||||
|
* @param key - localStorage key
|
||||||
|
* @param defaultValue - default value if no saved value exists
|
||||||
|
* @param onWrite - callback function to run when the value changes
|
||||||
|
* @returns Jotai atom with localStorage persistence and side effects
|
||||||
|
*/
|
||||||
|
export function createStorageAtomWithEffect<T>(
|
||||||
|
key: string,
|
||||||
|
defaultValue: T,
|
||||||
|
onWrite: (value: T) => void,
|
||||||
|
) {
|
||||||
|
const baseAtom = createStorageAtom(key, defaultValue);
|
||||||
|
|
||||||
|
return atom(
|
||||||
|
(get) => get(baseAtom),
|
||||||
|
(get, set, newValue: T) => {
|
||||||
|
set(baseAtom, newValue);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
onWrite(newValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize a value from localStorage and optionally apply it
|
||||||
|
* Useful for applying saved values on app startup (e.g., theme, fontSize)
|
||||||
|
*
|
||||||
|
* @param key - localStorage key
|
||||||
|
* @param defaultValue - default value if no saved value exists
|
||||||
|
* @param onInit - optional callback to run with the loaded value
|
||||||
|
* @returns The loaded value (or default if none exists)
|
||||||
|
*/
|
||||||
|
export function initializeFromStorage<T>(
|
||||||
|
key: string,
|
||||||
|
defaultValue: T,
|
||||||
|
onInit?: (value: T) => void,
|
||||||
|
): T {
|
||||||
|
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedValue = localStorage.getItem(key);
|
||||||
|
const value = savedValue ? (JSON.parse(savedValue) as T) : defaultValue;
|
||||||
|
|
||||||
|
if (onInit) {
|
||||||
|
onInit(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error initializing ${key} from localStorage, using default. Error:`, error);
|
||||||
|
|
||||||
|
// Reset corrupted value
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(defaultValue));
|
||||||
|
} catch (setError) {
|
||||||
|
console.error(`Error resetting corrupted ${key} in localStorage:`, setError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onInit) {
|
||||||
|
onInit(defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
client/src/store/showThinking.ts
Normal file
8
client/src/store/showThinking.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { createStorageAtom } from './jotai-utils';
|
||||||
|
|
||||||
|
const DEFAULT_SHOW_THINKING = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This atom controls whether AI reasoning/thinking content is expanded by default.
|
||||||
|
*/
|
||||||
|
export const showThinkingAtom = createStorageAtom<boolean>('showThinking', DEFAULT_SHOW_THINKING);
|
||||||
Loading…
Add table
Add a link
Reference in a new issue