feat: delete all threads (#4446)

* add delete all threads

* add testcase

* add testcase

* fix lint

* fix linter

* fix linter

* change position Delete All Threads
This commit is contained in:
Doan Bui 2025-01-15 11:00:31 +07:00 committed by GitHub
parent 154e9cc7fc
commit ffec1cfde3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 184 additions and 3 deletions

View File

@ -11,6 +11,7 @@ import { ModelParams } from '@/types/model'
export enum ThreadModalAction { export enum ThreadModalAction {
Clean = 'clean', Clean = 'clean',
Delete = 'delete', Delete = 'delete',
DeleteAll = 'deleteAll',
EditTitle = 'edit-title', EditTitle = 'edit-title',
} }
@ -272,7 +273,7 @@ export const activeSettingInputBoxAtom = atomWithStorage<boolean>(
) )
/** /**
* Whether thread thread is presenting a Modal or not * Whether thread is presenting a Modal or not
*/ */
export const modalActionThreadAtom = atom<{ export const modalActionThreadAtom = atom<{
showModal: ThreadModalAction | undefined showModal: ThreadModalAction | undefined

View File

@ -7,6 +7,10 @@ import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import useDeleteThread from './useDeleteThread' import useDeleteThread from './useDeleteThread'
import { extensionManager } from '@/extension/ExtensionManager' import { extensionManager } from '@/extension/ExtensionManager'
import { useCreateNewThread } from './useCreateNewThread' 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 the necessary dependencies
// Mock dependencies // Mock dependencies
jest.mock('jotai', () => ({ jest.mock('jotai', () => ({
@ -117,4 +121,44 @@ describe('useDeleteThread', () => {
consoleErrorSpy.mockRestore() 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('')
})
}) })

View File

@ -1,6 +1,6 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { ExtensionTypeEnum, ConversationalExtension } from '@janhq/core' import { ExtensionTypeEnum, ConversationalExtension, Thread } from '@janhq/core'
import { useAtom, useSetAtom } from 'jotai' 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<ConversationalExtension>(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 { return {
cleanThread, cleanThread,
deleteThread, deleteThread,
deleteAllThreads,
} }
} }

View File

@ -140,4 +140,13 @@ describe('Advanced', () => {
expect(screen.getByTestId(/reset-button/i)).toBeInTheDocument() expect(screen.getByTestId(/reset-button/i)).toBeInTheDocument()
}) })
}) })
it('renders DeleteAllThreads component', async () => {
render(<Advanced />)
await waitFor(() => {
const elements = screen.getAllByText('Delete All Threads')
expect(elements.length).toBeGreaterThan(0)
expect(screen.getByTestId('delete-all-threads-button')).toBeInTheDocument()
})
})
}) })

View File

@ -11,9 +11,10 @@ import {
Tooltip, Tooltip,
Checkbox, Checkbox,
useClickOutside, useClickOutside,
Button,
} from '@janhq/joi' } from '@janhq/joi'
import { useAtom, useAtomValue } from 'jotai' import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { ChevronDownIcon } from 'lucide-react' import { ChevronDownIcon } from 'lucide-react'
import { AlertTriangleIcon, AlertCircleIcon } from 'lucide-react' import { AlertTriangleIcon, AlertCircleIcon } from 'lucide-react'
@ -27,6 +28,8 @@ import { useActiveModel } from '@/hooks/useActiveModel'
import { useConfigurations } from '@/hooks/useConfigurations' import { useConfigurations } from '@/hooks/useConfigurations'
import { useSettings } from '@/hooks/useSettings' import { useSettings } from '@/hooks/useSettings'
import ModalDeleteAllThreads from '@/screens/Thread/ThreadLeftPanel/ModalDeleteAllThreads'
import DataFolder from './DataFolder' import DataFolder from './DataFolder'
import FactoryReset from './FactoryReset' import FactoryReset from './FactoryReset'
@ -39,6 +42,10 @@ import {
quickAskEnabledAtom, quickAskEnabledAtom,
} from '@/helpers/atoms/AppConfig.atom' } from '@/helpers/atoms/AppConfig.atom'
import { ThreadModalAction } from '@/helpers/atoms/Thread.atom'
import { modalActionThreadAtom } from '@/helpers/atoms/Thread.atom'
type GPU = { type GPU = {
id: string id: string
vram: number | null vram: number | null
@ -74,6 +81,7 @@ const Advanced = () => {
const { readSettings, saveSettings } = useSettings() const { readSettings, saveSettings } = useSettings()
const { stopModel } = useActiveModel() const { stopModel } = useActiveModel()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const setModalActionThread = useSetAtom(modalActionThreadAtom)
const selectedGpu = gpuList const selectedGpu = gpuList
.filter((x) => gpusInUse.includes(x.id)) .filter((x) => gpusInUse.includes(x.id))
@ -523,6 +531,31 @@ const Advanced = () => {
</div> </div>
)} )}
{/* Delete All Threads */}
<div className="flex w-full flex-col items-start justify-between gap-4 border-b border-[hsla(var(--app-border))] py-4 first:pt-0 last:border-none sm:flex-row">
<div className="space-y-1">
<div className="flex gap-x-2">
<h6 className="font-semibold capitalize">Delete All Threads</h6>
</div>
<p className="whitespace-pre-wrap font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
Delete all threads and associated chat history.
</p>
</div>
<Button
data-testid="delete-all-threads-button"
theme="destructive"
onClick={() => {
setModalActionThread({
showModal: ThreadModalAction.DeleteAll,
thread: undefined,
})
}}
>
Delete All Threads
</Button>
</div>
<ModalDeleteAllThreads />
{/* Factory Reset */} {/* Factory Reset */}
<FactoryReset /> <FactoryReset />
</div> </div>

View File

@ -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<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation()
deleteAllThreads(threads)
},
[deleteAllThreads, threads]
)
const onCloseModal = useCallback(() => {
setModalActionThread({
showModal: undefined,
thread: undefined,
})
}, [setModalActionThread])
return (
<Modal
title="Delete All Threads"
onOpenChange={onCloseModal}
open={modalActionThread.showModal === ThreadModalAction.DeleteAll}
content={
<div>
<p className="text-[hsla(var(--text-secondary))]">
Are you sure you want to delete all chat history? This will remove{' '}
all {threads.length} conversation threads in{' '}
<span className="font-mono">{janDataFolderPath}\threads</span> and
cannot be undone.
</p>
<div className="mt-4 flex justify-end gap-x-2">
<ModalClose asChild onClick={(e) => e.stopPropagation()}>
<Button theme="ghost">Cancel</Button>
</ModalClose>
<ModalClose asChild>
<Button
autoFocus
theme="destructive"
onClick={onDeleteAllThreads}
>
Delete
</Button>
</ModalClose>
</div>
</div>
}
/>
)
}
export default memo(ModalDeleteAllThreads)