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