diff --git a/web/helpers/atoms/Thread.atom.ts b/web/helpers/atoms/Thread.atom.ts index 6704f8e57..474dadeba 100644 --- a/web/helpers/atoms/Thread.atom.ts +++ b/web/helpers/atoms/Thread.atom.ts @@ -11,6 +11,7 @@ import { ModelParams } from '@/types/model' export enum ThreadModalAction { Clean = 'clean', Delete = 'delete', + DeleteAll = 'deleteAll', EditTitle = 'edit-title', } @@ -272,7 +273,7 @@ export const activeSettingInputBoxAtom = atomWithStorage( ) /** - * Whether thread thread is presenting a Modal or not + * Whether thread is presenting a Modal or not */ export const modalActionThreadAtom = atom<{ showModal: ThreadModalAction | undefined diff --git a/web/hooks/useDeleteThread.test.ts b/web/hooks/useDeleteThread.test.ts index 4a1b4e5a2..8d616cb42 100644 --- a/web/hooks/useDeleteThread.test.ts +++ b/web/hooks/useDeleteThread.test.ts @@ -7,6 +7,10 @@ import { useAtom, useAtomValue, useSetAtom } from 'jotai' import useDeleteThread from './useDeleteThread' import { extensionManager } from '@/extension/ExtensionManager' import { useCreateNewThread } from './useCreateNewThread' +import { Thread } from '@janhq/core/dist/types/types' +import { currentPromptAtom } from '@/containers/Providers/Jotai' +import { setActiveThreadIdAtom, deleteThreadStateAtom } from '@/helpers/atoms/Thread.atom' +import { deleteChatMessageAtom as deleteChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' // Mock the necessary dependencies // Mock dependencies jest.mock('jotai', () => ({ @@ -117,4 +121,44 @@ describe('useDeleteThread', () => { consoleErrorSpy.mockRestore() }) + + it('should delete all threads successfully', async () => { + const mockThreads = [ + { id: 'thread1', title: 'Thread 1' }, + { id: 'thread2', title: 'Thread 2' }, + ] + const mockSetThreads = jest.fn() + ;(useAtom as jest.Mock).mockReturnValue([mockThreads, mockSetThreads]) + + // create mock functions + const mockSetCurrentPrompt = jest.fn() + + // mock useSetAtom for each atom + let currentAtom: any + ;(useSetAtom as jest.Mock).mockImplementation((atom) => { + currentAtom = atom + if (currentAtom === currentPromptAtom) return mockSetCurrentPrompt + return jest.fn() + }) + + const mockDeleteThread = jest.fn().mockImplementation(() => ({ + catch: () => jest.fn, + })) + + extensionManager.get = jest.fn().mockReturnValue({ + deleteThread: mockDeleteThread, + }) + + const { result } = renderHook(() => useDeleteThread()) + + await act(async () => { + await result.current.deleteAllThreads(mockThreads as Thread[]) + }) + + expect(mockDeleteThread).toHaveBeenCalledTimes(2) + expect(mockDeleteThread).toHaveBeenCalledWith('thread1') + expect(mockDeleteThread).toHaveBeenCalledWith('thread2') + expect(mockSetThreads).toHaveBeenCalledWith([]) + expect(mockSetCurrentPrompt).toHaveBeenCalledWith('') + }) }) diff --git a/web/hooks/useDeleteThread.ts b/web/hooks/useDeleteThread.ts index 29b509631..59aa3a83b 100644 --- a/web/hooks/useDeleteThread.ts +++ b/web/hooks/useDeleteThread.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react' -import { ExtensionTypeEnum, ConversationalExtension } from '@janhq/core' +import { ExtensionTypeEnum, ConversationalExtension, Thread } from '@janhq/core' import { useAtom, useSetAtom } from 'jotai' @@ -96,8 +96,29 @@ export default function useDeleteThread() { } } + const deleteAllThreads = async (threads: Thread[]) => { + for (const thread of threads) { + await extensionManager + .get(ExtensionTypeEnum.Conversational) + ?.deleteThread(thread.id as string) + .catch(console.error) + deleteThreadState(thread.id as string) + deleteMessages(thread.id as string) + } + + setThreads([]) + setCurrentPrompt('') + setActiveThreadId(undefined) + toaster({ + title: 'All threads successfully deleted.', + description: `All thread data has been successfully deleted.`, + type: 'success', + }) + } + return { cleanThread, deleteThread, + deleteAllThreads, } } diff --git a/web/screens/Settings/Advanced/index.test.tsx b/web/screens/Settings/Advanced/index.test.tsx index 6141fb44c..7a0f3ade5 100644 --- a/web/screens/Settings/Advanced/index.test.tsx +++ b/web/screens/Settings/Advanced/index.test.tsx @@ -140,4 +140,13 @@ describe('Advanced', () => { expect(screen.getByTestId(/reset-button/i)).toBeInTheDocument() }) }) + + it('renders DeleteAllThreads component', async () => { + render() + await waitFor(() => { + const elements = screen.getAllByText('Delete All Threads') + expect(elements.length).toBeGreaterThan(0) + expect(screen.getByTestId('delete-all-threads-button')).toBeInTheDocument() + }) + }) }) diff --git a/web/screens/Settings/Advanced/index.tsx b/web/screens/Settings/Advanced/index.tsx index 3dbb56a86..a8c6fb02d 100644 --- a/web/screens/Settings/Advanced/index.tsx +++ b/web/screens/Settings/Advanced/index.tsx @@ -11,9 +11,10 @@ import { Tooltip, Checkbox, useClickOutside, + Button, } from '@janhq/joi' -import { useAtom, useAtomValue } from 'jotai' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { ChevronDownIcon } from 'lucide-react' import { AlertTriangleIcon, AlertCircleIcon } from 'lucide-react' @@ -27,6 +28,8 @@ import { useActiveModel } from '@/hooks/useActiveModel' import { useConfigurations } from '@/hooks/useConfigurations' import { useSettings } from '@/hooks/useSettings' +import ModalDeleteAllThreads from '@/screens/Thread/ThreadLeftPanel/ModalDeleteAllThreads' + import DataFolder from './DataFolder' import FactoryReset from './FactoryReset' @@ -39,6 +42,10 @@ import { quickAskEnabledAtom, } from '@/helpers/atoms/AppConfig.atom' +import { ThreadModalAction } from '@/helpers/atoms/Thread.atom' + +import { modalActionThreadAtom } from '@/helpers/atoms/Thread.atom' + type GPU = { id: string vram: number | null @@ -74,6 +81,7 @@ const Advanced = () => { const { readSettings, saveSettings } = useSettings() const { stopModel } = useActiveModel() const [open, setOpen] = useState(false) + const setModalActionThread = useSetAtom(modalActionThreadAtom) const selectedGpu = gpuList .filter((x) => gpusInUse.includes(x.id)) @@ -523,6 +531,31 @@ const Advanced = () => { )} + {/* Delete All Threads */} +
+
+
+
Delete All Threads
+
+

+ Delete all threads and associated chat history. +

+
+ +
+ + {/* Factory Reset */} diff --git a/web/screens/Thread/ThreadLeftPanel/ModalDeleteAllThreads/index.tsx b/web/screens/Thread/ThreadLeftPanel/ModalDeleteAllThreads/index.tsx new file mode 100644 index 000000000..c06dfc43a --- /dev/null +++ b/web/screens/Thread/ThreadLeftPanel/ModalDeleteAllThreads/index.tsx @@ -0,0 +1,73 @@ +import { useCallback, memo } from 'react' + +import { Modal, ModalClose, Button } from '@janhq/joi' + +import { useAtom, useAtomValue } from 'jotai' + +import useDeleteThread from '@/hooks/useDeleteThread' + +import { janDataFolderPathAtom } from '@/helpers/atoms/AppConfig.atom' + +import { + modalActionThreadAtom, + ThreadModalAction, + threadsAtom, +} from '@/helpers/atoms/Thread.atom' + +const ModalDeleteAllThreads = () => { + const { deleteAllThreads } = useDeleteThread() + const [modalActionThread, setModalActionThread] = useAtom( + modalActionThreadAtom + ) + const [threads] = useAtom(threadsAtom) + const janDataFolderPath = useAtomValue(janDataFolderPathAtom) + + const onDeleteAllThreads = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + deleteAllThreads(threads) + }, + [deleteAllThreads, threads] + ) + + const onCloseModal = useCallback(() => { + setModalActionThread({ + showModal: undefined, + thread: undefined, + }) + }, [setModalActionThread]) + + return ( + +

+ Are you sure you want to delete all chat history? This will remove{' '} + all {threads.length} conversation threads in{' '} + {janDataFolderPath}\threads and + cannot be undone. +

+
+ e.stopPropagation()}> + + + + + +
+ + } + /> + ) +} + +export default memo(ModalDeleteAllThreads)