🌿 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

@ -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';