From 773963a456aa95e12c518edeff8189f5b2d40438 Mon Sep 17 00:00:00 2001 From: NamH Date: Mon, 26 Feb 2024 16:15:10 +0700 Subject: [PATCH] feat: add import model (#2104) Signed-off-by: James Co-authored-by: James --- core/src/api/index.ts | 15 +- core/src/extensions/model.ts | 4 +- core/src/fs.ts | 11 +- core/src/node/api/processors/download.ts | 2 +- core/src/node/api/processors/fsExt.ts | 19 +- core/src/types/model/index.ts | 1 + core/src/types/model/modelImport.ts | 22 + electron/handlers/native.ts | 18 + electron/utils/dev.ts | 5 +- electron/utils/log.ts | 4 +- .../src/node/accelerator.ts | 2 +- extensions/model-extension/src/index.ts | 213 ++++++- server/middleware/s3.ts | 2 +- uikit/src/circular-progress/styles.scss | 66 +++ uikit/src/main.scss | 1 + uikit/src/modal/index.tsx | 2 +- web/.prettierrc | 8 + web/app/page.tsx | 8 +- web/containers/CardSidebar/index.tsx | 4 +- web/containers/DropdownListSidebar/index.tsx | 6 +- .../BottomBar/ImportingModelState/index.tsx | 61 ++ .../SystemMonitor/TableActiveModel/index.tsx | 4 +- .../Layout/BottomBar/SystemMonitor/index.tsx | 28 +- web/containers/Layout/BottomBar/index.tsx | 2 + web/containers/Layout/Ribbon/index.tsx | 5 +- .../CommandListDownloadedModel/index.tsx | 6 +- .../Layout/TopBar/CommandSearch/index.tsx | 13 +- web/containers/Layout/TopBar/index.tsx | 10 +- web/containers/Layout/index.tsx | 20 +- .../Providers/AppUpdateListener.tsx | 37 ++ web/containers/Providers/DataLoader.tsx | 18 +- web/containers/Providers/EventHandler.tsx | 4 +- web/containers/Providers/EventListener.tsx | 37 +- web/containers/Providers/KeyListener.tsx | 12 +- .../Providers/ModelImportListener.tsx | 86 +++ web/docker-compose.yml | 4 +- web/helpers/atoms/App.atom.ts | 5 + web/helpers/atoms/AppConfig.atom.ts | 3 + web/helpers/atoms/Model.atom.ts | 79 ++- web/hooks/useDeleteModel.ts | 38 +- web/hooks/useImportModel.ts | 70 +++ web/hooks/useMainViewState.ts | 11 - web/hooks/usePath.ts | 4 +- web/package.json | 1 + web/screens/Chat/ChatBody/index.tsx | 9 +- web/screens/Chat/ErrorMessage/index.tsx | 2 +- .../Chat/RequestDownloadModel/index.tsx | 7 +- web/screens/Chat/SimpleTextMessage/index.tsx | 6 +- .../ExploreModelItemHeader/index.tsx | 7 +- web/screens/ExploreModels/index.tsx | 60 +- .../Settings/Advanced/DataFolder/index.tsx | 20 +- .../FactoryReset/ModalConfirmReset.tsx | 2 - web/screens/Settings/Advanced/index.tsx | 534 +++++++++--------- web/screens/Settings/Appearance/index.tsx | 2 +- .../Settings/CancelModelImportModal/index.tsx | 61 ++ web/screens/Settings/CoreExtensions/index.tsx | 94 +-- .../Settings/EditModelInfoModal/index.tsx | 197 +++++++ .../Settings/ImportInProgressIcon/index.tsx | 59 ++ .../ImportModelOptionSelection.tsx | 29 + .../Settings/ImportModelOptionModal/index.tsx | 105 ++++ .../Settings/ImportSuccessIcon/index.tsx | 52 ++ .../ImportingModelItem.tsx | 45 ++ .../Settings/ImportingModelModal/index.tsx | 85 +++ web/screens/Settings/Models/index.tsx | 157 +++-- .../Settings/SelectingModelModal/index.tsx | 147 +++++ web/screens/Settings/SettingMenu/index.tsx | 55 ++ web/screens/Settings/index.tsx | 94 +-- web/utils/file.ts | 34 ++ 68 files changed, 2214 insertions(+), 620 deletions(-) create mode 100644 core/src/types/model/modelImport.ts create mode 100644 uikit/src/circular-progress/styles.scss create mode 100644 web/.prettierrc create mode 100644 web/containers/Layout/BottomBar/ImportingModelState/index.tsx create mode 100644 web/containers/Providers/AppUpdateListener.tsx create mode 100644 web/containers/Providers/ModelImportListener.tsx create mode 100644 web/helpers/atoms/App.atom.ts create mode 100644 web/helpers/atoms/AppConfig.atom.ts create mode 100644 web/hooks/useImportModel.ts delete mode 100644 web/hooks/useMainViewState.ts create mode 100644 web/screens/Settings/CancelModelImportModal/index.tsx create mode 100644 web/screens/Settings/EditModelInfoModal/index.tsx create mode 100644 web/screens/Settings/ImportInProgressIcon/index.tsx create mode 100644 web/screens/Settings/ImportModelOptionModal/ImportModelOptionSelection.tsx create mode 100644 web/screens/Settings/ImportModelOptionModal/index.tsx create mode 100644 web/screens/Settings/ImportSuccessIcon/index.tsx create mode 100644 web/screens/Settings/ImportingModelModal/ImportingModelItem.tsx create mode 100644 web/screens/Settings/ImportingModelModal/index.tsx create mode 100644 web/screens/Settings/SelectingModelModal/index.tsx create mode 100644 web/screens/Settings/SettingMenu/index.tsx create mode 100644 web/utils/file.ts diff --git a/core/src/api/index.ts b/core/src/api/index.ts index 6760207580..c7dd9146ec 100644 --- a/core/src/api/index.ts +++ b/core/src/api/index.ts @@ -7,6 +7,7 @@ export enum NativeRoute { openAppDirectory = 'openAppDirectory', openFileExplore = 'openFileExplorer', selectDirectory = 'selectDirectory', + selectModelFiles = 'selectModelFiles', relaunch = 'relaunch', } @@ -46,6 +47,13 @@ export enum DownloadEvent { onFileDownloadSuccess = 'onFileDownloadSuccess', } +export enum LocalImportModelEvent { + onLocalImportModelUpdate = 'onLocalImportModelUpdate', + onLocalImportModelError = 'onLocalImportModelError', + onLocalImportModelSuccess = 'onLocalImportModelSuccess', + onLocalImportModelFinished = 'onLocalImportModelFinished', +} + export enum ExtensionRoute { baseExtensions = 'baseExtensions', getActiveExtensions = 'getActiveExtensions', @@ -67,6 +75,7 @@ export enum FileSystemRoute { } export enum FileManagerRoute { syncFile = 'syncFile', + copyFile = 'copyFile', getJanDataFolderPath = 'getJanDataFolderPath', getResourcePath = 'getResourcePath', getUserHomePath = 'getUserHomePath', @@ -126,4 +135,8 @@ export const CoreRoutes = [ ] export const APIRoutes = [...CoreRoutes, ...Object.values(NativeRoute)] -export const APIEvents = [...Object.values(AppEvent), ...Object.values(DownloadEvent)] +export const APIEvents = [ + ...Object.values(AppEvent), + ...Object.values(DownloadEvent), + ...Object.values(LocalImportModelEvent), +] diff --git a/core/src/extensions/model.ts b/core/src/extensions/model.ts index df7d14f421..79202398b1 100644 --- a/core/src/extensions/model.ts +++ b/core/src/extensions/model.ts @@ -1,5 +1,5 @@ import { BaseExtension, ExtensionTypeEnum } from '../extension' -import { Model, ModelInterface } from '../index' +import { ImportingModel, Model, ModelInterface, OptionType } from '../index' /** * Model extension for managing models. @@ -21,4 +21,6 @@ export abstract class ModelExtension extends BaseExtension implements ModelInter abstract saveModel(model: Model): Promise abstract getDownloadedModels(): Promise abstract getConfiguredModels(): Promise + abstract importModels(models: ImportingModel[], optionType: OptionType): Promise + abstract updateModelInfo(modelInfo: Partial): Promise } diff --git a/core/src/fs.ts b/core/src/fs.ts index 0e570d1f5e..71538ae9cc 100644 --- a/core/src/fs.ts +++ b/core/src/fs.ts @@ -69,14 +69,20 @@ const syncFile: (src: string, dest: string) => Promise = (src, dest) => */ const copyFileSync = (...args: any[]) => global.core.api?.copyFileSync(...args) +const copyFile: (src: string, dest: string) => Promise = (src, dest) => + global.core.api?.copyFile(src, dest) + /** * Gets the file's stats. * * @param path - The path to the file. + * @param outsideJanDataFolder - Whether the file is outside the Jan data folder. * @returns {Promise} - A promise that resolves with the file's stats. */ -const fileStat: (path: string) => Promise = (path) => - global.core.api?.fileStat(path) +const fileStat: (path: string, outsideJanDataFolder?: boolean) => Promise = ( + path, + outsideJanDataFolder +) => global.core.api?.fileStat(path, outsideJanDataFolder) // TODO: Export `dummy` fs functions automatically // Currently adding these manually @@ -90,6 +96,7 @@ export const fs = { unlinkSync, appendFileSync, copyFileSync, + copyFile, syncFile, fileStat, writeBlob, diff --git a/core/src/node/api/processors/download.ts b/core/src/node/api/processors/download.ts index 686ba58a1e..bff6f47f04 100644 --- a/core/src/node/api/processors/download.ts +++ b/core/src/node/api/processors/download.ts @@ -50,7 +50,7 @@ export class Downloader implements Processor { fileName, downloadState: 'downloading', } - console.log('progress: ', downloadState) + console.debug('progress: ', downloadState) observer?.(DownloadEvent.onFileDownloadUpdate, downloadState) DownloadManager.instance.downloadProgressMap[modelId] = downloadState }) diff --git a/core/src/node/api/processors/fsExt.ts b/core/src/node/api/processors/fsExt.ts index 71e07ae57b..4787da65b3 100644 --- a/core/src/node/api/processors/fsExt.ts +++ b/core/src/node/api/processors/fsExt.ts @@ -1,6 +1,5 @@ import { join } from 'path' import fs from 'fs' -import { FileManagerRoute } from '../../../api' import { appResourcePath, normalizeFilePath } from '../../helper/path' import { getJanDataFolderPath, getJanDataFolderPath as getPath } from '../../helper' import { Processor } from './Processor' @@ -48,10 +47,12 @@ export class FSExt implements Processor { } // handle fs is directory here - fileStat(path: string) { + fileStat(path: string, outsideJanDataFolder?: boolean) { const normalizedPath = normalizeFilePath(path) - const fullPath = join(getJanDataFolderPath(), normalizedPath) + const fullPath = outsideJanDataFolder + ? normalizedPath + : join(getJanDataFolderPath(), normalizedPath) const isExist = fs.existsSync(fullPath) if (!isExist) return undefined @@ -75,4 +76,16 @@ export class FSExt implements Processor { console.error(`writeFile ${path} result: ${err}`) } } + + copyFile(src: string, dest: string): Promise { + return new Promise((resolve, reject) => { + fs.copyFile(src, dest, (err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + } } diff --git a/core/src/types/model/index.ts b/core/src/types/model/index.ts index cba06ea95a..fdbf018636 100644 --- a/core/src/types/model/index.ts +++ b/core/src/types/model/index.ts @@ -1,3 +1,4 @@ export * from './modelEntity' export * from './modelInterface' export * from './modelEvent' +export * from './modelImport' diff --git a/core/src/types/model/modelImport.ts b/core/src/types/model/modelImport.ts new file mode 100644 index 0000000000..8977c42a0c --- /dev/null +++ b/core/src/types/model/modelImport.ts @@ -0,0 +1,22 @@ +export type OptionType = 'SYMLINK' | 'MOVE_BINARY_FILE' + +export type ModelImportOption = { + type: OptionType + title: string + description: string +} + +export type ImportingModelStatus = 'PREPARING' | 'IMPORTING' | 'IMPORTED' | 'FAILED' + +export type ImportingModel = { + importId: string + modelId: string | undefined + name: string + description: string + path: string + tags: string[] + size: number + status: ImportingModelStatus + format: string + percentage?: number +} diff --git a/electron/handlers/native.ts b/electron/handlers/native.ts index 14ead07bd3..79fa994bfa 100644 --- a/electron/handlers/native.ts +++ b/electron/handlers/native.ts @@ -83,4 +83,22 @@ export function handleAppIPCs() { return filePaths[0] } }) + + ipcMain.handle(NativeRoute.selectModelFiles, async () => { + const mainWindow = WindowManager.instance.currentWindow + if (!mainWindow) { + console.error('No main window found') + return + } + const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, { + title: 'Select model files', + buttonLabel: 'Select', + properties: ['openFile', 'multiSelections'], + }) + if (canceled) { + return + } else { + return filePaths + } + }) } diff --git a/electron/utils/dev.ts b/electron/utils/dev.ts index b2a4928866..16e5241b62 100644 --- a/electron/utils/dev.ts +++ b/electron/utils/dev.ts @@ -8,10 +8,9 @@ export const setupReactDevTool = async () => { ) // Don't use import on top level, since the installer package is dev-only try { const name = await installExtension(REACT_DEVELOPER_TOOLS) - console.log(`Added Extension: ${name}`) + console.debug(`Added Extension: ${name}`) } catch (err) { - console.log('An error occurred while installing devtools:') - console.error(err) + console.error('An error occurred while installing devtools:', err) // Only log the error and don't throw it because it's not critical } } diff --git a/electron/utils/log.ts b/electron/utils/log.ts index 84c185d754..9dcd4563bb 100644 --- a/electron/utils/log.ts +++ b/electron/utils/log.ts @@ -35,7 +35,7 @@ export function cleanLogs( console.error('Error deleting log file:', err) return } - console.log( + console.debug( `Deleted log file due to exceeding size limit: ${filePath}` ) }) @@ -52,7 +52,7 @@ export function cleanLogs( console.error('Error deleting log file:', err) return } - console.log(`Deleted old log file: ${filePath}`) + console.debug(`Deleted old log file: ${filePath}`) }) } } diff --git a/extensions/inference-nitro-extension/src/node/accelerator.ts b/extensions/inference-nitro-extension/src/node/accelerator.ts index bba4c1b032..1ffdbc5bd6 100644 --- a/extensions/inference-nitro-extension/src/node/accelerator.ts +++ b/extensions/inference-nitro-extension/src/node/accelerator.ts @@ -149,7 +149,7 @@ export function updateCudaExistence( data['cuda'].exist = cudaExists data['cuda'].version = cudaVersion - console.log(data['is_initial'], data['gpus_in_use']) + console.debug(data['is_initial'], data['gpus_in_use']) if (cudaExists && data['is_initial'] && data['gpus_in_use'].length > 0) { data.run_mode = 'gpu' } diff --git a/extensions/model-extension/src/index.ts b/extensions/model-extension/src/index.ts index 926e65ee50..dd5bcdf26c 100644 --- a/extensions/model-extension/src/index.ts +++ b/extensions/model-extension/src/index.ts @@ -13,6 +13,9 @@ import { DownloadRoute, ModelEvent, DownloadState, + OptionType, + ImportingModel, + LocalImportModelEvent, } from '@janhq/core' import { extractFileName } from './helpers/path' @@ -158,18 +161,18 @@ export default class JanModelExtension extends ModelExtension { /** * Cancels the download of a specific machine learning model. + * * @param {string} modelId - The ID of the model whose download is to be cancelled. * @returns {Promise} A promise that resolves when the download has been cancelled. */ async cancelModelDownload(modelId: string): Promise { - const model = await this.getConfiguredModels() - return abortDownload( - await joinPath([JanModelExtension._homeDir, modelId, modelId]) - ).then(async () => { - fs.unlinkSync( - await joinPath([JanModelExtension._homeDir, modelId, modelId]) - ) - }) + const path = await joinPath([JanModelExtension._homeDir, modelId, modelId]) + try { + await abortDownload(path) + await fs.unlinkSync(path) + } catch (e) { + console.error(e) + } } /** @@ -180,6 +183,20 @@ export default class JanModelExtension extends ModelExtension { async deleteModel(modelId: string): Promise { try { const dirPath = await joinPath([JanModelExtension._homeDir, modelId]) + const jsonFilePath = await joinPath([ + dirPath, + JanModelExtension._modelMetadataFileName, + ]) + const modelInfo = JSON.parse( + await this.readModelMetadata(jsonFilePath) + ) as Model + + const isUserImportModel = + modelInfo.metadata?.author?.toLowerCase() === 'user' + if (isUserImportModel) { + // just delete the folder + return fs.rmdirSync(dirPath) + } // remove all files under dirPath except model.json const files = await fs.readdirSync(dirPath) @@ -389,7 +406,7 @@ export default class JanModelExtension extends ModelExtension { llama_model_path: binaryFileName, }, created: Date.now(), - description: `${dirName} - user self import model`, + description: '', metadata: { size: binaryFileSize, author: 'User', @@ -455,4 +472,182 @@ export default class JanModelExtension extends ModelExtension { ) } } + + private async importModelSymlink( + modelBinaryPath: string, + modelFolderName: string, + modelFolderPath: string + ): Promise { + const fileStats = await fs.fileStat(modelBinaryPath, true) + const binaryFileSize = fileStats.size + + // Just need to generate model.json there + const defaultModel = (await this.getDefaultModel()) as Model + if (!defaultModel) { + console.error('Unable to find default model') + return + } + + const binaryFileName = extractFileName(modelBinaryPath, '') + + const model: Model = { + ...defaultModel, + id: modelFolderName, + name: modelFolderName, + sources: [ + { + url: modelBinaryPath, + filename: binaryFileName, + }, + ], + settings: { + ...defaultModel.settings, + llama_model_path: binaryFileName, + }, + created: Date.now(), + description: '', + metadata: { + size: binaryFileSize, + author: 'User', + tags: [], + }, + } + + const modelFilePath = await joinPath([ + modelFolderPath, + JanModelExtension._modelMetadataFileName, + ]) + + await fs.writeFileSync(modelFilePath, JSON.stringify(model, null, 2)) + + return model + } + + async updateModelInfo(modelInfo: Partial): Promise { + const modelId = modelInfo.id + if (modelInfo.id == null) throw new Error('Model ID is required') + + const janDataFolderPath = await getJanDataFolderPath() + const jsonFilePath = await joinPath([ + janDataFolderPath, + 'models', + modelId, + JanModelExtension._modelMetadataFileName, + ]) + const model = JSON.parse( + await this.readModelMetadata(jsonFilePath) + ) as Model + + const updatedModel: Model = { + ...model, + ...modelInfo, + metadata: { + ...model.metadata, + tags: modelInfo.metadata?.tags ?? [], + }, + } + + await fs.writeFileSync(jsonFilePath, JSON.stringify(updatedModel, null, 2)) + return updatedModel + } + + private async importModel( + model: ImportingModel, + optionType: OptionType + ): Promise { + const binaryName = extractFileName(model.path, '').replace(/\s/g, '') + + let modelFolderName = binaryName + if (binaryName.endsWith(JanModelExtension._supportedModelFormat)) { + modelFolderName = binaryName.replace( + JanModelExtension._supportedModelFormat, + '' + ) + } + + const modelFolderPath = await this.getModelFolderName(modelFolderName) + await fs.mkdirSync(modelFolderPath) + + const uniqueFolderName = modelFolderPath.split('/').pop() + const modelBinaryFile = binaryName.endsWith( + JanModelExtension._supportedModelFormat + ) + ? binaryName + : `${binaryName}${JanModelExtension._supportedModelFormat}` + + const binaryPath = await joinPath([modelFolderPath, modelBinaryFile]) + + if (optionType === 'SYMLINK') { + return this.importModelSymlink( + model.path, + uniqueFolderName, + modelFolderPath + ) + } + + const srcStat = await fs.fileStat(model.path, true) + + // interval getting the file size to calculate the percentage + const interval = setInterval(async () => { + const destStats = await fs.fileStat(binaryPath, true) + const percentage = destStats.size / srcStat.size + events.emit(LocalImportModelEvent.onLocalImportModelUpdate, { + ...model, + percentage, + }) + }, 1000) + + await fs.copyFile(model.path, binaryPath) + + clearInterval(interval) + + // generate model json + return this.generateModelMetadata(uniqueFolderName) + } + + private async getModelFolderName( + modelFolderName: string, + count?: number + ): Promise { + const newModelFolderName = count + ? `${modelFolderName}-${count}` + : modelFolderName + + const janDataFolderPath = await getJanDataFolderPath() + const modelFolderPath = await joinPath([ + janDataFolderPath, + 'models', + newModelFolderName, + ]) + + const isFolderExist = await fs.existsSync(modelFolderPath) + if (!isFolderExist) { + return modelFolderPath + } else { + const newCount = (count ?? 0) + 1 + return this.getModelFolderName(modelFolderName, newCount) + } + } + + async importModels( + models: ImportingModel[], + optionType: OptionType + ): Promise { + const importedModels: Model[] = [] + + for (const model of models) { + events.emit(LocalImportModelEvent.onLocalImportModelUpdate, model) + const importedModel = await this.importModel(model, optionType) + + events.emit(LocalImportModelEvent.onLocalImportModelSuccess, { + ...model, + modelId: importedModel.id, + }) + importedModels.push(importedModel) + } + events.emit( + LocalImportModelEvent.onLocalImportModelFinished, + importedModels + ) + } } diff --git a/server/middleware/s3.ts b/server/middleware/s3.ts index 28971a42b4..3024285a3d 100644 --- a/server/middleware/s3.ts +++ b/server/middleware/s3.ts @@ -38,7 +38,7 @@ export const s3 = (req: any, reply: any, done: any) => { reply.status(200).send(result) return } catch (ex) { - console.log(ex) + console.error(ex) } } } diff --git a/uikit/src/circular-progress/styles.scss b/uikit/src/circular-progress/styles.scss new file mode 100644 index 0000000000..093cd435fb --- /dev/null +++ b/uikit/src/circular-progress/styles.scss @@ -0,0 +1,66 @@ +/* + * react-circular-progressbar styles + * All of the styles in this file are configurable! + */ + +.CircularProgressbar { + /* + * This fixes an issue where the CircularProgressbar svg has + * 0 width inside a "display: flex" container, and thus not visible. + */ + width: 100%; + /* + * This fixes a centering issue with CircularProgressbarWithChildren: + * https://github.com/kevinsqi/react-circular-progressbar/issues/94 + */ + vertical-align: middle; +} + +.CircularProgressbar .CircularProgressbar-path { + stroke: #3e98c7; + stroke-linecap: round; + transition: stroke-dashoffset 0.5s ease 0s; +} + +.CircularProgressbar .CircularProgressbar-trail { + stroke: #d6d6d6; + /* Used when trail is not full diameter, i.e. when props.circleRatio is set */ + stroke-linecap: round; +} + +.CircularProgressbar .CircularProgressbar-text { + fill: #3e98c7; + font-size: 20px; + dominant-baseline: middle; + text-anchor: middle; +} + +.CircularProgressbar .CircularProgressbar-background { + fill: #d6d6d6; +} + +/* + * Sample background styles. Use these with e.g.: + * + * + */ +.CircularProgressbar.CircularProgressbar-inverted + .CircularProgressbar-background { + fill: #3e98c7; +} + +.CircularProgressbar.CircularProgressbar-inverted .CircularProgressbar-text { + fill: #fff; +} + +.CircularProgressbar.CircularProgressbar-inverted .CircularProgressbar-path { + stroke: #fff; +} + +.CircularProgressbar.CircularProgressbar-inverted .CircularProgressbar-trail { + stroke: transparent; +} diff --git a/uikit/src/main.scss b/uikit/src/main.scss index c1326ba19c..f3294e12e5 100644 --- a/uikit/src/main.scss +++ b/uikit/src/main.scss @@ -17,6 +17,7 @@ @import './select/styles.scss'; @import './slider/styles.scss'; @import './checkbox/styles.scss'; +@import './circular-progress/styles.scss'; .animate-spin { animation: spin 1s linear infinite; diff --git a/uikit/src/modal/index.tsx b/uikit/src/modal/index.tsx index c41909843a..1c05866379 100644 --- a/uikit/src/modal/index.tsx +++ b/uikit/src/modal/index.tsx @@ -19,7 +19,7 @@ const ModalOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( )) diff --git a/web/.prettierrc b/web/.prettierrc new file mode 100644 index 0000000000..46f1abcb02 --- /dev/null +++ b/web/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": false, + "singleQuote": true, + "quoteProps": "consistent", + "trailingComma": "es5", + "endOfLine": "auto", + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/web/app/page.tsx b/web/app/page.tsx index 92d6545287..ab619f0616 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,19 +1,21 @@ 'use client' +import { useAtomValue } from 'jotai' + import BaseLayout from '@/containers/Layout' import { MainViewState } from '@/constants/screens' -import { useMainViewState } from '@/hooks/useMainViewState' - import ChatScreen from '@/screens/Chat' import ExploreModelsScreen from '@/screens/ExploreModels' import LocalServerScreen from '@/screens/LocalServer' import SettingsScreen from '@/screens/Settings' +import { mainViewStateAtom } from '@/helpers/atoms/App.atom' + export default function Page() { - const { mainViewState } = useMainViewState() + const mainViewState = useAtomValue(mainViewStateAtom) let children = null switch (mainViewState) { diff --git a/web/containers/CardSidebar/index.tsx b/web/containers/CardSidebar/index.tsx index 89ff60e664..132494d483 100644 --- a/web/containers/CardSidebar/index.tsx +++ b/web/containers/CardSidebar/index.tsx @@ -38,7 +38,7 @@ export default function CardSidebar({ const [menu, setMenu] = useState(null) const [toggle, setToggle] = useState(null) const activeThread = useAtomValue(activeThreadAtom) - const { onReviewInFinder, onViewJson } = usePath() + const { onRevealInFinder, onViewJson } = usePath() useClickOutside(() => setMore(false), null, [menu, toggle]) @@ -100,7 +100,7 @@ export default function CardSidebar({ title === 'Model' ? 'items-start' : 'items-center' )} onClick={() => { - onReviewInFinder && onReviewInFinder(title) + onRevealInFinder && onRevealInFinder(title) setMore(false) }} > diff --git a/web/containers/DropdownListSidebar/index.tsx b/web/containers/DropdownListSidebar/index.tsx index 191c7bcbe8..c05d26e515 100644 --- a/web/containers/DropdownListSidebar/index.tsx +++ b/web/containers/DropdownListSidebar/index.tsx @@ -30,7 +30,6 @@ import { MainViewState } from '@/constants/screens' import { useActiveModel } from '@/hooks/useActiveModel' import { useClipboard } from '@/hooks/useClipboard' -import { useMainViewState } from '@/hooks/useMainViewState' import useRecommendedModel from '@/hooks/useRecommendedModel' @@ -41,6 +40,7 @@ import { toGibibytes } from '@/utils/converter' import ModelLabel from '../ModelLabel' import OpenAiKeyInput from '../OpenAiKeyInput' +import { mainViewStateAtom } from '@/helpers/atoms/App.atom' import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' import { @@ -64,11 +64,13 @@ const DropdownListSidebar = ({ const [isTabActive, setIsTabActive] = useState(0) const { stateModel } = useActiveModel() const [serverEnabled, setServerEnabled] = useAtom(serverEnabledAtom) - const { setMainViewState } = useMainViewState() + + const setMainViewState = useSetAtom(mainViewStateAtom) const [loader, setLoader] = useState(0) const { recommendedModel, downloadedModels } = useRecommendedModel() const { updateModelParameter } = useUpdateModelParameters() const clipboard = useClipboard({ timeout: 1000 }) + const [copyId, setCopyId] = useState('') const localModel = downloadedModels.filter( diff --git a/web/containers/Layout/BottomBar/ImportingModelState/index.tsx b/web/containers/Layout/BottomBar/ImportingModelState/index.tsx new file mode 100644 index 0000000000..889a1cfd81 --- /dev/null +++ b/web/containers/Layout/BottomBar/ImportingModelState/index.tsx @@ -0,0 +1,61 @@ +import { Fragment, useCallback } from 'react' + +import { Progress } from '@janhq/uikit' +import { useAtomValue, useSetAtom } from 'jotai' + +import { setImportModelStageAtom } from '@/hooks/useImportModel' + +import { importingModelsAtom } from '@/helpers/atoms/Model.atom' + +const ImportingModelState: React.FC = () => { + const importingModels = useAtomValue(importingModelsAtom) + const setImportModelStage = useSetAtom(setImportModelStageAtom) + + const isImportingModels = + importingModels.filter((m) => m.status === 'IMPORTING').length > 0 + + const finishedImportModelCount = importingModels.filter( + (model) => model.status === 'IMPORTED' || model.status === 'FAILED' + ).length + + let transferredSize = 0 + importingModels.forEach((model) => { + transferredSize += (model.percentage ?? 0) * 100 * model.size + }) + + const totalSize = importingModels.reduce((acc, model) => acc + model.size, 0) + + const progress = totalSize === 0 ? 0 : transferredSize / totalSize + + const onClick = useCallback(() => { + setImportModelStage('IMPORTING_MODEL') + }, [setImportModelStage]) + + return ( + + {isImportingModels ? ( +
+

+ Importing model ({finishedImportModelCount}/{importingModels.length} + ) +

+ +
+ + + {progress.toFixed(2)}% + +
+
+ ) : null} +
+ ) +} + +export default ImportingModelState diff --git a/web/containers/Layout/BottomBar/SystemMonitor/TableActiveModel/index.tsx b/web/containers/Layout/BottomBar/SystemMonitor/TableActiveModel/index.tsx index a73ec687f5..8bcccdba23 100644 --- a/web/containers/Layout/BottomBar/SystemMonitor/TableActiveModel/index.tsx +++ b/web/containers/Layout/BottomBar/SystemMonitor/TableActiveModel/index.tsx @@ -25,8 +25,8 @@ const TableActiveModel = () => { const [serverEnabled, setServerEnabled] = useAtom(serverEnabledAtom) return ( -
-
+
+
diff --git a/web/containers/Layout/BottomBar/SystemMonitor/index.tsx b/web/containers/Layout/BottomBar/SystemMonitor/index.tsx index a7659d4252..aec91bf6ea 100644 --- a/web/containers/Layout/BottomBar/SystemMonitor/index.tsx +++ b/web/containers/Layout/BottomBar/SystemMonitor/index.tsx @@ -73,7 +73,7 @@ const SystemMonitor = () => {
{ @@ -88,29 +88,29 @@ const SystemMonitor = () => {
-
+
Running Models
-
+
{showFullScreen ? ( setShowFullScreen(!showFullScreen)} /> ) : ( setShowFullScreen(!showFullScreen)} /> )} { setSystemMonitorCollapse(false) setShowFullScreen(false) @@ -118,10 +118,10 @@ const SystemMonitor = () => { />
-
+
-
-
+
+
CPU
@@ -130,7 +130,7 @@ const SystemMonitor = () => {
-
+
Memory
@@ -148,7 +148,7 @@ const SystemMonitor = () => {
{gpus.length > 0 && ( -
+
GPU
@@ -159,9 +159,9 @@ const SystemMonitor = () => { {gpus.map((gpu, index) => (
- + {gpu.name}
diff --git a/web/containers/Layout/BottomBar/index.tsx b/web/containers/Layout/BottomBar/index.tsx index c76f211e84..66c0897446 100644 --- a/web/containers/Layout/BottomBar/index.tsx +++ b/web/containers/Layout/BottomBar/index.tsx @@ -15,6 +15,7 @@ import ProgressBar from '@/containers/ProgressBar' import { appDownloadProgress } from '@/containers/Providers/Jotai' +import ImportingModelState from './ImportingModelState' import SystemMonitor from './SystemMonitor' const menuLinks = [ @@ -41,6 +42,7 @@ const BottomBar = () => { ) : null}
+
diff --git a/web/containers/Layout/Ribbon/index.tsx b/web/containers/Layout/Ribbon/index.tsx index 8a3c4a3a3f..4545e60ce2 100644 --- a/web/containers/Layout/Ribbon/index.tsx +++ b/web/containers/Layout/Ribbon/index.tsx @@ -20,13 +20,12 @@ import LogoMark from '@/containers/Brand/Logo/Mark' import { MainViewState } from '@/constants/screens' -import { useMainViewState } from '@/hooks/useMainViewState' - +import { mainViewStateAtom } from '@/helpers/atoms/App.atom' import { editMessageAtom } from '@/helpers/atoms/ChatMessage.atom' import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' export default function RibbonNav() { - const { mainViewState, setMainViewState } = useMainViewState() + const [mainViewState, setMainViewState] = useAtom(mainViewStateAtom) const [serverEnabled] = useAtom(serverEnabledAtom) const setEditMessage = useSetAtom(editMessageAtom) diff --git a/web/containers/Layout/TopBar/CommandListDownloadedModel/index.tsx b/web/containers/Layout/TopBar/CommandListDownloadedModel/index.tsx index ac5756e9f3..ecec5c758d 100644 --- a/web/containers/Layout/TopBar/CommandListDownloadedModel/index.tsx +++ b/web/containers/Layout/TopBar/CommandListDownloadedModel/index.tsx @@ -11,7 +11,7 @@ import { Badge, } from '@janhq/uikit' -import { useAtom, useAtomValue } from 'jotai' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { DatabaseIcon, CpuIcon } from 'lucide-react' import { showSelectModelModalAtom } from '@/containers/Providers/KeyListener' @@ -19,13 +19,13 @@ import { showSelectModelModalAtom } from '@/containers/Providers/KeyListener' import { MainViewState } from '@/constants/screens' import { useActiveModel } from '@/hooks/useActiveModel' -import { useMainViewState } from '@/hooks/useMainViewState' +import { mainViewStateAtom } from '@/helpers/atoms/App.atom' import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom' export default function CommandListDownloadedModel() { - const { setMainViewState } = useMainViewState() + const setMainViewState = useSetAtom(mainViewStateAtom) const downloadedModels = useAtomValue(downloadedModelsAtom) const { activeModel, startModel, stopModel } = useActiveModel() const [serverEnabled] = useAtom(serverEnabledAtom) diff --git a/web/containers/Layout/TopBar/CommandSearch/index.tsx b/web/containers/Layout/TopBar/CommandSearch/index.tsx index 17887763e8..d92c7297bd 100644 --- a/web/containers/Layout/TopBar/CommandSearch/index.tsx +++ b/web/containers/Layout/TopBar/CommandSearch/index.tsx @@ -10,20 +10,15 @@ import { CommandList, } from '@janhq/uikit' -import { useAtom } from 'jotai' -import { - MessageCircleIcon, - SettingsIcon, - LayoutGridIcon, - MonitorIcon, -} from 'lucide-react' +import { useAtom, useSetAtom } from 'jotai' +import { MessageCircleIcon, SettingsIcon, LayoutGridIcon } from 'lucide-react' import { showCommandSearchModalAtom } from '@/containers/Providers/KeyListener' import ShortCut from '@/containers/Shortcut' import { MainViewState } from '@/constants/screens' -import { useMainViewState } from '@/hooks/useMainViewState' +import { mainViewStateAtom } from '@/helpers/atoms/App.atom' const menus = [ { @@ -48,7 +43,7 @@ const menus = [ ] export default function CommandSearch() { - const { setMainViewState } = useMainViewState() + const setMainViewState = useSetAtom(mainViewStateAtom) const [showCommandSearchModal, setShowCommandSearchModal] = useAtom( showCommandSearchModalAtom ) diff --git a/web/containers/Layout/TopBar/index.tsx b/web/containers/Layout/TopBar/index.tsx index 525cd97de5..605d8e44dc 100644 --- a/web/containers/Layout/TopBar/index.tsx +++ b/web/containers/Layout/TopBar/index.tsx @@ -20,7 +20,6 @@ import { MainViewState } from '@/constants/screens' import { useClickOutside } from '@/hooks/useClickOutside' import { useCreateNewThread } from '@/hooks/useCreateNewThread' -import { useMainViewState } from '@/hooks/useMainViewState' import { usePath } from '@/hooks/usePath' @@ -28,18 +27,19 @@ import { showRightSideBarAtom } from '@/screens/Chat/Sidebar' import { openFileTitle } from '@/utils/titleUtils' +import { mainViewStateAtom } from '@/helpers/atoms/App.atom' import { assistantsAtom } from '@/helpers/atoms/Assistant.atom' import { activeThreadAtom } from '@/helpers/atoms/Thread.atom' const TopBar = () => { const activeThread = useAtomValue(activeThreadAtom) - const { mainViewState } = useMainViewState() + const mainViewState = useAtomValue(mainViewStateAtom) const { requestCreateNewThread } = useCreateNewThread() const assistants = useAtomValue(assistantsAtom) const [showRightSideBar, setShowRightSideBar] = useAtom(showRightSideBarAtom) const [showLeftSideBar, setShowLeftSideBar] = useAtom(showLeftSideBarAtom) const showing = useAtomValue(showRightSideBarAtom) - const { onReviewInFinder, onViewJson } = usePath() + const { onRevealInFinder, onViewJson } = usePath() const [more, setMore] = useState(false) const [menu, setMenu] = useState(null) const [toggle, setToggle] = useState(null) @@ -151,7 +151,7 @@ const TopBar = () => {
{ - onReviewInFinder('Thread') + onRevealInFinder('Thread') setMore(false) }} > @@ -195,7 +195,7 @@ const TopBar = () => {
{ - onReviewInFinder('Model') + onRevealInFinder('Model') setMore(false) }} > diff --git a/web/containers/Layout/index.tsx b/web/containers/Layout/index.tsx index 77a1fe9711..7e3ad38ab3 100644 --- a/web/containers/Layout/index.tsx +++ b/web/containers/Layout/index.tsx @@ -4,6 +4,8 @@ import { useTheme } from 'next-themes' import { motion as m } from 'framer-motion' +import { useAtom, useAtomValue } from 'jotai' + import BottomBar from '@/containers/Layout/BottomBar' import RibbonNav from '@/containers/Layout/Ribbon' @@ -11,14 +13,21 @@ import TopBar from '@/containers/Layout/TopBar' import { MainViewState } from '@/constants/screens' -import { useMainViewState } from '@/hooks/useMainViewState' +import { getImportModelStageAtom } from '@/hooks/useImportModel' import { SUCCESS_SET_NEW_DESTINATION } from '@/screens/Settings/Advanced/DataFolder' +import CancelModelImportModal from '@/screens/Settings/CancelModelImportModal' +import EditModelInfoModal from '@/screens/Settings/EditModelInfoModal' +import ImportModelOptionModal from '@/screens/Settings/ImportModelOptionModal' +import ImportingModelModal from '@/screens/Settings/ImportingModelModal' +import SelectingModelModal from '@/screens/Settings/SelectingModelModal' + +import { mainViewStateAtom } from '@/helpers/atoms/App.atom' const BaseLayout = (props: PropsWithChildren) => { const { children } = props - const { mainViewState, setMainViewState } = useMainViewState() - + const [mainViewState, setMainViewState] = useAtom(mainViewStateAtom) + const importModelStage = useAtomValue(getImportModelStageAtom) const { theme, setTheme } = useTheme() useEffect(() => { @@ -54,6 +63,11 @@ const BaseLayout = (props: PropsWithChildren) => {
+ {importModelStage === 'SELECTING_MODEL' && } + {importModelStage === 'MODEL_SELECTED' && } + {importModelStage === 'IMPORTING_MODEL' && } + {importModelStage === 'EDIT_MODEL_INFO' && } + {importModelStage === 'CONFIRM_CANCEL' && }
) } diff --git a/web/containers/Providers/AppUpdateListener.tsx b/web/containers/Providers/AppUpdateListener.tsx new file mode 100644 index 0000000000..dceb4df13e --- /dev/null +++ b/web/containers/Providers/AppUpdateListener.tsx @@ -0,0 +1,37 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Fragment, PropsWithChildren, useEffect } from 'react' + +import { useSetAtom } from 'jotai' + +import { appDownloadProgress } from './Jotai' + +const AppUpdateListener = ({ children }: PropsWithChildren) => { + const setProgress = useSetAtom(appDownloadProgress) + + useEffect(() => { + if (window && window.electronAPI) { + window.electronAPI.onAppUpdateDownloadUpdate( + (_event: string, progress: any) => { + setProgress(progress.percent) + console.debug('app update progress:', progress.percent) + } + ) + + window.electronAPI.onAppUpdateDownloadError( + (_event: string, callback: any) => { + console.error('Download error', callback) + setProgress(-1) + } + ) + + window.electronAPI.onAppUpdateDownloadSuccess(() => { + setProgress(-1) + }) + } + return () => {} + }, [setProgress]) + + return {children} +} + +export default AppUpdateListener diff --git a/web/containers/Providers/DataLoader.tsx b/web/containers/Providers/DataLoader.tsx index d7b630043b..fb439c92f7 100644 --- a/web/containers/Providers/DataLoader.tsx +++ b/web/containers/Providers/DataLoader.tsx @@ -1,21 +1,37 @@ 'use client' -import { Fragment, ReactNode } from 'react' +import { Fragment, ReactNode, useEffect } from 'react' + +import { AppConfiguration } from '@janhq/core/.' +import { useSetAtom } from 'jotai' import useAssistants from '@/hooks/useAssistants' import useGetSystemResources from '@/hooks/useGetSystemResources' import useModels from '@/hooks/useModels' import useThreads from '@/hooks/useThreads' +import { janDataFolderPathAtom } from '@/helpers/atoms/AppConfig.atom' + type Props = { children: ReactNode } const DataLoader: React.FC = ({ children }) => { + const setJanDataFolderPath = useSetAtom(janDataFolderPathAtom) + useModels() useThreads() useAssistants() useGetSystemResources() + + useEffect(() => { + window.core?.api + ?.getAppConfigurations() + ?.then((appConfig: AppConfiguration) => { + setJanDataFolderPath(appConfig.data_folder) + }) + }, [setJanDataFolderPath]) + console.debug('Load Data...') return {children} diff --git a/web/containers/Providers/EventHandler.tsx b/web/containers/Providers/EventHandler.tsx index 102fa5f1c0..1dd0bd0422 100644 --- a/web/containers/Providers/EventHandler.tsx +++ b/web/containers/Providers/EventHandler.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { ReactNode, useCallback, useEffect, useRef } from 'react' +import { Fragment, ReactNode, useCallback, useEffect, useRef } from 'react' import { ChatCompletionMessage, @@ -302,5 +302,5 @@ export default function EventHandler({ children }: { children: ReactNode }) { events.off(MessageEvent.OnMessageUpdate, onMessageResponseUpdate) } }, [onNewMessageResponse, onMessageResponseUpdate]) - return <>{children} + return {children} } diff --git a/web/containers/Providers/EventListener.tsx b/web/containers/Providers/EventListener.tsx index 938db69c02..9febbade57 100644 --- a/web/containers/Providers/EventListener.tsx +++ b/web/containers/Providers/EventListener.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { PropsWithChildren, useCallback, useEffect } from 'react' import React from 'react' @@ -8,13 +7,13 @@ import { useSetAtom } from 'jotai' import { setDownloadStateAtom } from '@/hooks/useDownloadState' +import AppUpdateListener from './AppUpdateListener' import EventHandler from './EventHandler' -import { appDownloadProgress } from './Jotai' +import ModelImportListener from './ModelImportListener' const EventListenerWrapper = ({ children }: PropsWithChildren) => { const setDownloadState = useSetAtom(setDownloadStateAtom) - const setProgress = useSetAtom(appDownloadProgress) const onFileDownloadUpdate = useCallback( async (state: DownloadState) => { @@ -42,7 +41,6 @@ const EventListenerWrapper = ({ children }: PropsWithChildren) => { useEffect(() => { console.debug('EventListenerWrapper: registering event listeners...') - events.on(DownloadEvent.onFileDownloadUpdate, onFileDownloadUpdate) events.on(DownloadEvent.onFileDownloadError, onFileDownloadError) events.on(DownloadEvent.onFileDownloadSuccess, onFileDownloadSuccess) @@ -55,30 +53,13 @@ const EventListenerWrapper = ({ children }: PropsWithChildren) => { } }, [onFileDownloadUpdate, onFileDownloadError, onFileDownloadSuccess]) - useEffect(() => { - if (window && window.electronAPI) { - window.electronAPI.onAppUpdateDownloadUpdate( - (_event: string, progress: any) => { - setProgress(progress.percent) - console.debug('app update progress:', progress.percent) - } - ) - - window.electronAPI.onAppUpdateDownloadError( - (_event: string, callback: any) => { - console.error('Download error', callback) - setProgress(-1) - } - ) - - window.electronAPI.onAppUpdateDownloadSuccess(() => { - setProgress(-1) - }) - } - return () => {} - }, [setDownloadState, setProgress]) - - return {children} + return ( + + + {children} + + + ) } export default EventListenerWrapper diff --git a/web/containers/Providers/KeyListener.tsx b/web/containers/Providers/KeyListener.tsx index 02fc291418..a4702783cc 100644 --- a/web/containers/Providers/KeyListener.tsx +++ b/web/containers/Providers/KeyListener.tsx @@ -6,7 +6,7 @@ import { atom, useSetAtom } from 'jotai' import { MainViewState } from '@/constants/screens' -import { useMainViewState } from '@/hooks/useMainViewState' +import { mainViewStateAtom } from '@/helpers/atoms/App.atom' type Props = { children: ReactNode @@ -19,7 +19,7 @@ export const showCommandSearchModalAtom = atom(false) export default function KeyListener({ children }: Props) { const setShowLeftSideBar = useSetAtom(showLeftSideBarAtom) const setShowSelectModelModal = useSetAtom(showSelectModelModalAtom) - const { setMainViewState } = useMainViewState() + const setMainViewState = useSetAtom(mainViewStateAtom) const showCommandSearchModal = useSetAtom(showCommandSearchModalAtom) useEffect(() => { @@ -48,8 +48,12 @@ export default function KeyListener({ children }: Props) { } document.addEventListener('keydown', onKeyDown) return () => document.removeEventListener('keydown', onKeyDown) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [ + setMainViewState, + setShowLeftSideBar, + setShowSelectModelModal, + showCommandSearchModal, + ]) return {children} } diff --git a/web/containers/Providers/ModelImportListener.tsx b/web/containers/Providers/ModelImportListener.tsx new file mode 100644 index 0000000000..60347ba40e --- /dev/null +++ b/web/containers/Providers/ModelImportListener.tsx @@ -0,0 +1,86 @@ +import { Fragment, PropsWithChildren, useCallback, useEffect } from 'react' + +import { + ImportingModel, + LocalImportModelEvent, + Model, + ModelEvent, + events, +} from '@janhq/core' +import { useSetAtom } from 'jotai' + +import { snackbar } from '../Toast' + +import { + setImportingModelSuccessAtom, + updateImportingModelProgressAtom, +} from '@/helpers/atoms/Model.atom' + +const ModelImportListener = ({ children }: PropsWithChildren) => { + const updateImportingModelProgress = useSetAtom( + updateImportingModelProgressAtom + ) + const setImportingModelSuccess = useSetAtom(setImportingModelSuccessAtom) + + const onImportModelUpdate = useCallback( + async (state: ImportingModel) => { + if (!state.importId) return + updateImportingModelProgress(state.importId, state.percentage ?? 0) + }, + [updateImportingModelProgress] + ) + + const onImportModelSuccess = useCallback( + (state: ImportingModel) => { + if (!state.modelId) return + events.emit(ModelEvent.OnModelsUpdate, {}) + setImportingModelSuccess(state.importId, state.modelId) + }, + [setImportingModelSuccess] + ) + + const onImportModelFinished = useCallback((importedModels: Model[]) => { + const modelText = importedModels.length === 1 ? 'model' : 'models' + snackbar({ + description: `Successfully imported ${importedModels.length} ${modelText}`, + type: 'success', + }) + }, []) + + useEffect(() => { + console.debug('ModelImportListener: registering event listeners..') + + events.on( + LocalImportModelEvent.onLocalImportModelUpdate, + onImportModelUpdate + ) + events.on( + LocalImportModelEvent.onLocalImportModelSuccess, + onImportModelSuccess + ) + events.on( + LocalImportModelEvent.onLocalImportModelFinished, + onImportModelFinished + ) + + return () => { + console.debug('ModelImportListener: unregistering event listeners...') + events.off( + LocalImportModelEvent.onLocalImportModelUpdate, + onImportModelUpdate + ) + events.off( + LocalImportModelEvent.onLocalImportModelSuccess, + onImportModelSuccess + ) + events.off( + LocalImportModelEvent.onLocalImportModelFinished, + onImportModelFinished + ) + } + }, [onImportModelUpdate, onImportModelSuccess, onImportModelFinished]) + + return {children} +} + +export default ModelImportListener diff --git a/web/docker-compose.yml b/web/docker-compose.yml index aa12246f56..7662ff6a39 100644 --- a/web/docker-compose.yml +++ b/web/docker-compose.yml @@ -1,4 +1,4 @@ -version: "3.8" +version: '3.8' services: web: @@ -14,6 +14,6 @@ services: - /app/node_modules - /app/.next ports: - - "3000:3000" + - '3000:3000' environment: NODE_ENV: development diff --git a/web/helpers/atoms/App.atom.ts b/web/helpers/atoms/App.atom.ts new file mode 100644 index 0000000000..342c04819c --- /dev/null +++ b/web/helpers/atoms/App.atom.ts @@ -0,0 +1,5 @@ +import { atom } from 'jotai' + +import { MainViewState } from '@/constants/screens' + +export const mainViewStateAtom = atom(MainViewState.Thread) diff --git a/web/helpers/atoms/AppConfig.atom.ts b/web/helpers/atoms/AppConfig.atom.ts new file mode 100644 index 0000000000..9dfdfca90e --- /dev/null +++ b/web/helpers/atoms/AppConfig.atom.ts @@ -0,0 +1,3 @@ +import { atom } from 'jotai' + +export const janDataFolderPathAtom = atom('') diff --git a/web/helpers/atoms/Model.atom.ts b/web/helpers/atoms/Model.atom.ts index 512518df1f..7a6aa6440e 100644 --- a/web/helpers/atoms/Model.atom.ts +++ b/web/helpers/atoms/Model.atom.ts @@ -1,4 +1,4 @@ -import { Model } from '@janhq/core' +import { ImportingModel, Model } from '@janhq/core' import { atom } from 'jotai' export const stateModel = atom({ state: 'start', loading: false, model: '' }) @@ -32,4 +32,81 @@ 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([]) + +/// TODO: move this part to another atom +// store the paths of the models that are being imported +export const importingModelsAtom = atom([]) + +export const updateImportingModelProgressAtom = atom( + null, + (get, set, importId: string, percentage: number) => { + const model = get(importingModelsAtom).find((x) => x.importId === importId) + if (!model) return + const newModel: ImportingModel = { + ...model, + status: 'IMPORTING', + percentage, + } + const newList = get(importingModelsAtom).map((x) => + x.importId === importId ? newModel : x + ) + set(importingModelsAtom, newList) + } +) + +export const setImportingModelSuccessAtom = atom( + null, + (get, set, importId: string, modelId: string) => { + const model = get(importingModelsAtom).find((x) => x.importId === importId) + if (!model) return + const newModel: ImportingModel = { + ...model, + modelId, + status: 'IMPORTED', + percentage: 1, + } + const newList = get(importingModelsAtom).map((x) => + x.importId === importId ? newModel : x + ) + set(importingModelsAtom, newList) + } +) + +export const updateImportingModelAtom = atom( + null, + ( + get, + set, + importId: string, + name: string, + description: string, + tags: string[] + ) => { + const model = get(importingModelsAtom).find((x) => x.importId === importId) + if (!model) return + const newModel: ImportingModel = { + ...model, + name, + importId, + description, + tags, + } + const newList = get(importingModelsAtom).map((x) => + x.importId === importId ? newModel : x + ) + set(importingModelsAtom, newList) + } +) diff --git a/web/hooks/useDeleteModel.ts b/web/hooks/useDeleteModel.ts index d9f2b94be2..9736f82563 100644 --- a/web/hooks/useDeleteModel.ts +++ b/web/hooks/useDeleteModel.ts @@ -1,28 +1,32 @@ +import { useCallback } from 'react' + import { ExtensionTypeEnum, ModelExtension, Model } from '@janhq/core' -import { useAtom } from 'jotai' +import { useSetAtom } from 'jotai' import { toaster } from '@/containers/Toast' import { extensionManager } from '@/extension/ExtensionManager' -import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom' +import { removeDownloadedModelAtom } from '@/helpers/atoms/Model.atom' export default function useDeleteModel() { - const [downloadedModels, setDownloadedModels] = useAtom(downloadedModelsAtom) - - const deleteModel = async (model: Model) => { - await extensionManager - .get(ExtensionTypeEnum.Model) - ?.deleteModel(model.id) - - // reload models - setDownloadedModels(downloadedModels.filter((e) => e.id !== model.id)) - toaster({ - title: 'Model Deletion Successful', - description: `The model ${model.id} has been successfully deleted.`, - type: 'success', - }) - } + const removeDownloadedModel = useSetAtom(removeDownloadedModelAtom) + + const deleteModel = useCallback( + async (model: Model) => { + await localDeleteModel(model.id) + removeDownloadedModel(model.id) + toaster({ + title: 'Model Deletion Successful', + description: `Model ${model.name} has been successfully deleted.`, + type: 'success', + }) + }, + [removeDownloadedModel] + ) return { deleteModel } } + +const localDeleteModel = async (id: string) => + extensionManager.get(ExtensionTypeEnum.Model)?.deleteModel(id) diff --git a/web/hooks/useImportModel.ts b/web/hooks/useImportModel.ts new file mode 100644 index 0000000000..d4b6f2919b --- /dev/null +++ b/web/hooks/useImportModel.ts @@ -0,0 +1,70 @@ +import { useCallback } from 'react' + +import { + ExtensionTypeEnum, + ImportingModel, + Model, + ModelExtension, + OptionType, +} from '@janhq/core' + +import { atom } from 'jotai' + +import { extensionManager } from '@/extension' + +export type ImportModelStage = + | 'NONE' + | 'SELECTING_MODEL' + | 'MODEL_SELECTED' + | 'IMPORTING_MODEL' + | 'EDIT_MODEL_INFO' + | 'CONFIRM_CANCEL' + +const importModelStageAtom = atom('NONE') + +export const getImportModelStageAtom = atom((get) => get(importModelStageAtom)) + +export const setImportModelStageAtom = atom( + null, + (_get, set, stage: ImportModelStage) => { + set(importModelStageAtom, stage) + } +) + +export type ModelUpdate = { + name: string + description: string + tags: string[] +} + +const useImportModel = () => { + const importModels = useCallback( + (models: ImportingModel[], optionType: OptionType) => + localImportModels(models, optionType), + [] + ) + + const updateModelInfo = useCallback( + async (modelInfo: Partial) => localUpdateModelInfo(modelInfo), + [] + ) + + return { importModels, updateModelInfo } +} + +const localImportModels = async ( + models: ImportingModel[], + optionType: OptionType +): Promise => + extensionManager + .get(ExtensionTypeEnum.Model) + ?.importModels(models, optionType) + +const localUpdateModelInfo = async ( + modelInfo: Partial +): Promise => + extensionManager + .get(ExtensionTypeEnum.Model) + ?.updateModelInfo(modelInfo) + +export default useImportModel diff --git a/web/hooks/useMainViewState.ts b/web/hooks/useMainViewState.ts deleted file mode 100644 index 91c1a1c4d4..0000000000 --- a/web/hooks/useMainViewState.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { atom, useAtom } from 'jotai' - -import { MainViewState } from '@/constants/screens' - -const currentMainViewState = atom(MainViewState.Thread) - -export function useMainViewState() { - const [mainViewState, setMainViewState] = useAtom(currentMainViewState) - const viewStateName = MainViewState[mainViewState] - return { mainViewState, setMainViewState, viewStateName } -} diff --git a/web/hooks/usePath.ts b/web/hooks/usePath.ts index 35fb853b49..bc4a94d1fb 100644 --- a/web/hooks/usePath.ts +++ b/web/hooks/usePath.ts @@ -9,7 +9,7 @@ export const usePath = () => { const activeThread = useAtomValue(activeThreadAtom) const selectedModel = useAtomValue(selectedModelAtom) - const onReviewInFinder = async (type: string) => { + const onRevealInFinder = async (type: string) => { // TODO: this logic should be refactored. if (type !== 'Model' && !activeThread) return @@ -96,7 +96,7 @@ export const usePath = () => { } return { - onReviewInFinder, + onRevealInFinder, onViewJson, onViewFile, onViewFileContainer, diff --git a/web/package.json b/web/package.json index 498481aa37..0a8af0f926 100644 --- a/web/package.json +++ b/web/package.json @@ -32,6 +32,7 @@ "postcss": "8.4.31", "posthog-js": "^1.95.1", "react": "18.2.0", + "react-circular-progressbar": "^2.1.0", "react-dom": "18.2.0", "react-dropzone": "^14.2.3", "react-hook-form": "^7.47.0", diff --git a/web/screens/Chat/ChatBody/index.tsx b/web/screens/Chat/ChatBody/index.tsx index ee0b4592d6..f6fc7d7233 100644 --- a/web/screens/Chat/ChatBody/index.tsx +++ b/web/screens/Chat/ChatBody/index.tsx @@ -4,27 +4,24 @@ import ScrollToBottom from 'react-scroll-to-bottom' import { InferenceEngine, MessageStatus } from '@janhq/core' import { Button } from '@janhq/uikit' -import { useAtomValue } from 'jotai' +import { useAtomValue, useSetAtom } from 'jotai' import LogoMark from '@/containers/Brand/Logo/Mark' import { MainViewState } from '@/constants/screens' -import { useMainViewState } from '@/hooks/useMainViewState' - import ChatItem from '../ChatItem' import ErrorMessage from '../ErrorMessage' +import { mainViewStateAtom } from '@/helpers/atoms/App.atom' import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom' const ChatBody: React.FC = () => { const messages = useAtomValue(getCurrentChatMessagesAtom) - const downloadedModels = useAtomValue(downloadedModelsAtom) - - const { setMainViewState } = useMainViewState() + const setMainViewState = useSetAtom(mainViewStateAtom) if (downloadedModels.length === 0) return ( diff --git a/web/screens/Chat/ErrorMessage/index.tsx b/web/screens/Chat/ErrorMessage/index.tsx index 5aa0cd6ce4..c9041e23ae 100644 --- a/web/screens/Chat/ErrorMessage/index.tsx +++ b/web/screens/Chat/ErrorMessage/index.tsx @@ -48,7 +48,7 @@ const ErrorMessage = ({ message }: { message: ThreadMessage }) => { {loadModelError === PORT_NOT_AVAILABLE ? (

Port 3928 is currently unavailable. Check for conflicting apps, diff --git a/web/screens/Chat/RequestDownloadModel/index.tsx b/web/screens/Chat/RequestDownloadModel/index.tsx index 88fdadd573..3034067409 100644 --- a/web/screens/Chat/RequestDownloadModel/index.tsx +++ b/web/screens/Chat/RequestDownloadModel/index.tsx @@ -2,19 +2,18 @@ import React, { Fragment, useCallback } from 'react' import { Button } from '@janhq/uikit' -import { useAtomValue } from 'jotai' +import { useAtomValue, useSetAtom } from 'jotai' import LogoMark from '@/containers/Brand/Logo/Mark' import { MainViewState } from '@/constants/screens' -import { useMainViewState } from '@/hooks/useMainViewState' - +import { mainViewStateAtom } from '@/helpers/atoms/App.atom' import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom' const RequestDownloadModel: React.FC = () => { const downloadedModels = useAtomValue(downloadedModelsAtom) - const { setMainViewState } = useMainViewState() + const setMainViewState = useSetAtom(mainViewStateAtom) const onClick = useCallback(() => { setMainViewState(MainViewState.Hub) diff --git a/web/screens/Chat/SimpleTextMessage/index.tsx b/web/screens/Chat/SimpleTextMessage/index.tsx index c7f6db2746..c3bdc86618 100644 --- a/web/screens/Chat/SimpleTextMessage/index.tsx +++ b/web/screens/Chat/SimpleTextMessage/index.tsx @@ -32,6 +32,8 @@ import { usePath } from '@/hooks/usePath' import { toGibibytes } from '@/utils/converter' import { displayDate } from '@/utils/datetime' +import { openFileTitle } from '@/utils/titleUtils' + import EditChatInput from '../EditChatInput' import Icon from '../FileUploadPreview/Icon' import MessageToolbar from '../MessageToolbar' @@ -234,7 +236,7 @@ const SimpleTextMessage: React.FC = (props) => { - Show in finder + {openFileTitle()} @@ -261,7 +263,7 @@ const SimpleTextMessage: React.FC = (props) => { - Show in finder + {openFileTitle()} diff --git a/web/screens/ExploreModels/ExploreModelItemHeader/index.tsx b/web/screens/ExploreModels/ExploreModelItemHeader/index.tsx index 7af5d3d975..38e7f65a67 100644 --- a/web/screens/ExploreModels/ExploreModelItemHeader/index.tsx +++ b/web/screens/ExploreModels/ExploreModelItemHeader/index.tsx @@ -11,7 +11,7 @@ import { TooltipTrigger, } from '@janhq/uikit' -import { useAtomValue } from 'jotai' +import { useAtomValue, useSetAtom } from 'jotai' import { ChevronDownIcon } from 'lucide-react' @@ -24,10 +24,9 @@ import { MainViewState } from '@/constants/screens' import { useCreateNewThread } from '@/hooks/useCreateNewThread' import useDownloadModel from '@/hooks/useDownloadModel' -import { useMainViewState } from '@/hooks/useMainViewState' - import { toGibibytes } from '@/utils/converter' +import { mainViewStateAtom } from '@/helpers/atoms/App.atom' import { assistantsAtom } from '@/helpers/atoms/Assistant.atom' import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' @@ -70,7 +69,7 @@ const ExploreModelItemHeader: React.FC = ({ model, onClick, open }) => { const totalRam = useAtomValue(totalRamAtom) const nvidiaTotalVram = useAtomValue(nvidiaTotalVramAtom) - const { setMainViewState } = useMainViewState() + const setMainViewState = useSetAtom(mainViewStateAtom) // Default nvidia returns vram in MB, need to convert to bytes to match the unit of totalRamW let ram = nvidiaTotalVram * 1024 * 1024 diff --git a/web/screens/ExploreModels/index.tsx b/web/screens/ExploreModels/index.tsx index 96e1356ac6..b1a10d5d66 100644 --- a/web/screens/ExploreModels/index.tsx +++ b/web/screens/ExploreModels/index.tsx @@ -1,6 +1,5 @@ import { useCallback, useState } from 'react' -import { openExternalUrl } from '@janhq/core' import { Input, ScrollArea, @@ -10,10 +9,13 @@ import { SelectContent, SelectGroup, SelectItem, + Button, } from '@janhq/uikit' -import { useAtomValue } from 'jotai' -import { SearchIcon } from 'lucide-react' +import { useAtomValue, useSetAtom } from 'jotai' +import { Plus, SearchIcon } from 'lucide-react' + +import { setImportModelStageAtom } from '@/hooks/useImportModel' import ExploreModelList from './ExploreModelList' import { HuggingFaceModal } from './HuggingFaceModal' @@ -23,13 +25,16 @@ import { downloadedModelsAtom, } from '@/helpers/atoms/Model.atom' +const sortMenu = ['All Models', 'Recommended', 'Downloaded'] + const ExploreModelsScreen = () => { const configuredModels = useAtomValue(configuredModelsAtom) const downloadedModels = useAtomValue(downloadedModelsAtom) const [searchValue, setsearchValue] = useState('') const [sortSelected, setSortSelected] = useState('All Models') - const sortMenu = ['All Models', 'Recommended', 'Downloaded'] + const [showHuggingFaceModal, setShowHuggingFaceModal] = useState(false) + const setImportModelStage = useSetAtom(setImportModelStageAtom) const filteredModels = configuredModels.filter((x) => { if (sortSelected === 'Downloaded') { @@ -47,9 +52,9 @@ const ExploreModelsScreen = () => { } }) - const onHowToImportModelClick = useCallback(() => { - openExternalUrl('https://jan.ai/guides/using-models/import-manually/') - }, []) + const onImportModelClick = useCallback(() => { + setImportModelStage('SELECTING_MODEL') + }, [setImportModelStage]) const onHuggingFaceConverterClick = () => { setShowHuggingFaceModal(true) @@ -73,30 +78,29 @@ const ExploreModelsScreen = () => { alt="Hub Banner" className="w-full object-cover" /> -

-
- - { - setsearchValue(e.target.value) - }} - /> -
-
-

+

+
+ + setsearchValue(e.target.value)} + /> +
+
-

or

{ - const [janDataFolderPath, setJanDataFolderPath] = useState('') const [showLoader, setShowLoader] = useState(false) const setShowDirectoryConfirm = useSetAtom(showDirectoryConfirmModalAtom) const setShowSameDirectory = useSetAtom(showSamePathModalAtom) const setShowChangeFolderError = useSetAtom(showChangeFolderErrorAtom) const showDestNotEmptyConfirm = useSetAtom(showDestNotEmptyConfirmAtom) - const [destinationPath, setDestinationPath] = useState(undefined) - useEffect(() => { - window.core?.api - ?.getAppConfigurations() - ?.then((appConfig: AppConfiguration) => { - setJanDataFolderPath(appConfig.data_folder) - }) - }, []) + const [destinationPath, setDestinationPath] = useState(undefined) + const janDataFolderPath = useAtomValue(janDataFolderPathAtom) const onChangeFolderClick = useCallback(async () => { const destFolder = await window.core?.api?.selectDirectory() @@ -56,8 +51,7 @@ const DataFolder = () => { return } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const newDestChildren: any[] = await fs.readdirSync(destFolder) + const newDestChildren: string[] = await fs.readdirSync(destFolder) const isNotEmpty = newDestChildren.filter((x) => x !== '.DS_Store').length > 0 diff --git a/web/screens/Settings/Advanced/FactoryReset/ModalConfirmReset.tsx b/web/screens/Settings/Advanced/FactoryReset/ModalConfirmReset.tsx index 7b2a4027a3..4560ac1adc 100644 --- a/web/screens/Settings/Advanced/FactoryReset/ModalConfirmReset.tsx +++ b/web/screens/Settings/Advanced/FactoryReset/ModalConfirmReset.tsx @@ -2,7 +2,6 @@ import React, { useCallback, useState } from 'react' import { Modal, - ModalPortal, ModalContent, ModalHeader, ModalTitle, @@ -33,7 +32,6 @@ const ModalConfirmReset = () => { open={modalValidation} onOpenChange={() => setModalValidation(false)} > - diff --git a/web/screens/Settings/Advanced/index.tsx b/web/screens/Settings/Advanced/index.tsx index 60812a3504..6320d19218 100644 --- a/web/screens/Settings/Advanced/index.tsx +++ b/web/screens/Settings/Advanced/index.tsx @@ -26,6 +26,7 @@ import { TooltipArrow, TooltipContent, TooltipTrigger, + ScrollArea, } from '@janhq/uikit' import { AlertTriangleIcon, AlertCircleIcon } from 'lucide-react' @@ -138,301 +139,312 @@ const Advanced = () => { gpuList.length > 0 ? 'Select GPU' : "You don't have any compatible GPU" return ( -

- {/* Keyboard shortcut */} -
-
-
-
- Keyboard Shortcuts -
+ +
+ {/* Keyboard shortcut */} +
+
+
+
+ Keyboard Shortcuts +
+
+

+ Shortcuts that you might find useful in Jan app. +

-

- Shortcuts that you might find useful in Jan app. -

+
- -
- {/* Experimental */} -
-
-
-
- Experimental Mode -
+ {/* Experimental */} +
+
+
+
+ Experimental Mode +
+
+

+ Enable experimental features that may be unstable tested. +

-

- Enable experimental features that may be unstable tested. -

+
- -
- {/* CPU / GPU switching */} - {!isMac && ( -
-
-
-
-
- GPU Acceleration -
+ {/* CPU / GPU switching */} + {!isMac && ( +
+
+
+
+
+ GPU Acceleration +
+
+

+ Enable to enhance model performance by utilizing your GPU + devices for acceleration. Read{' '} + + {' '} + + openExternalUrl( + 'https://jan.ai/guides/troubleshooting/gpu-not-used/' + ) + } + > + troubleshooting guide + {' '} + {' '} + for further assistance. +

-

- Enable to enhance model performance by utilizing your GPU - devices for acceleration. Read{' '} - - {' '} - - openExternalUrl( - 'https://jan.ai/guides/troubleshooting/gpu-not-used/' - ) - } + {gpuList.length > 0 && !gpuEnabled && ( + + + + + - troubleshooting guide - {' '} - {' '} - for further assistance. -

-
- {gpuList.length > 0 && !gpuEnabled && ( + + Disabling NVIDIA GPU Acceleration may result in reduced + performance. It is recommended to keep this enabled for + optimal user experience. + + + + + )} + - + { + if (e === true) { + saveSettings({ runMode: 'gpu' }) + setGpuEnabled(true) + setShowNotification(false) + snackbar({ + description: 'Successfully turned on GPU Accelertion', + type: 'success', + }) + setTimeout(() => { + validateSettings() + }, 300) + } else { + saveSettings({ runMode: 'cpu' }) + setGpuEnabled(false) + snackbar({ + description: + 'Successfully turned off GPU Accelertion', + type: 'success', + }) + } + // Stop any running model to apply the changes + if (e !== gpuEnabled) stopModel() + }} + /> - - - Disabling NVIDIA GPU Acceleration may result in reduced - performance. It is recommended to keep this enabled for - optimal user experience. - - - + {gpuList.length === 0 && ( + + + Your current device does not have a compatible GPU for + monitoring. To enable GPU monitoring, please ensure your + device has a supported Nvidia or AMD GPU with updated + drivers. + + + + )} - )} - - - - { - if (e === true) { - saveSettings({ runMode: 'gpu' }) - setGpuEnabled(true) - setShowNotification(false) - snackbar({ - description: 'Successfully turned on GPU Accelertion', - type: 'success', - }) - setTimeout(() => { - validateSettings() - }, 300) - } else { - saveSettings({ runMode: 'cpu' }) - setGpuEnabled(false) - snackbar({ - description: 'Successfully turned off GPU Accelertion', - type: 'success', - }) - } - // Stop any running model to apply the changes - if (e !== gpuEnabled) stopModel() - }} - /> - - {gpuList.length === 0 && ( - - - Your current device does not have a compatible GPU for - monitoring. To enable GPU monitoring, please ensure your - device has a supported Nvidia or AMD GPU with updated - drivers. - - - - )} - -
-
- - + + + + {selectedGpu.join()} + + + + + + + + {vulkanEnabled ? 'Vulkan Supported GPUs' : 'Nvidia'} + +
+
+ {gpuList + .filter((gpu) => + vulkanEnabled + ? gpu.name + : gpu.name?.toLowerCase().includes('nvidia') + ) + .map((gpu) => ( +
- {gpu.name} - {!vulkanEnabled && ( - {gpu.vram}MB VRAM - )} - -
- ))} -
- {/* Warning message */} - {gpuEnabled && gpusInUse.length > 1 && ( -
- -

- If multi-GPU is enabled with different GPU models or - without NVLink, it could impact token speed. -

+ + handleGPUChange(gpu.id) + } + /> + +
+ ))}
- )} -
- + {/* Warning message */} + {gpuEnabled && gpusInUse.length > 1 && ( +
+ +

+ If multi-GPU is enabled with different GPU models + or without NVLink, it could impact token speed. +

+
+ )} +
+ + + {/* TODO enable this when we support AMD */} + + + +
+
+ )} + + {/* Vulkan for AMD GPU/ APU and Intel Arc GPU */} + {!isMac && experimentalFeature && ( +
+
+
+
+ Vulkan Support +
+
+

+ Enable Vulkan with AMD GPU/APU and Intel Arc GPU for better + model performance (reload needed). +

+
- {/* TODO enable this when we support AMD */} - - - + { + toaster({ + title: 'Reload', + description: + 'Vulkan settings updated. Reload now to apply the changes.', + }) + stopModel() + saveSettings({ vulkan: e, gpusInUse: [] }) + setVulkanEnabled(e) + }} + /> +
+ )} + + + {/* Proxy */} +
+
+
+
HTTPS Proxy
+ setProxyEnabled(!proxyEnabled)} + /> +
+

+ Specify the HTTPS proxy or leave blank (proxy auto-configuration + and SOCKS not supported). +

+ :@:'} + value={partialProxy} + onChange={onProxyChange} + className="w-2/3" + />
- )} - {/* Vulkan for AMD GPU/ APU and Intel Arc GPU */} - {!isMac && experimentalFeature && ( + {/* Ignore SSL certificates */}
- Vulkan Support + Ignore SSL certificates
-

- Enable Vulkan with AMD GPU/APU and Intel Arc GPU for better model - performance (reload needed). +

+ Allow self-signed or unverified certificates - may be required for + certain proxies.

- { - toaster({ - title: 'Reload', - description: - 'Vulkan settings updated. Reload now to apply the changes.', - }) - stopModel() - saveSettings({ vulkan: e, gpusInUse: [] }) - setVulkanEnabled(e) - }} + checked={ignoreSSL} + onCheckedChange={(e) => setIgnoreSSL(e)} />
- )} - - - {/* Proxy */} -
-
-
-
HTTPS Proxy
- setProxyEnabled(!proxyEnabled)} - /> -
-

- Specify the HTTPS proxy or leave blank (proxy auto-configuration and - SOCKS not supported). -

- :@:'} - value={partialProxy} - onChange={onProxyChange} - className="w-2/3" - /> -
-
- {/* Ignore SSL certificates */} -
-
-
-
- Ignore SSL certificates -
+ {/* Clear log */} +
+
+
+
Clear logs
+
+

Clear all logs from Jan app.

-

- Allow self-signed or unverified certificates - may be required for - certain proxies. -

+
- setIgnoreSSL(e)} /> -
- {/* Clear log */} -
-
-
-
Clear logs
-
-

Clear all logs from Jan app.

-
- + {/* Factory Reset */} +
- - {/* Factory Reset */} - -
+ ) } diff --git a/web/screens/Settings/Appearance/index.tsx b/web/screens/Settings/Appearance/index.tsx index ecf37b91cb..51899ba40c 100644 --- a/web/screens/Settings/Appearance/index.tsx +++ b/web/screens/Settings/Appearance/index.tsx @@ -3,7 +3,7 @@ import ToggleTheme from '@/screens/Settings/Appearance/ToggleTheme' export default function AppearanceOptions() { return ( -
+
diff --git a/web/screens/Settings/CancelModelImportModal/index.tsx b/web/screens/Settings/CancelModelImportModal/index.tsx new file mode 100644 index 0000000000..320e18d587 --- /dev/null +++ b/web/screens/Settings/CancelModelImportModal/index.tsx @@ -0,0 +1,61 @@ +import React from 'react' + +import { + Modal, + ModalContent, + ModalHeader, + ModalTitle, + ModalFooter, + ModalClose, + Button, +} from '@janhq/uikit' +import { useAtomValue, useSetAtom } from 'jotai' + +import { + getImportModelStageAtom, + setImportModelStageAtom, +} from '@/hooks/useImportModel' + +const CancelModelImportModal: React.FC = () => { + const importModelStage = useAtomValue(getImportModelStageAtom) + const setImportModelStage = useSetAtom(setImportModelStageAtom) + + const onContinueClick = () => { + setImportModelStage('IMPORTING_MODEL') + } + + const onCancelAllClick = () => { + setImportModelStage('NONE') + } + + return ( + + + + Cancel Model Import? + + +

+ The model import process is not complete. Are you sure you want to + cancel all ongoing model imports? This action is irreversible and the + progress will be lost. +

+ + +
+ + + + + + +
+
+
+
+ ) +} + +export default React.memo(CancelModelImportModal) diff --git a/web/screens/Settings/CoreExtensions/index.tsx b/web/screens/Settings/CoreExtensions/index.tsx index 6ca8d82f76..8c9f92d7ac 100644 --- a/web/screens/Settings/CoreExtensions/index.tsx +++ b/web/screens/Settings/CoreExtensions/index.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react' -import { Button } from '@janhq/uikit' +import { Button, ScrollArea } from '@janhq/uikit' import { formatExtensionsName } from '@/utils/converter' @@ -68,58 +68,60 @@ const ExtensionCatalog = () => { } return ( -
- {activeExtensions.map((item, i) => { - return ( -
-
-
-
- {formatExtensionsName(item.name ?? item.description ?? '')} -
-

- v{item.version} + +

+ {activeExtensions.map((item, i) => { + return ( +
+
+
+
+ {formatExtensionsName(item.name ?? item.description ?? '')} +
+

+ v{item.version} +

+
+

+ {item.description}

-

- {item.description} -

+ ) + })} + {/* Manual Installation */} +
+
+
+
+ Manual Installation +
+
+

+ Select a extension file to install (.tgz) +

- ) - })} - {/* Manual Installation */} -
-
-
-
- Manual Installation -
+
+ +
-

- Select a extension file to install (.tgz) -

-
-
- -
-
+ ) } diff --git a/web/screens/Settings/EditModelInfoModal/index.tsx b/web/screens/Settings/EditModelInfoModal/index.tsx new file mode 100644 index 0000000000..bb87b7ed95 --- /dev/null +++ b/web/screens/Settings/EditModelInfoModal/index.tsx @@ -0,0 +1,197 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { Model, ModelEvent, events, openFileExplorer } from '@janhq/core' +import { + Modal, + ModalContent, + ModalHeader, + ModalTitle, + ModalFooter, + ModalClose, + Button, + Input, + Textarea, +} from '@janhq/uikit' +import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' + +import { Paperclip } from 'lucide-react' + +import useImportModel, { + getImportModelStageAtom, + setImportModelStageAtom, +} from '@/hooks/useImportModel' + +import { toGibibytes } from '@/utils/converter' + +import { openFileTitle } from '@/utils/titleUtils' + +import { janDataFolderPathAtom } from '@/helpers/atoms/AppConfig.atom' +import { + importingModelsAtom, + updateImportingModelAtom, +} from '@/helpers/atoms/Model.atom' + +export const editingModelIdAtom = atom(undefined) + +const EditModelInfoModal: React.FC = () => { + const importModelStage = useAtomValue(getImportModelStageAtom) + const importingModels = useAtomValue(importingModelsAtom) + const setImportModelStage = useSetAtom(setImportModelStageAtom) + const [editingModelId, setEditingModelId] = useAtom(editingModelIdAtom) + + const [modelName, setModelName] = useState('') + const [modelId, setModelId] = useState('') + const [description, setDescription] = useState('') + const [tags, setTags] = useState([]) + + const janDataFolder = useAtomValue(janDataFolderPathAtom) + const updateImportingModel = useSetAtom(updateImportingModelAtom) + const { updateModelInfo } = useImportModel() + + const editingModel = importingModels.find( + (model) => model.importId === editingModelId + ) + + useEffect(() => { + if (editingModel && editingModel.modelId != null) { + setModelName(editingModel.name) + setModelId(editingModel.modelId) + setDescription(editingModel.description) + setTags(editingModel.tags) + } + }, [editingModel]) + + const onCancelClick = () => { + setImportModelStage('IMPORTING_MODEL') + setEditingModelId(undefined) + } + + const onSaveClick = async () => { + if (!editingModel || !editingModel.modelId) return + + const modelInfo: Partial = { + id: editingModel.modelId, + name: modelName, + description, + metadata: { + author: 'User', + tags, + size: 0, + }, + } + + await updateModelInfo(modelInfo) + events.emit(ModelEvent.OnModelsUpdate, {}) + updateImportingModel(editingModel.importId, modelName, description, tags) + + setImportModelStage('IMPORTING_MODEL') + setEditingModelId(undefined) + } + + const modelFolderPath = useMemo(() => { + return `${janDataFolder}/models/${editingModel?.modelId}` + }, [janDataFolder, editingModel]) + + const onShowInFinderClick = useCallback(() => { + openFileExplorer(modelFolderPath) + }, [modelFolderPath]) + + if (!editingModel) { + setImportModelStage('IMPORTING_MODEL') + setEditingModelId(undefined) + + return null + } + + return ( + + + + Edit Model Information + + +
+
+ +
+ +
+

{editingModel.name}

+
+ + {toGibibytes(editingModel.size)} + + + Format:{' '} + + + {editingModel.format.toUpperCase()} + +
+
+ + {modelFolderPath} + + +
+
+
+ +
+
+ + { + e.preventDefault() + setModelName(e.target.value) + }} + /> +
+
+ + { + e.preventDefault() + setModelId(e.target.value) + }} + /> +
+
+ +