mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-20 10:20:15 +01:00
🌿 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:
parent
c8baceac76
commit
25fceb78b7
37 changed files with 1831 additions and 523 deletions
29
client/src/components/Nav/SettingsTabs/Data/ClearChats.tsx
Normal file
29
client/src/components/Nav/SettingsTabs/Data/ClearChats.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue