🌿 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

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

@ -0,0 +1,47 @@
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 './ClearChats';
import { RecoilRoot } from 'recoil';
describe('ClearChatsButton', () => {
let mockOnClick;
beforeEach(() => {
mockOnClick = jest.fn();
});
it('renders correctly', () => {
const { getByText } = render(
<RecoilRoot>
<ClearChatsButton confirmClear={false} showText={true} onClick={mockOnClick} />
</RecoilRoot>,
);
expect(getByText('Clear all chats')).toBeInTheDocument();
expect(getByText('Clear')).toBeInTheDocument();
});
it('renders confirm clear when confirmClear is true', () => {
const { getByText } = render(
<RecoilRoot>
<ClearChatsButton confirmClear={true} showText={true} onClick={mockOnClick} />
</RecoilRoot>,
);
expect(getByText('Confirm Clear')).toBeInTheDocument();
});
it('calls onClick when the button is clicked', () => {
const { getByText } = render(
<RecoilRoot>
<ClearChatsButton confirmClear={false} showText={true} onClick={mockOnClick} />
</RecoilRoot>,
);
fireEvent.click(getByText('Clear'));
expect(mockOnClick).toHaveBeenCalled();
});
});

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')}