From 87e1754e3af92a48cb6e41fb547da20cccec821c Mon Sep 17 00:00:00 2001 From: Louis Date: Tue, 1 Oct 2024 10:15:30 +0700 Subject: [PATCH] chore: improve models and threads caching (#3744) * chore: managing and maintaining models and threads in the cache * test: add tests for hooks --- web/containers/Layout/RibbonPanel/index.tsx | 6 +- web/containers/ModelDropdown/index.tsx | 10 +- web/helpers/atoms/BottomPanel.atom.ts | 0 web/helpers/atoms/Extension.atom.test.ts | 78 ++++++++ web/helpers/atoms/Model.atom.test.ts | 7 +- web/helpers/atoms/Model.atom.ts | 95 ++++++++-- web/helpers/atoms/SystemBar.atom.test.ts | 146 +++++++++++++++ web/helpers/atoms/Thread.atom.test.ts | 187 +++++++++++++++++++ web/helpers/atoms/Thread.atom.ts | 157 +++++++++++----- web/hooks/useAssistant.test.ts | 95 ++++++++++ web/hooks/useClipboard.test.ts | 105 +++++++++++ web/hooks/useDeleteModel.test.ts | 73 ++++++++ web/hooks/useDeleteThread.test.ts | 106 +++++++++++ web/hooks/useDownloadModel.test.ts | 98 ++++++++++ web/hooks/useDropModelBinaries.test.ts | 129 +++++++++++++ web/hooks/useFactoryReset.test.ts | 89 +++++++++ web/hooks/useGetHFRepoData.test.ts | 39 ++++ web/hooks/useGetSystemResources.test.ts | 103 +++++++++++ web/hooks/useGpuSetting.test.ts | 87 +++++++++ web/hooks/useImportModel.test.ts | 70 +++++++ web/hooks/useLoadTheme.test.ts | 111 +++++++++++ web/hooks/useLogs.test.ts | 103 +++++++++++ web/hooks/useModels.test.ts | 61 +++++++ web/hooks/useModels.ts | 9 + web/hooks/useSetActiveThread.ts | 2 +- web/hooks/useThread.test.ts | 192 ++++++++++++++++++++ web/hooks/useThreads.ts | 2 +- web/hooks/useUpdateModelParameters.ts | 2 +- web/types/model.d.ts | 4 + web/utils/modelParam.ts | 2 +- 30 files changed, 2084 insertions(+), 84 deletions(-) delete mode 100644 web/helpers/atoms/BottomPanel.atom.ts create mode 100644 web/helpers/atoms/Extension.atom.test.ts create mode 100644 web/helpers/atoms/SystemBar.atom.test.ts create mode 100644 web/helpers/atoms/Thread.atom.test.ts create mode 100644 web/hooks/useAssistant.test.ts create mode 100644 web/hooks/useClipboard.test.ts create mode 100644 web/hooks/useDeleteModel.test.ts create mode 100644 web/hooks/useDeleteThread.test.ts create mode 100644 web/hooks/useDownloadModel.test.ts create mode 100644 web/hooks/useDropModelBinaries.test.ts create mode 100644 web/hooks/useFactoryReset.test.ts create mode 100644 web/hooks/useGetHFRepoData.test.ts create mode 100644 web/hooks/useGetSystemResources.test.ts create mode 100644 web/hooks/useGpuSetting.test.ts create mode 100644 web/hooks/useImportModel.test.ts create mode 100644 web/hooks/useLoadTheme.test.ts create mode 100644 web/hooks/useLogs.test.ts create mode 100644 web/hooks/useModels.test.ts create mode 100644 web/hooks/useThread.test.ts create mode 100644 web/types/model.d.ts diff --git a/web/containers/Layout/RibbonPanel/index.tsx b/web/containers/Layout/RibbonPanel/index.tsx index 7613584e0e..2eb1bad70f 100644 --- a/web/containers/Layout/RibbonPanel/index.tsx +++ b/web/containers/Layout/RibbonPanel/index.tsx @@ -16,14 +16,12 @@ import { mainViewStateAtom, showLeftPanelAtom } from '@/helpers/atoms/App.atom' import { editMessageAtom } from '@/helpers/atoms/ChatMessage.atom' import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' +import { isDownloadALocalModelAtom } from '@/helpers/atoms/Model.atom' import { reduceTransparentAtom, selectedSettingAtom, } from '@/helpers/atoms/Setting.atom' -import { - isDownloadALocalModelAtom, - threadsAtom, -} from '@/helpers/atoms/Thread.atom' +import { threadsAtom } from '@/helpers/atoms/Thread.atom' export default function RibbonPanel() { const [mainViewState, setMainViewState] = useAtom(mainViewStateAtom) diff --git a/web/containers/ModelDropdown/index.tsx b/web/containers/ModelDropdown/index.tsx index 9ebcf4fa2b..192c181312 100644 --- a/web/containers/ModelDropdown/index.tsx +++ b/web/containers/ModelDropdown/index.tsx @@ -513,7 +513,7 @@ const ModelDropdown = ({ const isDownloading = downloadingModels.some( (md) => md.id === model.id ) - const isdDownloaded = downloadedModels.some( + const isDownloaded = downloadedModels.some( (c) => c.id === model.id ) return ( @@ -528,7 +528,7 @@ const ModelDropdown = ({ onClick={() => { if (!apiKey && !isLocalEngine(model.engine)) return null - if (isdDownloaded) { + if (isDownloaded) { onClickModelItem(model.id) } }} @@ -537,7 +537,7 @@ const ModelDropdown = ({

- {!isdDownloaded && ( + {!isDownloaded && ( {toGibibytes(model.metadata.size)} )} - {!isDownloading && !isdDownloaded ? ( + {!isDownloading && !isDownloaded ? ( { + afterEach(() => { + jest.clearAllMocks() + }) + + describe('installingExtensionAtom', () => { + it('should initialize as an empty array', () => { + const { result } = renderHook(() => useAtomValue(ExtensionAtoms.installingExtensionAtom)) + expect(result.current).toEqual([]) + }) + }) + + describe('setInstallingExtensionAtom', () => { + it('should add a new installing extension', () => { + const { result: setAtom } = renderHook(() => useSetAtom(ExtensionAtoms.setInstallingExtensionAtom)) + const { result: getAtom } = renderHook(() => useAtomValue(ExtensionAtoms.installingExtensionAtom)) + + act(() => { + setAtom.current('ext1', { extensionId: 'ext1', percentage: 0 }) + }) + + expect(getAtom.current).toEqual([{ extensionId: 'ext1', percentage: 0 }]) + }) + + it('should update an existing installing extension', () => { + const { result: setAtom } = renderHook(() => useSetAtom(ExtensionAtoms.setInstallingExtensionAtom)) + const { result: getAtom } = renderHook(() => useAtomValue(ExtensionAtoms.installingExtensionAtom)) + + act(() => { + setAtom.current('ext1', { extensionId: 'ext1', percentage: 0 }) + setAtom.current('ext1', { extensionId: 'ext1', percentage: 50 }) + }) + + expect(getAtom.current).toEqual([{ extensionId: 'ext1', percentage: 50 }]) + }) + }) + + describe('removeInstallingExtensionAtom', () => { + it('should remove an installing extension', () => { + const { result: setAtom } = renderHook(() => useSetAtom(ExtensionAtoms.setInstallingExtensionAtom)) + const { result: removeAtom } = renderHook(() => useSetAtom(ExtensionAtoms.removeInstallingExtensionAtom)) + const { result: getAtom } = renderHook(() => useAtomValue(ExtensionAtoms.installingExtensionAtom)) + + act(() => { + setAtom.current('ext1', { extensionId: 'ext1', percentage: 0 }) + setAtom.current('ext2', { extensionId: 'ext2', percentage: 50 }) + removeAtom.current('ext1') + }) + + expect(getAtom.current).toEqual([{ extensionId: 'ext2', percentage: 50 }]) + }) + }) + + describe('inActiveEngineProviderAtom', () => { + it('should initialize as an empty array', () => { + const { result } = renderHook(() => useAtomValue(ExtensionAtoms.inActiveEngineProviderAtom)) + expect(result.current).toEqual([]) + }) + + it('should persist value in storage', () => { + const { result } = renderHook(() => useAtom(ExtensionAtoms.inActiveEngineProviderAtom)) + + act(() => { + result.current[1](['provider1', 'provider2']) + }) + + // Simulate a re-render to check if the value persists + const { result: newResult } = renderHook(() => useAtomValue(ExtensionAtoms.inActiveEngineProviderAtom)) + expect(newResult.current).toEqual(['provider1', 'provider2']) + }) + }) +}) diff --git a/web/helpers/atoms/Model.atom.test.ts b/web/helpers/atoms/Model.atom.test.ts index 4ab02cad9b..57827efec1 100644 --- a/web/helpers/atoms/Model.atom.test.ts +++ b/web/helpers/atoms/Model.atom.test.ts @@ -1,4 +1,4 @@ -import { act, renderHook, waitFor } from '@testing-library/react' +import { act, renderHook } from '@testing-library/react' import * as ModelAtoms from './Model.atom' import { useAtom, useAtomValue, useSetAtom } from 'jotai' @@ -24,11 +24,6 @@ describe('Model.atom.ts', () => { }) }) }) - describe('activeAssistantModelAtom', () => { - it('should initialize as undefined', () => { - expect(ModelAtoms.activeAssistantModelAtom.init).toBeUndefined() - }) - }) describe('selectedModelAtom', () => { it('should initialize as undefined', () => { diff --git a/web/helpers/atoms/Model.atom.ts b/web/helpers/atoms/Model.atom.ts index c817ee74b1..6abc42c9e4 100644 --- a/web/helpers/atoms/Model.atom.ts +++ b/web/helpers/atoms/Model.atom.ts @@ -1,8 +1,59 @@ import { ImportingModel, InferenceEngine, Model, ModelFile } from '@janhq/core' import { atom } from 'jotai' +import { atomWithStorage } from 'jotai/utils' + +/** + * Enum for the keys used to store models in the local storage. + */ +enum ModelStorageAtomKeys { + DownloadedModels = 'downloadedModels', + AvailableModels = 'availableModels', +} +//// Models Atom +/** + * Downloaded Models Atom + * This atom stores the list of models that have been downloaded. + */ +export const downloadedModelsAtom = atomWithStorage( + ModelStorageAtomKeys.DownloadedModels, + [] +) + +/** + * Configured Models Atom + * This atom stores the list of models that have been configured and available to download + */ +export const configuredModelsAtom = atomWithStorage( + ModelStorageAtomKeys.AvailableModels, + [] +) + +export const removeDownloadedModelAtom = atom( + null, + (get, set, modelId: string) => { + const downloadedModels = get(downloadedModelsAtom) + + set( + downloadedModelsAtom, + downloadedModels.filter((e) => e.id !== modelId) + ) + } +) + +/** + * Atom to store the selected model (from ModelDropdown) + */ +export const selectedModelAtom = atom(undefined) + +/** + * Atom to store the expanded engine sections (from ModelDropdown) + */ +export const showEngineListModelAtom = atom([InferenceEngine.nitro]) + +/// End Models Atom +/// Model Download Atom export const stateModel = atom({ state: 'start', loading: false, model: '' }) -export const activeAssistantModelAtom = atom(undefined) /** * Stores the list of models which are being downloaded. @@ -30,28 +81,20 @@ export const removeDownloadingModelAtom = atom( } ) -export const downloadedModelsAtom = atom([]) - -export const removeDownloadedModelAtom = atom( - null, - (get, set, modelId: string) => { - const downloadedModels = get(downloadedModelsAtom) - - set( - downloadedModelsAtom, - downloadedModels.filter((e) => e.id !== modelId) - ) - } -) - -export const configuredModelsAtom = atom([]) - -export const defaultModelAtom = atom(undefined) +/// End Model Download Atom +/// Model Import Atom /// TODO: move this part to another atom // store the paths of the models that are being imported export const importingModelsAtom = atom([]) +// DEPRECATED: Remove when moving to cortex.cpp +// Default model template when importing +export const defaultModelAtom = atom(undefined) + +/** + * Importing progress Atom + */ export const updateImportingModelProgressAtom = atom( null, (get, set, importId: string, percentage: number) => { @@ -69,6 +112,9 @@ export const updateImportingModelProgressAtom = atom( } ) +/** + * Importing error Atom + */ export const setImportingModelErrorAtom = atom( null, (get, set, importId: string, error: string) => { @@ -87,6 +133,9 @@ export const setImportingModelErrorAtom = atom( } ) +/** + * Importing success Atom + */ export const setImportingModelSuccessAtom = atom( null, (get, set, importId: string, modelId: string) => { @@ -105,6 +154,9 @@ export const setImportingModelSuccessAtom = atom( } ) +/** + * Update importing model metadata Atom + */ export const updateImportingModelAtom = atom( null, ( @@ -131,6 +183,9 @@ export const updateImportingModelAtom = atom( } ) -export const selectedModelAtom = atom(undefined) +/// End Model Import Atom -export const showEngineListModelAtom = atom([InferenceEngine.nitro]) +/// ModelDropdown States Atom +export const isDownloadALocalModelAtom = atom(false) +export const isAnyRemoteModelConfiguredAtom = atom(false) +/// End ModelDropdown States Atom diff --git a/web/helpers/atoms/SystemBar.atom.test.ts b/web/helpers/atoms/SystemBar.atom.test.ts new file mode 100644 index 0000000000..57a7c2ada3 --- /dev/null +++ b/web/helpers/atoms/SystemBar.atom.test.ts @@ -0,0 +1,146 @@ +import { renderHook, act } from '@testing-library/react' +import { useAtom } from 'jotai' +import * as SystemBarAtoms from './SystemBar.atom' + +describe('SystemBar.atom.ts', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + describe('totalRamAtom', () => { + it('should initialize as 0', () => { + const { result } = renderHook(() => useAtom(SystemBarAtoms.totalRamAtom)) + expect(result.current[0]).toBe(0) + }) + + it('should update correctly', () => { + const { result } = renderHook(() => useAtom(SystemBarAtoms.totalRamAtom)) + act(() => { + result.current[1](16384) + }) + expect(result.current[0]).toBe(16384) + }) + }) + + describe('usedRamAtom', () => { + it('should initialize as 0', () => { + const { result } = renderHook(() => useAtom(SystemBarAtoms.usedRamAtom)) + expect(result.current[0]).toBe(0) + }) + + it('should update correctly', () => { + const { result } = renderHook(() => useAtom(SystemBarAtoms.usedRamAtom)) + act(() => { + result.current[1](8192) + }) + expect(result.current[0]).toBe(8192) + }) + }) + + describe('cpuUsageAtom', () => { + it('should initialize as 0', () => { + const { result } = renderHook(() => useAtom(SystemBarAtoms.cpuUsageAtom)) + expect(result.current[0]).toBe(0) + }) + + it('should update correctly', () => { + const { result } = renderHook(() => useAtom(SystemBarAtoms.cpuUsageAtom)) + act(() => { + result.current[1](50) + }) + expect(result.current[0]).toBe(50) + }) + }) + + describe('ramUtilitizedAtom', () => { + it('should initialize as 0', () => { + const { result } = renderHook(() => + useAtom(SystemBarAtoms.ramUtilitizedAtom) + ) + expect(result.current[0]).toBe(0) + }) + + it('should update correctly', () => { + const { result } = renderHook(() => + useAtom(SystemBarAtoms.ramUtilitizedAtom) + ) + act(() => { + result.current[1](75) + }) + expect(result.current[0]).toBe(75) + }) + }) + + describe('gpusAtom', () => { + it('should initialize as an empty array', () => { + const { result } = renderHook(() => useAtom(SystemBarAtoms.gpusAtom)) + expect(result.current[0]).toEqual([]) + }) + + it('should update correctly', () => { + const { result } = renderHook(() => useAtom(SystemBarAtoms.gpusAtom)) + const gpus = [{ id: 'gpu1' }, { id: 'gpu2' }] + act(() => { + result.current[1](gpus as any) + }) + expect(result.current[0]).toEqual(gpus) + }) + }) + + describe('nvidiaTotalVramAtom', () => { + it('should initialize as 0', () => { + const { result } = renderHook(() => + useAtom(SystemBarAtoms.nvidiaTotalVramAtom) + ) + expect(result.current[0]).toBe(0) + }) + + it('should update correctly', () => { + const { result } = renderHook(() => + useAtom(SystemBarAtoms.nvidiaTotalVramAtom) + ) + act(() => { + result.current[1](8192) + }) + expect(result.current[0]).toBe(8192) + }) + }) + + describe('availableVramAtom', () => { + it('should initialize as 0', () => { + const { result } = renderHook(() => + useAtom(SystemBarAtoms.availableVramAtom) + ) + expect(result.current[0]).toBe(0) + }) + + it('should update correctly', () => { + const { result } = renderHook(() => + useAtom(SystemBarAtoms.availableVramAtom) + ) + act(() => { + result.current[1](4096) + }) + expect(result.current[0]).toBe(4096) + }) + }) + + describe('systemMonitorCollapseAtom', () => { + it('should initialize as false', () => { + const { result } = renderHook(() => + useAtom(SystemBarAtoms.systemMonitorCollapseAtom) + ) + expect(result.current[0]).toBe(false) + }) + + it('should update correctly', () => { + const { result } = renderHook(() => + useAtom(SystemBarAtoms.systemMonitorCollapseAtom) + ) + act(() => { + result.current[1](true) + }) + expect(result.current[0]).toBe(true) + }) + }) +}) diff --git a/web/helpers/atoms/Thread.atom.test.ts b/web/helpers/atoms/Thread.atom.test.ts new file mode 100644 index 0000000000..cc88dd66e2 --- /dev/null +++ b/web/helpers/atoms/Thread.atom.test.ts @@ -0,0 +1,187 @@ +// Thread.atom.test.ts + +import { act, renderHook } from '@testing-library/react' +import * as ThreadAtoms from './Thread.atom' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' + +describe('Thread.atom.ts', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + describe('threadStatesAtom', () => { + it('should initialize as an empty object', () => { + const { result: threadStatesAtom } = renderHook(() => + useAtom(ThreadAtoms.threadsAtom) + ) + expect(threadStatesAtom.current[0]).toEqual([]) + }) + }) + + describe('threadsAtom', () => { + it('should initialize as an empty array', () => { + const { result: threadsAtom } = renderHook(() => + useAtom(ThreadAtoms.threadsAtom) + ) + expect(threadsAtom.current[0]).toEqual([]) + }) + }) + + describe('threadDataReadyAtom', () => { + it('should initialize as false', () => { + const { result: threadDataReadyAtom } = renderHook(() => + useAtom(ThreadAtoms.threadsAtom) + ) + expect(threadDataReadyAtom.current[0]).toEqual([]) + }) + }) + + describe('activeThreadIdAtom', () => { + it('should set and get active thread id', () => { + const { result: getAtom } = renderHook(() => + useAtomValue(ThreadAtoms.getActiveThreadIdAtom) + ) + const { result: setAtom } = renderHook(() => + useSetAtom(ThreadAtoms.setActiveThreadIdAtom) + ) + + expect(getAtom.current).toBeUndefined() + + act(() => { + setAtom.current('thread-1') + }) + + expect(getAtom.current).toBe('thread-1') + }) + }) + + describe('activeThreadAtom', () => { + it('should return the active thread', () => { + const { result: threadsAtom } = renderHook(() => + useAtom(ThreadAtoms.threadsAtom) + ) + const { result: setActiveThreadId } = renderHook(() => + useSetAtom(ThreadAtoms.setActiveThreadIdAtom) + ) + const { result: activeThread } = renderHook(() => + useAtomValue(ThreadAtoms.activeThreadAtom) + ) + + act(() => { + threadsAtom.current[1]([ + { id: 'thread-1', title: 'Test Thread' }, + ] as any) + setActiveThreadId.current('thread-1') + }) + + expect(activeThread.current).toEqual({ + id: 'thread-1', + title: 'Test Thread', + }) + }) + }) + + describe('updateThreadAtom', () => { + it('should update an existing thread', () => { + const { result: threadsAtom } = renderHook(() => + useAtom(ThreadAtoms.threadsAtom) + ) + const { result: updateThread } = renderHook(() => + useSetAtom(ThreadAtoms.updateThreadAtom) + ) + + act(() => { + threadsAtom.current[1]([ + { + id: 'thread-1', + title: 'Old Title', + updated: new Date('2023-01-01').toISOString(), + }, + { + id: 'thread-2', + title: 'Thread 2', + updated: new Date('2023-01-02').toISOString(), + }, + ] as any) + }) + + act(() => { + updateThread.current({ + id: 'thread-1', + title: 'New Title', + updated: new Date('2023-01-03').toISOString(), + } as any) + }) + + expect(threadsAtom.current[0]).toEqual([ + { + id: 'thread-1', + title: 'New Title', + updated: new Date('2023-01-03').toISOString(), + }, + { + id: 'thread-2', + title: 'Thread 2', + updated: new Date('2023-01-02').toISOString(), + }, + ]) + }) + }) + + describe('setThreadModelParamsAtom', () => { + it('should set thread model params', () => { + const { result: paramsAtom } = renderHook(() => + useAtom(ThreadAtoms.threadModelParamsAtom) + ) + const { result: setParams } = renderHook(() => + useSetAtom(ThreadAtoms.setThreadModelParamsAtom) + ) + + act(() => { + setParams.current('thread-1', { modelName: 'gpt-3' } as any) + }) + + expect(paramsAtom.current[0]).toEqual({ + 'thread-1': { modelName: 'gpt-3' }, + }) + }) + }) + + describe('deleteThreadStateAtom', () => { + it('should delete a thread state', () => { + const { result: statesAtom } = renderHook(() => + useAtom(ThreadAtoms.threadStatesAtom) + ) + const { result: deleteState } = renderHook(() => + useSetAtom(ThreadAtoms.deleteThreadStateAtom) + ) + + act(() => { + statesAtom.current[1]({ + 'thread-1': { lastMessage: 'Hello' }, + 'thread-2': { lastMessage: 'Hi' }, + } as any) + }) + + act(() => { + deleteState.current('thread-1') + }) + + expect(statesAtom.current[0]).toEqual({ + 'thread-2': { lastMessage: 'Hi' }, + }) + }) + }) + + describe('modalActionThreadAtom', () => { + it('should initialize with undefined values', () => { + const { result } = renderHook(() => + useAtomValue(ThreadAtoms.modalActionThreadAtom) + ) + expect(result.current).toEqual({ + showModal: undefined, + thread: undefined, + }) + }) + }) +}) diff --git a/web/helpers/atoms/Thread.atom.ts b/web/helpers/atoms/Thread.atom.ts index 6e94c9e172..1945fea45d 100644 --- a/web/helpers/atoms/Thread.atom.ts +++ b/web/helpers/atoms/Thread.atom.ts @@ -1,45 +1,91 @@ -import { - ModelRuntimeParams, - ModelSettingParams, - Thread, - ThreadContent, - ThreadState, -} from '@janhq/core' +import { Thread, ThreadContent, ThreadState } from '@janhq/core' import { atom } from 'jotai' import { atomWithStorage } from 'jotai/utils' +import { ModelParams } from '@/types/model' + +/** + * Thread Modal Action Enum + */ export enum ThreadModalAction { Clean = 'clean', Delete = 'delete', EditTitle = 'edit-title', } -export const engineParamsUpdateAtom = atom(false) +const ACTIVE_SETTING_INPUT_BOX = 'activeSettingInputBox' + +/** + * Enum for the keys used to store models in the local storage. + */ +enum ThreadStorageAtomKeys { + ThreadStates = 'threadStates', + ThreadList = 'threadList', + ThreadListReady = 'threadListReady', +} + +//// Threads Atom +/** + * Stores all thread states for the current user + */ +export const threadStatesAtom = atomWithStorage>( + ThreadStorageAtomKeys.ThreadStates, + {} +) +/** + * Stores all threads for the current user + */ +export const threadsAtom = atomWithStorage( + ThreadStorageAtomKeys.ThreadList, + [] +) + +/** + * Whether thread data is ready or not + * */ +export const threadDataReadyAtom = atomWithStorage( + ThreadStorageAtomKeys.ThreadListReady, + false +) + +/** + * Store model params at thread level settings + */ +export const threadModelParamsAtom = atom>({}) + +//// End Thread Atom + +/// Active Thread Atom /** * Stores the current active thread id. */ const activeThreadIdAtom = atom(undefined) +/** + * Get the active thread id + */ export const getActiveThreadIdAtom = atom((get) => get(activeThreadIdAtom)) +/** + * Set the active thread id + */ export const setActiveThreadIdAtom = atom( null, (_get, set, threadId: string | undefined) => set(activeThreadIdAtom, threadId) ) -export const waitingToSendMessage = atom(undefined) - -export const isGeneratingResponseAtom = atom(undefined) /** - * Stores all thread states for the current user + * Get the current active thread metadata */ -export const threadStatesAtom = atom>({}) - -// Whether thread data is ready or not -export const threadDataReadyAtom = atom(false) +export const activeThreadAtom = atom((get) => + get(threadsAtom).find((c) => c.id === get(getActiveThreadIdAtom)) +) +/** + * Get the active thread state + */ export const activeThreadStateAtom = atom((get) => { const threadId = get(activeThreadIdAtom) if (!threadId) { @@ -50,6 +96,38 @@ export const activeThreadStateAtom = atom((get) => { return get(threadStatesAtom)[threadId] }) +/** + * Get the active thread model params + */ +export const getActiveThreadModelParamsAtom = atom( + (get) => { + const threadId = get(activeThreadIdAtom) + if (!threadId) { + console.debug('Active thread id is undefined') + return undefined + } + + return get(threadModelParamsAtom)[threadId] + } +) +/// End Active Thread Atom + +/// Threads State Atom +export const engineParamsUpdateAtom = atom(false) + +/** + * Whether the thread is waiting to send a message + */ +export const waitingToSendMessage = atom(undefined) + +/** + * Whether the thread is generating a response + */ +export const isGeneratingResponseAtom = atom(undefined) + +/** + * Remove a thread state from the atom + */ export const deleteThreadStateAtom = atom( null, (get, set, threadId: string) => { @@ -59,6 +137,9 @@ export const deleteThreadStateAtom = atom( } ) +/** + * Update the thread state with the new state + */ export const updateThreadWaitingForResponseAtom = atom( null, (get, set, threadId: string, waitingForResponse: boolean) => { @@ -71,6 +152,10 @@ export const updateThreadWaitingForResponseAtom = atom( set(threadStatesAtom, currentState) } ) + +/** + * Update the thread last message + */ export const updateThreadStateLastMessageAtom = atom( null, (get, set, threadId: string, lastContent?: ThreadContent[]) => { @@ -84,6 +169,9 @@ export const updateThreadStateLastMessageAtom = atom( } ) +/** + * Update a thread with the new thread metadata + */ export const updateThreadAtom = atom( null, (get, set, updatedThread: Thread) => { @@ -103,33 +191,8 @@ export const updateThreadAtom = atom( ) /** - * Stores all threads for the current user - */ -export const threadsAtom = atom([]) - -export const activeThreadAtom = atom((get) => - get(threadsAtom).find((c) => c.id === get(getActiveThreadIdAtom)) -) - -/** - * Store model params at thread level settings + * Update the thread model params */ -export const threadModelParamsAtom = atom>({}) - -export type ModelParams = ModelRuntimeParams | ModelSettingParams - -export const getActiveThreadModelParamsAtom = atom( - (get) => { - const threadId = get(activeThreadIdAtom) - if (!threadId) { - console.debug('Active thread id is undefined') - return undefined - } - - return get(threadModelParamsAtom)[threadId] - } -) - export const setThreadModelParamsAtom = atom( null, (get, set, threadId: string, params: ModelParams) => { @@ -139,12 +202,17 @@ export const setThreadModelParamsAtom = atom( } ) -const ACTIVE_SETTING_INPUT_BOX = 'activeSettingInputBox' +/** + * Settings input box active state + */ export const activeSettingInputBoxAtom = atomWithStorage( ACTIVE_SETTING_INPUT_BOX, false ) +/** + * Whether thread thread is presenting a Modal or not + */ export const modalActionThreadAtom = atom<{ showModal: ThreadModalAction | undefined thread: Thread | undefined @@ -153,5 +221,4 @@ export const modalActionThreadAtom = atom<{ thread: undefined, }) -export const isDownloadALocalModelAtom = atom(false) -export const isAnyRemoteModelConfiguredAtom = atom(false) +/// Ebd Threads State Atom diff --git a/web/hooks/useAssistant.test.ts b/web/hooks/useAssistant.test.ts new file mode 100644 index 0000000000..e029bb7f64 --- /dev/null +++ b/web/hooks/useAssistant.test.ts @@ -0,0 +1,95 @@ +import { renderHook, act } from '@testing-library/react' +import { useSetAtom } from 'jotai' +import { events, AssistantEvent, ExtensionTypeEnum } from '@janhq/core' + +// Mock dependencies +jest.mock('jotai', () => ({ + useAtomValue: jest.fn(), + useSetAtom: jest.fn(), + useAtom: jest.fn(), + atom: jest.fn(), +})) +jest.mock('@janhq/core') +jest.mock('@/extension') + +import useAssistants from './useAssistants' +import { extensionManager } from '@/extension' + +// Mock data +const mockAssistants = [ + { id: 'assistant-1', name: 'Assistant 1' }, + { id: 'assistant-2', name: 'Assistant 2' }, +] + +const mockAssistantExtension = { + getAssistants: jest.fn().mockResolvedValue(mockAssistants), +} as any + +describe('useAssistants', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.spyOn(extensionManager, 'get').mockReturnValue(mockAssistantExtension) + }) + + it('should fetch and set assistants on mount', async () => { + const mockSetAssistants = jest.fn() + ;(useSetAtom as jest.Mock).mockReturnValue(mockSetAssistants) + + renderHook(() => useAssistants()) + + // Wait for useEffect to complete + await act(async () => {}) + + expect(mockAssistantExtension.getAssistants).toHaveBeenCalled() + expect(mockSetAssistants).toHaveBeenCalledWith(mockAssistants) + }) + + it('should update assistants when AssistantEvent.OnAssistantsUpdate is emitted', async () => { + const mockSetAssistants = jest.fn() + ;(useSetAtom as jest.Mock).mockReturnValue(mockSetAssistants) + + renderHook(() => useAssistants()) + + // Wait for initial useEffect to complete + await act(async () => {}) + + // Clear previous calls + mockSetAssistants.mockClear() + + // Simulate AssistantEvent.OnAssistantsUpdate event + await act(async () => { + events.emit(AssistantEvent.OnAssistantsUpdate, '') + }) + + expect(mockAssistantExtension.getAssistants).toHaveBeenCalledTimes(1) + }) + + it('should unsubscribe from events on unmount', async () => { + const { unmount } = renderHook(() => useAssistants()) + + // Wait for useEffect to complete + await act(async () => {}) + + const offSpy = jest.spyOn(events, 'off') + + unmount() + + expect(offSpy).toHaveBeenCalledWith( + AssistantEvent.OnAssistantsUpdate, + expect.any(Function) + ) + }) + + it('should handle case when AssistantExtension is not available', async () => { + const mockSetAssistants = jest.fn() + ;(useSetAtom as jest.Mock).mockReturnValue(mockSetAssistants) + ;(extensionManager.get as jest.Mock).mockReturnValue(undefined) + + renderHook(() => useAssistants()) + + // Wait for useEffect to complete + await act(async () => {}) + + expect(mockSetAssistants).toHaveBeenCalledWith([]) + }) +}) diff --git a/web/hooks/useClipboard.test.ts b/web/hooks/useClipboard.test.ts new file mode 100644 index 0000000000..a79f8132bc --- /dev/null +++ b/web/hooks/useClipboard.test.ts @@ -0,0 +1,105 @@ +import { renderHook, act } from '@testing-library/react' +import { useClipboard } from './useClipboard' + +describe('useClipboard', () => { + let originalClipboard: any + + beforeAll(() => { + originalClipboard = { ...global.navigator.clipboard } + const mockClipboard = { + writeText: jest.fn(() => Promise.resolve()), + } + // @ts-ignore + global.navigator.clipboard = mockClipboard + }) + + afterAll(() => { + // @ts-ignore + global.navigator.clipboard = originalClipboard + }) + + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.clearAllTimers() + jest.useRealTimers() + }) + + it('should copy text to clipboard', async () => { + const { result } = renderHook(() => useClipboard()) + + await act(async () => { + result.current.copy('Test text') + }) + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Test text') + expect(result.current.copied).toBe(true) + expect(result.current.error).toBe(null) + }) + + it('should set copied to false after timeout', async () => { + const { result } = renderHook(() => useClipboard({ timeout: 1000 })) + + await act(async () => { + result.current.copy('Test text') + }) + + expect(result.current.copied).toBe(true) + + act(() => { + jest.advanceTimersByTime(1000) + }) + + expect(result.current.copied).toBe(false) + }) + + it('should handle clipboard errors', async () => { + const mockError = new Error('Clipboard error') + // @ts-ignore + navigator.clipboard.writeText.mockRejectedValueOnce(mockError) + + const { result } = renderHook(() => useClipboard()) + + await act(async () => { + result.current.copy('Test text') + }) + + expect(result.current.error).toEqual(mockError) + expect(result.current.copied).toBe(false) + }) + + it('should reset state', async () => { + const { result } = renderHook(() => useClipboard()) + + await act(async () => { + result.current.copy('Test text') + }) + + expect(result.current.copied).toBe(true) + + act(() => { + result.current.reset() + }) + + expect(result.current.copied).toBe(false) + expect(result.current.error).toBe(null) + }) + + it('should handle missing clipboard API', () => { + // @ts-ignore + delete global.navigator.clipboard + + const { result } = renderHook(() => useClipboard()) + + act(() => { + result.current.copy('Test text') + }) + + expect(result.current.error).toEqual( + new Error('useClipboard: navigator.clipboard is not supported') + ) + expect(result.current.copied).toBe(false) + }) +}) diff --git a/web/hooks/useDeleteModel.test.ts b/web/hooks/useDeleteModel.test.ts new file mode 100644 index 0000000000..336a1cd0c0 --- /dev/null +++ b/web/hooks/useDeleteModel.test.ts @@ -0,0 +1,73 @@ +import { renderHook, act } from '@testing-library/react' +import { extensionManager } from '@/extension/ExtensionManager' +import useDeleteModel from './useDeleteModel' +import { toaster } from '@/containers/Toast' +import { useSetAtom } from 'jotai' + +// Mock the dependencies +jest.mock('@/extension/ExtensionManager') +jest.mock('@/containers/Toast') +jest.mock('jotai', () => ({ + useSetAtom: jest.fn(() => jest.fn()), + atom: jest.fn(), +})) + +describe('useDeleteModel', () => { + const mockModel: any = { + id: 'test-model', + name: 'Test Model', + // Add other required properties of ModelFile + } + + const mockDeleteModel = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + ;(extensionManager.get as jest.Mock).mockReturnValue({ + deleteModel: mockDeleteModel, + }) + }) + + it('should delete a model successfully', async () => { + const { result } = renderHook(() => useDeleteModel()) + + await act(async () => { + await result.current.deleteModel(mockModel) + }) + + expect(mockDeleteModel).toHaveBeenCalledWith(mockModel) + expect(toaster).toHaveBeenCalledWith({ + title: 'Model Deletion Successful', + description: `Model ${mockModel.name} has been successfully deleted.`, + type: 'success', + }) + }) + + it('should call removeDownloadedModel with the model id', async () => { + const { result } = renderHook(() => useDeleteModel()) + + await act(async () => { + await result.current.deleteModel(mockModel) + }) + + // Assuming useSetAtom returns a mock function + ;(useSetAtom as jest.Mock).mockReturnValue(jest.fn()) + expect(useSetAtom).toHaveBeenCalled() + }) + + it('should handle errors during model deletion', async () => { + const error = new Error('Deletion failed') + mockDeleteModel.mockRejectedValue(error) + + const { result } = renderHook(() => useDeleteModel()) + + await act(async () => { + await expect(result.current.deleteModel(mockModel)).rejects.toThrow( + 'Deletion failed' + ) + }) + + expect(mockDeleteModel).toHaveBeenCalledWith(mockModel) + expect(toaster).not.toHaveBeenCalled() + }) +}) diff --git a/web/hooks/useDeleteThread.test.ts b/web/hooks/useDeleteThread.test.ts new file mode 100644 index 0000000000..d3a6138d07 --- /dev/null +++ b/web/hooks/useDeleteThread.test.ts @@ -0,0 +1,106 @@ +import { renderHook, act } from '@testing-library/react' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import useDeleteThread from './useDeleteThread' +import { extensionManager } from '@/extension/ExtensionManager' +import { toaster } from '@/containers/Toast' + +// Mock the necessary dependencies +// Mock dependencies +jest.mock('jotai', () => ({ + useAtomValue: jest.fn(), + useSetAtom: jest.fn(), + useAtom: jest.fn(), + atom: jest.fn(), +})) +jest.mock('@/extension/ExtensionManager') +jest.mock('@/containers/Toast') + +describe('useDeleteThread', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should delete a thread 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]) + + const mockDeleteThread = jest.fn() + extensionManager.get = jest.fn().mockReturnValue({ + deleteThread: mockDeleteThread, + }) + + const { result } = renderHook(() => useDeleteThread()) + + await act(async () => { + await result.current.deleteThread('thread1') + }) + + expect(mockDeleteThread).toHaveBeenCalledWith('thread1') + expect(mockSetThreads).toHaveBeenCalledWith([mockThreads[1]]) + }) + + it('should clean a thread successfully', async () => { + const mockThreads = [{ id: 'thread1', title: 'Thread 1', metadata: {} }] + const mockSetThreads = jest.fn() + ;(useAtom as jest.Mock).mockReturnValue([mockThreads, mockSetThreads]) + const mockCleanMessages = jest.fn() + ;(useSetAtom as jest.Mock).mockReturnValue(() => mockCleanMessages) + ;(useAtomValue as jest.Mock).mockReturnValue(['thread 1']) + + const mockWriteMessages = jest.fn() + const mockSaveThread = jest.fn() + extensionManager.get = jest.fn().mockReturnValue({ + writeMessages: mockWriteMessages, + saveThread: mockSaveThread, + }) + + const { result } = renderHook(() => useDeleteThread()) + + await act(async () => { + await result.current.cleanThread('thread1') + }) + + expect(mockWriteMessages).toHaveBeenCalled() + expect(mockSaveThread).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'thread1', + title: 'New Thread', + metadata: expect.objectContaining({ lastMessage: undefined }), + }) + ) + }) + + it('should handle errors when deleting a thread', async () => { + const mockThreads = [{ id: 'thread1', title: 'Thread 1' }] + const mockSetThreads = jest.fn() + ;(useAtom as jest.Mock).mockReturnValue([mockThreads, mockSetThreads]) + + const mockDeleteThread = jest + .fn() + .mockRejectedValue(new Error('Delete error')) + extensionManager.get = jest.fn().mockReturnValue({ + deleteThread: mockDeleteThread, + }) + + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}) + + const { result } = renderHook(() => useDeleteThread()) + + await act(async () => { + await result.current.deleteThread('thread1') + }) + + expect(mockDeleteThread).toHaveBeenCalledWith('thread1') + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.any(Error)) + expect(mockSetThreads).not.toHaveBeenCalled() + expect(toaster).not.toHaveBeenCalled() + + consoleErrorSpy.mockRestore() + }) +}) diff --git a/web/hooks/useDownloadModel.test.ts b/web/hooks/useDownloadModel.test.ts new file mode 100644 index 0000000000..fc0b7c21f4 --- /dev/null +++ b/web/hooks/useDownloadModel.test.ts @@ -0,0 +1,98 @@ +import { renderHook, act } from '@testing-library/react' +import { useAtom, useSetAtom } from 'jotai' +import useDownloadModel from './useDownloadModel' +import * as core from '@janhq/core' +import { extensionManager } from '@/extension/ExtensionManager' + +// Mock the necessary dependencies +jest.mock('jotai', () => ({ + useAtomValue: jest.fn(), + useSetAtom: jest.fn(), + useAtom: jest.fn(), + atom: jest.fn(), +})) +jest.mock('@janhq/core') +jest.mock('@/extension/ExtensionManager') +jest.mock('./useGpuSetting', () => ({ + __esModule: true, + default: () => ({ + getGpuSettings: jest.fn().mockResolvedValue({ some: 'gpuSettings' }), + }), +})) + +describe('useDownloadModel', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(useAtom as jest.Mock).mockReturnValue([false, jest.fn()]) + }) + + it('should download a model', async () => { + const mockModel: core.Model = { + id: 'test-model', + sources: [{ filename: 'test.bin' }], + } as core.Model + + const mockExtension = { + downloadModel: jest.fn().mockResolvedValue(undefined), + } + ;(useSetAtom as jest.Mock).mockReturnValue(() => undefined) + ;(extensionManager.get as jest.Mock).mockReturnValue(mockExtension) + + const { result } = renderHook(() => useDownloadModel()) + + await act(async () => { + await result.current.downloadModel(mockModel) + }) + + expect(mockExtension.downloadModel).toHaveBeenCalledWith( + mockModel, + { some: 'gpuSettings' }, + { ignoreSSL: undefined, proxy: '' } + ) + }) + + it('should abort model download', async () => { + const mockModel: core.Model = { + id: 'test-model', + sources: [{ filename: 'test.bin' }], + } as core.Model + + ;(core.joinPath as jest.Mock).mockResolvedValue('/path/to/model/test.bin') + ;(core.abortDownload as jest.Mock).mockResolvedValue(undefined) + ;(useSetAtom as jest.Mock).mockReturnValue(() => undefined) + const { result } = renderHook(() => useDownloadModel()) + + await act(async () => { + await result.current.abortModelDownload(mockModel) + }) + + expect(core.abortDownload).toHaveBeenCalledWith('/path/to/model/test.bin') + }) + + it('should handle proxy settings', async () => { + const mockModel: core.Model = { + id: 'test-model', + sources: [{ filename: 'test.bin' }], + } as core.Model + + const mockExtension = { + downloadModel: jest.fn().mockResolvedValue(undefined), + } + ;(useSetAtom as jest.Mock).mockReturnValue(() => undefined) + ;(extensionManager.get as jest.Mock).mockReturnValue(mockExtension) + ;(useAtom as jest.Mock).mockReturnValueOnce([true, jest.fn()]) // proxyEnabled + ;(useAtom as jest.Mock).mockReturnValueOnce(['http://proxy.com', jest.fn()]) // proxy + + const { result } = renderHook(() => useDownloadModel()) + + await act(async () => { + await result.current.downloadModel(mockModel) + }) + + expect(mockExtension.downloadModel).toHaveBeenCalledWith( + mockModel, + expect.objectContaining({ some: 'gpuSettings' }), + expect.anything() + ) + }) +}) diff --git a/web/hooks/useDropModelBinaries.test.ts b/web/hooks/useDropModelBinaries.test.ts new file mode 100644 index 0000000000..dad8c6178f --- /dev/null +++ b/web/hooks/useDropModelBinaries.test.ts @@ -0,0 +1,129 @@ +// useDropModelBinaries.test.ts + +import { renderHook, act } from '@testing-library/react' +import { useSetAtom } from 'jotai' +import { v4 as uuidv4 } from 'uuid' +import useDropModelBinaries from './useDropModelBinaries' +import { getFileInfoFromFile } from '@/utils/file' +import { snackbar } from '@/containers/Toast' + +// Mock dependencies +// Mock the necessary dependencies +jest.mock('jotai', () => ({ + useAtomValue: jest.fn(), + useSetAtom: jest.fn(), + useAtom: jest.fn(), + atom: jest.fn(), +})) +jest.mock('uuid') +jest.mock('@/utils/file') +jest.mock('@/containers/Toast') + +describe('useDropModelBinaries', () => { + const mockSetImportingModels = jest.fn() + const mockSetImportModelStage = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + ;(useSetAtom as jest.Mock).mockReturnValueOnce(mockSetImportingModels) + ;(useSetAtom as jest.Mock).mockReturnValueOnce(mockSetImportModelStage) + ;(uuidv4 as jest.Mock).mockReturnValue('mock-uuid') + ;(getFileInfoFromFile as jest.Mock).mockResolvedValue([]) + }) + + it('should handle dropping supported files', async () => { + const { result } = renderHook(() => useDropModelBinaries()) + + const mockFiles = [ + { name: 'model1.gguf', path: '/path/to/model1.gguf', size: 1000 }, + { name: 'model2.gguf', path: '/path/to/model2.gguf', size: 2000 }, + ] + + ;(getFileInfoFromFile as jest.Mock).mockResolvedValue(mockFiles) + + await act(async () => { + await result.current.onDropModels([]) + }) + + expect(mockSetImportingModels).toHaveBeenCalledWith([ + { + importId: 'mock-uuid', + modelId: undefined, + name: 'model1', + description: '', + path: '/path/to/model1.gguf', + tags: [], + size: 1000, + status: 'PREPARING', + format: 'gguf', + }, + { + importId: 'mock-uuid', + modelId: undefined, + name: 'model2', + description: '', + path: '/path/to/model2.gguf', + tags: [], + size: 2000, + status: 'PREPARING', + format: 'gguf', + }, + ]) + expect(mockSetImportModelStage).toHaveBeenCalledWith('MODEL_SELECTED') + }) + + it('should handle dropping unsupported files', async () => { + const { result } = renderHook(() => useDropModelBinaries()) + + const mockFiles = [ + { name: 'unsupported.txt', path: '/path/to/unsupported.txt', size: 500 }, + ] + + ;(getFileInfoFromFile as jest.Mock).mockResolvedValue(mockFiles) + + await act(async () => { + await result.current.onDropModels([]) + }) + + expect(snackbar).toHaveBeenCalledWith({ + description: 'Only files with .gguf extension can be imported.', + type: 'error', + }) + expect(mockSetImportingModels).not.toHaveBeenCalled() + expect(mockSetImportModelStage).not.toHaveBeenCalled() + }) + + it('should handle dropping both supported and unsupported files', async () => { + const { result } = renderHook(() => useDropModelBinaries()) + + const mockFiles = [ + { name: 'model.gguf', path: '/path/to/model.gguf', size: 1000 }, + { name: 'unsupported.txt', path: '/path/to/unsupported.txt', size: 500 }, + ] + + ;(getFileInfoFromFile as jest.Mock).mockResolvedValue(mockFiles) + + await act(async () => { + await result.current.onDropModels([]) + }) + + expect(snackbar).toHaveBeenCalledWith({ + description: 'Only files with .gguf extension can be imported.', + type: 'error', + }) + expect(mockSetImportingModels).toHaveBeenCalledWith([ + { + importId: 'mock-uuid', + modelId: undefined, + name: 'model', + description: '', + path: '/path/to/model.gguf', + tags: [], + size: 1000, + status: 'PREPARING', + format: 'gguf', + }, + ]) + expect(mockSetImportModelStage).toHaveBeenCalledWith('MODEL_SELECTED') + }) +}) diff --git a/web/hooks/useFactoryReset.test.ts b/web/hooks/useFactoryReset.test.ts new file mode 100644 index 0000000000..b9ec10d6b4 --- /dev/null +++ b/web/hooks/useFactoryReset.test.ts @@ -0,0 +1,89 @@ +import { renderHook, act } from '@testing-library/react' +import { useAtomValue, useSetAtom } from 'jotai' +import useFactoryReset, { FactoryResetState } from './useFactoryReset' +import { useActiveModel } from './useActiveModel' +import { fs } from '@janhq/core' + +// Mock the dependencies +jest.mock('jotai', () => ({ + atom: jest.fn(), + useAtomValue: jest.fn(), + useSetAtom: jest.fn(), +})) +jest.mock('./useActiveModel', () => ({ + useActiveModel: jest.fn(), +})) +jest.mock('@janhq/core', () => ({ + fs: { + rm: jest.fn(), + }, +})) + +describe('useFactoryReset', () => { + const mockStopModel = jest.fn() + const mockSetFactoryResetState = jest.fn() + const mockGetAppConfigurations = jest.fn() + const mockUpdateAppConfiguration = jest.fn() + const mockRelaunch = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + ;(useAtomValue as jest.Mock).mockReturnValue('/default/jan/data/folder') + ;(useSetAtom as jest.Mock).mockReturnValue(mockSetFactoryResetState) + ;(useActiveModel as jest.Mock).mockReturnValue({ stopModel: mockStopModel }) + global.window ??= Object.create(window) + global.window.core = { + api: { + getAppConfigurations: mockGetAppConfigurations, + updateAppConfiguration: mockUpdateAppConfiguration, + relaunch: mockRelaunch, + }, + } + mockGetAppConfigurations.mockResolvedValue({ + data_folder: '/current/jan/data/folder', + quick_ask: false, + }) + jest.spyOn(global, 'setTimeout') + }) + + it('should reset all correctly', async () => { + const { result } = renderHook(() => useFactoryReset()) + + await act(async () => { + await result.current.resetAll() + }) + + expect(mockSetFactoryResetState).toHaveBeenCalledWith( + FactoryResetState.Starting + ) + expect(mockSetFactoryResetState).toHaveBeenCalledWith( + FactoryResetState.StoppingModel + ) + expect(mockStopModel).toHaveBeenCalled() + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 4000) + expect(mockSetFactoryResetState).toHaveBeenCalledWith( + FactoryResetState.DeletingData + ) + expect(fs.rm).toHaveBeenCalledWith('/current/jan/data/folder') + expect(mockUpdateAppConfiguration).toHaveBeenCalledWith({ + data_folder: '/default/jan/data/folder', + quick_ask: false, + }) + expect(mockSetFactoryResetState).toHaveBeenCalledWith( + FactoryResetState.ClearLocalStorage + ) + expect(mockRelaunch).toHaveBeenCalled() + }) + + it('should keep current folder when specified', async () => { + const { result } = renderHook(() => useFactoryReset()) + + await act(async () => { + await result.current.resetAll(true) + }) + + expect(mockUpdateAppConfiguration).not.toHaveBeenCalled() + }) + + // Add more tests as needed for error cases, edge cases, etc. +}) diff --git a/web/hooks/useGetHFRepoData.test.ts b/web/hooks/useGetHFRepoData.test.ts new file mode 100644 index 0000000000..eaf86d79a0 --- /dev/null +++ b/web/hooks/useGetHFRepoData.test.ts @@ -0,0 +1,39 @@ +import { renderHook, act } from '@testing-library/react' +import { useGetHFRepoData } from './useGetHFRepoData' +import { extensionManager } from '@/extension' + +jest.mock('@/extension', () => ({ + extensionManager: { + get: jest.fn(), + }, +})) + +describe('useGetHFRepoData', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should fetch HF repo data successfully', async () => { + const mockData = { name: 'Test Repo', stars: 100 } + const mockFetchHuggingFaceRepoData = jest.fn().mockResolvedValue(mockData) + ;(extensionManager.get as jest.Mock).mockReturnValue({ + fetchHuggingFaceRepoData: mockFetchHuggingFaceRepoData, + }) + + const { result } = renderHook(() => useGetHFRepoData()) + + expect(result.current.loading).toBe(false) + expect(result.current.error).toBeUndefined() + + let data + act(() => { + data = result.current.getHfRepoData('test-repo') + }) + + expect(result.current.loading).toBe(true) + + expect(result.current.error).toBeUndefined() + expect(await data).toEqual(mockData) + expect(mockFetchHuggingFaceRepoData).toHaveBeenCalledWith('test-repo') + }) +}) diff --git a/web/hooks/useGetSystemResources.test.ts b/web/hooks/useGetSystemResources.test.ts new file mode 100644 index 0000000000..10e539e07a --- /dev/null +++ b/web/hooks/useGetSystemResources.test.ts @@ -0,0 +1,103 @@ +// useGetSystemResources.test.ts + +import { renderHook, act } from '@testing-library/react' +import useGetSystemResources from './useGetSystemResources' +import { extensionManager } from '@/extension/ExtensionManager' + +// Mock the extensionManager +jest.mock('@/extension/ExtensionManager', () => ({ + extensionManager: { + get: jest.fn(), + }, +})) + +// Mock the necessary dependencies +jest.mock('jotai', () => ({ + useAtomValue: jest.fn(), + useSetAtom: () => jest.fn(), + useAtom: jest.fn(), + atom: jest.fn(), +})) + +describe('useGetSystemResources', () => { + const mockMonitoringExtension = { + getResourcesInfo: jest.fn(), + getCurrentLoad: jest.fn(), + } + + beforeEach(() => { + jest.useFakeTimers() + ;(extensionManager.get as jest.Mock).mockReturnValue( + mockMonitoringExtension + ) + }) + + afterEach(() => { + jest.clearAllMocks() + jest.useRealTimers() + }) + + it('should fetch system resources on initial render', async () => { + mockMonitoringExtension.getResourcesInfo.mockResolvedValue({ + mem: { usedMemory: 4000, totalMemory: 8000 }, + }) + mockMonitoringExtension.getCurrentLoad.mockResolvedValue({ + cpu: { usage: 50 }, + gpu: [], + }) + + const { result } = renderHook(() => useGetSystemResources()) + + expect(mockMonitoringExtension.getResourcesInfo).toHaveBeenCalledTimes(1) + }) + + it('should start watching system resources when watch is called', () => { + const { result } = renderHook(() => useGetSystemResources()) + + act(() => { + result.current.watch() + }) + + expect(mockMonitoringExtension.getResourcesInfo).toHaveBeenCalled() + + // Fast-forward time by 2 seconds + act(() => { + jest.advanceTimersByTime(2000) + }) + + expect(mockMonitoringExtension.getResourcesInfo).toHaveBeenCalled() + }) + + it('should stop watching when stopWatching is called', () => { + const { result } = renderHook(() => useGetSystemResources()) + + act(() => { + result.current.watch() + }) + + act(() => { + result.current.stopWatching() + }) + + // Fast-forward time by 2 seconds + act(() => { + jest.advanceTimersByTime(2000) + }) + + // Expect no additional calls after stopping + expect(mockMonitoringExtension.getResourcesInfo).toHaveBeenCalled() + }) + + it('should not fetch resources if monitoring extension is not available', async () => { + ;(extensionManager.get as jest.Mock).mockReturnValue(null) + + const { result } = renderHook(() => useGetSystemResources()) + + await act(async () => { + result.current.getSystemResources() + }) + + expect(mockMonitoringExtension.getResourcesInfo).not.toHaveBeenCalled() + expect(mockMonitoringExtension.getCurrentLoad).not.toHaveBeenCalled() + }) +}) diff --git a/web/hooks/useGpuSetting.test.ts b/web/hooks/useGpuSetting.test.ts new file mode 100644 index 0000000000..f52f07af8d --- /dev/null +++ b/web/hooks/useGpuSetting.test.ts @@ -0,0 +1,87 @@ +// useGpuSetting.test.ts + +import { renderHook, act } from '@testing-library/react' +import { ExtensionTypeEnum, MonitoringExtension } from '@janhq/core' + +// Mock dependencies +jest.mock('@/extension') + +import useGpuSetting from './useGpuSetting' +import { extensionManager } from '@/extension' + +describe('useGpuSetting', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return GPU settings when available', async () => { + const mockGpuSettings = { + gpuCount: 2, + gpuNames: ['NVIDIA GeForce RTX 3080', 'NVIDIA GeForce RTX 3070'], + totalMemory: 20000, + freeMemory: 15000, + } + + const mockMonitoringExtension: Partial = { + getGpuSetting: jest.fn().mockResolvedValue(mockGpuSettings), + } + + jest + .spyOn(extensionManager, 'get') + .mockReturnValue(mockMonitoringExtension as MonitoringExtension) + + const { result } = renderHook(() => useGpuSetting()) + + let gpuSettings + await act(async () => { + gpuSettings = await result.current.getGpuSettings() + }) + + expect(gpuSettings).toEqual(mockGpuSettings) + expect(extensionManager.get).toHaveBeenCalledWith( + ExtensionTypeEnum.SystemMonitoring + ) + expect(mockMonitoringExtension.getGpuSetting).toHaveBeenCalled() + }) + + it('should return undefined when no GPU settings are found', async () => { + const mockMonitoringExtension: Partial = { + getGpuSetting: jest.fn().mockResolvedValue(undefined), + } + + jest + .spyOn(extensionManager, 'get') + .mockReturnValue(mockMonitoringExtension as MonitoringExtension) + + const { result } = renderHook(() => useGpuSetting()) + + let gpuSettings + await act(async () => { + gpuSettings = await result.current.getGpuSettings() + }) + + expect(gpuSettings).toBeUndefined() + expect(extensionManager.get).toHaveBeenCalledWith( + ExtensionTypeEnum.SystemMonitoring + ) + expect(mockMonitoringExtension.getGpuSetting).toHaveBeenCalled() + }) + + it('should handle missing MonitoringExtension', async () => { + jest.spyOn(extensionManager, 'get').mockReturnValue(undefined) + jest.spyOn(console, 'debug').mockImplementation(() => {}) + + const { result } = renderHook(() => useGpuSetting()) + + let gpuSettings + await act(async () => { + gpuSettings = await result.current.getGpuSettings() + }) + + expect(gpuSettings).toBeUndefined() + expect(extensionManager.get).toHaveBeenCalledWith( + ExtensionTypeEnum.SystemMonitoring + ) + expect(console.debug).toHaveBeenCalledWith('No GPU setting found') + }) +}) diff --git a/web/hooks/useImportModel.test.ts b/web/hooks/useImportModel.test.ts new file mode 100644 index 0000000000..2148f581b8 --- /dev/null +++ b/web/hooks/useImportModel.test.ts @@ -0,0 +1,70 @@ +// useImportModel.test.ts + +import { renderHook, act } from '@testing-library/react' +import { extensionManager } from '@/extension' +import useImportModel from './useImportModel' + +// Mock dependencies +jest.mock('@janhq/core') +jest.mock('@/extension') +jest.mock('@/containers/Toast') +jest.mock('uuid', () => ({ v4: () => 'mocked-uuid' })) + +describe('useImportModel', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should import models successfully', async () => { + const mockImportModels = jest.fn().mockResolvedValue(undefined) + const mockExtension = { + importModels: mockImportModels, + } as any + + jest.spyOn(extensionManager, 'get').mockReturnValue(mockExtension) + + const { result } = renderHook(() => useImportModel()) + + const models = [ + { importId: '1', name: 'Model 1', path: '/path/to/model1' }, + { importId: '2', name: 'Model 2', path: '/path/to/model2' }, + ] as any + + await act(async () => { + await result.current.importModels(models, 'local' as any) + }) + + expect(mockImportModels).toHaveBeenCalledWith(models, 'local') + }) + + it('should update model info successfully', async () => { + const mockUpdateModelInfo = jest + .fn() + .mockResolvedValue({ id: 'model-1', name: 'Updated Model' }) + const mockExtension = { + updateModelInfo: mockUpdateModelInfo, + } as any + + jest.spyOn(extensionManager, 'get').mockReturnValue(mockExtension) + + const { result } = renderHook(() => useImportModel()) + + const modelInfo = { id: 'model-1', name: 'Updated Model' } + + await act(async () => { + await result.current.updateModelInfo(modelInfo) + }) + + expect(mockUpdateModelInfo).toHaveBeenCalledWith(modelInfo) + }) + + it('should handle empty file paths', async () => { + const { result } = renderHook(() => useImportModel()) + + await act(async () => { + await result.current.sanitizeFilePaths([]) + }) + + // Expect no state changes or side effects + }) +}) diff --git a/web/hooks/useLoadTheme.test.ts b/web/hooks/useLoadTheme.test.ts new file mode 100644 index 0000000000..a0d117fc59 --- /dev/null +++ b/web/hooks/useLoadTheme.test.ts @@ -0,0 +1,111 @@ +import { renderHook, act } from '@testing-library/react' +import { useTheme } from 'next-themes' +import { fs, joinPath } from '@janhq/core' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' + +import { useLoadTheme } from './useLoadTheme' + +// Mock dependencies +jest.mock('next-themes') +jest.mock('@janhq/core') + +// Mock dependencies +jest.mock('jotai', () => ({ + useAtomValue: jest.fn(), + useSetAtom: jest.fn(), + useAtom: jest.fn(), + atom: jest.fn(), +})) + +describe('useLoadTheme', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + const mockJanDataFolderPath = '/mock/path' + const mockThemesPath = '/mock/path/themes' + const mockSelectedThemeId = 'joi-light' + const mockThemeData = { + id: 'joi-light', + displayName: 'Joi Light', + nativeTheme: 'light', + variables: { + '--primary-color': '#007bff', + }, + } + + it('should load theme and set variables', async () => { + // Mock Jotai hooks + ;(useAtomValue as jest.Mock).mockReturnValue(mockJanDataFolderPath) + ;(useSetAtom as jest.Mock).mockReturnValue(jest.fn()) + ;(useAtom as jest.Mock).mockReturnValue([mockSelectedThemeId, jest.fn()]) + ;(useAtom as jest.Mock).mockReturnValue([mockThemeData, jest.fn()]) + + // Mock fs and joinPath + ;(fs.readdirSync as jest.Mock).mockResolvedValue(['joi-light', 'joi-dark']) + ;(fs.readFileSync as jest.Mock).mockResolvedValue( + JSON.stringify(mockThemeData) + ) + ;(joinPath as jest.Mock).mockImplementation((paths) => paths.join('/')) + + // Mock setTheme from next-themes + const mockSetTheme = jest.fn() + ;(useTheme as jest.Mock).mockReturnValue({ setTheme: mockSetTheme }) + + // Mock window.electronAPI + Object.defineProperty(window, 'electronAPI', { + value: { + setNativeThemeLight: jest.fn(), + setNativeThemeDark: jest.fn(), + }, + writable: true, + }) + + const { result } = renderHook(() => useLoadTheme()) + + await act(async () => { + await result.current + }) + + // Assertions + expect(fs.readdirSync).toHaveBeenCalledWith(mockThemesPath) + expect(fs.readFileSync).toHaveBeenCalledWith( + `${mockThemesPath}/${mockSelectedThemeId}/theme.json`, + 'utf-8' + ) + expect(mockSetTheme).toHaveBeenCalledWith('light') + expect(window.electronAPI.setNativeThemeLight).toHaveBeenCalled() + }) + + it('should set default theme if no selected theme', async () => { + // Mock Jotai hooks with empty selected theme + ;(useAtomValue as jest.Mock).mockReturnValue(mockJanDataFolderPath) + ;(useSetAtom as jest.Mock).mockReturnValue(jest.fn()) + ;(useAtom as jest.Mock).mockReturnValue(['', jest.fn()]) + ;(useAtom as jest.Mock).mockReturnValue([{}, jest.fn()]) + + const mockSetSelectedThemeId = jest.fn() + ;(useAtom as jest.Mock).mockReturnValue(['', mockSetSelectedThemeId]) + + const { result } = renderHook(() => useLoadTheme()) + + await act(async () => { + await result.current + }) + + expect(mockSetSelectedThemeId).toHaveBeenCalledWith('joi-light') + }) + + it('should handle missing janDataFolderPath', async () => { + // Mock Jotai hooks with empty janDataFolderPath + ;(useAtomValue as jest.Mock).mockReturnValue('') + + const { result } = renderHook(() => useLoadTheme()) + + await act(async () => { + await result.current + }) + + expect(fs.readdirSync).not.toHaveBeenCalled() + }) +}) diff --git a/web/hooks/useLogs.test.ts b/web/hooks/useLogs.test.ts new file mode 100644 index 0000000000..a7a055bbd4 --- /dev/null +++ b/web/hooks/useLogs.test.ts @@ -0,0 +1,103 @@ +// useLogs.test.ts + +import { renderHook, act } from '@testing-library/react' +import { useAtomValue } from 'jotai' +import { fs, joinPath, openFileExplorer } from '@janhq/core' + +import { useLogs } from './useLogs' + +// Mock dependencies +jest.mock('jotai', () => ({ + useAtomValue: jest.fn(), + atom: jest.fn(), +})) + +jest.mock('@janhq/core', () => ({ + fs: { + existsSync: jest.fn(), + readFileSync: jest.fn(), + writeFileSync: jest.fn(), + }, + joinPath: jest.fn(), + openFileExplorer: jest.fn(), +})) + +describe('useLogs', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(useAtomValue as jest.Mock).mockReturnValue('/mock/jan/data/folder') + }) + + it('should get logs and sanitize them', async () => { + const mockLogs = '/mock/jan/data/folder/some/log/content' + const expectedSanitizedLogs = 'jan-data-folder/some/log/content' + + ;(joinPath as jest.Mock).mockResolvedValue('file://logs/test.log') + ;(fs.existsSync as jest.Mock).mockResolvedValue(true) + ;(fs.readFileSync as jest.Mock).mockResolvedValue(mockLogs) + + const { result } = renderHook(() => useLogs()) + + await act(async () => { + const logs = await result.current.getLogs('test') + expect(logs).toBe(expectedSanitizedLogs) + }) + + expect(joinPath).toHaveBeenCalledWith(['file://logs', 'test.log']) + expect(fs.existsSync).toHaveBeenCalledWith('file://logs/test.log') + expect(fs.readFileSync).toHaveBeenCalledWith( + 'file://logs/test.log', + 'utf-8' + ) + }) + + it('should return empty string if log file does not exist', async () => { + ;(joinPath as jest.Mock).mockResolvedValue('file://logs/nonexistent.log') + ;(fs.existsSync as jest.Mock).mockResolvedValue(false) + + const { result } = renderHook(() => useLogs()) + + await act(async () => { + const logs = await result.current.getLogs('nonexistent') + expect(logs).toBe('') + }) + + expect(fs.readFileSync).not.toHaveBeenCalled() + }) + + it('should open server log', async () => { + ;(joinPath as jest.Mock).mockResolvedValue( + '/mock/jan/data/folder/logs/app.log' + ) + ;(openFileExplorer as jest.Mock).mockResolvedValue(undefined) + + const { result } = renderHook(() => useLogs()) + + await act(async () => { + await result.current.openServerLog() + }) + + expect(joinPath).toHaveBeenCalledWith([ + '/mock/jan/data/folder', + 'logs', + 'app.log', + ]) + expect(openFileExplorer).toHaveBeenCalledWith( + '/mock/jan/data/folder/logs/app.log' + ) + }) + + it('should clear server log', async () => { + ;(joinPath as jest.Mock).mockResolvedValue('file://logs/app.log') + ;(fs.writeFileSync as jest.Mock).mockResolvedValue(undefined) + + const { result } = renderHook(() => useLogs()) + + await act(async () => { + await result.current.clearServerLog() + }) + + expect(joinPath).toHaveBeenCalledWith(['file://logs', 'app.log']) + expect(fs.writeFileSync).toHaveBeenCalledWith('file://logs/app.log', '') + }) +}) diff --git a/web/hooks/useModels.test.ts b/web/hooks/useModels.test.ts new file mode 100644 index 0000000000..4c53ffaa71 --- /dev/null +++ b/web/hooks/useModels.test.ts @@ -0,0 +1,61 @@ +// useModels.test.ts + +import { renderHook, act } from '@testing-library/react' +import { events, ModelEvent } from '@janhq/core' +import { extensionManager } from '@/extension' + +// Mock dependencies +jest.mock('@janhq/core') +jest.mock('@/extension') + +import useModels from './useModels' + +// Mock data +const mockDownloadedModels = [ + { id: 'model-1', name: 'Model 1' }, + { id: 'model-2', name: 'Model 2' }, +] + +const mockConfiguredModels = [ + { id: 'model-3', name: 'Model 3' }, + { id: 'model-4', name: 'Model 4' }, +] + +const mockDefaultModel = { id: 'default-model', name: 'Default Model' } + +describe('useModels', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should fetch and set models on mount', async () => { + const mockModelExtension = { + getDownloadedModels: jest.fn().mockResolvedValue(mockDownloadedModels), + getConfiguredModels: jest.fn().mockResolvedValue(mockConfiguredModels), + getDefaultModel: jest.fn().mockResolvedValue(mockDefaultModel), + } as any + + jest.spyOn(extensionManager, 'get').mockReturnValue(mockModelExtension) + + await act(async () => { + renderHook(() => useModels()) + }) + + expect(mockModelExtension.getDownloadedModels).toHaveBeenCalled() + expect(mockModelExtension.getConfiguredModels).toHaveBeenCalled() + expect(mockModelExtension.getDefaultModel).toHaveBeenCalled() + }) + + it('should remove event listener on unmount', async () => { + const removeListenerSpy = jest.spyOn(events, 'off') + + const { unmount } = renderHook(() => useModels()) + + unmount() + + expect(removeListenerSpy).toHaveBeenCalledWith( + ModelEvent.OnModelsUpdate, + expect.any(Function) + ) + }) +}) diff --git a/web/hooks/useModels.ts b/web/hooks/useModels.ts index 8333c35c35..58def79c62 100644 --- a/web/hooks/useModels.ts +++ b/web/hooks/useModels.ts @@ -18,6 +18,11 @@ import { downloadedModelsAtom, } from '@/helpers/atoms/Model.atom' +/** + * useModels hook - Handles the state of models + * It fetches the downloaded models, configured models and default model from Model Extension + * and updates the atoms accordingly. + */ const useModels = () => { const setDownloadedModels = useSetAtom(downloadedModelsAtom) const setConfiguredModels = useSetAtom(configuredModelsAtom) @@ -39,6 +44,7 @@ const useModels = () => { setDefaultModel(defaultModel) } + // Fetch all data Promise.all([ getDownloadedModels(), getConfiguredModels(), @@ -59,16 +65,19 @@ const useModels = () => { }, [getData]) } +// TODO: Deprecated - Remove when moving to cortex.cpp const getLocalDefaultModel = async (): Promise => extensionManager .get(ExtensionTypeEnum.Model) ?.getDefaultModel() +// TODO: Deprecated - Remove when moving to cortex.cpp const getLocalConfiguredModels = async (): Promise => extensionManager .get(ExtensionTypeEnum.Model) ?.getConfiguredModels() ?? [] +// TODO: Deprecated - Remove when moving to cortex.cpp const getLocalDownloadedModels = async (): Promise => extensionManager .get(ExtensionTypeEnum.Model) diff --git a/web/hooks/useSetActiveThread.ts b/web/hooks/useSetActiveThread.ts index 8e92680650..6b306224db 100644 --- a/web/hooks/useSetActiveThread.ts +++ b/web/hooks/useSetActiveThread.ts @@ -8,10 +8,10 @@ import { setConvoMessagesAtom, } from '@/helpers/atoms/ChatMessage.atom' import { - ModelParams, setActiveThreadIdAtom, setThreadModelParamsAtom, } from '@/helpers/atoms/Thread.atom' +import { ModelParams } from '@/types/model' export default function useSetActiveThread() { const setActiveThreadId = useSetAtom(setActiveThreadIdAtom) diff --git a/web/hooks/useThread.test.ts b/web/hooks/useThread.test.ts new file mode 100644 index 0000000000..a40c709be6 --- /dev/null +++ b/web/hooks/useThread.test.ts @@ -0,0 +1,192 @@ +// useThreads.test.ts + +import { renderHook, act } from '@testing-library/react' +import { useSetAtom } from 'jotai' +import { ExtensionTypeEnum } from '@janhq/core' +import { extensionManager } from '@/extension/ExtensionManager' +import useThreads from './useThreads' +import { + threadDataReadyAtom, + threadModelParamsAtom, + threadsAtom, + threadStatesAtom, +} from '@/helpers/atoms/Thread.atom' + +// Mock the necessary dependencies +jest.mock('jotai', () => ({ + useAtomValue: jest.fn(), + useSetAtom: jest.fn(), + useAtom: jest.fn(), + atom: jest.fn(), +})) +jest.mock('@/extension/ExtensionManager') + +describe('useThreads', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + const mockThreads = [ + { + id: 'thread1', + metadata: { lastMessage: 'Hello' }, + assistants: [ + { + model: { + parameters: { param1: 'value1' }, + settings: { setting1: 'value1' }, + }, + }, + ], + }, + { + id: 'thread2', + metadata: { lastMessage: 'Hi there' }, + assistants: [ + { + model: { + parameters: { param2: 'value2' }, + settings: { setting2: 'value2' }, + }, + }, + ], + }, + ] + + it('should fetch and set threads data', async () => { + // Mock Jotai hooks + const mockSetThreadStates = jest.fn() + const mockSetThreads = jest.fn() + const mockSetThreadModelRuntimeParams = jest.fn() + const mockSetThreadDataReady = jest.fn() + + ;(useSetAtom as jest.Mock).mockImplementation((atom) => { + switch (atom) { + case threadStatesAtom: + return mockSetThreadStates + case threadsAtom: + return mockSetThreads + case threadModelParamsAtom: + return mockSetThreadModelRuntimeParams + case threadDataReadyAtom: + return mockSetThreadDataReady + default: + return jest.fn() + } + }) + + // Mock extensionManager + const mockGetThreads = jest.fn().mockResolvedValue(mockThreads) + ;(extensionManager.get as jest.Mock).mockReturnValue({ + getThreads: mockGetThreads, + }) + + const { result } = renderHook(() => useThreads()) + + await act(async () => { + // Wait for useEffect to complete + }) + + // Assertions + expect(extensionManager.get).toHaveBeenCalledWith( + ExtensionTypeEnum.Conversational + ) + expect(mockGetThreads).toHaveBeenCalled() + + expect(mockSetThreadStates).toHaveBeenCalledWith({ + thread1: { + hasMore: false, + waitingForResponse: false, + lastMessage: 'Hello', + }, + thread2: { + hasMore: false, + waitingForResponse: false, + lastMessage: 'Hi there', + }, + }) + + expect(mockSetThreads).toHaveBeenCalledWith(mockThreads) + + expect(mockSetThreadModelRuntimeParams).toHaveBeenCalledWith({ + thread1: { param1: 'value1', setting1: 'value1' }, + thread2: { param2: 'value2', setting2: 'value2' }, + }) + + expect(mockSetThreadDataReady).toHaveBeenCalledWith(true) + }) + + it('should handle empty threads', async () => { + // Mock empty threads + ;(extensionManager.get as jest.Mock).mockReturnValue({ + getThreads: jest.fn().mockResolvedValue([]), + }) + + const mockSetThreadStates = jest.fn() + const mockSetThreads = jest.fn() + const mockSetThreadModelRuntimeParams = jest.fn() + const mockSetThreadDataReady = jest.fn() + + ;(useSetAtom as jest.Mock).mockImplementation((atom) => { + switch (atom) { + case threadStatesAtom: + return mockSetThreadStates + case threadsAtom: + return mockSetThreads + case threadModelParamsAtom: + return mockSetThreadModelRuntimeParams + case threadDataReadyAtom: + return mockSetThreadDataReady + default: + return jest.fn() + } + }) + + const { result } = renderHook(() => useThreads()) + + await act(async () => { + // Wait for useEffect to complete + }) + + expect(mockSetThreadStates).toHaveBeenCalledWith({}) + expect(mockSetThreads).toHaveBeenCalledWith([]) + expect(mockSetThreadModelRuntimeParams).toHaveBeenCalledWith({}) + expect(mockSetThreadDataReady).toHaveBeenCalledWith(true) + }) + + it('should handle missing ConversationalExtension', async () => { + // Mock missing ConversationalExtension + ;(extensionManager.get as jest.Mock).mockReturnValue(null) + + const mockSetThreadStates = jest.fn() + const mockSetThreads = jest.fn() + const mockSetThreadModelRuntimeParams = jest.fn() + const mockSetThreadDataReady = jest.fn() + + ;(useSetAtom as jest.Mock).mockImplementation((atom) => { + switch (atom) { + case threadStatesAtom: + return mockSetThreadStates + case threadsAtom: + return mockSetThreads + case threadModelParamsAtom: + return mockSetThreadModelRuntimeParams + case threadDataReadyAtom: + return mockSetThreadDataReady + default: + return jest.fn() + } + }) + + const { result } = renderHook(() => useThreads()) + + await act(async () => { + // Wait for useEffect to complete + }) + + expect(mockSetThreadStates).toHaveBeenCalledWith({}) + expect(mockSetThreads).toHaveBeenCalledWith([]) + expect(mockSetThreadModelRuntimeParams).toHaveBeenCalledWith({}) + expect(mockSetThreadDataReady).toHaveBeenCalledWith(true) + }) +}) diff --git a/web/hooks/useThreads.ts b/web/hooks/useThreads.ts index fd0b3456d4..9366101c3a 100644 --- a/web/hooks/useThreads.ts +++ b/web/hooks/useThreads.ts @@ -11,12 +11,12 @@ import { useSetAtom } from 'jotai' import { extensionManager } from '@/extension/ExtensionManager' import { - ModelParams, threadDataReadyAtom, threadModelParamsAtom, threadStatesAtom, threadsAtom, } from '@/helpers/atoms/Thread.atom' +import { ModelParams } from '@/types/model' const useThreads = () => { const setThreadStates = useSetAtom(threadStatesAtom) diff --git a/web/hooks/useUpdateModelParameters.ts b/web/hooks/useUpdateModelParameters.ts index af30210adc..2af6e33233 100644 --- a/web/hooks/useUpdateModelParameters.ts +++ b/web/hooks/useUpdateModelParameters.ts @@ -18,10 +18,10 @@ import { import { extensionManager } from '@/extension' import { selectedModelAtom } from '@/helpers/atoms/Model.atom' import { - ModelParams, getActiveThreadModelParamsAtom, setThreadModelParamsAtom, } from '@/helpers/atoms/Thread.atom' +import { ModelParams } from '@/types/model' export type UpdateModelParameter = { params?: ModelParams diff --git a/web/types/model.d.ts b/web/types/model.d.ts new file mode 100644 index 0000000000..bbe9d2cc67 --- /dev/null +++ b/web/types/model.d.ts @@ -0,0 +1,4 @@ +/** + * ModelParams types + */ +export type ModelParams = ModelRuntimeParams | ModelSettingParams diff --git a/web/utils/modelParam.ts b/web/utils/modelParam.ts index dda9cf7611..315aeaeb3c 100644 --- a/web/utils/modelParam.ts +++ b/web/utils/modelParam.ts @@ -2,7 +2,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { ModelRuntimeParams, ModelSettingParams } from '@janhq/core' -import { ModelParams } from '@/helpers/atoms/Thread.atom' +import { ModelParams } from '@/types/model' /** * Validation rules for model parameters