🌿 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>

View file

@ -2,44 +2,13 @@ import { LocalStorageKeys } from 'librechat-data-provider';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { UseMutationResult } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
import type {
TFile,
BatchFile,
TFileUpload,
TImportStartResponse,
AssistantListResponse,
UploadMutationOptions,
UploadConversationsMutationOptions,
DeleteFilesResponse,
DeleteFilesBody,
DeleteMutationOptions,
UpdatePresetOptions,
DeletePresetOptions,
PresetDeleteResponse,
LogoutOptions,
TPreset,
UploadAvatarOptions,
AvatarUploadResponse,
TConversation,
Assistant,
AssistantCreateParams,
AssistantUpdateParams,
UploadAssistantAvatarOptions,
AssistantAvatarVariables,
CreateAssistantMutationOptions,
UpdateAssistantMutationOptions,
DeleteAssistantMutationOptions,
DeleteAssistantBody,
DeleteConversationOptions,
UpdateActionOptions,
UpdateActionVariables,
UpdateActionResponse,
DeleteActionOptions,
DeleteActionVariables,
Action,
} from 'librechat-data-provider';
import {
addConversation,
updateConversation,
deleteConversation,
updateConvoFields,
} from '~/utils';
import { dataService, MutationKeys, QueryKeys, defaultOrderQuery } from 'librechat-data-provider';
import { updateConversation, deleteConversation, updateConvoFields } from '~/utils';
import { useSetRecoilState } from 'recoil';
import store from '~/store';
@ -55,7 +24,7 @@ export const useGenTitleMutation = (): UseMutationResult<
onSuccess: (response, vars) => {
queryClient.setQueryData(
[QueryKeys.conversation, vars.conversationId],
(convo: TConversation | undefined) => {
(convo: t.TConversation | undefined) => {
if (!convo) {
return convo;
}
@ -69,7 +38,7 @@ export const useGenTitleMutation = (): UseMutationResult<
return updateConvoFields(convoData, {
conversationId: vars.conversationId,
title: response.title,
} as TConversation);
} as t.TConversation);
});
document.title = response.title;
},
@ -102,7 +71,7 @@ export const useUpdateConversationMutation = (
};
export const useDeleteConversationMutation = (
options?: DeleteConversationOptions,
options?: t.DeleteConversationOptions,
): UseMutationResult<
t.TDeleteConversationResponse,
unknown,
@ -133,9 +102,41 @@ export const useDeleteConversationMutation = (
);
};
export const useUploadConversationsMutation = (_options?: UploadConversationsMutationOptions) => {
export const useForkConvoMutation = (
options?: t.ForkConvoOptions,
): UseMutationResult<t.TForkConvoResponse, unknown, t.TForkConvoRequest, unknown> => {
const queryClient = useQueryClient();
const { onSuccess, onError } = _options || {};
const { onSuccess, ..._options } = options || {};
return useMutation((payload: t.TForkConvoRequest) => dataService.forkConversation(payload), {
onSuccess: (data, vars, context) => {
if (!vars.conversationId) {
return;
}
queryClient.setQueryData(
[QueryKeys.conversation, data.conversation.conversationId],
data.conversation,
);
queryClient.setQueryData<t.ConversationData>([QueryKeys.allConversations], (convoData) => {
if (!convoData) {
return convoData;
}
return addConversation(convoData, data.conversation);
});
queryClient.setQueryData<t.TMessage[]>(
[QueryKeys.messages, data.conversation.conversationId],
data.messages,
);
onSuccess?.(data, vars, context);
},
...(_options || {}),
});
};
export const useUploadConversationsMutation = (
_options?: t.MutationOptions<t.TImportJobStatus, FormData>,
) => {
const queryClient = useQueryClient();
const { onSuccess, onError, onMutate } = _options || {};
// returns the job status or reason of failure
const checkJobStatus = async (jobId) => {
@ -182,7 +183,8 @@ export const useUploadConversationsMutation = (_options?: UploadConversationsMut
}
}, pollInterval);
};
return useMutation<TImportStartResponse, unknown, FormData>({
return useMutation<t.TImportStartResponse, unknown, FormData>({
mutationFn: (formData: FormData) => dataService.importConversationsFile(formData),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries([QueryKeys.allConversations]);
@ -213,13 +215,14 @@ export const useUploadConversationsMutation = (_options?: UploadConversationsMut
onError(err, variables, context);
}
},
onMutate,
});
};
export const useUploadFileMutation = (
_options?: UploadMutationOptions,
_options?: t.UploadMutationOptions,
): UseMutationResult<
TFileUpload, // response data
t.TFileUpload, // response data
unknown, // error
FormData, // request
unknown // context
@ -238,7 +241,7 @@ export const useUploadFileMutation = (
},
...(options || {}),
onSuccess: (data, formData, context) => {
queryClient.setQueryData<TFile[] | undefined>([QueryKeys.files], (_files) => [
queryClient.setQueryData<t.TFile[] | undefined>([QueryKeys.files], (_files) => [
data,
...(_files ?? []),
]);
@ -251,7 +254,7 @@ export const useUploadFileMutation = (
return;
}
queryClient.setQueryData<AssistantListResponse>(
queryClient.setQueryData<t.AssistantListResponse>(
[QueryKeys.assistants, defaultOrderQuery],
(prev) => {
if (!prev) {
@ -278,26 +281,26 @@ export const useUploadFileMutation = (
};
export const useDeleteFilesMutation = (
_options?: DeleteMutationOptions,
_options?: t.DeleteMutationOptions,
): UseMutationResult<
DeleteFilesResponse, // response data
t.DeleteFilesResponse, // response data
unknown, // error
DeleteFilesBody, // request
t.DeleteFilesBody, // request
unknown // context
> => {
const queryClient = useQueryClient();
const { onSuccess, ...options } = _options || {};
return useMutation([MutationKeys.fileDelete], {
mutationFn: (body: DeleteFilesBody) => dataService.deleteFiles(body.files, body.assistant_id),
mutationFn: (body: t.DeleteFilesBody) => dataService.deleteFiles(body.files, body.assistant_id),
...(options || {}),
onSuccess: (data, ...args) => {
queryClient.setQueryData<TFile[] | undefined>([QueryKeys.files], (cachefiles) => {
queryClient.setQueryData<t.TFile[] | undefined>([QueryKeys.files], (cachefiles) => {
const { files: filesDeleted } = args[0];
const fileMap = filesDeleted.reduce((acc, file) => {
acc.set(file.file_id, file);
return acc;
}, new Map<string, BatchFile>());
}, new Map<string, t.BatchFile>());
return (cachefiles ?? []).filter((file) => !fileMap.has(file.file_id));
});
@ -307,36 +310,36 @@ export const useDeleteFilesMutation = (
};
export const useUpdatePresetMutation = (
options?: UpdatePresetOptions,
options?: t.UpdatePresetOptions,
): UseMutationResult<
TPreset, // response data
t.TPreset, // response data
unknown,
TPreset,
t.TPreset,
unknown
> => {
return useMutation([MutationKeys.updatePreset], {
mutationFn: (preset: TPreset) => dataService.updatePreset(preset),
mutationFn: (preset: t.TPreset) => dataService.updatePreset(preset),
...(options || {}),
});
};
export const useDeletePresetMutation = (
options?: DeletePresetOptions,
options?: t.DeletePresetOptions,
): UseMutationResult<
PresetDeleteResponse, // response data
t.PresetDeleteResponse, // response data
unknown,
TPreset | undefined,
t.TPreset | undefined,
unknown
> => {
return useMutation([MutationKeys.deletePreset], {
mutationFn: (preset: TPreset | undefined) => dataService.deletePreset(preset),
mutationFn: (preset: t.TPreset | undefined) => dataService.deletePreset(preset),
...(options || {}),
});
};
/* login/logout */
export const useLogoutUserMutation = (
options?: LogoutOptions,
options?: t.LogoutOptions,
): UseMutationResult<unknown, unknown, undefined, unknown> => {
const queryClient = useQueryClient();
const setDefaultPreset = useSetRecoilState(store.defaultPreset);
@ -362,9 +365,9 @@ export const useLogoutUserMutation = (
/* Avatar upload */
export const useUploadAvatarMutation = (
options?: UploadAvatarOptions,
options?: t.UploadAvatarOptions,
): UseMutationResult<
AvatarUploadResponse, // response data
t.AvatarUploadResponse, // response data
unknown, // error
FormData, // request
unknown // context
@ -383,16 +386,16 @@ export const useUploadAvatarMutation = (
* Create a new assistant
*/
export const useCreateAssistantMutation = (
options?: CreateAssistantMutationOptions,
): UseMutationResult<Assistant, Error, AssistantCreateParams> => {
options?: t.CreateAssistantMutationOptions,
): UseMutationResult<t.Assistant, Error, t.AssistantCreateParams> => {
const queryClient = useQueryClient();
return useMutation(
(newAssistantData: AssistantCreateParams) => dataService.createAssistant(newAssistantData),
(newAssistantData: t.AssistantCreateParams) => dataService.createAssistant(newAssistantData),
{
onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (newAssistant, variables, context) => {
const listRes = queryClient.getQueryData<AssistantListResponse>([
const listRes = queryClient.getQueryData<t.AssistantListResponse>([
QueryKeys.assistants,
defaultOrderQuery,
]);
@ -403,10 +406,13 @@ export const useCreateAssistantMutation = (
const currentAssistants = [newAssistant, ...JSON.parse(JSON.stringify(listRes.data))];
queryClient.setQueryData<AssistantListResponse>([QueryKeys.assistants, defaultOrderQuery], {
...listRes,
data: currentAssistants,
});
queryClient.setQueryData<t.AssistantListResponse>(
[QueryKeys.assistants, defaultOrderQuery],
{
...listRes,
data: currentAssistants,
},
);
return options?.onSuccess?.(newAssistant, variables, context);
},
},
@ -417,17 +423,21 @@ export const useCreateAssistantMutation = (
* Hook for updating an assistant
*/
export const useUpdateAssistantMutation = (
options?: UpdateAssistantMutationOptions,
): UseMutationResult<Assistant, Error, { assistant_id: string; data: AssistantUpdateParams }> => {
options?: t.UpdateAssistantMutationOptions,
): UseMutationResult<
t.Assistant,
Error,
{ assistant_id: string; data: t.AssistantUpdateParams }
> => {
const queryClient = useQueryClient();
return useMutation(
({ assistant_id, data }: { assistant_id: string; data: AssistantUpdateParams }) =>
({ assistant_id, data }: { assistant_id: string; data: t.AssistantUpdateParams }) =>
dataService.updateAssistant(assistant_id, data),
{
onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (updatedAssistant, variables, context) => {
const listRes = queryClient.getQueryData<AssistantListResponse>([
const listRes = queryClient.getQueryData<t.AssistantListResponse>([
QueryKeys.assistants,
defaultOrderQuery,
]);
@ -436,15 +446,18 @@ export const useUpdateAssistantMutation = (
return options?.onSuccess?.(updatedAssistant, variables, context);
}
queryClient.setQueryData<AssistantListResponse>([QueryKeys.assistants, defaultOrderQuery], {
...listRes,
data: listRes.data.map((assistant) => {
if (assistant.id === variables.assistant_id) {
return updatedAssistant;
}
return assistant;
}),
});
queryClient.setQueryData<t.AssistantListResponse>(
[QueryKeys.assistants, defaultOrderQuery],
{
...listRes,
data: listRes.data.map((assistant) => {
if (assistant.id === variables.assistant_id) {
return updatedAssistant;
}
return assistant;
}),
},
);
return options?.onSuccess?.(updatedAssistant, variables, context);
},
},
@ -455,17 +468,17 @@ export const useUpdateAssistantMutation = (
* Hook for deleting an assistant
*/
export const useDeleteAssistantMutation = (
options?: DeleteAssistantMutationOptions,
): UseMutationResult<void, Error, DeleteAssistantBody> => {
options?: t.DeleteAssistantMutationOptions,
): UseMutationResult<void, Error, t.DeleteAssistantBody> => {
const queryClient = useQueryClient();
return useMutation(
({ assistant_id, model }: DeleteAssistantBody) =>
({ assistant_id, model }: t.DeleteAssistantBody) =>
dataService.deleteAssistant(assistant_id, model),
{
onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (_data, variables, context) => {
const listRes = queryClient.getQueryData<AssistantListResponse>([
const listRes = queryClient.getQueryData<t.AssistantListResponse>([
QueryKeys.assistants,
defaultOrderQuery,
]);
@ -476,10 +489,13 @@ export const useDeleteAssistantMutation = (
const data = listRes.data.filter((assistant) => assistant.id !== variables.assistant_id);
queryClient.setQueryData<AssistantListResponse>([QueryKeys.assistants, defaultOrderQuery], {
...listRes,
data,
});
queryClient.setQueryData<t.AssistantListResponse>(
[QueryKeys.assistants, defaultOrderQuery],
{
...listRes,
data,
},
);
return options?.onSuccess?.(_data, variables, data);
},
@ -491,16 +507,16 @@ export const useDeleteAssistantMutation = (
* Hook for uploading an assistant avatar
*/
export const useUploadAssistantAvatarMutation = (
options?: UploadAssistantAvatarOptions,
options?: t.UploadAssistantAvatarOptions,
): UseMutationResult<
Assistant, // response data
t.Assistant, // response data
unknown, // error
AssistantAvatarVariables, // request
t.AssistantAvatarVariables, // request
unknown // context
> => {
return useMutation([MutationKeys.assistantAvatarUpload], {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
mutationFn: ({ postCreation, ...variables }: AssistantAvatarVariables) =>
mutationFn: ({ postCreation, ...variables }: t.AssistantAvatarVariables) =>
dataService.uploadAssistantAvatar(variables),
...(options || {}),
});
@ -510,21 +526,21 @@ export const useUploadAssistantAvatarMutation = (
* Hook for updating Assistant Actions
*/
export const useUpdateAction = (
options?: UpdateActionOptions,
options?: t.UpdateActionOptions,
): UseMutationResult<
UpdateActionResponse, // response data
t.UpdateActionResponse, // response data
unknown, // error
UpdateActionVariables, // request
t.UpdateActionVariables, // request
unknown // context
> => {
const queryClient = useQueryClient();
return useMutation([MutationKeys.updateAction], {
mutationFn: (variables: UpdateActionVariables) => dataService.updateAction(variables),
mutationFn: (variables: t.UpdateActionVariables) => dataService.updateAction(variables),
onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (updateActionResponse, variables, context) => {
const listRes = queryClient.getQueryData<AssistantListResponse>([
const listRes = queryClient.getQueryData<t.AssistantListResponse>([
QueryKeys.assistants,
defaultOrderQuery,
]);
@ -535,7 +551,7 @@ export const useUpdateAction = (
const updatedAssistant = updateActionResponse[1];
queryClient.setQueryData<AssistantListResponse>([QueryKeys.assistants, defaultOrderQuery], {
queryClient.setQueryData<t.AssistantListResponse>([QueryKeys.assistants, defaultOrderQuery], {
...listRes,
data: listRes.data.map((assistant) => {
if (assistant.id === variables.assistant_id) {
@ -545,7 +561,7 @@ export const useUpdateAction = (
}),
});
queryClient.setQueryData<Action[]>([QueryKeys.actions], (prev) => {
queryClient.setQueryData<t.Action[]>([QueryKeys.actions], (prev) => {
return prev
?.map((action) => {
if (action.action_id === variables.action_id) {
@ -565,30 +581,30 @@ export const useUpdateAction = (
* Hook for deleting an Assistant Action
*/
export const useDeleteAction = (
options?: DeleteActionOptions,
options?: t.DeleteActionOptions,
): UseMutationResult<
void, // response data for a delete operation is typically void
Error, // error type
DeleteActionVariables, // request variables
t.DeleteActionVariables, // request variables
unknown // context
> => {
const queryClient = useQueryClient();
return useMutation([MutationKeys.deleteAction], {
mutationFn: (variables: DeleteActionVariables) =>
mutationFn: (variables: t.DeleteActionVariables) =>
dataService.deleteAction(variables.assistant_id, variables.action_id, variables.model),
onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (_data, variables, context) => {
let domain: string | undefined = '';
queryClient.setQueryData<Action[]>([QueryKeys.actions], (prev) => {
queryClient.setQueryData<t.Action[]>([QueryKeys.actions], (prev) => {
return prev?.filter((action) => {
domain = action.metadata.domain;
return action.action_id !== variables.action_id;
});
});
queryClient.setQueryData<AssistantListResponse>(
queryClient.setQueryData<t.AssistantListResponse>(
[QueryKeys.assistants, defaultOrderQuery],
(prev) => {
if (!prev) {

View file

@ -58,7 +58,10 @@ export default function useGenerationsByLatest({
!branchingSupported ||
(!isEditableEndpoint && !isCreatedByUser);
const forkingSupported = endpoint !== EModelEndpoint.assistants && !searchResult;
return {
forkingSupported,
continueSupported,
regenerateEnabled,
hideEditButton,

View file

@ -125,6 +125,35 @@ export default {
com_user_message: 'You',
com_ui_copy_to_clipboard: 'Copy to clipboard',
com_ui_copied_to_clipboard: 'Copied to clipboard',
com_ui_fork_info_1: 'Use this setting to fork messages with the desired behavior.',
com_ui_fork_info_2:
'"Forking" refers to creating a new conversation that start/end from specific messages in the current conversation, creating a copy according to the options selected.',
com_ui_fork_info_3:
'The "target message" refers to either the message this popup was opened from, or, if you check "{0}", the latest message in the conversation.',
com_ui_fork_info_visible:
'This option forks only the visible messages; in other words, the direct path to the target message, without any branches.',
com_ui_fork_info_branches:
'This option forks the visible messages, along with related branches; in other words, the direct path to the target message, including branches along the path.',
com_ui_fork_info_target:
'This option forks all messages leading up to the target message, including its neighbors; in other words, all message branches, whether or not they are visible or along the same path, are included.',
com_ui_fork_info_start:
'If checked, forking will commence from this message to the latest message in the conversation, according to the behavior selected above.',
com_ui_fork_info_remember:
'Check this to remember the options you select for future usage, making it quicker to fork conversations as preferred.',
com_ui_fork_success: 'Successfully forked conversation',
com_ui_fork_processing: 'Forking conversation...',
com_ui_fork_error: 'There was an error forking the conversation',
com_ui_fork_change_default: 'Change default fork option',
com_ui_fork_default: 'Use default fork option',
com_ui_fork_remember: 'Remember',
com_ui_fork_split_target_setting: 'Start fork from target message by default',
com_ui_fork_split_target: 'Start fork here',
com_ui_fork_remember_checked:
'Your selection will be remembered after usage. Change this at any time in the settings.',
com_ui_fork_all_target: 'Include all to/from here',
com_ui_fork_branches: 'Include related branches',
com_ui_fork_visible: 'Visible messages only',
com_ui_fork_from_message: 'Select a fork option',
com_ui_regenerate: 'Regenerate',
com_ui_continue: 'Continue',
com_ui_edit: 'Edit',
@ -232,6 +261,7 @@ export default {
'WARNING: Misuse of this feature can get you BANNED from using Bing! Click on \'System Message\' for full instructions and the default message if omitted, which is the \'Sydney\' preset that is considered safe.',
com_endpoint_system_message: 'System Message',
com_endpoint_message: 'Message',
com_endpoint_messages: 'Messages',
com_endpoint_message_not_appendable: 'Edit your message or Regenerate.',
com_endpoint_default_blank: 'default: blank',
com_endpoint_default_false: 'default: false',

View file

@ -1,5 +1,5 @@
import { atom } from 'recoil';
import { SettingsViews } from 'librechat-data-provider';
import { SettingsViews, LocalStorageKeys } from 'librechat-data-provider';
import type { TOptionSettings } from '~/common';
const abortScroll = atom<boolean>({
@ -137,6 +137,63 @@ const LaTeXParsing = atom<boolean>({
] as const,
});
const forkSetting = atom<string>({
key: LocalStorageKeys.FORK_SETTING,
default: '',
effects: [
({ setSelf, onSet }) => {
const savedValue = localStorage.getItem(LocalStorageKeys.FORK_SETTING);
if (savedValue != null) {
setSelf(savedValue);
}
onSet((newValue: unknown) => {
if (typeof newValue === 'string') {
localStorage.setItem(LocalStorageKeys.FORK_SETTING, newValue.toString());
}
});
},
] as const,
});
const rememberForkOption = atom<boolean>({
key: LocalStorageKeys.REMEMBER_FORK_OPTION,
default: false,
effects: [
({ setSelf, onSet }) => {
const savedValue = localStorage.getItem(LocalStorageKeys.REMEMBER_FORK_OPTION);
if (savedValue != null) {
setSelf(savedValue === 'true');
}
onSet((newValue: unknown) => {
if (typeof newValue === 'boolean') {
localStorage.setItem(LocalStorageKeys.REMEMBER_FORK_OPTION, newValue.toString());
}
});
},
] as const,
});
const splitAtTarget = atom<boolean>({
key: LocalStorageKeys.FORK_SPLIT_AT_TARGET,
default: false,
effects: [
({ setSelf, onSet }) => {
const savedValue = localStorage.getItem(LocalStorageKeys.FORK_SPLIT_AT_TARGET);
if (savedValue != null) {
setSelf(savedValue === 'true');
}
onSet((newValue: unknown) => {
if (typeof newValue === 'boolean') {
localStorage.setItem(LocalStorageKeys.FORK_SPLIT_AT_TARGET, newValue.toString());
}
});
},
] as const,
});
const UsernameDisplay = atom<boolean>({
key: 'UsernameDisplay',
default: localStorage.getItem('UsernameDisplay') === 'true',
@ -191,4 +248,7 @@ export default {
modularChat,
LaTeXParsing,
UsernameDisplay,
forkSetting,
splitAtTarget,
rememberForkOption,
};