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
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!
+
+ ) : (
+ <>
+
+
+ {conversionStatus === 'stopping' ? 'Stopping...' : 'Stop'}
+
+ >
+ )}
+ >
+ )
+}
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}
+
+
+
+
+ {quantization}
+
+
+
+
+
+
+ {Object.values(Quantization).map((x, i) => (
+
+
+ {x}
+
+
+ ))}
+
+
+
+
+
+ {loading ? '' : 'Convert'}
+
+ >
+ )
+}
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 (
+ <>
+
+ {fetchError.message}
+
+ {loading ? '' : 'Try Again'}
+
+ >
+ )
+}
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}
+ />
+
+ {loading ? '' : 'OK'}
+
+ >
+ )
+}
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)}
+ />
+
+
- How to manually import models
-
+
+ Import Model
+
+ {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.
-
-
-
- )}
-
-
-
-
- Choose device(s)
-
-
-
-
-
- {selectedGpu.join()}
-
-
-
-
-
-
-
- {vulkanEnabled ? 'Vulkan Supported GPUs' : 'Nvidia'}
-
-
-
- {gpuList
- .filter((gpu) =>
- vulkanEnabled
- ? gpu.name
- : gpu.name?.toLowerCase().includes('nvidia')
- )
- .map((gpu) => (
-
- handleGPUChange(gpu.id)}
- />
-
- {gpu.name}
- {!vulkanEnabled && (
- {gpu.vram}MB VRAM
- )}
-
-
- ))}
-
- {/* Warning message */}
- {gpuEnabled && gpusInUse.length > 1 && (
-
-
-
- If multi-GPU is enabled with different GPU models or
- without NVLink, it could impact token speed.
-
-
- )}
-
-
-
- {/* TODO enable this when we support AMD */}
-
-
-
+
+ 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.
+
+
+
+ )}
+
+
+
+
+ Choose device(s)
+
+
+
+
+
+ {selectedGpu.join()}
+
+
+
+
+
+
+
+ {vulkanEnabled ? 'Vulkan Supported GPUs' : 'Nvidia'}
+
+
+
+ {gpuList
+ .filter((gpu) =>
+ vulkanEnabled
+ ? gpu.name
+ : gpu.name?.toLowerCase().includes('nvidia')
+ )
+ .map((gpu) => (
+
+
+ handleGPUChange(gpu.id)
+ }
+ />
+
+ {gpu.name}
+ {!vulkanEnabled && (
+ {gpu.vram}MB VRAM
+ )}
+
+
+ ))}
+
+ {/* Warning message */}
+ {gpuEnabled && gpusInUse.length > 1 && (
+
+
+
+ If multi-GPU is enabled with different GPU models
+ or without NVLink, it could impact token speed.
+
+
+ )}
+
+
+
+ {/* TODO enable this when we support AMD */}
+
+
+
+
+
+ )}
+
+ {/* Vulkan for AMD GPU/ APU and Intel Arc GPU */}
+ {!isMac && experimentalFeature && (
+
+
+
+
+ Vulkan Support
+
+
+
+ Enable Vulkan with AMD GPU/APU and Intel Arc GPU for better
+ model performance (reload needed).
+
+
+
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.
-
+
+ Clear
+
-
setIgnoreSSL(e)} />
-
- {/* Clear log */}
-
-
-
-
Clear logs
-
-
Clear all logs from Jan app.
-
-
- Clear
-
+ {/* 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.
+
+
+
+
+
+ Continue
+
+
+
+ Cancel All
+
+
+
+
+
+
+ )
+}
+
+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
-
+
+
+ fileInputRef.current?.click()}
+ >
+ Select
+
-
- Select a extension file to install (.tgz)
-
-
-
-
- fileInputRef.current?.click()}
- >
- Select
-
-
+
)
}
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}
+
+
+ {openFileTitle()}
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+
+
+
+ )
+}
+
+export default EditModelInfoModal
diff --git a/web/screens/Settings/ImportInProgressIcon/index.tsx b/web/screens/Settings/ImportInProgressIcon/index.tsx
new file mode 100644
index 000000000..962b52903
--- /dev/null
+++ b/web/screens/Settings/ImportInProgressIcon/index.tsx
@@ -0,0 +1,59 @@
+import React, { useCallback, useState } from 'react'
+import { CircularProgressbar } from 'react-circular-progressbar'
+
+import { X } from 'lucide-react'
+
+type Props = {
+ percentage: number
+ onDeleteModelClick: () => void
+}
+
+const ImportInProgressIcon: React.FC = ({
+ percentage,
+ onDeleteModelClick,
+}) => {
+ const [isHovered, setIsHovered] = useState(false)
+
+ const onMouseOver = () => {
+ setIsHovered(true)
+ }
+
+ const onMouseOut = () => {
+ setIsHovered(false)
+ }
+
+ return (
+
+ {isHovered ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+const ProgressIcon: React.FC> = ({ percentage }) => (
+
+
+
+)
+
+const DeleteIcon: React.FC> = React.memo(
+ ({ onDeleteModelClick }) => {
+ const onClick = useCallback(() => {
+ onDeleteModelClick?.()
+ }, [onDeleteModelClick])
+
+ return (
+
+
+
+ )
+ }
+)
+
+export default ImportInProgressIcon
diff --git a/web/screens/Settings/ImportModelOptionModal/ImportModelOptionSelection.tsx b/web/screens/Settings/ImportModelOptionModal/ImportModelOptionSelection.tsx
new file mode 100644
index 000000000..5276ee195
--- /dev/null
+++ b/web/screens/Settings/ImportModelOptionModal/ImportModelOptionSelection.tsx
@@ -0,0 +1,29 @@
+import { ModelImportOption, OptionType } from '@janhq/core'
+
+type Props = {
+ option: ModelImportOption
+ checked: boolean
+ setSelectedOptionType: (type: OptionType) => void
+}
+
+const ImportModelOptionSelection: React.FC = ({
+ option,
+ checked,
+ setSelectedOptionType,
+}) => (
+ setSelectedOptionType(option.type)}
+ >
+
+
+
+
{option.title}
+
{option.description}
+
+
+)
+
+export default ImportModelOptionSelection
diff --git a/web/screens/Settings/ImportModelOptionModal/index.tsx b/web/screens/Settings/ImportModelOptionModal/index.tsx
new file mode 100644
index 000000000..eff7eab5d
--- /dev/null
+++ b/web/screens/Settings/ImportModelOptionModal/index.tsx
@@ -0,0 +1,105 @@
+import React, { useCallback, useRef, useState } from 'react'
+
+import { ModelImportOption } from '@janhq/core'
+import {
+ Button,
+ Modal,
+ ModalClose,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalTitle,
+} from '@janhq/uikit'
+import { useAtomValue, useSetAtom } from 'jotai'
+
+import useImportModel, {
+ getImportModelStageAtom,
+ setImportModelStageAtom,
+} from '@/hooks/useImportModel'
+
+import ImportModelOptionSelection from './ImportModelOptionSelection'
+
+import { importingModelsAtom } from '@/helpers/atoms/Model.atom'
+
+const importOptions: ModelImportOption[] = [
+ {
+ type: 'SYMLINK',
+ title: 'Keep Original Files & Symlink',
+ description:
+ 'You maintain your model files outside of Jan. Keeping your files where they are, and Jan will create a smart link to them.',
+ },
+ {
+ type: 'MOVE_BINARY_FILE',
+ title: 'Move model binary file',
+ description:
+ 'Jan will move your model binary file from your current folder into Jan Data Folder.',
+ },
+]
+
+const ImportModelOptionModal: React.FC = () => {
+ const importingModels = useAtomValue(importingModelsAtom)
+ const importStage = useAtomValue(getImportModelStageAtom)
+ const setImportStage = useSetAtom(setImportModelStageAtom)
+ const { importModels } = useImportModel()
+
+ const [importOption, setImportOption] = useState(importOptions[0])
+ const destinationModal = useRef<'NONE' | 'IMPORTING_MODEL'>('NONE')
+
+ const onCancelClick = useCallback(() => {
+ setImportStage('NONE')
+ }, [setImportStage])
+
+ const onContinueClick = useCallback(() => {
+ importModels(importingModels, importOption.type)
+ setImportStage('IMPORTING_MODEL')
+ }, [importingModels, importOption, setImportStage, importModels])
+
+ return (
+ {
+ if (destinationModal.current === 'NONE') {
+ setImportStage('NONE')
+ } else {
+ onContinueClick()
+ }
+ }}
+ >
+
+
+ How would you like Jan to handle your models?
+
+
+ {importOptions.map((option) => (
+ setImportOption(option)}
+ />
+ ))}
+
+
+
+
+ Cancel
+
+
+ {
+ destinationModal.current = 'IMPORTING_MODEL'
+ }}
+ >
+ Continue Importing
+
+
+
+
+
+
+ )
+}
+
+export default ImportModelOptionModal
diff --git a/web/screens/Settings/ImportSuccessIcon/index.tsx b/web/screens/Settings/ImportSuccessIcon/index.tsx
new file mode 100644
index 000000000..ae6526d78
--- /dev/null
+++ b/web/screens/Settings/ImportSuccessIcon/index.tsx
@@ -0,0 +1,52 @@
+import React, { useCallback, useState } from 'react'
+
+import { Check, Pencil } from 'lucide-react'
+
+type Props = {
+ onEditModelClick: () => void
+}
+
+const ImportSuccessIcon: React.FC = ({ onEditModelClick }) => {
+ const [isHovered, setIsHovered] = useState(false)
+
+ const onMouseOver = () => {
+ setIsHovered(true)
+ }
+
+ const onMouseOut = () => {
+ setIsHovered(false)
+ }
+
+ return (
+
+ {isHovered ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+const SuccessIcon: React.FC = React.memo(() => (
+
+
+
+))
+
+const EditIcon: React.FC = React.memo(({ onEditModelClick }) => {
+ const onClick = useCallback(() => {
+ onEditModelClick()
+ }, [onEditModelClick])
+
+ return (
+
+ )
+})
+
+export default ImportSuccessIcon
diff --git a/web/screens/Settings/ImportingModelModal/ImportingModelItem.tsx b/web/screens/Settings/ImportingModelModal/ImportingModelItem.tsx
new file mode 100644
index 000000000..b7cea35c9
--- /dev/null
+++ b/web/screens/Settings/ImportingModelModal/ImportingModelItem.tsx
@@ -0,0 +1,45 @@
+import { ImportingModel } from '@janhq/core/.'
+import { useSetAtom } from 'jotai'
+
+import { setImportModelStageAtom } from '@/hooks/useImportModel'
+
+import { toGibibytes } from '@/utils/converter'
+
+import { editingModelIdAtom } from '../EditModelInfoModal'
+import ImportInProgressIcon from '../ImportInProgressIcon'
+import ImportSuccessIcon from '../ImportSuccessIcon'
+
+type Props = {
+ model: ImportingModel
+}
+
+const ImportingModelItem: React.FC = ({ model }) => {
+ const setImportModelStage = useSetAtom(setImportModelStageAtom)
+ const setEditingModelId = useSetAtom(editingModelIdAtom)
+ const sizeInGb = toGibibytes(model.size)
+
+ const onEditModelInfoClick = () => {
+ setEditingModelId(model.importId)
+ setImportModelStage('EDIT_MODEL_INFO')
+ }
+
+ const onDeleteModelClick = () => {}
+
+ return (
+
+
{model.name}
+
{sizeInGb}
+
+ {model.status === 'IMPORTED' || model.status === 'FAILED' ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+export default ImportingModelItem
diff --git a/web/screens/Settings/ImportingModelModal/index.tsx b/web/screens/Settings/ImportingModelModal/index.tsx
new file mode 100644
index 000000000..ef4739f85
--- /dev/null
+++ b/web/screens/Settings/ImportingModelModal/index.tsx
@@ -0,0 +1,85 @@
+import { useCallback, useMemo } from 'react'
+
+import { openFileExplorer } from '@janhq/core'
+import {
+ Button,
+ Modal,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalTitle,
+} from '@janhq/uikit'
+import { useAtomValue, useSetAtom } from 'jotai'
+
+import { AlertCircle } from 'lucide-react'
+
+import {
+ getImportModelStageAtom,
+ setImportModelStageAtom,
+} from '@/hooks/useImportModel'
+
+import { openFileTitle } from '@/utils/titleUtils'
+
+import ImportingModelItem from './ImportingModelItem'
+
+import { janDataFolderPathAtom } from '@/helpers/atoms/AppConfig.atom'
+import { importingModelsAtom } from '@/helpers/atoms/Model.atom'
+
+const ImportingModelModal: React.FC = () => {
+ const importingModels = useAtomValue(importingModelsAtom)
+ const importModelStage = useAtomValue(getImportModelStageAtom)
+ const setImportModelStage = useSetAtom(setImportModelStageAtom)
+ const janDataFolder = useAtomValue(janDataFolderPathAtom)
+
+ const modelFolder = useMemo(() => `${janDataFolder}/models`, [janDataFolder])
+
+ const finishedImportModel = importingModels.filter(
+ (model) => model.status === 'IMPORTED'
+ ).length
+
+ const onOpenModelFolderClick = useCallback(() => {
+ openFileExplorer(modelFolder)
+ }, [modelFolder])
+
+ return (
+ {
+ setImportModelStage('NONE')
+ }}
+ >
+
+
+
+ Importing model ({finishedImportModel}/{importingModels.length})
+
+
+ {modelFolder}
+
+ {openFileTitle()}
+
+
+
+
+
+ {importingModels.map((model) => (
+
+ ))}
+
+
+
+
+ Own your model configurations, use at your own risk.
+ Misconfigurations may result in lower quality or unexpected outputs.{' '}
+
+
+
+
+ )
+}
+
+export default ImportingModelModal
diff --git a/web/screens/Settings/Models/index.tsx b/web/screens/Settings/Models/index.tsx
index f8997e751..a77706da2 100644
--- a/web/screens/Settings/Models/index.tsx
+++ b/web/screens/Settings/Models/index.tsx
@@ -1,66 +1,145 @@
-import { useState } from 'react'
+import { useCallback, useState } from 'react'
-import { Input } from '@janhq/uikit'
+import { useDropzone } from 'react-dropzone'
-import { useAtomValue } from 'jotai'
-import { SearchIcon } from 'lucide-react'
+import { ImportingModel } from '@janhq/core'
+import { Button, Input, ScrollArea } from '@janhq/uikit'
+
+import { useAtomValue, useSetAtom } from 'jotai'
+import { Plus, SearchIcon, UploadCloudIcon } from 'lucide-react'
+
+import { twMerge } from 'tailwind-merge'
+
+import { v4 as uuidv4 } from 'uuid'
+
+import { setImportModelStageAtom } from '@/hooks/useImportModel'
+
+import { getFileInfoFromFile } from '@/utils/file'
import RowModel from './Row'
-import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
+import {
+ downloadedModelsAtom,
+ importingModelsAtom,
+} from '@/helpers/atoms/Model.atom'
const Column = ['Name', 'Model ID', 'Size', 'Version', 'Status', '']
-export default function Models() {
+const Models: React.FC = () => {
const downloadedModels = useAtomValue(downloadedModelsAtom)
+ const setImportModelStage = useSetAtom(setImportModelStageAtom)
+ const setImportingModels = useSetAtom(importingModelsAtom)
const [searchValue, setsearchValue] = useState('')
- const filteredDownloadedModels = downloadedModels.filter((x) => {
- return x.name?.toLowerCase().includes(searchValue.toLowerCase())
+ const filteredDownloadedModels = downloadedModels
+ .filter((x) => x.name?.toLowerCase().includes(searchValue.toLowerCase()))
+ .sort((a, b) => a.name.localeCompare(b.name))
+
+ const onDrop = useCallback(
+ (acceptedFiles: File[]) => {
+ const filePathWithSize = getFileInfoFromFile(acceptedFiles)
+
+ const importingModels: ImportingModel[] = filePathWithSize.map(
+ (file) => ({
+ importId: uuidv4(),
+ modelId: undefined,
+ name: file.name,
+ description: '',
+ path: file.path,
+ tags: [],
+ size: file.size,
+ status: 'PREPARING',
+ format: 'gguf',
+ })
+ )
+ if (importingModels.length === 0) return
+
+ setImportingModels(importingModels)
+ setImportModelStage('MODEL_SELECTED')
+ },
+ [setImportModelStage, setImportingModels]
+ )
+
+ const { getRootProps, isDragActive } = useDropzone({
+ noClick: true,
+ multiple: true,
+ onDrop,
})
+ const onImportModelClick = useCallback(() => {
+ setImportModelStage('SELECTING_MODEL')
+ }, [setImportModelStage])
+
return (
-
-
-
-
-
{
- setsearchValue(e.target.value)
- }}
- />
+
+ {isDragActive && (
+
+
+
+
+
+
+
+
Drop file here
+
File (GGUF) or folder
+
+
+
-
-
-
+ )}
+
+
+
+
+ {
+ setsearchValue(e.target.value)
+ }}
+ />
+
+
+
+
+ Import Model
+
+
+
- {Column.map((col, i) => {
- return (
-
- {col}
-
- )
- })}
+ {Column.map((col) => (
+
+ {col}
+
+ ))}
{filteredDownloadedModels
- ? filteredDownloadedModels.map((x, i) => {
- return
- })
+ ? filteredDownloadedModels.map((x) => (
+
+ ))
: null}
-
+
)
}
+
+export default Models
diff --git a/web/screens/Settings/SelectingModelModal/index.tsx b/web/screens/Settings/SelectingModelModal/index.tsx
new file mode 100644
index 000000000..cfdf21392
--- /dev/null
+++ b/web/screens/Settings/SelectingModelModal/index.tsx
@@ -0,0 +1,147 @@
+import { useCallback } from 'react'
+import { useDropzone } from 'react-dropzone'
+
+import { ImportingModel, fs } from '@janhq/core'
+import { Modal, ModalContent, ModalHeader, ModalTitle } from '@janhq/uikit'
+import { useAtomValue, useSetAtom } from 'jotai'
+
+import { UploadCloudIcon } from 'lucide-react'
+
+import { v4 as uuidv4 } from 'uuid'
+
+import {
+ getImportModelStageAtom,
+ setImportModelStageAtom,
+} from '@/hooks/useImportModel'
+
+import {
+ FilePathWithSize,
+ getFileInfoFromFile,
+ getFileNameFromPath,
+} from '@/utils/file'
+
+import { importingModelsAtom } from '@/helpers/atoms/Model.atom'
+
+const SelectingModelModal: React.FC = () => {
+ const setImportModelStage = useSetAtom(setImportModelStageAtom)
+ const importModelStage = useAtomValue(getImportModelStageAtom)
+ const setImportingModels = useSetAtom(importingModelsAtom)
+
+ const onSelectFileClick = useCallback(async () => {
+ const filePaths = await window.core?.api?.selectModelFiles()
+ if (!filePaths || filePaths.length === 0) return
+
+ const sanitizedFilePaths: FilePathWithSize[] = []
+ for (const filePath of filePaths) {
+ const fileStats = await fs.fileStat(filePath, true)
+ if (!fileStats || fileStats.isDirectory) continue
+
+ const fileName = getFileNameFromPath(filePath)
+ sanitizedFilePaths.push({
+ path: filePath,
+ name: fileName,
+ size: fileStats.size,
+ })
+ }
+
+ const importingModels: ImportingModel[] = sanitizedFilePaths.map(
+ ({ path, name, size }: FilePathWithSize) => {
+ return {
+ importId: uuidv4(),
+ modelId: undefined,
+ name: name,
+ description: '',
+ path: path,
+ tags: [],
+ size: size,
+ status: 'PREPARING',
+ format: 'gguf',
+ }
+ }
+ )
+ if (importingModels.length === 0) return
+
+ setImportingModels(importingModels)
+ setImportModelStage('MODEL_SELECTED')
+ }, [setImportingModels, setImportModelStage])
+
+ const onDrop = useCallback(
+ (acceptedFiles: File[]) => {
+ const filePathWithSize = getFileInfoFromFile(acceptedFiles)
+
+ const importingModels: ImportingModel[] = filePathWithSize.map(
+ (file) => ({
+ importId: uuidv4(),
+ modelId: undefined,
+ name: file.name,
+ description: '',
+ path: file.path,
+ tags: [],
+ size: file.size,
+ status: 'PREPARING',
+ format: 'gguf',
+ })
+ )
+ if (importingModels.length === 0) return
+
+ setImportingModels(importingModels)
+ setImportModelStage('MODEL_SELECTED')
+ },
+ [setImportModelStage, setImportingModels]
+ )
+
+ const { isDragActive, getRootProps } = useDropzone({
+ noClick: true,
+ multiple: true,
+ onDrop,
+ })
+
+ const borderColor = isDragActive ? 'border-primary' : 'border-[#F4F4F5]'
+ const textColor = isDragActive ? 'text-primary' : 'text-[#71717A]'
+ const dragAndDropBgColor = isDragActive ? 'bg-[#EFF6FF]' : 'bg-white'
+
+ return (
+ {
+ setImportModelStage('NONE')
+ }}
+ >
+
+
+ Import Model
+
+
+ Import any model file (GGUF) or folder. Your imported model will be
+ private to you.
+
+
+
+
+
+
+
+
+
+
+
+ Click to upload
+
+
+ {' '}
+ or drag and drop
+
+
+
(GGUF)
+
+
+
+
+ )
+}
+
+export default SelectingModelModal
diff --git a/web/screens/Settings/SettingMenu/index.tsx b/web/screens/Settings/SettingMenu/index.tsx
new file mode 100644
index 000000000..fd0ea1560
--- /dev/null
+++ b/web/screens/Settings/SettingMenu/index.tsx
@@ -0,0 +1,55 @@
+import { useEffect, useState } from 'react'
+
+import { ScrollArea } from '@janhq/uikit'
+import { motion as m } from 'framer-motion'
+import { twMerge } from 'tailwind-merge'
+
+type Props = {
+ activeMenu: string
+ onMenuClick: (menu: string) => void
+}
+
+const SettingMenu: React.FC = ({ activeMenu, onMenuClick }) => {
+ const [menus, setMenus] = useState([])
+
+ useEffect(() => {
+ setMenus([
+ 'My Models',
+ 'My Settings',
+ 'Advanced Settings',
+ ...(window.electronAPI ? ['Extensions'] : []),
+ ])
+ }, [])
+
+ return (
+
+
+
+ {menus.map((menu) => {
+ const isActive = activeMenu === menu
+ return (
+
onMenuClick(menu)}
+ >
+
+ {menu}
+
+
+ {isActive && (
+
+ )}
+
+ )
+ })}
+
+
+
+ )
+}
+
+export default SettingMenu
diff --git a/web/screens/Settings/index.tsx b/web/screens/Settings/index.tsx
index c70938f91..1a5e4011e 100644
--- a/web/screens/Settings/index.tsx
+++ b/web/screens/Settings/index.tsx
@@ -1,12 +1,5 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-
import { useEffect, useState } from 'react'
-import { ScrollArea } from '@janhq/uikit'
-import { motion as m } from 'framer-motion'
-
-import { twMerge } from 'tailwind-merge'
-
import Advanced from '@/screens/Settings/Advanced'
import AppearanceOptions from '@/screens/Settings/Appearance'
import ExtensionCatalog from '@/screens/Settings/CoreExtensions'
@@ -14,37 +7,26 @@ import ExtensionCatalog from '@/screens/Settings/CoreExtensions'
import Models from '@/screens/Settings/Models'
import { SUCCESS_SET_NEW_DESTINATION } from './Advanced/DataFolder'
+import SettingMenu from './SettingMenu'
-const SettingsScreen = () => {
- const [activeStaticMenu, setActiveStaticMenu] = useState('My Models')
- const [menus, setMenus] = useState([])
+const handleShowOptions = (menu: string) => {
+ switch (menu) {
+ case 'Extensions':
+ return
- useEffect(() => {
- const menu = ['My Models', 'My Settings', 'Advanced Settings']
+ case 'My Settings':
+ return
- if (typeof window !== 'undefined' && window.electronAPI) {
- menu.push('Extensions')
- }
- setMenus(menu)
- }, [])
+ case 'Advanced Settings':
+ return
- const [activePreferenceExtension, setActivePreferenceExtension] = useState('')
-
- const handleShowOptions = (menu: string) => {
- switch (menu) {
- case 'Extensions':
- return
-
- case 'My Settings':
- return
-
- case 'Advanced Settings':
- return
-
- case 'My Models':
- return
- }
+ case 'My Models':
+ return
}
+}
+
+const SettingsScreen: React.FC = () => {
+ const [activeStaticMenu, setActiveStaticMenu] = useState('My Models')
useEffect(() => {
if (localStorage.getItem(SUCCESS_SET_NEW_DESTINATION) === 'true') {
@@ -58,48 +40,12 @@ const SettingsScreen = () => {
className="flex h-full bg-background"
data-testid="testid-setting-description"
>
-
-
-
-
-
- {menus.map((menu, i) => {
- const isActive = activeStaticMenu === menu
- return (
-
-
{
- setActiveStaticMenu(menu)
- setActivePreferenceExtension('')
- }}
- className="block w-full cursor-pointer"
- >
-
- {menu}
-
-
- {isActive && (
-
- )}
-
- )
- })}
-
-
-
-
-
+
-
-
-
- {handleShowOptions(activeStaticMenu || activePreferenceExtension)}
-
-
-
+ {handleShowOptions(activeStaticMenu)}
)
}
diff --git a/web/utils/converter.ts b/web/utils/converter.ts
index 3e8cf21cd..a0b05c9dd 100644
--- a/web/utils/converter.ts
+++ b/web/utils/converter.ts
@@ -1,13 +1,16 @@
-export const toGibibytes = (input: number) => {
+export const toGibibytes = (
+ input: number,
+ options?: { hideUnit?: boolean }
+) => {
if (!input) return ''
if (input > 1024 ** 3) {
- return (input / 1024 ** 3).toFixed(2) + 'GB'
+ return (input / 1024 ** 3).toFixed(2) + (options?.hideUnit ? '' : 'GB')
} else if (input > 1024 ** 2) {
- return (input / 1024 ** 2).toFixed(2) + 'MB'
+ return (input / 1024 ** 2).toFixed(2) + (options?.hideUnit ? '' : 'MB')
} else if (input > 1024) {
- return (input / 1024).toFixed(2) + 'KB'
+ return (input / 1024).toFixed(2) + (options?.hideUnit ? '' : 'KB')
} else {
- return input + 'B'
+ return input + (options?.hideUnit ? '' : 'B')
}
}
diff --git a/web/utils/file.ts b/web/utils/file.ts
new file mode 100644
index 000000000..6a812a8e2
--- /dev/null
+++ b/web/utils/file.ts
@@ -0,0 +1,34 @@
+export type FilePathWithSize = {
+ path: string
+ name: string
+ size: number
+}
+
+export interface FileWithPath extends File {
+ path?: string
+}
+
+export const getFileNameFromPath = (filePath: string): string => {
+ let fileName = filePath.split('/').pop() ?? ''
+ if (fileName.split('.').length > 1) {
+ fileName = fileName.split('.').slice(0, -1).join('.')
+ }
+
+ return fileName
+}
+
+export const getFileInfoFromFile = (
+ files: FileWithPath[]
+): FilePathWithSize[] => {
+ const result: FilePathWithSize[] = []
+ for (const file of files) {
+ if (file.path && file.path.length > 0) {
+ result.push({
+ path: file.path,
+ name: getFileNameFromPath(file.path),
+ size: file.size,
+ })
+ }
+ }
+ return result
+}