diff --git a/README.md b/README.md index 425ea69be..d91366581 100644 --- a/README.md +++ b/README.md @@ -43,31 +43,31 @@ Jan is an open-source ChatGPT alternative that runs 100% offline on your compute Stable (Recommended) - + jan.exe - + Intel - + M1/M2 - + jan.deb - + jan.AppImage @@ -76,31 +76,31 @@ Jan is an open-source ChatGPT alternative that runs 100% offline on your compute Experimental (Nightly Build) - + jan.exe - + Intel - + M1/M2 - + jan.deb - + jan.AppImage @@ -304,7 +304,7 @@ This will build the app MacOS m1/m2 for production (with code signing already do ```bash # GPU mode with default file system - docker compose --profile gpu up -d + docker compose --profile gpu-fs up -d # GPU mode with S3 file system docker compose --profile gpu-s3fs up -d @@ -319,6 +319,7 @@ This will start the web server and you can access Jan at `http://localhost:3000` Jan builds on top of other open-source projects: - [llama.cpp](https://github.com/ggerganov/llama.cpp) +- [LangChain](https://github.com/langchain-ai) - [TensorRT](https://github.com/NVIDIA/TensorRT) ## Contact diff --git a/core/src/api/index.ts b/core/src/api/index.ts index 676020758..c7dd9146e 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/extension.ts b/core/src/extension.ts index 3528f581c..3b3edc7b3 100644 --- a/core/src/extension.ts +++ b/core/src/extension.ts @@ -4,6 +4,7 @@ export enum ExtensionTypeEnum { Inference = 'inference', Model = 'model', SystemMonitoring = 'systemMonitoring', + HuggingFace = 'huggingFace', } export interface ExtensionType { diff --git a/core/src/extensions/huggingface.ts b/core/src/extensions/huggingface.ts new file mode 100644 index 000000000..16a1d9b8a --- /dev/null +++ b/core/src/extensions/huggingface.ts @@ -0,0 +1,30 @@ +import { BaseExtension, ExtensionTypeEnum } from '../extension' +import { HuggingFaceInterface, HuggingFaceRepoData, Quantization } from '../types/huggingface' +import { Model } from '../types/model' + +/** + * Hugging Face extension for converting HF models to GGUF. + */ +export abstract class HuggingFaceExtension extends BaseExtension implements HuggingFaceInterface { + interrupted = false + /** + * Hugging Face extension type. + */ + type(): ExtensionTypeEnum | undefined { + return ExtensionTypeEnum.HuggingFace + } + + abstract downloadModelFiles( + repoID: string, + repoData: HuggingFaceRepoData, + network?: { ignoreSSL?: boolean; proxy?: string } + ): Promise + abstract convert(repoID: string): Promise + abstract quantize(repoID: string, quantization: Quantization): Promise + abstract generateMetadata( + repoID: string, + repoData: HuggingFaceRepoData, + quantization: Quantization + ): Promise + abstract cancelConvert(repoID: string, repoData: HuggingFaceRepoData): Promise +} diff --git a/core/src/extensions/index.ts b/core/src/extensions/index.ts index 522334548..c6834482c 100644 --- a/core/src/extensions/index.ts +++ b/core/src/extensions/index.ts @@ -23,3 +23,8 @@ export { AssistantExtension } from './assistant' * Model extension for managing models. */ export { ModelExtension } from './model' + +/** + * Hugging Face extension for converting HF models to GGUF. + */ +export { HuggingFaceExtension } from './huggingface' diff --git a/core/src/extensions/model.ts b/core/src/extensions/model.ts index df7d14f42..79202398b 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 0e570d1f5..71538ae9c 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 686ba58a1..bff6f47f0 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 71e07ae57..4787da65b 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/huggingface/huggingfaceEntity.ts b/core/src/types/huggingface/huggingfaceEntity.ts new file mode 100644 index 000000000..c3c320354 --- /dev/null +++ b/core/src/types/huggingface/huggingfaceEntity.ts @@ -0,0 +1,34 @@ +export interface HuggingFaceRepoData { + id: string + author: string + tags: Array<'transformers' | 'pytorch' | 'safetensors' | string> + siblings: { + rfilename: string + }[] + createdAt: string // ISO 8601 timestamp +} + +/* eslint-disable @typescript-eslint/naming-convention */ +export enum Quantization { + Q3_K_S = 'Q3_K_S', + Q3_K_M = 'Q3_K_M', // eslint-disable-line @typescript-eslint/no-duplicate-enum-values + Q3_K_L = 'Q3_K_L', + Q4_K_S = 'Q4_K_S', + Q4_K_M = 'Q4_K_M', // eslint-disable-line @typescript-eslint/no-duplicate-enum-values + Q5_K_S = 'Q5_K_S', + Q5_K_M = 'Q5_K_M', // eslint-disable-line @typescript-eslint/no-duplicate-enum-values + Q4_0 = 'Q4_0', + Q4_1 = 'Q4_1', + Q5_0 = 'Q5_0', + Q5_1 = 'Q5_1', + IQ2_XXS = 'IQ2_XXS', + IQ2_XS = 'IQ2_XS', + Q2_K = 'Q2_K', + Q2_K_S = 'Q2_K_S', + Q6_K = 'Q6_K', + Q8_0 = 'Q8_0', + F16 = 'F16', + F32 = 'F32', + COPY = 'COPY', +} +/* eslint-enable @typescript-eslint/naming-convention */ diff --git a/core/src/types/huggingface/huggingfaceInterface.ts b/core/src/types/huggingface/huggingfaceInterface.ts new file mode 100644 index 000000000..c99b2177d --- /dev/null +++ b/core/src/types/huggingface/huggingfaceInterface.ts @@ -0,0 +1,58 @@ +import { Model } from '../model' +import { HuggingFaceRepoData, Quantization } from './huggingfaceEntity' + +/** + * Hugging Face extension for converting HF models to GGUF. + * @extends BaseExtension + */ +export interface HuggingFaceInterface { + interrupted: boolean + /** + * Downloads a Hugging Face model. + * @param repoID - The repo ID of the model to convert. + * @param repoData - The repo data of the model to convert. + * @param network - Optional object to specify proxy/whether to ignore SSL certificates. + * @returns A promise that resolves when the download is complete. + */ + downloadModelFiles( + repoID: string, + repoData: HuggingFaceRepoData, + network?: { ignoreSSL?: boolean; proxy?: string } + ): Promise + + /** + * Converts a Hugging Face model to GGUF. + * @param repoID - The repo ID of the model to convert. + * @returns A promise that resolves when the conversion is complete. + */ + convert(repoID: string): Promise + + /** + * Quantizes a GGUF model. + * @param repoID - The repo ID of the model to quantize. + * @param quantization - The quantization to use. + * @returns A promise that resolves when the quantization is complete. + */ + quantize(repoID: string, quantization: Quantization): Promise + + /** + * Generates Jan model metadata from a Hugging Face model. + * @param repoID - The repo ID of the model to generate metadata for. + * @param repoData - The repo data of the model to generate metadata for. + * @param quantization - The quantization of the model. + * @returns A promise that resolves when the model metadata generation is complete. + */ + generateMetadata( + repoID: string, + repoData: HuggingFaceRepoData, + quantization: Quantization + ): Promise + + /** + * Cancels the convert of current Hugging Face model. + * @param repoID - The repository ID to cancel. + * @param repoData - The repository data to cancel. + * @returns {Promise} A promise that resolves when the download has been cancelled. + */ + cancelConvert(repoID: string, repoData: HuggingFaceRepoData): Promise +} diff --git a/core/src/types/huggingface/index.ts b/core/src/types/huggingface/index.ts new file mode 100644 index 000000000..c108c55e2 --- /dev/null +++ b/core/src/types/huggingface/index.ts @@ -0,0 +1,2 @@ +export * from './huggingfaceInterface' +export * from './huggingfaceEntity' diff --git a/core/src/types/index.ts b/core/src/types/index.ts index ee6f4ef08..295d054e7 100644 --- a/core/src/types/index.ts +++ b/core/src/types/index.ts @@ -6,4 +6,5 @@ export * from './inference' export * from './monitoring' export * from './file' export * from './config' +export * from './huggingface' export * from './miscellaneous' diff --git a/core/src/types/model/index.ts b/core/src/types/model/index.ts index cba06ea95..fdbf01863 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 000000000..8977c42a0 --- /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/docs/docs/acknowledgements.md b/docs/docs/acknowledgements.md new file mode 100644 index 000000000..c68c4ed86 --- /dev/null +++ b/docs/docs/acknowledgements.md @@ -0,0 +1,26 @@ +--- +title: Acknowledgements +description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server. +slug: /acknowledgements +keywords: + [ + Jan AI, + Jan, + ChatGPT alternative, + local AI, + private AI, + conversational AI, + no-subscription fee, + large language model, + acknowledgements, + third-party libraries, + ] +--- + +# Acknowledgements + +We would like to express our gratitude to the following third-party libraries that have made the development of Jan possible. + +- [llama.cpp](https://github.com/ggerganov/llama.cpp/blob/master/LICENSE) +- [LangChain.js](https://github.com/langchain-ai/langchainjs/blob/main/LICENSE) +- [TensorRT](https://github.com/NVIDIA/TensorRT/blob/main/LICENSE) diff --git a/docs/docs/guides/02-installation/05-docker.md b/docs/docs/guides/02-installation/05-docker.md index 6236ed92e..5973e9771 100644 --- a/docs/docs/guides/02-installation/05-docker.md +++ b/docs/docs/guides/02-installation/05-docker.md @@ -13,13 +13,13 @@ keywords: no-subscription fee, large language model, docker installation, + cpu mode, + gpu mode, ] --- # Installing Jan using Docker -## Installation - ### Pre-requisites :::note @@ -37,66 +37,87 @@ sudo sh ./get-docker.sh --dry-run - If you intend to run Jan in GPU mode, you need to install `nvidia-driver` and `nvidia-docker2`. Follow the instruction [here](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) for installation. -### Instructions +### Run Jan in Docker Mode -- Run Jan in Docker mode +| Docker compose Profile | Description | +| ---------------------- | -------------------------------------------- | +| `cpu-fs` | Run Jan in CPU mode with default file system | +| `cpu-s3fs` | Run Jan in CPU mode with S3 file system | +| `gpu-fs` | Run Jan in GPU mode with default file system | +| `gpu-s3fs` | Run Jan in GPU mode with S3 file system | - - **Option 1**: Run Jan in CPU mode +| Environment Variable | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------------- | +| `S3_BUCKET_NAME` | S3 bucket name - leave blank for default file system | +| `AWS_ACCESS_KEY_ID` | AWS access key ID - leave blank for default file system | +| `AWS_SECRET_ACCESS_KEY` | AWS secret access key - leave blank for default file system | +| `AWS_ENDPOINT` | AWS endpoint URL - leave blank for default file system | +| `AWS_REGION` | AWS region - leave blank for default file system | +| `API_BASE_URL` | Jan Server URL, please modify it as your public ip address or domain name default http://localhost:1377 | + +- **Option 1**: Run Jan in CPU mode + + ```bash + # cpu mode with default file system + docker compose --profile cpu-fs up -d + + # cpu mode with S3 file system + docker compose --profile cpu-s3fs up -d + ``` + +- **Option 2**: Run Jan in GPU mode + + - **Step 1**: Check CUDA compatibility with your NVIDIA driver by running `nvidia-smi` and check the CUDA version in the output + + ```bash + nvidia-smi + + # Output + +---------------------------------------------------------------------------------------+ + | NVIDIA-SMI 531.18 Driver Version: 531.18 CUDA Version: 12.1 | + |-----------------------------------------+----------------------+----------------------+ + | GPU Name TCC/WDDM | Bus-Id Disp.A | Volatile Uncorr. ECC | + | Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. | + | | | MIG M. | + |=========================================+======================+======================| + | 0 NVIDIA GeForce RTX 4070 Ti WDDM | 00000000:01:00.0 On | N/A | + | 0% 44C P8 16W / 285W| 1481MiB / 12282MiB | 2% Default | + | | | N/A | + +-----------------------------------------+----------------------+----------------------+ + | 1 NVIDIA GeForce GTX 1660 Ti WDDM | 00000000:02:00.0 Off | N/A | + | 0% 49C P8 14W / 120W| 0MiB / 6144MiB | 0% Default | + | | | N/A | + +-----------------------------------------+----------------------+----------------------+ + | 2 NVIDIA GeForce GTX 1660 Ti WDDM | 00000000:05:00.0 Off | N/A | + | 29% 38C P8 11W / 120W| 0MiB / 6144MiB | 0% Default | + | | | N/A | + +-----------------------------------------+----------------------+----------------------+ + + +---------------------------------------------------------------------------------------+ + | Processes: | + | GPU GI CI PID Type Process name GPU Memory | + | ID ID Usage | + |=======================================================================================| + ``` + + - **Step 2**: Visit [NVIDIA NGC Catalog ](https://catalog.ngc.nvidia.com/orgs/nvidia/containers/cuda/tags) and find the smallest minor version of image tag that matches your CUDA version (e.g., 12.1 -> 12.1.0) + + - **Step 3**: Update the `Dockerfile.gpu` line number 5 with the latest minor version of the image tag from step 2 (e.g. change `FROM nvidia/cuda:12.2.0-runtime-ubuntu22.04 AS base` to `FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04 AS base`) + + - **Step 4**: Run command to start Jan in GPU mode ```bash - docker compose --profile cpu up -d + # GPU mode with default file system + docker compose --profile gpu up -d + + # GPU mode with S3 file system + docker compose --profile gpu-s3fs up -d ``` - - **Option 2**: Run Jan in GPU mode - - - **Step 1**: Check CUDA compatibility with your NVIDIA driver by running `nvidia-smi` and check the CUDA version in the output - - ```bash - nvidia-smi - - # Output - +---------------------------------------------------------------------------------------+ - | NVIDIA-SMI 531.18 Driver Version: 531.18 CUDA Version: 12.1 | - |-----------------------------------------+----------------------+----------------------+ - | GPU Name TCC/WDDM | Bus-Id Disp.A | Volatile Uncorr. ECC | - | Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. | - | | | MIG M. | - |=========================================+======================+======================| - | 0 NVIDIA GeForce RTX 4070 Ti WDDM | 00000000:01:00.0 On | N/A | - | 0% 44C P8 16W / 285W| 1481MiB / 12282MiB | 2% Default | - | | | N/A | - +-----------------------------------------+----------------------+----------------------+ - | 1 NVIDIA GeForce GTX 1660 Ti WDDM | 00000000:02:00.0 Off | N/A | - | 0% 49C P8 14W / 120W| 0MiB / 6144MiB | 0% Default | - | | | N/A | - +-----------------------------------------+----------------------+----------------------+ - | 2 NVIDIA GeForce GTX 1660 Ti WDDM | 00000000:05:00.0 Off | N/A | - | 29% 38C P8 11W / 120W| 0MiB / 6144MiB | 0% Default | - | | | N/A | - +-----------------------------------------+----------------------+----------------------+ - - +---------------------------------------------------------------------------------------+ - | Processes: | - | GPU GI CI PID Type Process name GPU Memory | - | ID ID Usage | - |=======================================================================================| - ``` - - - **Step 2**: Visit [NVIDIA NGC Catalog ](https://catalog.ngc.nvidia.com/orgs/nvidia/containers/cuda/tags) and find the smallest minor version of image tag that matches your CUDA version (e.g., 12.1 -> 12.1.0) - - - **Step 3**: Update the `Dockerfile.gpu` line number 5 with the latest minor version of the image tag from step 2 (e.g. change `FROM nvidia/cuda:12.2.0-runtime-ubuntu22.04 AS base` to `FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04 AS base`) - - - **Step 4**: Run command to start Jan in GPU mode - - ```bash - # GPU mode - docker compose --profile gpu up -d - ``` - - This will start the web server and you can access Jan at `http://localhost:3000`. +This will start the web server and you can access Jan at `http://localhost:3000`. :::warning -- Docker mode is currently only suitable for development and localhost. Production is not supported yet, and the RAG feature is not available in Docker mode. +- RAG feature is not supported in Docker mode with s3fs yet. ::: diff --git a/docs/sidebars.js b/docs/sidebars.js index a521437f2..50c92f33e 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -51,6 +51,7 @@ const sidebars = { "how-we-work/website-docs/website-docs", ], }, + "acknowledgements", ], productSidebar: [ { diff --git a/docs/src/styles/tweaks/markdown.scss b/docs/src/styles/tweaks/markdown.scss index 1093f2318..ade07e35b 100644 --- a/docs/src/styles/tweaks/markdown.scss +++ b/docs/src/styles/tweaks/markdown.scss @@ -1,4 +1,10 @@ .theme-doc-markdown { + a, + p, + span, + li { + @apply leading-loose; + } a { @apply text-blue-600 dark:text-blue-400; } @@ -10,9 +16,9 @@ } ul, ol { - padding-left: 16px; + padding-left: 28px; li { - @apply leading-normal; + @apply leading-loose; p { margin-bottom: 0; } diff --git a/docs/src/styles/tweaks/sidebar.scss b/docs/src/styles/tweaks/sidebar.scss index 5508a3bfa..02fed8ce8 100644 --- a/docs/src/styles/tweaks/sidebar.scss +++ b/docs/src/styles/tweaks/sidebar.scss @@ -1,12 +1,12 @@ // * Classname from Docusaurus template // * We just overide the styling with applied class from tailwind -[class*="docSidebarContainer_"] { +[class*='docSidebarContainer_'] { margin-top: 0 !important; @apply dark:border-gray-800 border-gray-300; } -[class*="sidebar_"] { +[class*='sidebar_'] { padding-top: 0px !important; } @@ -14,32 +14,40 @@ padding-top: 20px !important; } -[class*="sidebarViewport_"] { +[class*='sidebarViewport_'] { top: 80px !important; // height: unset !important; } -[class*="docItemCol_"] { +[class*='docItemCol_'] { @apply lg:px-8; } // * Including custom sidebar table of content .table-of-contents { - @apply text-base py-0 dark:border-gray-800 border-gray-300; + @apply text-sm py-0 dark:border-gray-800 border-gray-300; } .menu__caret:before { background: var(--ifm-menu-link-sublist-icon) 50% / 1.5rem 1.5rem; } -[class*="codeBlockContainer_"] { +[class*='codeBlockContainer_'] { margin: 4px; } -[class*="codeBlockTitle_"] { +[class*='codeBlockTitle_'] { border-bottom: 1px solid #52525a !important; } -[class*="iconExternalLink_"] { +[class*='iconExternalLink_'] { display: none; } + +[class*='docMainContainer'] { + @media (min-width: 1440px) { + .container { + max-width: var(--ifm-container-width-xl); + } + } +} diff --git a/electron/handlers/native.ts b/electron/handlers/native.ts index 14ead07bd..79fa994bf 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/package.json b/electron/package.json index a89803077..7cdb98360 100644 --- a/electron/package.json +++ b/electron/package.json @@ -15,12 +15,14 @@ "build/**/*.{js,map}", "pre-install", "models/**/*", - "docs/**/*" + "docs/**/*", + "scripts/**/*" ], "asarUnpack": [ "pre-install", "models", - "docs" + "docs", + "scripts" ], "publish": [ { diff --git a/electron/utils/dev.ts b/electron/utils/dev.ts index b2a492886..16e5241b6 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 84c185d75..9dcd4563b 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/huggingface-extension/.gitignore b/extensions/huggingface-extension/.gitignore new file mode 100644 index 000000000..bdf39cc7f --- /dev/null +++ b/extensions/huggingface-extension/.gitignore @@ -0,0 +1,3 @@ +bin +scripts/convert* +scripts/gguf-py diff --git a/extensions/huggingface-extension/.prettierrc b/extensions/huggingface-extension/.prettierrc new file mode 100644 index 000000000..46f1abcb0 --- /dev/null +++ b/extensions/huggingface-extension/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": false, + "singleQuote": true, + "quoteProps": "consistent", + "trailingComma": "es5", + "endOfLine": "auto", + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/extensions/huggingface-extension/README.md b/extensions/huggingface-extension/README.md new file mode 100644 index 000000000..ae70eb4ec --- /dev/null +++ b/extensions/huggingface-extension/README.md @@ -0,0 +1,73 @@ +# Create a Jan Plugin using Typescript + +Use this template to bootstrap the creation of a TypeScript Jan plugin. 🚀 + +## Create Your Own Plugin + +To create your own plugin, you can use this repository as a template! Just follow the below instructions: + +1. Click the Use this template button at the top of the repository +2. Select Create a new repository +3. Select an owner and name for your new repository +4. Click Create repository +5. Clone your new repository + +## Initial Setup + +After you've cloned the repository to your local machine or codespace, you'll need to perform some initial setup steps before you can develop your plugin. + +> [!NOTE] +> +> You'll need to have a reasonably modern version of +> [Node.js](https://nodejs.org) handy. If you are using a version manager like +> [`nodenv`](https://github.com/nodenv/nodenv) or +> [`nvm`](https://github.com/nvm-sh/nvm), you can run `nodenv install` in the +> root of your repository to install the version specified in +> [`package.json`](./package.json). Otherwise, 20.x or later should work! + +1. :hammer_and_wrench: Install the dependencies + + ```bash + npm install + ``` + +1. :building_construction: Package the TypeScript for distribution + + ```bash + npm run bundle + ``` + +1. :white_check_mark: Check your artifact + + There will be a tgz file in your plugin directory now + +## Update the Plugin Metadata + +The [`package.json`](package.json) file defines metadata about your plugin, such as +plugin name, main entry, description and version. + +When you copy this repository, update `package.json` with the name, description for your plugin. + +## Update the Plugin Code + +The [`src/`](./src/) directory is the heart of your plugin! This contains the +source code that will be run when your plugin extension functions are invoked. You can replace the +contents of this directory with your own code. + +There are a few things to keep in mind when writing your plugin code: + +- Most Jan Plugin Extension functions are processed asynchronously. + In `index.ts`, you will see that the extension function will return a `Promise`. + + ```typescript + import { core } from "@janhq/core"; + + function onStart(): Promise { + return core.invokePluginFunc(MODULE_PATH, "run", 0); + } + ``` + + For more information about the Jan Plugin Core module, see the + [documentation](https://github.com/janhq/jan/blob/main/core/README.md). + +So, what are you waiting for? Go ahead and start customizing your plugin! diff --git a/extensions/huggingface-extension/bin/mac-arm64/quantize b/extensions/huggingface-extension/bin/mac-arm64/quantize new file mode 100755 index 000000000..f8a149b10 Binary files /dev/null and b/extensions/huggingface-extension/bin/mac-arm64/quantize differ diff --git a/extensions/huggingface-extension/download.bat b/extensions/huggingface-extension/download.bat new file mode 100644 index 000000000..de055cb80 --- /dev/null +++ b/extensions/huggingface-extension/download.bat @@ -0,0 +1,3 @@ +@echo off +set /p LLAMA_CPP_VERSION=<./scripts/version.txt +.\node_modules\.bin\download https://github.com/ggerganov/llama.cpp/archive/refs/tags/%LLAMA_CPP_VERSION%.tar.gz -o . --filename ./scripts/llama.cpp.tar.gz && tar -xzf .\scripts\llama.cpp.tar.gz "llama.cpp-%LLAMA_CPP_VERSION%/convert.py" "llama.cpp-%LLAMA_CPP_VERSION%/convert-hf-to-gguf.py" "llama.cpp-%LLAMA_CPP_VERSION%/gguf-py" && cpx "./llama.cpp-%LLAMA_CPP_VERSION%/**" "scripts" && rimraf "./scripts/llama.cpp.tar.gz" && rimraf "./llama.cpp-%LLAMA_CPP_VERSION%" \ No newline at end of file diff --git a/extensions/huggingface-extension/package.json b/extensions/huggingface-extension/package.json new file mode 100644 index 000000000..e71dc7406 --- /dev/null +++ b/extensions/huggingface-extension/package.json @@ -0,0 +1,57 @@ +{ + "name": "@janhq/huggingface-extension", + "version": "1.0.0", + "description": "Hugging Face extension for converting HF models to GGUF", + "main": "dist/index.js", + "node": "dist/node/index.cjs.js", + "author": "Jan ", + "license": "AGPL-3.0", + "scripts": { + "build": "tsc --module commonjs && rollup -c rollup.config.ts --configPlugin @rollup/plugin-typescript --bundleConfigAsCjs", + "download:llama": "run-script-os", + "download:llama:linux": "LLAMA_CPP_VERSION=$(cat ./scripts/version.txt) && download https://github.com/ggerganov/llama.cpp/archive/refs/tags/${LLAMA_CPP_VERSION}.tar.gz -o . --filename ./scripts/llama.cpp.tar.gz && tar -xzf ./scripts/llama.cpp.tar.gz --wildcards '*/convert.py' '*/convert-hf-to-gguf.py' '*/gguf-py' && cpx \"./llama.cpp-$LLAMA_CPP_VERSION/**\" \"scripts\" && rimraf \"./scripts/llama.cpp.tar.gz\" && rimraf \"./llama.cpp-$LLAMA_CPP_VERSION\"", + "download:llama:darwin": "LLAMA_CPP_VERSION=$(cat ./scripts/version.txt) && download https://github.com/ggerganov/llama.cpp/archive/refs/tags/${LLAMA_CPP_VERSION}.tar.gz -o . --filename ./scripts/llama.cpp.tar.gz && tar -xzf ./scripts/llama.cpp.tar.gz '*/convert.py' '*/convert-hf-to-gguf.py' '*/gguf-py' && cpx \"./llama.cpp-$LLAMA_CPP_VERSION/**\" \"scripts\" && rimraf \"./scripts/llama.cpp.tar.gz\" && rimraf \"./llama.cpp-$LLAMA_CPP_VERSION\"", + "download:llama:win32": "download.bat", + "build:publish:linux": "rimraf *.tgz --glob && npm run build && npm run download:llama && cpx \"scripts/**\" \"dist/scripts\" && cpx \"bin/**\" \"dist/bin\" && npm pack && cpx *.tgz ../../pre-install", + "build:publish:darwin": "rimraf *.tgz --glob && npm run build && npm run download:llama && cpx \"scripts/**\" \"dist/scripts\" && cpx \"bin/**\" \"dist/bin\" && ../../.github/scripts/auto-sign.sh && npm pack && cpx *.tgz ../../pre-install", + "build:publish:win32": "rimraf *.tgz --glob && npm run build && npm run download:llama && cpx \"scripts/**\" \"dist/scripts\" && cpx \"bin/**\" \"dist/bin\" && npm pack && cpx *.tgz ../../pre-install", + "build:publish": "run-script-os" + }, + "exports": { + ".": "./dist/index.js", + "./main": "./dist/node/index.cjs.js" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-replace": "^5.0.5", + "@rollup/plugin-typescript": "^11.1.6", + "@types/node": "^20.11.16", + "cpx": "^1.5.0", + "download-cli": "^1.1.1", + "rimraf": "^5.0.5", + "rollup": "^4.9.6", + "rollup-plugin-sourcemaps": "^0.6.3", + "rollup-plugin-typescript2": "^0.36.0", + "run-script-os": "^1.1.6", + "typescript": "^5.3.3" + }, + "dependencies": { + "@janhq/core": "file:../../core", + "hyllama": "^0.1.2", + "python-shell": "^5.0.0", + "ts-loader": "^9.5.0" + }, + "bundledDependencies": [ + "python-shell" + ], + "engines": { + "node": ">=18.0.0" + }, + "files": [ + "dist/*", + "package.json", + "README.md" + ] +} diff --git a/extensions/huggingface-extension/rollup.config.ts b/extensions/huggingface-extension/rollup.config.ts new file mode 100644 index 000000000..7ae2c5781 --- /dev/null +++ b/extensions/huggingface-extension/rollup.config.ts @@ -0,0 +1,72 @@ +import resolve from '@rollup/plugin-node-resolve' +import commonjs from '@rollup/plugin-commonjs' +import sourceMaps from 'rollup-plugin-sourcemaps' +import typescript from 'rollup-plugin-typescript2' +import json from '@rollup/plugin-json' +import replace from '@rollup/plugin-replace' + +const packageJson = require('./package.json') + +export default [ + { + input: `src/index.ts`, + output: [{ file: packageJson.main, format: 'es', sourcemap: true }], + // Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash') + external: [], + watch: { + include: 'src/**', + }, + plugins: [ + replace({ + EXTENSION_NAME: JSON.stringify(packageJson.name), + NODE_MODULE_PATH: JSON.stringify( + `${packageJson.name}/${packageJson.node}` + ), + }), + // Allow json resolution + json(), + // Compile TypeScript files + typescript({ useTsconfigDeclarationDir: true }), + // Compile TypeScript files + // Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs) + commonjs(), + // Allow node_modules resolution, so you can use 'external' to control + // which external modules to include in the bundle + // https://github.com/rollup/rollup-plugin-node-resolve#usage + resolve({ + extensions: ['.js', '.ts'], + }), + + // Resolve source maps to the original source + sourceMaps(), + ], + }, + { + input: `src/node/index.ts`, + output: [ + { file: 'dist/node/index.cjs.js', format: 'cjs', sourcemap: true }, + ], + // Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash') + external: [], + watch: { + include: 'src/node/**', + }, + plugins: [ + // Allow json resolution + json(), + // Compile TypeScript files + typescript({ useTsconfigDeclarationDir: true }), + // Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs) + commonjs(), + // Allow node_modules resolution, so you can use 'external' to control + // which external modules to include in the bundle + // https://github.com/rollup/rollup-plugin-node-resolve#usage + resolve({ + extensions: ['.ts', '.js', '.json'], + }), + + // Resolve source maps to the original source + sourceMaps(), + ], + }, +] diff --git a/extensions/huggingface-extension/scripts/install_deps.py b/extensions/huggingface-extension/scripts/install_deps.py new file mode 100644 index 000000000..2dfabed07 --- /dev/null +++ b/extensions/huggingface-extension/scripts/install_deps.py @@ -0,0 +1,14 @@ +import subprocess +import sys + +deps = [ + 'numpy~=1.24.4', + 'sentencepiece~=0.1.98', + 'transformers>=4.35.2,<5.0.0', + 'gguf>=0.1.0', + 'protobuf>=4.21.0,<5.0.0', + 'torch~=2.1.1', + 'packaging>=20.0', + 'tiktoken~=0.5.0' +] +subprocess.check_call([sys.executable, '-m', 'pip', 'install', '--upgrade', '--force-reinstall', *deps]) diff --git a/extensions/huggingface-extension/scripts/version.txt b/extensions/huggingface-extension/scripts/version.txt new file mode 100644 index 000000000..f743d6c4a --- /dev/null +++ b/extensions/huggingface-extension/scripts/version.txt @@ -0,0 +1 @@ +b2106 \ No newline at end of file diff --git a/extensions/huggingface-extension/src/@types/global.d.ts b/extensions/huggingface-extension/src/@types/global.d.ts new file mode 100644 index 000000000..495ecf00e --- /dev/null +++ b/extensions/huggingface-extension/src/@types/global.d.ts @@ -0,0 +1,2 @@ +declare const EXTENSION_NAME: string +declare const NODE_MODULE_PATH: string diff --git a/extensions/huggingface-extension/src/index.ts b/extensions/huggingface-extension/src/index.ts new file mode 100644 index 000000000..d8f755080 --- /dev/null +++ b/extensions/huggingface-extension/src/index.ts @@ -0,0 +1,396 @@ +import { + fs, + downloadFile, + abortDownload, + joinPath, + HuggingFaceExtension, + HuggingFaceRepoData, + executeOnMain, + Quantization, + Model, + InferenceEngine, + getJanDataFolderPath, + events, + DownloadEvent, + log, +} from '@janhq/core' +import { ggufMetadata } from 'hyllama' + +declare global { + interface Window { + electronAPI?: any + } +} + +/** + * A extension for models + */ +export default class JanHuggingFaceExtension extends HuggingFaceExtension { + private static readonly _safetensorsRegexs = [ + /model\.safetensors$/, + /model-[0-9]+-of-[0-9]+\.safetensors$/, + ] + private static readonly _pytorchRegexs = [ + /pytorch_model\.bin$/, + /consolidated\.[0-9]+\.pth$/, + /pytorch_model-[0-9]+-of-[0-9]+\.bin$/, + /.*\.pt$/, + ] + interrupted = false + + /** + * Called when the extension is loaded. + * @override + */ + onLoad() {} + + /** + * Called when the extension is unloaded. + * @override + */ + onUnload(): void {} + + private getFileList(repoData: HuggingFaceRepoData): string[] { + // SafeTensors first, if not, then PyTorch + const modelFiles = repoData.siblings + .map((file) => file.rfilename) + .filter((file) => + JanHuggingFaceExtension._safetensorsRegexs.some((regex) => + regex.test(file) + ) + ) + if (modelFiles.length === 0) { + repoData.siblings.forEach((file) => { + if ( + JanHuggingFaceExtension._pytorchRegexs.some((regex) => + regex.test(file.rfilename) + ) + ) { + modelFiles.push(file.rfilename) + } + }) + } + + const vocabFiles = [ + 'tokenizer.model', + 'vocab.json', + 'tokenizer.json', + ].filter((file) => + repoData.siblings.some((sibling) => sibling.rfilename === file) + ) + + const etcFiles = repoData.siblings + .map((file) => file.rfilename) + .filter( + (file) => + (file.endsWith('.json') && !vocabFiles.includes(file)) || + file.endsWith('.txt') || + file.endsWith('.py') || + file.endsWith('.tiktoken') + ) + + return [...modelFiles, ...vocabFiles, ...etcFiles] + } + + private async getModelDirPath(repoID: string): Promise { + const modelName = repoID.split('/').slice(1).join('/') + return joinPath([await getJanDataFolderPath(), 'models', modelName]) + } + private async getConvertedModelPath(repoID: string): Promise { + const modelName = repoID.split('/').slice(1).join('/') + const modelDirPath = await this.getModelDirPath(repoID) + return joinPath([modelDirPath, modelName + '.gguf']) + } + private async getQuantizedModelPath( + repoID: string, + quantization: Quantization + ): Promise { + const modelName = repoID.split('/').slice(1).join('/') + const modelDirPath = await this.getModelDirPath(repoID) + return joinPath([ + modelDirPath, + modelName + `-${quantization.toLowerCase()}.gguf`, + ]) + } + private getCtxLength(config: { + max_sequence_length?: number + max_position_embeddings?: number + n_ctx?: number + }): number { + if (config.max_sequence_length) return config.max_sequence_length + if (config.max_position_embeddings) return config.max_position_embeddings + if (config.n_ctx) return config.n_ctx + return 4096 + } + + /** + * Downloads a Hugging Face model. + * @param repoID - The repo ID of the model to convert. + * @param repoData - The repo data of the model to convert. + * @param network - Optional object to specify proxy/whether to ignore SSL certificates. + * @returns A promise that resolves when the download is complete. + */ + async downloadModelFiles( + repoID: string, + repoData: HuggingFaceRepoData, + network?: { ignoreSSL?: boolean; proxy?: string } + ): Promise { + if (this.interrupted) return + const modelDirPath = await this.getModelDirPath(repoID) + if (!(await fs.existsSync(modelDirPath))) await fs.mkdirSync(modelDirPath) + const files = this.getFileList(repoData) + const filePaths: string[] = [] + + for (const file of files) { + const filePath = file + const localPath = await joinPath([modelDirPath, filePath]) + const url = `https://huggingface.co/${repoID}/resolve/main/${filePath}` + + if (this.interrupted) return + if (!(await fs.existsSync(localPath))) { + downloadFile(url, localPath, network) + filePaths.push(filePath) + } + } + + await new Promise((resolve, reject) => { + if (filePaths.length === 0) resolve() + const onDownloadSuccess = async ({ fileName }: { fileName: string }) => { + if (filePaths.includes(fileName)) { + filePaths.splice(filePaths.indexOf(fileName), 1) + if (filePaths.length === 0) { + events.off(DownloadEvent.onFileDownloadSuccess, onDownloadSuccess) + events.off(DownloadEvent.onFileDownloadError, onDownloadError) + resolve() + } + } + } + + const onDownloadError = async ({ + fileName, + error, + }: { + fileName: string + error: Error + }) => { + if (filePaths.includes(fileName)) { + this.cancelConvert(repoID, repoData) + events.off(DownloadEvent.onFileDownloadSuccess, onDownloadSuccess) + events.off(DownloadEvent.onFileDownloadError, onDownloadError) + reject(error) + } + } + + events.on(DownloadEvent.onFileDownloadSuccess, onDownloadSuccess) + events.on(DownloadEvent.onFileDownloadError, onDownloadError) + }) + } + + /** + * Converts a Hugging Face model to GGUF. + * @param repoID - The repo ID of the model to convert. + * @returns A promise that resolves when the conversion is complete. + */ + async convert(repoID: string): Promise { + if (this.interrupted) return + const modelDirPath = await this.getModelDirPath(repoID) + const modelOutPath = await this.getConvertedModelPath(repoID) + if (!(await fs.existsSync(modelDirPath))) { + throw new Error('Model dir not found') + } + if (await fs.existsSync(modelOutPath)) return + + await executeOnMain(NODE_MODULE_PATH, 'installDeps') + if (this.interrupted) return + + try { + await executeOnMain( + NODE_MODULE_PATH, + 'convertHf', + modelDirPath, + modelOutPath + '.temp' + ) + } catch (err) { + log(`[Conversion]::Debug: Error using hf-to-gguf.py, trying convert.py`) + + let ctx = 4096 + try { + const config = await fs.readFileSync( + await joinPath([modelDirPath, 'config.json']), + 'utf8' + ) + const configParsed = JSON.parse(config) + ctx = this.getCtxLength(configParsed) + configParsed.max_sequence_length = ctx + await fs.writeFileSync( + await joinPath([modelDirPath, 'config.json']), + JSON.stringify(configParsed, null, 2) + ) + } catch (err) { + log(`${err}`) + // ignore missing config.json + } + + const bpe = await fs.existsSync( + await joinPath([modelDirPath, 'vocab.json']) + ) + + await executeOnMain( + NODE_MODULE_PATH, + 'convert', + modelDirPath, + modelOutPath + '.temp', + { + ctx, + bpe, + } + ) + } + await executeOnMain( + NODE_MODULE_PATH, + 'renameSync', + modelOutPath + '.temp', + modelOutPath + ) + + for (const file of await fs.readdirSync(modelDirPath)) { + if ( + modelOutPath.endsWith(file) || + (file.endsWith('config.json') && !file.endsWith('_config.json')) + ) + continue + await fs.unlinkSync(await joinPath([modelDirPath, file])) + } + } + + /** + * Quantizes a GGUF model. + * @param repoID - The repo ID of the model to quantize. + * @param quantization - The quantization to use. + * @returns A promise that resolves when the quantization is complete. + */ + async quantize(repoID: string, quantization: Quantization): Promise { + if (this.interrupted) return + const modelDirPath = await this.getModelDirPath(repoID) + const modelOutPath = await this.getQuantizedModelPath(repoID, quantization) + if (!(await fs.existsSync(modelDirPath))) { + throw new Error('Model dir not found') + } + if (await fs.existsSync(modelOutPath)) return + + await executeOnMain( + NODE_MODULE_PATH, + 'quantize', + await this.getConvertedModelPath(repoID), + modelOutPath + '.temp', + quantization + ) + await executeOnMain( + NODE_MODULE_PATH, + 'renameSync', + modelOutPath + '.temp', + modelOutPath + ) + + await fs.unlinkSync(await this.getConvertedModelPath(repoID)) + } + + /** + * Generates Jan model metadata from a Hugging Face model. + * @param repoID - The repo ID of the model to generate metadata for. + * @param repoData - The repo data of the model to generate metadata for. + * @param quantization - The quantization of the model. + * @returns A promise that resolves when the model metadata generation is complete. + */ + async generateMetadata( + repoID: string, + repoData: HuggingFaceRepoData, + quantization: Quantization + ): Promise { + const modelName = repoID.split('/').slice(1).join('/') + const filename = `${modelName}-${quantization.toLowerCase()}.gguf` + const modelDirPath = await this.getModelDirPath(repoID) + const modelPath = await this.getQuantizedModelPath(repoID, quantization) + const modelConfigPath = await joinPath([modelDirPath, 'model.json']) + if (!(await fs.existsSync(modelPath))) { + throw new Error('Model not found') + } + + const size = await executeOnMain(NODE_MODULE_PATH, 'getSize', modelPath) + let ctx = 4096 + try { + const config = await fs.readFileSync( + await joinPath([modelDirPath, 'config.json']), + 'utf8' + ) + ctx = this.getCtxLength(JSON.parse(config)) + fs.unlinkSync(await joinPath([modelDirPath, 'config.json'])) + } catch (err) { + // ignore missing config.json + } + // maybe later, currently it's gonna use too much memory + // const buffer = await fs.readFileSync(quantizedModelPath) + // const ggufData = ggufMetadata(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)) + + const metadata: Model = { + object: 'model', + version: 1, + format: 'gguf', + sources: [ + { + url: `https://huggingface.co/${repoID}`, // i think this is just for download but not sure, + filename, + }, + ], + id: modelName, + name: modelName, + created: Date.now(), + description: `Auto converted from Hugging Face model: ${repoID}`, + settings: { + ctx_len: ctx, + prompt_template: '', + llama_model_path: modelName, + }, + parameters: { + temperature: 0.7, + top_p: 0.95, + stream: true, + max_tokens: 4096, + // stop: [''], seems like we dont really need this..? + frequency_penalty: 0, + presence_penalty: 0, + }, + metadata: { + author: repoData.author, + tags: repoData.tags, + size, + }, + engine: InferenceEngine.nitro, + } + + await fs.writeFileSync(modelConfigPath, JSON.stringify(metadata, null, 2)) + } + + /** + * Cancels the convert of current Hugging Face model. + * @param repoID - The repository ID to cancel. + * @param repoData - The repository data to cancel. + * @returns {Promise} A promise that resolves when the download has been cancelled. + */ + async cancelConvert( + repoID: string, + repoData: HuggingFaceRepoData + ): Promise { + this.interrupted = true + const modelDirPath = await this.getModelDirPath(repoID) + const files = this.getFileList(repoData) + for (const file of files) { + const filePath = file + const localPath = await joinPath([modelDirPath, filePath]) + await abortDownload(localPath) + } + // ;(await fs.existsSync(modelDirPath)) && (await fs.rmdirSync(modelDirPath)) + + executeOnMain(NODE_MODULE_PATH, 'killProcesses') + } +} diff --git a/extensions/huggingface-extension/src/node/index.ts b/extensions/huggingface-extension/src/node/index.ts new file mode 100644 index 000000000..cd36c1ab9 --- /dev/null +++ b/extensions/huggingface-extension/src/node/index.ts @@ -0,0 +1,187 @@ +import { PythonShell } from 'python-shell' +import { spawn, ChildProcess } from 'child_process' +import { resolve as presolve, join as pjoin } from 'path' +import type { Quantization } from '@janhq/core' +import { log } from '@janhq/core/node' +import { statSync } from 'fs' +export { renameSync } from 'fs' + +let pythonShell: PythonShell | undefined = undefined +let quantizeProcess: ChildProcess | undefined = undefined + +export const getSize = (path: string): number => statSync(path).size + +export const killProcesses = () => { + if (pythonShell) { + pythonShell.kill() + pythonShell = undefined + } + if (quantizeProcess) { + quantizeProcess.kill() + quantizeProcess = undefined + } +} + +export const getQuantizeExecutable = (): string => { + let binaryFolder = pjoin(__dirname, '..', 'bin') // Current directory by default + let binaryName = 'quantize' + /** + * The binary folder is different for each platform. + */ + if (process.platform === 'win32') { + binaryFolder = pjoin(binaryFolder, 'win') + binaryName = 'quantize.exe' + } else if (process.platform === 'darwin') { + /** + * For MacOS: mac-arm64 (Silicon), mac-x64 (InteL) + */ + if (process.arch === 'arm64') { + binaryFolder = pjoin(binaryFolder, 'mac-arm64') + } else { + binaryFolder = pjoin(binaryFolder, 'mac-x64') + } + } else { + binaryFolder = pjoin(binaryFolder, 'linux-cpu') + } + return pjoin(binaryFolder, binaryName) +} + +export const installDeps = (): Promise => { + return new Promise((resolve, reject) => { + const _pythonShell = new PythonShell( + presolve(__dirname, '..', 'scripts', 'install_deps.py') + ) + _pythonShell.on('message', (message) => { + log(`[Install Deps]::Debug: ${message}`) + }) + _pythonShell.on('stderr', (stderr) => { + log(`[Install Deps]::Error: ${stderr}`) + }) + _pythonShell.on('error', (err) => { + pythonShell = undefined + log(`[Install Deps]::Error: ${err}`) + reject(err) + }) + _pythonShell.on('close', () => { + const exitCode = _pythonShell.exitCode + pythonShell = undefined + log( + `[Install Deps]::Debug: Deps installation exited with code: ${exitCode}` + ) + exitCode === 0 ? resolve() : reject(exitCode) + }) + }) +} + +export const convertHf = async ( + modelDirPath: string, + outPath: string +): Promise => { + return await new Promise((resolve, reject) => { + const _pythonShell = new PythonShell( + presolve(__dirname, '..', 'scripts', 'convert-hf-to-gguf.py'), + { + args: [modelDirPath, '--outfile', outPath], + } + ) + pythonShell = _pythonShell + _pythonShell.on('message', (message) => { + log(`[Conversion]::Debug: ${message}`) + }) + _pythonShell.on('stderr', (stderr) => { + log(`[Conversion]::Error: ${stderr}`) + }) + _pythonShell.on('error', (err) => { + pythonShell = undefined + log(`[Conversion]::Error: ${err}`) + reject(err) + }) + _pythonShell.on('close', () => { + const exitCode = _pythonShell.exitCode + pythonShell = undefined + if (exitCode !== 0) { + log(`[Conversion]::Debug: Conversion exited with code: ${exitCode}`) + reject(exitCode) + } else { + resolve() + } + }) + }) +} + +export const convert = async ( + modelDirPath: string, + outPath: string, + { ctx, bpe }: { ctx?: number; bpe?: boolean } +): Promise => { + const args = [modelDirPath, '--outfile', outPath] + if (ctx) { + args.push('--ctx') + args.push(ctx.toString()) + } + if (bpe) { + args.push('--vocab-type') + args.push('bpe') + } + return await new Promise((resolve, reject) => { + const _pythonShell = new PythonShell( + presolve(__dirname, '..', 'scripts', 'convert.py'), + { + args, + } + ) + _pythonShell.on('message', (message) => { + log(`[Conversion]::Debug: ${message}`) + }) + _pythonShell.on('stderr', (stderr) => { + log(`[Conversion]::Error: ${stderr}`) + }) + _pythonShell.on('error', (err) => { + pythonShell = undefined + log(`[Conversion]::Error: ${err}`) + reject(err) + }) + _pythonShell.on('close', () => { + const exitCode = _pythonShell.exitCode + pythonShell = undefined + if (exitCode !== 0) { + log(`[Conversion]::Debug: Conversion exited with code: ${exitCode}`) + reject(exitCode) + } else { + resolve() + } + }) + }) +} + +export const quantize = async ( + modelPath: string, + outPath: string, + quantization: Quantization +): Promise => { + return await new Promise((resolve, reject) => { + const quantizeExecutable = getQuantizeExecutable() + const _quantizeProcess = spawn(quantizeExecutable, [ + modelPath, + outPath, + quantization, + ]) + quantizeProcess = _quantizeProcess + + _quantizeProcess.stdout?.on('data', (data) => { + log(`[Quantization]::Debug: ${data}`) + }) + _quantizeProcess.stderr?.on('data', (data) => { + log(`[Quantization]::Error: ${data}`) + }) + + _quantizeProcess.on('close', (code) => { + if (code !== 0) { + log(`[Quantization]::Debug: Quantization exited with code: ${code}`) + reject(code) + } else { + resolve() + } + }) + }) +} diff --git a/extensions/huggingface-extension/tsconfig.json b/extensions/huggingface-extension/tsconfig.json new file mode 100644 index 000000000..a42f31602 --- /dev/null +++ b/extensions/huggingface-extension/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "target": "es2020", + "module": "ES2020", + "lib": ["es2015", "es2016", "es2017", "dom"], + "strict": true, + "sourceMap": true, + "declaration": true, + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "declarationDir": "dist/types", + "outDir": "dist", + "importHelpers": true, + "typeRoots": ["node_modules/@types"], + "resolveJsonModule": true, + }, + "include": ["src"], +} diff --git a/extensions/inference-nitro-extension/bin/version.txt b/extensions/inference-nitro-extension/bin/version.txt index 0b9c01996..e4737652c 100644 --- a/extensions/inference-nitro-extension/bin/version.txt +++ b/extensions/inference-nitro-extension/bin/version.txt @@ -1 +1 @@ -0.3.12 +0.3.13 diff --git a/extensions/inference-nitro-extension/src/node/accelerator.ts b/extensions/inference-nitro-extension/src/node/accelerator.ts index 972f88681..1ffdbc5bd 100644 --- a/extensions/inference-nitro-extension/src/node/accelerator.ts +++ b/extensions/inference-nitro-extension/src/node/accelerator.ts @@ -23,10 +23,7 @@ const DEFALT_SETTINGS = { gpus_in_use: [], is_initial: true, // TODO: This needs to be set based on user toggle in settings - vulkan: { - enabled: true, - gpu_in_use: '1', - }, + vulkan: false } /** @@ -152,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/inference-nitro-extension/src/node/execute.ts b/extensions/inference-nitro-extension/src/node/execute.ts index 08baba0d5..f9a668507 100644 --- a/extensions/inference-nitro-extension/src/node/execute.ts +++ b/extensions/inference-nitro-extension/src/node/execute.ts @@ -67,7 +67,7 @@ export const executableNitroFile = (): NitroExecutableOptions => { if (gpuInfo['vulkan'] === true) { binaryFolder = path.join(__dirname, '..', 'bin') - binaryFolder = path.join(binaryFolder, 'win-vulkan') + binaryFolder = path.join(binaryFolder, 'linux-vulkan') vkVisibleDevices = gpuInfo['gpus_in_use'].toString() } } diff --git a/extensions/model-extension/src/index.ts b/extensions/model-extension/src/index.ts index 926e65ee5..dd5bcdf26 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 28971a42b..3024285a3 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 000000000..093cd435f --- /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 c1326ba19..f3294e12e 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 c41909843..1c0586637 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 000000000..46f1abcb0 --- /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 92d654528..ab619f061 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 89ff60e66..132494d48 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 191c7bcbe..c05d26e51 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 000000000..889a1cfd8 --- /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 a73ec687f..8bcccdba2 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 a7659d425..90510aae7 100644 --- a/web/containers/Layout/BottomBar/SystemMonitor/index.tsx +++ b/web/containers/Layout/BottomBar/SystemMonitor/index.tsx @@ -57,23 +57,12 @@ const SystemMonitor = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - const calculateUtilization = () => { - let sum = 0 - const util = gpus.map((x) => { - return Number(x['utilization']) - }) - util.forEach((num) => { - sum += num - }) - return sum - } - return (
{ @@ -88,29 +77,29 @@ const SystemMonitor = () => {
-
+
Running Models
-
+
{showFullScreen ? ( setShowFullScreen(!showFullScreen)} /> ) : ( setShowFullScreen(!showFullScreen)} /> )} { setSystemMonitorCollapse(false) setShowFullScreen(false) @@ -118,10 +107,10 @@ const SystemMonitor = () => { />
-
+
-
-
+
+
CPU
@@ -130,11 +119,12 @@ const SystemMonitor = () => {
-
-
+
+
Memory
- - {toGibibytes(usedRam)} of {toGibibytes(totalRam)} used + + {toGibibytes(usedRam, { hideUnit: true })}/ + {toGibibytes(totalRam, { hideUnit: true })} GB
@@ -148,30 +138,29 @@ const SystemMonitor = () => {
{gpus.length > 0 && ( -
-
GPU
-
- - - {calculateUtilization()}% - -
+
{gpus.map((gpu, index) => ( -
- - {gpu.name} - -
- +
+
+ + {gpu.name} + +
+
+ + {gpu.memoryTotal - gpu.memoryFree}/ + {gpu.memoryTotal} + + MB +
+
+
+ +
+ + {gpu.utilization}% -
- {gpu.vram} - MB VRAM -
))} diff --git a/web/containers/Layout/BottomBar/index.tsx b/web/containers/Layout/BottomBar/index.tsx index c76f211e8..66c089744 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 8a3c4a3a3..c0bc46586 100644 --- a/web/containers/Layout/Ribbon/index.tsx +++ b/web/containers/Layout/Ribbon/index.tsx @@ -2,6 +2,7 @@ import { Tooltip, TooltipContent, TooltipTrigger, + TooltipPortal, TooltipArrow, } from '@janhq/uikit' import { motion as m } from 'framer-motion' @@ -20,13 +21,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) @@ -119,24 +119,26 @@ export default function RibbonNav() { /> )} - {serverEnabled && - primary.state === MainViewState.Thread ? ( - - - Threads are disabled while the server is running - - - - ) : ( - - {primary.name} - - - )} + + {serverEnabled && + primary.state === MainViewState.Thread ? ( + + + Threads are disabled while the server is running + + + + ) : ( + + {primary.name} + + + )} +
) @@ -169,10 +171,12 @@ export default function RibbonNav() { /> )} - - {secondary.name} - - + + + {secondary.name} + + +
) diff --git a/web/containers/Layout/TopBar/CommandListDownloadedModel/index.tsx b/web/containers/Layout/TopBar/CommandListDownloadedModel/index.tsx index ac5756e9f..ecec5c758 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 17887763e..d92c7297b 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 525cd97de..605d8e44d 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 77a1fe971..7e3ad38ab 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/ModalTroubleShoot/index.tsx b/web/containers/ModalTroubleShoot/index.tsx index 547398c4f..2438d6333 100644 --- a/web/containers/ModalTroubleShoot/index.tsx +++ b/web/containers/ModalTroubleShoot/index.tsx @@ -69,7 +69,7 @@ const ModalTroubleShooting: React.FC = () => { > Discord -   & send it to #🆘|get-help channel for further support. +  & send it to #🆘|get-help channel for further support.

diff --git a/web/containers/Providers/AppUpdateListener.tsx b/web/containers/Providers/AppUpdateListener.tsx new file mode 100644 index 000000000..dceb4df13 --- /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 d7b630043..fb439c92f 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 102fa5f1c..1dd0bd042 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 938db69c0..9febbade5 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 02fc29141..a4702783c 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 000000000..60347ba40 --- /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 aa12246f5..7662ff6a3 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 000000000..342c04819 --- /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 000000000..9dfdfca90 --- /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/HFConverter.atom.ts b/web/helpers/atoms/HFConverter.atom.ts new file mode 100644 index 000000000..717ab05a9 --- /dev/null +++ b/web/helpers/atoms/HFConverter.atom.ts @@ -0,0 +1,44 @@ +import { HuggingFaceRepoData } from '@janhq/core' +import { atom } from 'jotai' + +export const repoIDAtom = atom(null) +export const loadingAtom = atom(false) +export const fetchErrorAtom = atom(null) +export const conversionStatusAtom = atom< + | 'downloading' + | 'converting' + | 'quantizing' + | 'done' + | 'stopping' + | 'generating' + | null +>(null) +export const conversionErrorAtom = atom(null) +const _repoDataAtom = atom(null) +const _unsupportedAtom = atom(false) + +export const resetAtom = atom(null, (_get, set) => { + set(repoIDAtom, null) + set(loadingAtom, false) + set(fetchErrorAtom, null) + set(conversionStatusAtom, null) + set(conversionErrorAtom, null) + set(_repoDataAtom, null) + set(_unsupportedAtom, false) +}) + +export const repoDataAtom = atom( + (get) => get(_repoDataAtom), + (_get, set, repoData: HuggingFaceRepoData) => { + set(_repoDataAtom, repoData) + if ( + !repoData.tags.includes('transformers') || + (!repoData.tags.includes('pytorch') && + !repoData.tags.includes('safetensors')) + ) { + set(_unsupportedAtom, true) + } + } +) + +export const unsupportedAtom = atom((get) => get(_unsupportedAtom)) diff --git a/web/helpers/atoms/Model.atom.ts b/web/helpers/atoms/Model.atom.ts index 512518df1..7a6aa6440 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/useConvertHuggingFaceModel.ts b/web/hooks/useConvertHuggingFaceModel.ts new file mode 100644 index 000000000..bbf33207b --- /dev/null +++ b/web/hooks/useConvertHuggingFaceModel.ts @@ -0,0 +1,81 @@ +import { useContext } from 'react' + +import { + ExtensionTypeEnum, + HuggingFaceExtension, + HuggingFaceRepoData, + Quantization, +} from '@janhq/core' + +import { useSetAtom } from 'jotai' + +import { FeatureToggleContext } from '@/context/FeatureToggle' + +import { extensionManager } from '@/extension/ExtensionManager' +import { + conversionStatusAtom, + conversionErrorAtom, +} from '@/helpers/atoms/HFConverter.atom' + +export const useConvertHuggingFaceModel = () => { + const { ignoreSSL, proxy } = useContext(FeatureToggleContext) + const setConversionStatus = useSetAtom(conversionStatusAtom) + const setConversionError = useSetAtom(conversionErrorAtom) + + const convertHuggingFaceModel = async ( + repoID: string, + repoData: HuggingFaceRepoData, + quantization: Quantization + ) => { + const extension = await extensionManager.get( + ExtensionTypeEnum.HuggingFace + ) + try { + if (extension) { + extension.interrupted = false + } + setConversionStatus('downloading') + await extension?.downloadModelFiles(repoID, repoData, { + ignoreSSL, + proxy, + }) + if (extension?.interrupted) return + setConversionStatus('converting') + await extension?.convert(repoID) + if (extension?.interrupted) return + setConversionStatus('quantizing') + await extension?.quantize(repoID, quantization) + if (extension?.interrupted) return + setConversionStatus('generating') + await extension?.generateMetadata(repoID, repoData, quantization) + setConversionStatus('done') + } catch (err) { + if (extension?.interrupted) return + extension?.cancelConvert(repoID, repoData) + if (typeof err === 'number') { + setConversionError(new Error(`exit code: ${err}`)) + } else { + setConversionError(err as Error) + } + console.error(err) + } + } + + const cancelConvertHuggingFaceModel = async ( + repoID: string, + repoData: HuggingFaceRepoData + ) => { + const extension = await extensionManager.get( + ExtensionTypeEnum.HuggingFace + ) + + setConversionStatus('stopping') + await extension?.cancelConvert(repoID, repoData) + setConversionStatus(null) + } + + return { + convertHuggingFaceModel, + cancelConvertHuggingFaceModel, + } +} diff --git a/web/hooks/useDeleteModel.ts b/web/hooks/useDeleteModel.ts index d9f2b94be..9736f8256 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 removeDownloadedModel = useSetAtom(removeDownloadedModelAtom) - 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 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/useGetHFRepoData.ts b/web/hooks/useGetHFRepoData.ts new file mode 100644 index 000000000..45f979fbd --- /dev/null +++ b/web/hooks/useGetHFRepoData.ts @@ -0,0 +1,29 @@ +import { useAtomValue, useSetAtom } from 'jotai' + +import { + repoDataAtom, + repoIDAtom, + loadingAtom, + fetchErrorAtom, +} from '@/helpers/atoms/HFConverter.atom' + +export const useGetHFRepoData = () => { + const repoID = useAtomValue(repoIDAtom) + const setRepoData = useSetAtom(repoDataAtom) + const setLoading = useSetAtom(loadingAtom) + const setFetchError = useSetAtom(fetchErrorAtom) + + const getRepoData = async () => { + setLoading(true) + try { + const res = await fetch(`https://huggingface.co/api/models/${repoID}`) + const data = await res.json() + setRepoData(data) + } catch (err) { + setFetchError(err as Error) + } + setLoading(false) + } + + return getRepoData +} diff --git a/web/hooks/useImportModel.ts b/web/hooks/useImportModel.ts new file mode 100644 index 000000000..d4b6f2919 --- /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 91c1a1c4d..000000000 --- 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 35fb853b4..bc4a94d1f 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 498481aa3..0a8af0f92 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 ee0b4592d..f6fc7d723 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 5aa0cd6ce..c9041e23a 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 88fdadd57..303406740 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 f064132b8..c3bdc8661 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' @@ -207,7 +209,7 @@ const SimpleTextMessage: React.FC = (props) => { {messages[messages.length - 1]?.id === props.id && (props.status === MessageStatus.Pending || tokenSpeed > 0) && (

- Token Speed: {Number(tokenSpeed).toFixed(2)}/s + Token Speed: {Number(tokenSpeed).toFixed(2)}t/s

)}
@@ -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 7af5d3d97..38e7f65a6 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/HuggingFaceConvertingErrorModal/index.tsx b/web/screens/ExploreModels/HuggingFaceConvertingErrorModal/index.tsx new file mode 100644 index 000000000..863249d41 --- /dev/null +++ b/web/screens/ExploreModels/HuggingFaceConvertingErrorModal/index.tsx @@ -0,0 +1,27 @@ +import { useAtomValue } from 'jotai' + +import { + conversionStatusAtom, + repoDataAtom, +} from '@/helpers/atoms/HFConverter.atom' + +export const HuggingFaceConvertingErrorModal = () => { + // This component only loads when repoData is not null + const repoData = useAtomValue(repoDataAtom)! + // This component only loads when conversionStatus is not null + const conversionStatus = useAtomValue(conversionStatusAtom)! + + return ( + <> +
+

Hugging Face Converter

+
+
+

+ An error occured while {conversionStatus} model {repoData.id}. +

+

Please close this modal and try again.

+
+ + ) +} diff --git a/web/screens/ExploreModels/HuggingFaceConvertingModal/index.tsx b/web/screens/ExploreModels/HuggingFaceConvertingModal/index.tsx new file mode 100644 index 000000000..175722dda --- /dev/null +++ b/web/screens/ExploreModels/HuggingFaceConvertingModal/index.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react' + +import { Button } from '@janhq/uikit' +import { useAtomValue } from 'jotai' + +import { useConvertHuggingFaceModel } from '@/hooks/useConvertHuggingFaceModel' + +import { + conversionStatusAtom, + repoDataAtom, +} from '@/helpers/atoms/HFConverter.atom' + +export const HuggingFaceConvertingModal = () => { + // This component only loads when repoData is not null + const repoData = useAtomValue(repoDataAtom)! + // This component only loads when conversionStatus is not null + const conversionStatus = useAtomValue(conversionStatusAtom)! + const [status, setStatus] = useState('') + const { cancelConvertHuggingFaceModel } = useConvertHuggingFaceModel() + + useEffect(() => { + switch (conversionStatus) { + case 'downloading': + setStatus('Downloading files...') + break + case 'converting': + setStatus('Converting...') + break + case 'quantizing': + setStatus('Quantizing...') + break + case 'stopping': + setStatus('Stopping...') + break + case 'generating': + setStatus('Generating metadata...') + break + } + }, [conversionStatus]) + + const onStopClick = () => { + cancelConvertHuggingFaceModel(repoData.id, repoData) + } + + return ( + <> +
+

Hugging Face Converter

+
+ {conversionStatus === 'done' ? ( +
+

Done!

+

Now you can use the model on Jan as usual. Have fun!

+
+ ) : ( + <> +
+

{status}

+
+ + + )} + + ) +} diff --git a/web/screens/ExploreModels/HuggingFaceModal/index.tsx b/web/screens/ExploreModels/HuggingFaceModal/index.tsx new file mode 100644 index 000000000..9051e15e6 --- /dev/null +++ b/web/screens/ExploreModels/HuggingFaceModal/index.tsx @@ -0,0 +1,70 @@ +import { CommandModal, Modal, ModalContent } from '@janhq/uikit' +import { useAtomValue, useSetAtom } from 'jotai' + +import { HuggingFaceConvertingErrorModal } from '../HuggingFaceConvertingErrorModal' +import { HuggingFaceConvertingModal } from '../HuggingFaceConvertingModal' +import { HuggingFaceRepoDataLoadedModal } from '../HuggingFaceRepoDataLoadedModal' +import { HuggingFaceSearchErrorModal } from '../HuggingFaceSearchErrorModal' +import { HuggingFaceSearchModal } from '../HuggingFaceSearchModal' + +import { + repoDataAtom, + fetchErrorAtom, + resetAtom, + conversionStatusAtom, + conversionErrorAtom, +} from '@/helpers/atoms/HFConverter.atom' + +const HuggingFaceModal = ({ + ...props +}: Omit[0], 'children'>) => { + const repoData = useAtomValue(repoDataAtom) + const fetchError = useAtomValue(fetchErrorAtom) + const conversionStatus = useAtomValue(conversionStatusAtom) + const conversionError = useAtomValue(conversionErrorAtom) + const setReset = useSetAtom(resetAtom) + + return ( + { + if (open === false) { + if ( + !repoData || + ['done', 'stopping'].includes(conversionStatus ?? '') || + conversionError + ) { + setReset() + } + } + if (props.onOpenChange) { + props.onOpenChange(open) + } + }} + > + +
+
+ {repoData ? ( + conversionStatus ? ( + conversionError ? ( + + ) : ( + + ) + ) : ( + + ) + ) : fetchError ? ( + + ) : ( + + )} +
+
+
+
+ ) +} + +export { HuggingFaceModal } diff --git a/web/screens/ExploreModels/HuggingFaceRepoDataLoadedModal/index.tsx b/web/screens/ExploreModels/HuggingFaceRepoDataLoadedModal/index.tsx new file mode 100644 index 000000000..c4e9131bc --- /dev/null +++ b/web/screens/ExploreModels/HuggingFaceRepoDataLoadedModal/index.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react' + +import { Quantization } from '@janhq/core' +import { + Button, + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectPortal, + SelectTrigger, + SelectValue, +} from '@janhq/uikit' +import { useAtomValue } from 'jotai' + +import { twMerge } from 'tailwind-merge' + +import { useConvertHuggingFaceModel } from '@/hooks/useConvertHuggingFaceModel' + +import { + loadingAtom, + repoDataAtom, + unsupportedAtom, +} from '@/helpers/atoms/HFConverter.atom' + +export const HuggingFaceRepoDataLoadedModal = () => { + const loading = useAtomValue(loadingAtom) + // This component only loads when repoData is not null + const repoData = useAtomValue(repoDataAtom)! + const unsupported = useAtomValue(unsupportedAtom) + const [quantization, setQuantization] = useState( + Quantization.Q4_K_M + ) + const { convertHuggingFaceModel } = useConvertHuggingFaceModel() + + const onValueSelected = (value: Quantization) => { + setQuantization(value) + } + const onConvertClick = () => { + convertHuggingFaceModel(repoData.id, repoData, quantization) + } + + return ( + <> +
+

Hugging Face Converter

+

Found the repository!

+
+
+

{repoData.id}

+

+ {unsupported + ? '❌ This model is not supported!' + : '✅ This model is supported!'} +

+ {repoData.tags.includes('gguf') ? ( +

...But you can import it manually!

+ ) : null} +
+ + + + ) +} diff --git a/web/screens/ExploreModels/HuggingFaceSearchErrorModal/index.tsx b/web/screens/ExploreModels/HuggingFaceSearchErrorModal/index.tsx new file mode 100644 index 000000000..31c7d48d4 --- /dev/null +++ b/web/screens/ExploreModels/HuggingFaceSearchErrorModal/index.tsx @@ -0,0 +1,32 @@ +import { Button } from '@janhq/uikit' +import { useAtomValue } from 'jotai' + +import { useGetHFRepoData } from '@/hooks/useGetHFRepoData' + +import { fetchErrorAtom, loadingAtom } from '@/helpers/atoms/HFConverter.atom' + +export const HuggingFaceSearchErrorModal = () => { + // This component only loads when fetchError is not null + const fetchError = useAtomValue(fetchErrorAtom)! + const loading = useAtomValue(loadingAtom) + + const getRepoData = useGetHFRepoData() + + return ( + <> +
+

Error!

+

Fetch error

+
+

{fetchError.message}

+ + + ) +} diff --git a/web/screens/ExploreModels/HuggingFaceSearchModal/index.tsx b/web/screens/ExploreModels/HuggingFaceSearchModal/index.tsx new file mode 100644 index 000000000..a81df29fa --- /dev/null +++ b/web/screens/ExploreModels/HuggingFaceSearchModal/index.tsx @@ -0,0 +1,45 @@ +import { Button, Input } from '@janhq/uikit' +import { useSetAtom, useAtomValue } from 'jotai' + +import { useGetHFRepoData } from '@/hooks/useGetHFRepoData' + +import { repoIDAtom, loadingAtom } from '@/helpers/atoms/HFConverter.atom' + +export const HuggingFaceSearchModal = () => { + const setRepoID = useSetAtom(repoIDAtom) + const loading = useAtomValue(loadingAtom) + + const getRepoData = useGetHFRepoData() + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + getRepoData() + } + } + + return ( + <> +
+

Hugging Face Convertor

+

Type the repository id below

+
+ { + setRepoID(e.target.value) + }} + onKeyDown={onKeyDown} + /> + + + ) +} diff --git a/web/screens/ExploreModels/index.tsx b/web/screens/ExploreModels/index.tsx index 7002c60b7..c413e3c3b 100644 --- a/web/screens/ExploreModels/index.tsx +++ b/web/screens/ExploreModels/index.tsx @@ -1,6 +1,5 @@ -import { useCallback, useState } from 'react' +import { useCallback, useContext, useState } from 'react' -import { openExternalUrl } from '@janhq/core' import { Input, ScrollArea, @@ -10,24 +9,36 @@ 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 { FeatureToggleContext } from '@/context/FeatureToggle' + +import { setImportModelStageAtom } from '@/hooks/useImportModel' import ExploreModelList from './ExploreModelList' +import { HuggingFaceModal } from './HuggingFaceModal' import { configuredModelsAtom, 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 { experimentalFeature } = useContext(FeatureToggleContext) const filteredModels = configuredModels.filter((x) => { if (sortSelected === 'Downloaded') { @@ -45,9 +56,13 @@ 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) + } return (
{ >
+
{ alt="Hub Banner" className="w-full object-cover" /> -
-
- - { - setsearchValue(e.target.value) - }} - /> -
-
-

+

+
+ + setsearchValue(e.target.value)} + /> +
+
+ {experimentalFeature && ( +
+

+ Convert from Hugging Face +

+
+ )}
diff --git a/web/screens/Settings/Advanced/DataFolder/index.tsx b/web/screens/Settings/Advanced/DataFolder/index.tsx index c11c49fa4..f34abd1c1 100644 --- a/web/screens/Settings/Advanced/DataFolder/index.tsx +++ b/web/screens/Settings/Advanced/DataFolder/index.tsx @@ -1,8 +1,8 @@ -import { Fragment, useCallback, useEffect, useState } from 'react' +import { Fragment, useCallback, useState } from 'react' import { fs, AppConfiguration, isSubdirectory } from '@janhq/core' import { Button, Input } from '@janhq/uikit' -import { useSetAtom } from 'jotai' +import { useAtomValue, useSetAtom } from 'jotai' import { PencilIcon, FolderOpenIcon } from 'lucide-react' import Loader from '@/containers/Loader' @@ -21,22 +21,17 @@ import ModalErrorSetDestGlobal, { import ModalSameDirectory, { showSamePathModalAtom } from './ModalSameDirectory' +import { janDataFolderPathAtom } from '@/helpers/atoms/AppConfig.atom' + const DataFolder = () => { - 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 7b2a4027a..4560ac1ad 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 60812a350..6320d1921 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 -
-
-

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

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

- Enable experimental features that may be unstable tested. -

-
- -
- - {/* 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. -

+ +
+ {/* Keyboard shortcut */} +
+
+
+
+ Keyboard Shortcuts +
- {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() - }} - /> - - {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. - - - - )} - -
-
- - +

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

+
- )} - {/* Vulkan for AMD GPU/ APU and Intel Arc GPU */} - {!isMac && experimentalFeature && ( + {/* Experimental */}
- Vulkan Support + Experimental Mode
-

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

+ Enable experimental features that may be unstable tested.

- { - toaster({ - title: 'Reload', - description: - 'Vulkan settings updated. Reload now to apply the changes.', - }) - stopModel() - saveSettings({ vulkan: e, gpusInUse: [] }) - setVulkanEnabled(e) - }} + checked={experimentalFeature} + onCheckedChange={setExperimentalFeature} />
- )} - - {/* Proxy */} -
-
-
-
HTTPS Proxy
+ {/* 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. +

+
+ {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() + }} + /> + + {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. + + + + )} + +
+
+ + +
+
+ )} + + {/* 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). +

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

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

- :@:'} - value={partialProxy} - onChange={onProxyChange} - className="w-2/3" + )} + + + {/* 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 +
+
+

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

+
+ setIgnoreSSL(e)} />
-
- {/* 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 ecf37b91c..51899ba40 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 000000000..320e18d58 --- /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 6ca8d82f7..8c9f92d7a 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 000000000..bb87b7ed9 --- /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) + }} + /> +
+
+ +