🌿 feat: Fork Messages/Conversations (#2617)

* typedef for ImportBatchBuilder

* feat: first pass, fork conversations

* feat: fork - getMessagesUpToTargetLevel

* fix: additional tests and fix getAllMessagesUpToParent

* chore: arrow function return

* refactor: fork 3 options

* chore: remove unused genbuttons

* chore: remove unused hover buttons code

* feat: fork first pass

* wip: fork remember setting

* style: user icon

* chore: move clear chats to data tab

* WIP: fork UI options

* feat: data-provider fork types/services/vars and use generic MutationOptions

* refactor: use single param for fork option, use enum, fix mongo errors, use Date.now(), add records flag for testing, use endpoint from original convo and messages, pass originalConvo to finishConversation

* feat: add fork mutation hook and consolidate type imports

* refactor: use enum

* feat: first pass, fork mutation

* chore: add enum for target level fork option

* chore: add enum for target level fork option

* show toast when checking remember selection

* feat: splitAtTarget

* feat: split at target option

* feat: navigate to new fork, show toasts, set result query data

* feat: hover info for all fork options

* refactor: add Messages settings tab

* fix(Fork): remember text info

* ci: test for single message and is target edge case

* feat: additional tests for getAllMessagesUpToParent

* ci: additional tests and cycle detection for getMessagesUpToTargetLevel

* feat: circular dependency checks for getAllMessagesUpToParent

* fix: getMessagesUpToTargetLevel circular dep. check

* ci: more tests for getMessagesForConversation

* style: hover text for checkbox fork items

* refactor: add statefulness to conversation import
This commit is contained in:
Danny Avila 2024-05-05 11:48:20 -04:00 committed by GitHub
parent c8baceac76
commit 25fceb78b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1831 additions and 523 deletions

View file

@ -1,86 +0,0 @@
import { useEffect, useState } from 'react';
import type { TMessage } from 'librechat-data-provider';
import { useMediaQuery, useGenerationsByLatest } from '~/hooks';
import Regenerate from '~/components/Input/Generations/Regenerate';
import Continue from '~/components/Input/Generations/Continue';
import Stop from '~/components/Input/Generations/Stop';
import { useChatContext } from '~/Providers';
import { cn } from '~/utils';
type GenerationButtonsProps = {
endpoint: string;
showPopover?: boolean;
opacityClass?: string;
};
export default function GenerationButtons({
endpoint,
showPopover = false,
opacityClass = 'full-opacity',
}: GenerationButtonsProps) {
const {
getMessages,
isSubmitting,
latestMessage,
handleContinue,
handleRegenerate,
handleStopGenerating,
} = useChatContext();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const { continueSupported, regenerateEnabled } = useGenerationsByLatest({
endpoint,
message: latestMessage as TMessage,
isSubmitting,
latestMessage,
});
const [userStopped, setUserStopped] = useState(false);
const messages = getMessages();
const handleStop = (e: React.MouseEvent<HTMLButtonElement>) => {
setUserStopped(true);
handleStopGenerating(e);
};
useEffect(() => {
let timer: NodeJS.Timeout;
if (userStopped) {
timer = setTimeout(() => {
setUserStopped(false);
}, 200);
}
return () => {
clearTimeout(timer);
};
}, [userStopped]);
if (isSmallScreen) {
return null;
}
let button: React.ReactNode = null;
if (isSubmitting) {
button = <Stop onClick={handleStop} />;
} else if (userStopped || continueSupported) {
button = <Continue onClick={handleContinue} />;
} else if (messages && messages.length > 0 && regenerateEnabled) {
button = <Regenerate onClick={handleRegenerate} />;
}
return (
<div className="absolute bottom-0 right-0 z-[62]">
<div className="grow" />
<div className="flex items-center md:items-end">
<div
className={cn('option-buttons', showPopover ? '' : opacityClass)}
data-projection-id="173"
>
{button}
</div>
</div>
</div>
);
}

View file

@ -3,6 +3,7 @@ import { EModelEndpoint } from 'librechat-data-provider';
import type { TConversation, TMessage } from 'librechat-data-provider';
import { Clipboard, CheckMark, EditIcon, RegenerateIcon, ContinueIcon } from '~/components/svg';
import { useGenerationsByLatest, useLocalize } from '~/hooks';
import { Fork } from '~/components/Conversations';
import { cn } from '~/utils';
type THoverButtons = {
@ -34,13 +35,14 @@ export default function HoverButtons({
const { endpoint: _endpoint, endpointType } = conversation ?? {};
const endpoint = endpointType ?? _endpoint;
const [isCopied, setIsCopied] = useState(false);
const { hideEditButton, regenerateEnabled, continueSupported } = useGenerationsByLatest({
isEditing,
isSubmitting,
message,
endpoint: endpoint ?? '',
latestMessage,
});
const { hideEditButton, regenerateEnabled, continueSupported, forkingSupported } =
useGenerationsByLatest({
isEditing,
isSubmitting,
message,
endpoint: endpoint ?? '',
latestMessage,
});
if (!conversation) {
return null;
}
@ -113,6 +115,13 @@ export default function HoverButtons({
<ContinueIcon className="h-4 w-4 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>
) : null}
<Fork
isLast={isLast}
messageId={message.messageId}
conversationId={conversation.conversationId}
forkingSupported={forkingSupported}
latestMessage={latestMessage}
/>
</div>
);
}

View file

@ -0,0 +1,331 @@
import { useState, useRef } from 'react';
import { useRecoilState } from 'recoil';
import { GitFork, InfoIcon } from 'lucide-react';
import * as Popover from '@radix-ui/react-popover';
import { ForkOptions, TMessage } from 'librechat-data-provider';
import { GitCommit, GitBranchPlus, ListTree } from 'lucide-react';
import {
Checkbox,
HoverCard,
HoverCardTrigger,
HoverCardPortal,
HoverCardContent,
} from '~/components/ui';
import OptionHover from '~/components/SidePanel/Parameters/OptionHover';
import { useToastContext, useChatContext } from '~/Providers';
import { useLocalize, useNavigateToConvo } from '~/hooks';
import { useForkConvoMutation } from '~/data-provider';
import { ESide } from '~/common';
import { cn } from '~/utils';
import store from '~/store';
interface PopoverButtonProps {
children: React.ReactNode;
setting: string;
onClick: (setting: string) => void;
setActiveSetting: React.Dispatch<React.SetStateAction<string>>;
sideOffset?: number;
timeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
hoverInfo?: React.ReactNode;
hoverTitle?: React.ReactNode;
hoverDescription?: React.ReactNode;
}
const optionLabels = {
[ForkOptions.DIRECT_PATH]: 'com_ui_fork_visible',
[ForkOptions.INCLUDE_BRANCHES]: 'com_ui_fork_branches',
[ForkOptions.TARGET_LEVEL]: 'com_ui_fork_all_target',
default: 'com_ui_fork_from_message',
};
const PopoverButton: React.FC<PopoverButtonProps> = ({
children,
setting,
onClick,
setActiveSetting,
sideOffset = 30,
timeoutRef,
hoverInfo,
hoverTitle,
hoverDescription,
}) => {
return (
<HoverCard openDelay={200}>
<Popover.Close
onClick={() => onClick(setting)}
onMouseEnter={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setActiveSetting(optionLabels[setting]);
}}
onMouseLeave={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setActiveSetting(optionLabels.default);
}, 175);
}}
className="mx-1 max-w-14 flex-1 rounded-lg border-2 bg-white transition duration-300 ease-in-out hover:bg-black dark:border-gray-400 dark:bg-gray-700/95 dark:text-gray-400 hover:dark:border-gray-200 hover:dark:text-gray-200"
type="button"
>
{children}
</Popover.Close>
{(hoverInfo || hoverTitle || hoverDescription) && (
<HoverCardPortal>
<HoverCardContent
side="right"
className="z-[999] w-80 dark:bg-gray-700"
sideOffset={sideOffset}
>
<div className="space-y-2">
<p className="flex flex-col gap-2 text-sm text-gray-600 dark:text-gray-300">
{hoverInfo && hoverInfo}
{hoverTitle && <span className="flex flex-wrap gap-1 font-bold">{hoverTitle}</span>}
{hoverDescription && hoverDescription}
</p>
</div>
</HoverCardContent>
</HoverCardPortal>
)}
</HoverCard>
);
};
export default function Fork({
isLast,
messageId,
conversationId,
forkingSupported,
latestMessage,
}: {
isLast?: boolean;
messageId: string;
conversationId: string | null;
forkingSupported?: boolean;
latestMessage: TMessage | null;
}) {
const localize = useLocalize();
const { index } = useChatContext();
const { showToast } = useToastContext();
const [remember, setRemember] = useState(false);
const { navigateToConvo } = useNavigateToConvo(index);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const [forkSetting, setForkSetting] = useRecoilState(store.forkSetting);
const [activeSetting, setActiveSetting] = useState(optionLabels.default);
const [splitAtTarget, setSplitAtTarget] = useRecoilState(store.splitAtTarget);
const [rememberGlobal, setRememberGlobal] = useRecoilState(store.rememberForkOption);
const forkConvo = useForkConvoMutation({
onSuccess: (data) => {
if (data) {
navigateToConvo(data.conversation);
showToast({
message: localize('com_ui_fork_success'),
status: 'success',
});
}
},
onMutate: () => {
showToast({
message: localize('com_ui_fork_processing'),
status: 'info',
});
},
onError: () => {
showToast({
message: localize('com_ui_fork_error'),
status: 'error',
});
},
});
if (!forkingSupported || !conversationId || !messageId) {
return null;
}
const onClick = (option: string) => {
if (remember) {
setRememberGlobal(true);
setForkSetting(option);
}
forkConvo.mutate({
messageId,
conversationId,
option,
splitAtTarget,
latestMessageId: latestMessage?.messageId,
});
};
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
className={cn(
'hover-button active rounded-md p-1 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible ',
'data-[state=open]:active data-[state=open]:bg-gray-200 data-[state=open]:text-gray-700 data-[state=open]:dark:bg-gray-700 data-[state=open]:dark:text-gray-200',
!isLast ? 'data-[state=open]:opacity-100 md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={(e) => {
if (rememberGlobal) {
e.preventDefault();
forkConvo.mutate({
messageId,
splitAtTarget,
conversationId,
option: forkSetting,
latestMessageId: latestMessage?.messageId,
});
}
}}
type="button"
title={localize('com_ui_continue')}
>
<GitFork className="h-4 w-4 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>
</Popover.Trigger>
<Popover.Portal>
<div dir="ltr">
<Popover.Content
side="top"
role="menu"
className="bg-token-surface-primary flex min-h-[120px] min-w-[215px] flex-col gap-3 overflow-hidden rounded-lg bg-white p-2 px-3 shadow-lg dark:bg-gray-700/95"
style={{ outline: 'none', pointerEvents: 'auto', boxSizing: 'border-box' }}
tabIndex={-1}
sideOffset={5}
align="center"
>
<div className="flex h-6 w-full items-center justify-center text-sm dark:text-gray-200">
{localize(activeSetting)}
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<InfoIcon className="ml-auto flex h-4 w-4 gap-2 text-white/50" />
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent
side="right"
className="z-[999] w-80 dark:bg-gray-700"
sideOffset={19}
>
<div className="flex flex-col gap-2 space-y-2 text-sm text-gray-600 dark:text-gray-300">
<span>{localize('com_ui_fork_info_1')}</span>
<span>{localize('com_ui_fork_info_2')}</span>
<span>
{localize('com_ui_fork_info_3', localize('com_ui_fork_split_target'))}
</span>
</div>
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
</div>
<div className="flex h-full w-full items-center justify-center gap-1">
<PopoverButton
sideOffset={155}
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.DIRECT_PATH}
hoverTitle={
<>
<GitCommit className="h-5 w-5 rotate-90" />
{localize(optionLabels[ForkOptions.DIRECT_PATH])}
</>
}
hoverDescription={localize('com_ui_fork_info_visible')}
>
<HoverCardTrigger asChild>
<GitCommit className="h-full w-full rotate-90 p-2" />
</HoverCardTrigger>
</PopoverButton>
<PopoverButton
sideOffset={90}
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.INCLUDE_BRANCHES}
hoverTitle={
<>
<GitBranchPlus className="h-4 w-4 rotate-180" />
{localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])}
</>
}
hoverDescription={localize('com_ui_fork_info_branches')}
>
<HoverCardTrigger asChild>
<GitBranchPlus className="h-full w-full rotate-180 p-2" />
</HoverCardTrigger>
</PopoverButton>
<PopoverButton
sideOffset={25}
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.TARGET_LEVEL}
hoverTitle={
<>
<ListTree className="h-5 w-5" />
{`${localize(optionLabels[ForkOptions.TARGET_LEVEL])} (${localize(
'com_endpoint_default',
)})`}
</>
}
hoverDescription={localize('com_ui_fork_info_target')}
>
<HoverCardTrigger asChild>
<ListTree className="h-full w-full p-2" />
</HoverCardTrigger>
</PopoverButton>
</div>
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<div className="flex h-6 w-full items-center justify-start text-sm dark:text-gray-300 dark:hover:text-gray-200">
<Checkbox
checked={splitAtTarget}
onCheckedChange={(checked: boolean) => setSplitAtTarget(checked)}
className="m-2 transition duration-300 ease-in-out"
/>
{localize('com_ui_fork_split_target')}
</div>
</HoverCardTrigger>
<OptionHover
side={ESide.Right}
description="com_ui_fork_info_start"
langCode={true}
sideOffset={20}
/>
</HoverCard>
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<div className="flex h-6 w-full items-center justify-start text-sm dark:text-gray-300 dark:hover:text-gray-200">
<Checkbox
checked={remember}
onCheckedChange={(checked: boolean) => {
if (checked) {
showToast({
message: localize('com_ui_fork_remember_checked'),
status: 'info',
});
}
setRemember(checked);
}}
className="m-2 transition duration-300 ease-in-out"
/>
{localize('com_ui_fork_remember')}
</div>
</HoverCardTrigger>
<OptionHover
side={ESide.Right}
description="com_ui_fork_info_remember"
langCode={true}
sideOffset={20}
/>
</HoverCard>
</Popover.Content>
</div>
</Popover.Portal>
</Popover.Root>
);
}

View file

@ -1,3 +1,4 @@
export { default as Fork } from './Fork';
export { default as Pages } from './Pages';
export { default as Conversation } from './Conversation';
export { default as RenameButton } from './RenameButton';

View file

@ -1,48 +0,0 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { cn, removeFocusOutlines } from '~/utils/';
type GenerationButtonsProps = {
showPopover: boolean;
opacityClass: string;
};
export default function GenerationButtons({ showPopover, opacityClass }: GenerationButtonsProps) {
return (
<div className="absolute bottom-4 right-0 z-[62]">
<div className="grow"></div>
<div className="flex items-center md:items-end">
<div
className={cn('option-buttons', showPopover ? '' : opacityClass)}
data-projection-id="173"
>
{/* <button
className={cn(
'custom-btn btn-neutral relative -z-0 whitespace-nowrap border-0 md:border',
removeFocusOutlines,
)}
>
<div className="flex w-full items-center justify-center gap-2">
<svg
stroke="currentColor"
fill="none"
strokeWidth="1.5"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-3 w-3 flex-shrink-0"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<polyline points="1 4 1 10 7 10"></polyline>
<polyline points="23 20 23 14 17 14"></polyline>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"></path>
</svg>
Regenerate
</div>
</button> */}
</div>
</div>
</div>
);
}

View file

@ -1,101 +0,0 @@
import { useState } from 'react';
import type { TConversation, TMessage } from 'librechat-data-provider';
import { Clipboard, CheckMark, EditIcon, RegenerateIcon, ContinueIcon } from '~/components/svg';
import { useGenerations, useLocalize } from '~/hooks';
import { cn } from '~/utils';
type THoverButtons = {
isEditing: boolean;
enterEdit: (cancel?: boolean) => void;
copyToClipboard: (setIsCopied: React.Dispatch<React.SetStateAction<boolean>>) => void;
conversation: TConversation | null;
isSubmitting: boolean;
message: TMessage;
regenerate: () => void;
handleContinue: (e: React.MouseEvent<HTMLButtonElement>) => void;
};
export default function HoverButtons({
isEditing,
enterEdit,
copyToClipboard,
conversation,
isSubmitting,
message,
regenerate,
handleContinue,
}: THoverButtons) {
const localize = useLocalize();
const { endpoint } = conversation ?? {};
const [isCopied, setIsCopied] = useState(false);
const { hideEditButton, regenerateEnabled, continueSupported } = useGenerations({
isEditing,
isSubmitting,
message,
endpoint: endpoint ?? '',
});
if (!conversation) {
return null;
}
const { isCreatedByUser } = message;
const onEdit = () => {
if (isEditing) {
return enterEdit(true);
}
enterEdit();
};
return (
<div className="visible mt-2 flex justify-center gap-3 self-end text-gray-400 md:gap-4 lg:absolute lg:right-0 lg:top-0 lg:mt-0 lg:translate-x-full lg:gap-1 lg:self-center lg:pl-2">
<button
className={cn(
'hover-button rounded-md p-1 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible',
isCreatedByUser ? '' : 'active',
hideEditButton ? 'opacity-0' : '',
isEditing ? 'active bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200' : '',
)}
onClick={onEdit}
type="button"
title={localize('com_ui_edit')}
disabled={hideEditButton}
>
<EditIcon />
</button>
<button
className={cn(
'hover-button rounded-md p-1 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible',
isCreatedByUser ? '' : 'active',
)}
onClick={() => copyToClipboard(setIsCopied)}
type="button"
title={
isCopied ? localize('com_ui_copied_to_clipboard') : localize('com_ui_copy_to_clipboard')
}
>
{isCopied ? <CheckMark /> : <Clipboard />}
</button>
{regenerateEnabled ? (
<button
className="hover-button active rounded-md p-1 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible"
onClick={regenerate}
type="button"
title={localize('com_ui_regenerate')}
>
<RegenerateIcon className="hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>
) : null}
{continueSupported ? (
<button
className="hover-button active rounded-md p-1 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible "
onClick={handleContinue}
type="button"
title={localize('com_ui_continue')}
>
<ContinueIcon className="h-4 w-4 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>
) : null}
</div>
);
}

View file

@ -79,11 +79,11 @@ function NavLinks() {
<div
style={{
backgroundColor: 'rgb(121, 137, 255)',
width: '20px',
height: '20px',
width: '32px',
height: '32px',
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
}}
className="relative flex h-8 w-8 items-center justify-center rounded-full p-1 text-white"
className="relative flex items-center justify-center rounded-full p-1 text-white"
>
<UserIcon />
</div>

View file

@ -1,9 +1,10 @@
import * as Tabs from '@radix-ui/react-tabs';
import { MessageSquare } from 'lucide-react';
import { SettingsTabValues } from 'librechat-data-provider';
import type { TDialogProps } from '~/common';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui';
import { GearIcon, DataIcon, UserIcon, ExperimentIcon } from '~/components/svg';
import { General, Beta, Data, Account } from './SettingsTabs';
import { General, Messages, Beta, Data, Account } from './SettingsTabs';
import { useMediaQuery, useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -54,6 +55,20 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
<GearIcon />
{localize('com_nav_setting_general')}
</Tabs.Trigger>
<Tabs.Trigger
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
isSmallScreen
? 'flex-1 flex-col items-center justify-center text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
isSmallScreen ? '' : 'dark:bg-gray-700',
)}
value={SettingsTabValues.MESSAGES}
style={{ userSelect: 'none' }}
>
<MessageSquare className="icon-sm" />
{localize('com_endpoint_messages')}
</Tabs.Trigger>
<Tabs.Trigger
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
@ -98,6 +113,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
</Tabs.Trigger>
</Tabs.List>
<General />
<Messages />
<Beta />
<Data />
<Account />

View file

@ -40,7 +40,7 @@ const DangerButton = (props: TDangerButtonProps, ref: ForwardedRef<HTMLButtonEle
disabled={disabled}
onClick={onClick}
className={cn(
' btn btn-danger relative border-none bg-red-700 text-white hover:bg-red-800 dark:hover:bg-red-800',
' btn btn-danger relative min-w-[70px] border-none bg-red-700 text-white hover:bg-red-800 dark:hover:bg-red-800',
className,
)}
>

View file

@ -0,0 +1,29 @@
import type { TDangerButtonProps } from '~/common';
import DangerButton from '../DangerButton';
export const ClearChatsButton = ({
confirmClear,
className = '',
showText = true,
mutation,
onClick,
}: Pick<
TDangerButtonProps,
'confirmClear' | 'mutation' | 'className' | 'showText' | 'onClick'
>) => {
return (
<DangerButton
id="clearConvosBtn"
mutation={mutation}
confirmClear={confirmClear}
className={className}
showText={showText}
infoTextCode="com_nav_clear_all_chats"
actionTextCode="com_ui_clear"
confirmActionTextCode="com_nav_confirm_clear"
dataTestIdInitial="clear-convos-initial"
dataTestIdConfirm="clear-convos-confirm"
onClick={onClick}
/>
);
};

View file

@ -2,7 +2,7 @@ import 'test/matchMedia.mock';
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { ClearChatsButton } from './General';
import { ClearChatsButton } from './ClearChats';
import { RecoilRoot } from 'recoil';
describe('ClearChatsButton', () => {

View file

@ -1,13 +1,15 @@
import * as Tabs from '@radix-ui/react-tabs';
import {
useRevokeAllUserKeysMutation,
useRevokeUserKeyMutation,
useRevokeAllUserKeysMutation,
useClearConversationsMutation,
} from 'librechat-data-provider/react-query';
import { SettingsTabValues } from 'librechat-data-provider';
import React, { useState, useCallback, useRef } from 'react';
import { useOnClickOutside } from '~/hooks';
import DangerButton from '../DangerButton';
import { useConversation, useConversations, useOnClickOutside } from '~/hooks';
import ImportConversations from './ImportConversations';
import { ClearChatsButton } from './ClearChats';
import DangerButton from '../DangerButton';
export const RevokeKeysButton = ({
showText = true,
@ -20,42 +22,43 @@ export const RevokeKeysButton = ({
all?: boolean;
disabled?: boolean;
}) => {
const [confirmClear, setConfirmClear] = useState(false);
const revokeKeyMutation = useRevokeUserKeyMutation(endpoint);
const revokeKeysMutation = useRevokeAllUserKeysMutation();
const [confirmRevoke, setConfirmRevoke] = useState(false);
const contentRef = useRef(null);
useOnClickOutside(contentRef, () => confirmClear && setConfirmClear(false), []);
const revokeKeysMutation = useRevokeAllUserKeysMutation();
const revokeKeyMutation = useRevokeUserKeyMutation(endpoint);
const revokeContentRef = useRef(null);
useOnClickOutside(revokeContentRef, () => confirmRevoke && setConfirmRevoke(false), []);
const revokeAllUserKeys = useCallback(() => {
if (confirmClear) {
if (confirmRevoke) {
revokeKeysMutation.mutate({});
setConfirmClear(false);
setConfirmRevoke(false);
} else {
setConfirmClear(true);
setConfirmRevoke(true);
}
}, [confirmClear, revokeKeysMutation]);
}, [confirmRevoke, revokeKeysMutation]);
const revokeUserKey = useCallback(() => {
if (!endpoint) {
return;
} else if (confirmClear) {
} else if (confirmRevoke) {
revokeKeyMutation.mutate({});
setConfirmClear(false);
setConfirmRevoke(false);
} else {
setConfirmClear(true);
setConfirmRevoke(true);
}
}, [confirmClear, revokeKeyMutation, endpoint]);
}, [confirmRevoke, revokeKeyMutation, endpoint]);
const onClick = all ? revokeAllUserKeys : revokeUserKey;
return (
<DangerButton
ref={contentRef}
ref={revokeContentRef}
showText={showText}
onClick={onClick}
disabled={disabled}
confirmClear={confirmClear}
confirmClear={confirmRevoke}
id={'revoke-all-user-keys'}
actionTextCode={'com_ui_revoke'}
infoTextCode={'com_ui_revoke_info'}
@ -67,18 +70,54 @@ export const RevokeKeysButton = ({
};
function Data() {
const dataTabRef = useRef(null);
const [confirmClearConvos, setConfirmClearConvos] = useState(false);
useOnClickOutside(dataTabRef, () => confirmClearConvos && setConfirmClearConvos(false), []);
const { newConversation } = useConversation();
const { refreshConversations } = useConversations();
const clearConvosMutation = useClearConversationsMutation();
const clearConvos = () => {
if (confirmClearConvos) {
console.log('Clearing conversations...');
setConfirmClearConvos(false);
clearConvosMutation.mutate(
{},
{
onSuccess: () => {
newConversation();
refreshConversations();
},
},
);
} else {
setConfirmClearConvos(true);
}
};
return (
<Tabs.Content
value={SettingsTabValues.DATA}
role="tabpanel"
className="w-full md:min-h-[300px]"
ref={dataTabRef}
>
<div className="flex flex-col gap-3 text-sm text-gray-600 dark:text-gray-50">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<RevokeKeysButton all={true} />
<ImportConversations />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<ImportConversations />
<RevokeKeysButton all={true} />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<ClearChatsButton
confirmClear={confirmClearConvos}
onClick={clearConvos}
showText={true}
mutation={clearConvosMutation}
/>
</div>
</div>
</Tabs.Content>

View file

@ -1,15 +1,17 @@
import { useState } from 'react';
import { Import } from 'lucide-react';
import { cn } from '~/utils';
import { useUploadConversationsMutation } from '~/data-provider';
import { useLocalize, useConversations } from '~/hooks';
import { useState } from 'react';
import { useToastContext } from '~/Providers';
import { Spinner } from '~/components/svg';
import { cn } from '~/utils';
function ImportConversations() {
const localize = useLocalize();
const { showToast } = useToastContext();
const [, setErrors] = useState<string[]>([]);
const [allowImport, setAllowImport] = useState(true);
const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]);
const { refreshConversations } = useConversations();
@ -17,9 +19,11 @@ function ImportConversations() {
onSuccess: () => {
refreshConversations();
showToast({ message: localize('com_ui_import_conversation_success') });
setAllowImport(true);
},
onError: (error) => {
console.error('Error: ', error);
setAllowImport(true);
setError(
(error as { response: { data: { message?: string } } })?.response?.data?.message ??
'An error occurred while uploading the file.',
@ -33,6 +37,9 @@ function ImportConversations() {
showToast({ message: localize('com_ui_import_conversation_error'), status: 'error' });
}
},
onMutate: () => {
setAllowImport(false);
},
});
const startUpload = async (file: File) => {
@ -43,8 +50,6 @@ function ImportConversations() {
};
const handleFiles = async (_file: File) => {
console.log('Handling files...');
/* Process files */
try {
await startUpload(_file);
@ -55,7 +60,6 @@ function ImportConversations() {
};
const handleFileChange = (event) => {
console.log('file change');
const file = event.target.files[0];
if (file) {
handleFiles(file);
@ -67,12 +71,17 @@ function ImportConversations() {
<span>{localize('com_ui_import_conversation_info')}</span>
<label
htmlFor={'import-conversations-file'}
className="flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium font-normal transition-colors hover:bg-gray-100 hover:text-green-700 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:hover:text-green-500"
className="flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-3 text-xs font-medium font-normal transition-colors hover:bg-gray-100 hover:text-green-700 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:hover:text-green-500"
>
<Import className="mr-1 flex w-[22px] items-center stroke-1" />
{allowImport ? (
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" />
) : (
<Spinner className="mr-1 w-4" />
)}
<span>{localize('com_ui_import_conversation')}</span>
<input
id={'import-conversations-file'}
disabled={!allowImport}
value=""
type="file"
className={cn('hidden')}

View file

@ -1,21 +1,11 @@
import { useRecoilState } from 'recoil';
import * as Tabs from '@radix-ui/react-tabs';
import { SettingsTabValues } from 'librechat-data-provider';
import React, { useState, useContext, useCallback, useRef } from 'react';
import { useClearConversationsMutation } from 'librechat-data-provider/react-query';
import {
ThemeContext,
useLocalize,
useOnClickOutside,
useConversation,
useConversations,
useLocalStorage,
} from '~/hooks';
import React, { useContext, useCallback, useRef } from 'react';
import type { TDangerButtonProps } from '~/common';
import { ThemeContext, useLocalize, useLocalStorage } from '~/hooks';
import HideSidePanelSwitch from './HideSidePanelSwitch';
import AutoScrollSwitch from './AutoScrollSwitch';
import SendMessageKeyEnter from './EnterToSend';
import ShowCodeSwitch from './ShowCodeSwitch';
import { Dropdown } from '~/components/ui';
import DangerButton from '../DangerButton';
import store from '~/store';
@ -119,33 +109,11 @@ export const LangSelector = ({
function General() {
const { theme, setTheme } = useContext(ThemeContext);
const clearConvosMutation = useClearConversationsMutation();
const [confirmClear, setConfirmClear] = useState(false);
const [langcode, setLangcode] = useRecoilState(store.lang);
const [selectedLang, setSelectedLang] = useLocalStorage('selectedLang', langcode);
const { newConversation } = useConversation();
const { refreshConversations } = useConversations();
const contentRef = useRef(null);
useOnClickOutside(contentRef, () => confirmClear && setConfirmClear(false), []);
const clearConvos = () => {
if (confirmClear) {
console.log('Clearing conversations...');
setConfirmClear(false);
clearConvosMutation.mutate(
{},
{
onSuccess: () => {
newConversation();
refreshConversations();
},
},
);
} else {
setConfirmClear(true);
}
};
const changeTheme = useCallback(
(value: string) => {
@ -183,28 +151,14 @@ function General() {
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<LangSelector langcode={selectedLang} onChange={changeLang} />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<AutoScrollSwitch />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<SendMessageKeyEnter />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<ShowCodeSwitch />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<HideSidePanelSwitch />
</div>
{/* Clear Chats should be last */}
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<ClearChatsButton
confirmClear={confirmClear}
onClick={clearConvos}
showText={true}
mutation={clearConvosMutation}
/>
</div>
{/* <div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
</div> */}
</div>
</Tabs.Content>
);

View file

@ -30,4 +30,4 @@ export default function SendMessageKeyEnter({
/>
</div>
);
}
}

View file

@ -0,0 +1,59 @@
import { useRecoilState } from 'recoil';
import { ForkOptions } from 'librechat-data-provider';
import { Dropdown, Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export const ForkSettings = () => {
const localize = useLocalize();
const [forkSetting, setForkSetting] = useRecoilState(store.forkSetting);
const [splitAtTarget, setSplitAtTarget] = useRecoilState(store.splitAtTarget);
const [remember, setRemember] = useRecoilState<boolean>(store.rememberForkOption);
const forkOptions = [
{ value: ForkOptions.DIRECT_PATH, display: localize('com_ui_fork_visible') },
{ value: ForkOptions.INCLUDE_BRANCHES, display: localize('com_ui_fork_branches') },
{ value: ForkOptions.TARGET_LEVEL, display: localize('com_ui_fork_all_target') },
];
return (
<>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<div className="flex items-center justify-between">
<div> {localize('com_ui_fork_change_default')} </div>
<Dropdown
value={forkSetting}
onChange={setForkSetting}
options={forkOptions}
width={200}
testId="fork-setting-dropdown"
/>
</div>
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<div className="flex items-center justify-between">
<div> {localize('com_ui_fork_default')} </div>
<Switch
id="rememberForkOption"
checked={remember}
onCheckedChange={setRemember}
className="ml-4 mt-2"
data-testid="rememberForkOption"
/>
</div>
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<div className="flex items-center justify-between">
<div> {localize('com_ui_fork_split_target_setting')} </div>
<Switch
id="splitAtTarget"
checked={splitAtTarget}
onCheckedChange={setSplitAtTarget}
className="ml-4 mt-2"
data-testid="splitAtTarget"
/>
</div>
</div>
</>
);
};

View file

@ -0,0 +1,28 @@
import { memo } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import { SettingsTabValues } from 'librechat-data-provider';
import SendMessageKeyEnter from './EnterToSend';
import ShowCodeSwitch from './ShowCodeSwitch';
import { ForkSettings } from './ForkSettings';
function Messages() {
return (
<Tabs.Content
value={SettingsTabValues.MESSAGES}
role="tabpanel"
className="w-full md:min-h-[300px]"
>
<div className="flex flex-col gap-3 text-sm text-gray-600 dark:text-gray-50">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<SendMessageKeyEnter />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<ShowCodeSwitch />
</div>
<ForkSettings />
</div>
</Tabs.Content>
);
}
export default memo(Messages);

View file

@ -1,4 +1,5 @@
export { default as General } from './General/General';
export { default as Messages } from './Messages/Messages';
export { ClearChatsButton } from './General/General';
export { default as Data } from './Data/Data';
export { default as Beta } from './Beta/Beta';

View file

@ -6,15 +6,20 @@ import { ESide } from '~/common';
type TOptionHoverProps = {
description: string;
langCode?: boolean;
sideOffset?: number;
side: ESide;
};
function OptionHover({ side, description, langCode }: TOptionHoverProps) {
function OptionHover({ side, description, langCode, sideOffset = 30 }: TOptionHoverProps) {
const localize = useLocalize();
const text = langCode ? localize(description) : description;
return (
<HoverCardPortal>
<HoverCardContent side={side} className="z-[999] w-80 dark:bg-gray-700" sideOffset={30}>
<HoverCardContent
side={side}
className="z-[999] w-80 dark:bg-gray-700"
sideOffset={sideOffset}
>
<div className="space-y-2">
<p className="text-sm text-gray-600 dark:text-gray-300">{text}</p>
</div>