Merge branch 'docs-pena-team' of github.com:janhq/jan into docs-pena-team

This commit is contained in:
Arista Indrajaya 2024-02-28 08:57:40 +07:00
commit 67cfb838b2
108 changed files with 3917 additions and 771 deletions

View File

@ -43,31 +43,31 @@ Jan is an open-source ChatGPT alternative that runs 100% offline on your compute
<tr style="text-align:center"> <tr style="text-align:center">
<td style="text-align:center"><b>Stable (Recommended)</b></td> <td style="text-align:center"><b>Stable (Recommended)</b></td>
<td style="text-align:center"> <td style="text-align:center">
<a href='https://github.com/janhq/jan/releases/download/v0.4.6/jan-win-x64-0.4.6.exe'> <a href='https://github.com/janhq/jan/releases/download/v0.4.7/jan-win-x64-0.4.7.exe'>
<img src='./docs/static/img/windows.png' style="height:14px; width: 14px" /> <img src='./docs/static/img/windows.png' style="height:14px; width: 14px" />
<b>jan.exe</b> <b>jan.exe</b>
</a> </a>
</td> </td>
<td style="text-align:center"> <td style="text-align:center">
<a href='https://github.com/janhq/jan/releases/download/v0.4.6/jan-mac-x64-0.4.6.dmg'> <a href='https://github.com/janhq/jan/releases/download/v0.4.7/jan-mac-x64-0.4.7.dmg'>
<img src='./docs/static/img/mac.png' style="height:15px; width: 15px" /> <img src='./docs/static/img/mac.png' style="height:15px; width: 15px" />
<b>Intel</b> <b>Intel</b>
</a> </a>
</td> </td>
<td style="text-align:center"> <td style="text-align:center">
<a href='https://github.com/janhq/jan/releases/download/v0.4.6/jan-mac-arm64-0.4.6.dmg'> <a href='https://github.com/janhq/jan/releases/download/v0.4.7/jan-mac-arm64-0.4.7.dmg'>
<img src='./docs/static/img/mac.png' style="height:15px; width: 15px" /> <img src='./docs/static/img/mac.png' style="height:15px; width: 15px" />
<b>M1/M2</b> <b>M1/M2</b>
</a> </a>
</td> </td>
<td style="text-align:center"> <td style="text-align:center">
<a href='https://github.com/janhq/jan/releases/download/v0.4.6/jan-linux-amd64-0.4.6.deb'> <a href='https://github.com/janhq/jan/releases/download/v0.4.7/jan-linux-amd64-0.4.7.deb'>
<img src='./docs/static/img/linux.png' style="height:14px; width: 14px" /> <img src='./docs/static/img/linux.png' style="height:14px; width: 14px" />
<b>jan.deb</b> <b>jan.deb</b>
</a> </a>
</td> </td>
<td style="text-align:center"> <td style="text-align:center">
<a href='https://github.com/janhq/jan/releases/download/v0.4.6/jan-linux-x86_64-0.4.6.AppImage'> <a href='https://github.com/janhq/jan/releases/download/v0.4.7/jan-linux-x86_64-0.4.7.AppImage'>
<img src='./docs/static/img/linux.png' style="height:14px; width: 14px" /> <img src='./docs/static/img/linux.png' style="height:14px; width: 14px" />
<b>jan.AppImage</b> <b>jan.AppImage</b>
</a> </a>
@ -76,31 +76,31 @@ Jan is an open-source ChatGPT alternative that runs 100% offline on your compute
<tr style="text-align:center"> <tr style="text-align:center">
<td style="text-align:center"><b>Experimental (Nightly Build)</b></td> <td style="text-align:center"><b>Experimental (Nightly Build)</b></td>
<td style="text-align:center"> <td style="text-align:center">
<a href='https://delta.jan.ai/latest/jan-win-x64-0.4.6-285.exe'> <a href='https://delta.jan.ai/latest/jan-win-x64-0.4.7-289.exe'>
<img src='./docs/static/img/windows.png' style="height:14px; width: 14px" /> <img src='./docs/static/img/windows.png' style="height:14px; width: 14px" />
<b>jan.exe</b> <b>jan.exe</b>
</a> </a>
</td> </td>
<td style="text-align:center"> <td style="text-align:center">
<a href='https://delta.jan.ai/latest/jan-mac-x64-0.4.6-285.dmg'> <a href='https://delta.jan.ai/latest/jan-mac-x64-0.4.7-289.dmg'>
<img src='./docs/static/img/mac.png' style="height:15px; width: 15px" /> <img src='./docs/static/img/mac.png' style="height:15px; width: 15px" />
<b>Intel</b> <b>Intel</b>
</a> </a>
</td> </td>
<td style="text-align:center"> <td style="text-align:center">
<a href='https://delta.jan.ai/latest/jan-mac-arm64-0.4.6-285.dmg'> <a href='https://delta.jan.ai/latest/jan-mac-arm64-0.4.7-289.dmg'>
<img src='./docs/static/img/mac.png' style="height:15px; width: 15px" /> <img src='./docs/static/img/mac.png' style="height:15px; width: 15px" />
<b>M1/M2</b> <b>M1/M2</b>
</a> </a>
</td> </td>
<td style="text-align:center"> <td style="text-align:center">
<a href='https://delta.jan.ai/latest/jan-linux-amd64-0.4.6-285.deb'> <a href='https://delta.jan.ai/latest/jan-linux-amd64-0.4.7-289.deb'>
<img src='./docs/static/img/linux.png' style="height:14px; width: 14px" /> <img src='./docs/static/img/linux.png' style="height:14px; width: 14px" />
<b>jan.deb</b> <b>jan.deb</b>
</a> </a>
</td> </td>
<td style="text-align:center"> <td style="text-align:center">
<a href='https://delta.jan.ai/latest/jan-linux-x86_64-0.4.6-285.AppImage'> <a href='https://delta.jan.ai/latest/jan-linux-x86_64-0.4.7-289.AppImage'>
<img src='./docs/static/img/linux.png' style="height:14px; width: 14px" /> <img src='./docs/static/img/linux.png' style="height:14px; width: 14px" />
<b>jan.AppImage</b> <b>jan.AppImage</b>
</a> </a>
@ -304,7 +304,7 @@ This will build the app MacOS m1/m2 for production (with code signing already do
```bash ```bash
# GPU mode with default file system # 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 # GPU mode with S3 file system
docker compose --profile gpu-s3fs up -d 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: Jan builds on top of other open-source projects:
- [llama.cpp](https://github.com/ggerganov/llama.cpp) - [llama.cpp](https://github.com/ggerganov/llama.cpp)
- [LangChain](https://github.com/langchain-ai)
- [TensorRT](https://github.com/NVIDIA/TensorRT) - [TensorRT](https://github.com/NVIDIA/TensorRT)
## Contact ## Contact

View File

@ -7,6 +7,7 @@ export enum NativeRoute {
openAppDirectory = 'openAppDirectory', openAppDirectory = 'openAppDirectory',
openFileExplore = 'openFileExplorer', openFileExplore = 'openFileExplorer',
selectDirectory = 'selectDirectory', selectDirectory = 'selectDirectory',
selectModelFiles = 'selectModelFiles',
relaunch = 'relaunch', relaunch = 'relaunch',
} }
@ -46,6 +47,13 @@ export enum DownloadEvent {
onFileDownloadSuccess = 'onFileDownloadSuccess', onFileDownloadSuccess = 'onFileDownloadSuccess',
} }
export enum LocalImportModelEvent {
onLocalImportModelUpdate = 'onLocalImportModelUpdate',
onLocalImportModelError = 'onLocalImportModelError',
onLocalImportModelSuccess = 'onLocalImportModelSuccess',
onLocalImportModelFinished = 'onLocalImportModelFinished',
}
export enum ExtensionRoute { export enum ExtensionRoute {
baseExtensions = 'baseExtensions', baseExtensions = 'baseExtensions',
getActiveExtensions = 'getActiveExtensions', getActiveExtensions = 'getActiveExtensions',
@ -67,6 +75,7 @@ export enum FileSystemRoute {
} }
export enum FileManagerRoute { export enum FileManagerRoute {
syncFile = 'syncFile', syncFile = 'syncFile',
copyFile = 'copyFile',
getJanDataFolderPath = 'getJanDataFolderPath', getJanDataFolderPath = 'getJanDataFolderPath',
getResourcePath = 'getResourcePath', getResourcePath = 'getResourcePath',
getUserHomePath = 'getUserHomePath', getUserHomePath = 'getUserHomePath',
@ -126,4 +135,8 @@ export const CoreRoutes = [
] ]
export const APIRoutes = [...CoreRoutes, ...Object.values(NativeRoute)] 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),
]

View File

@ -4,6 +4,7 @@ export enum ExtensionTypeEnum {
Inference = 'inference', Inference = 'inference',
Model = 'model', Model = 'model',
SystemMonitoring = 'systemMonitoring', SystemMonitoring = 'systemMonitoring',
HuggingFace = 'huggingFace',
} }
export interface ExtensionType { export interface ExtensionType {

View File

@ -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<void>
abstract convert(repoID: string): Promise<void>
abstract quantize(repoID: string, quantization: Quantization): Promise<void>
abstract generateMetadata(
repoID: string,
repoData: HuggingFaceRepoData,
quantization: Quantization
): Promise<void>
abstract cancelConvert(repoID: string, repoData: HuggingFaceRepoData): Promise<void>
}

View File

@ -23,3 +23,8 @@ export { AssistantExtension } from './assistant'
* Model extension for managing models. * Model extension for managing models.
*/ */
export { ModelExtension } from './model' export { ModelExtension } from './model'
/**
* Hugging Face extension for converting HF models to GGUF.
*/
export { HuggingFaceExtension } from './huggingface'

View File

@ -1,5 +1,5 @@
import { BaseExtension, ExtensionTypeEnum } from '../extension' import { BaseExtension, ExtensionTypeEnum } from '../extension'
import { Model, ModelInterface } from '../index' import { ImportingModel, Model, ModelInterface, OptionType } from '../index'
/** /**
* Model extension for managing models. * Model extension for managing models.
@ -21,4 +21,6 @@ export abstract class ModelExtension extends BaseExtension implements ModelInter
abstract saveModel(model: Model): Promise<void> abstract saveModel(model: Model): Promise<void>
abstract getDownloadedModels(): Promise<Model[]> abstract getDownloadedModels(): Promise<Model[]>
abstract getConfiguredModels(): Promise<Model[]> abstract getConfiguredModels(): Promise<Model[]>
abstract importModels(models: ImportingModel[], optionType: OptionType): Promise<void>
abstract updateModelInfo(modelInfo: Partial<Model>): Promise<Model>
} }

View File

@ -69,14 +69,20 @@ const syncFile: (src: string, dest: string) => Promise<any> = (src, dest) =>
*/ */
const copyFileSync = (...args: any[]) => global.core.api?.copyFileSync(...args) const copyFileSync = (...args: any[]) => global.core.api?.copyFileSync(...args)
const copyFile: (src: string, dest: string) => Promise<void> = (src, dest) =>
global.core.api?.copyFile(src, dest)
/** /**
* Gets the file's stats. * Gets the file's stats.
* *
* @param path - The path to the file. * @param path - The path to the file.
* @param outsideJanDataFolder - Whether the file is outside the Jan data folder.
* @returns {Promise<FileStat>} - A promise that resolves with the file's stats. * @returns {Promise<FileStat>} - A promise that resolves with the file's stats.
*/ */
const fileStat: (path: string) => Promise<FileStat | undefined> = (path) => const fileStat: (path: string, outsideJanDataFolder?: boolean) => Promise<FileStat | undefined> = (
global.core.api?.fileStat(path) path,
outsideJanDataFolder
) => global.core.api?.fileStat(path, outsideJanDataFolder)
// TODO: Export `dummy` fs functions automatically // TODO: Export `dummy` fs functions automatically
// Currently adding these manually // Currently adding these manually
@ -90,6 +96,7 @@ export const fs = {
unlinkSync, unlinkSync,
appendFileSync, appendFileSync,
copyFileSync, copyFileSync,
copyFile,
syncFile, syncFile,
fileStat, fileStat,
writeBlob, writeBlob,

View File

@ -50,7 +50,7 @@ export class Downloader implements Processor {
fileName, fileName,
downloadState: 'downloading', downloadState: 'downloading',
} }
console.log('progress: ', downloadState) console.debug('progress: ', downloadState)
observer?.(DownloadEvent.onFileDownloadUpdate, downloadState) observer?.(DownloadEvent.onFileDownloadUpdate, downloadState)
DownloadManager.instance.downloadProgressMap[modelId] = downloadState DownloadManager.instance.downloadProgressMap[modelId] = downloadState
}) })

View File

@ -1,6 +1,5 @@
import { join } from 'path' import { join } from 'path'
import fs from 'fs' import fs from 'fs'
import { FileManagerRoute } from '../../../api'
import { appResourcePath, normalizeFilePath } from '../../helper/path' import { appResourcePath, normalizeFilePath } from '../../helper/path'
import { getJanDataFolderPath, getJanDataFolderPath as getPath } from '../../helper' import { getJanDataFolderPath, getJanDataFolderPath as getPath } from '../../helper'
import { Processor } from './Processor' import { Processor } from './Processor'
@ -48,10 +47,12 @@ export class FSExt implements Processor {
} }
// handle fs is directory here // handle fs is directory here
fileStat(path: string) { fileStat(path: string, outsideJanDataFolder?: boolean) {
const normalizedPath = normalizeFilePath(path) const normalizedPath = normalizeFilePath(path)
const fullPath = join(getJanDataFolderPath(), normalizedPath) const fullPath = outsideJanDataFolder
? normalizedPath
: join(getJanDataFolderPath(), normalizedPath)
const isExist = fs.existsSync(fullPath) const isExist = fs.existsSync(fullPath)
if (!isExist) return undefined if (!isExist) return undefined
@ -75,4 +76,16 @@ export class FSExt implements Processor {
console.error(`writeFile ${path} result: ${err}`) console.error(`writeFile ${path} result: ${err}`)
} }
} }
copyFile(src: string, dest: string): Promise<void> {
return new Promise((resolve, reject) => {
fs.copyFile(src, dest, (err) => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
}
} }

View File

@ -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 */

View File

@ -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<void>
/**
* 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<void>
/**
* 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<void>
/**
* 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<void>
/**
* Cancels the convert of current Hugging Face model.
* @param repoID - The repository ID to cancel.
* @param repoData - The repository data to cancel.
* @returns {Promise<void>} A promise that resolves when the download has been cancelled.
*/
cancelConvert(repoID: string, repoData: HuggingFaceRepoData): Promise<void>
}

View File

@ -0,0 +1,2 @@
export * from './huggingfaceInterface'
export * from './huggingfaceEntity'

View File

@ -6,4 +6,5 @@ export * from './inference'
export * from './monitoring' export * from './monitoring'
export * from './file' export * from './file'
export * from './config' export * from './config'
export * from './huggingface'
export * from './miscellaneous' export * from './miscellaneous'

View File

@ -1,3 +1,4 @@
export * from './modelEntity' export * from './modelEntity'
export * from './modelInterface' export * from './modelInterface'
export * from './modelEvent' export * from './modelEvent'
export * from './modelImport'

View File

@ -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
}

View File

@ -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)

View File

@ -13,13 +13,13 @@ keywords:
no-subscription fee, no-subscription fee,
large language model, large language model,
docker installation, docker installation,
cpu mode,
gpu mode,
] ]
--- ---
# Installing Jan using Docker # Installing Jan using Docker
## Installation
### Pre-requisites ### Pre-requisites
:::note :::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. - 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 ```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 This will start the web server and you can access Jan at `http://localhost:3000`.
- **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`.
:::warning :::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.
::: :::

View File

@ -51,6 +51,7 @@ const sidebars = {
"how-we-work/website-docs/website-docs", "how-we-work/website-docs/website-docs",
], ],
}, },
"acknowledgements",
], ],
productSidebar: [ productSidebar: [
{ {

View File

@ -1,4 +1,10 @@
.theme-doc-markdown { .theme-doc-markdown {
a,
p,
span,
li {
@apply leading-loose;
}
a { a {
@apply text-blue-600 dark:text-blue-400; @apply text-blue-600 dark:text-blue-400;
} }
@ -10,9 +16,9 @@
} }
ul, ul,
ol { ol {
padding-left: 16px; padding-left: 28px;
li { li {
@apply leading-normal; @apply leading-loose;
p { p {
margin-bottom: 0; margin-bottom: 0;
} }

View File

@ -1,12 +1,12 @@
// * Classname from Docusaurus template // * Classname from Docusaurus template
// * We just overide the styling with applied class from tailwind // * We just overide the styling with applied class from tailwind
[class*="docSidebarContainer_"] { [class*='docSidebarContainer_'] {
margin-top: 0 !important; margin-top: 0 !important;
@apply dark:border-gray-800 border-gray-300; @apply dark:border-gray-800 border-gray-300;
} }
[class*="sidebar_"] { [class*='sidebar_'] {
padding-top: 0px !important; padding-top: 0px !important;
} }
@ -14,32 +14,40 @@
padding-top: 20px !important; padding-top: 20px !important;
} }
[class*="sidebarViewport_"] { [class*='sidebarViewport_'] {
top: 80px !important; top: 80px !important;
// height: unset !important; // height: unset !important;
} }
[class*="docItemCol_"] { [class*='docItemCol_'] {
@apply lg:px-8; @apply lg:px-8;
} }
// * Including custom sidebar table of content // * Including custom sidebar table of content
.table-of-contents { .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 { .menu__caret:before {
background: var(--ifm-menu-link-sublist-icon) 50% / 1.5rem 1.5rem; background: var(--ifm-menu-link-sublist-icon) 50% / 1.5rem 1.5rem;
} }
[class*="codeBlockContainer_"] { [class*='codeBlockContainer_'] {
margin: 4px; margin: 4px;
} }
[class*="codeBlockTitle_"] { [class*='codeBlockTitle_'] {
border-bottom: 1px solid #52525a !important; border-bottom: 1px solid #52525a !important;
} }
[class*="iconExternalLink_"] { [class*='iconExternalLink_'] {
display: none; display: none;
} }
[class*='docMainContainer'] {
@media (min-width: 1440px) {
.container {
max-width: var(--ifm-container-width-xl);
}
}
}

View File

@ -83,4 +83,22 @@ export function handleAppIPCs() {
return filePaths[0] 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
}
})
} }

View File

@ -15,12 +15,14 @@
"build/**/*.{js,map}", "build/**/*.{js,map}",
"pre-install", "pre-install",
"models/**/*", "models/**/*",
"docs/**/*" "docs/**/*",
"scripts/**/*"
], ],
"asarUnpack": [ "asarUnpack": [
"pre-install", "pre-install",
"models", "models",
"docs" "docs",
"scripts"
], ],
"publish": [ "publish": [
{ {

View File

@ -8,10 +8,9 @@ export const setupReactDevTool = async () => {
) // Don't use import on top level, since the installer package is dev-only ) // Don't use import on top level, since the installer package is dev-only
try { try {
const name = await installExtension(REACT_DEVELOPER_TOOLS) const name = await installExtension(REACT_DEVELOPER_TOOLS)
console.log(`Added Extension: ${name}`) console.debug(`Added Extension: ${name}`)
} catch (err) { } catch (err) {
console.log('An error occurred while installing devtools:') console.error('An error occurred while installing devtools:', err)
console.error(err)
// Only log the error and don't throw it because it's not critical // Only log the error and don't throw it because it's not critical
} }
} }

View File

@ -35,7 +35,7 @@ export function cleanLogs(
console.error('Error deleting log file:', err) console.error('Error deleting log file:', err)
return return
} }
console.log( console.debug(
`Deleted log file due to exceeding size limit: ${filePath}` `Deleted log file due to exceeding size limit: ${filePath}`
) )
}) })
@ -52,7 +52,7 @@ export function cleanLogs(
console.error('Error deleting log file:', err) console.error('Error deleting log file:', err)
return return
} }
console.log(`Deleted old log file: ${filePath}`) console.debug(`Deleted old log file: ${filePath}`)
}) })
} }
} }

View File

@ -0,0 +1,3 @@
bin
scripts/convert*
scripts/gguf-py

View File

@ -0,0 +1,8 @@
{
"semi": false,
"singleQuote": true,
"quoteProps": "consistent",
"trailingComma": "es5",
"endOfLine": "auto",
"plugins": ["prettier-plugin-tailwindcss"]
}

View File

@ -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<any>`.
```typescript
import { core } from "@janhq/core";
function onStart(): Promise<any> {
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!

Binary file not shown.

View File

@ -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%"

View File

@ -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 <service@jan.ai>",
"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"
]
}

View File

@ -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(),
],
},
]

View File

@ -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])

View File

@ -0,0 +1 @@
b2106

View File

@ -0,0 +1,2 @@
declare const EXTENSION_NAME: string
declare const NODE_MODULE_PATH: string

View File

@ -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<string> {
const modelName = repoID.split('/').slice(1).join('/')
return joinPath([await getJanDataFolderPath(), 'models', modelName])
}
private async getConvertedModelPath(repoID: string): Promise<string> {
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<string> {
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<void> {
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<void>((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<void> {
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<void> {
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<void> {
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<void>} A promise that resolves when the download has been cancelled.
*/
async cancelConvert(
repoID: string,
repoData: HuggingFaceRepoData
): Promise<void> {
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')
}
}

View File

@ -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<void> => {
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<void> => {
return await new Promise<void>((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<void> => {
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<void>((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<void> => {
return await new Promise<void>((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()
}
})
})
}

View File

@ -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"],
}

View File

@ -1 +1 @@
0.3.12 0.3.13

View File

@ -23,10 +23,7 @@ const DEFALT_SETTINGS = {
gpus_in_use: [], gpus_in_use: [],
is_initial: true, is_initial: true,
// TODO: This needs to be set based on user toggle in settings // TODO: This needs to be set based on user toggle in settings
vulkan: { vulkan: false
enabled: true,
gpu_in_use: '1',
},
} }
/** /**
@ -152,7 +149,7 @@ export function updateCudaExistence(
data['cuda'].exist = cudaExists data['cuda'].exist = cudaExists
data['cuda'].version = cudaVersion 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) { if (cudaExists && data['is_initial'] && data['gpus_in_use'].length > 0) {
data.run_mode = 'gpu' data.run_mode = 'gpu'
} }

View File

@ -67,7 +67,7 @@ export const executableNitroFile = (): NitroExecutableOptions => {
if (gpuInfo['vulkan'] === true) { if (gpuInfo['vulkan'] === true) {
binaryFolder = path.join(__dirname, '..', 'bin') binaryFolder = path.join(__dirname, '..', 'bin')
binaryFolder = path.join(binaryFolder, 'win-vulkan') binaryFolder = path.join(binaryFolder, 'linux-vulkan')
vkVisibleDevices = gpuInfo['gpus_in_use'].toString() vkVisibleDevices = gpuInfo['gpus_in_use'].toString()
} }
} }

View File

@ -13,6 +13,9 @@ import {
DownloadRoute, DownloadRoute,
ModelEvent, ModelEvent,
DownloadState, DownloadState,
OptionType,
ImportingModel,
LocalImportModelEvent,
} from '@janhq/core' } from '@janhq/core'
import { extractFileName } from './helpers/path' import { extractFileName } from './helpers/path'
@ -158,18 +161,18 @@ export default class JanModelExtension extends ModelExtension {
/** /**
* Cancels the download of a specific machine learning model. * Cancels the download of a specific machine learning model.
*
* @param {string} modelId - The ID of the model whose download is to be cancelled. * @param {string} modelId - The ID of the model whose download is to be cancelled.
* @returns {Promise<void>} A promise that resolves when the download has been cancelled. * @returns {Promise<void>} A promise that resolves when the download has been cancelled.
*/ */
async cancelModelDownload(modelId: string): Promise<void> { async cancelModelDownload(modelId: string): Promise<void> {
const model = await this.getConfiguredModels() const path = await joinPath([JanModelExtension._homeDir, modelId, modelId])
return abortDownload( try {
await joinPath([JanModelExtension._homeDir, modelId, modelId]) await abortDownload(path)
).then(async () => { await fs.unlinkSync(path)
fs.unlinkSync( } catch (e) {
await joinPath([JanModelExtension._homeDir, modelId, modelId]) console.error(e)
) }
})
} }
/** /**
@ -180,6 +183,20 @@ export default class JanModelExtension extends ModelExtension {
async deleteModel(modelId: string): Promise<void> { async deleteModel(modelId: string): Promise<void> {
try { try {
const dirPath = await joinPath([JanModelExtension._homeDir, modelId]) 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 // remove all files under dirPath except model.json
const files = await fs.readdirSync(dirPath) const files = await fs.readdirSync(dirPath)
@ -389,7 +406,7 @@ export default class JanModelExtension extends ModelExtension {
llama_model_path: binaryFileName, llama_model_path: binaryFileName,
}, },
created: Date.now(), created: Date.now(),
description: `${dirName} - user self import model`, description: '',
metadata: { metadata: {
size: binaryFileSize, size: binaryFileSize,
author: 'User', author: 'User',
@ -455,4 +472,182 @@ export default class JanModelExtension extends ModelExtension {
) )
} }
} }
private async importModelSymlink(
modelBinaryPath: string,
modelFolderName: string,
modelFolderPath: string
): Promise<Model> {
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<Model>): Promise<Model> {
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<Model> {
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<string> {
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<void> {
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
)
}
} }

View File

@ -38,7 +38,7 @@ export const s3 = (req: any, reply: any, done: any) => {
reply.status(200).send(result) reply.status(200).send(result)
return return
} catch (ex) { } catch (ex) {
console.log(ex) console.error(ex)
} }
} }
} }

View File

@ -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
* className="CircularProgressbar-inverted"
* background
* percentage={50}
* />
*/
.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;
}

View File

@ -17,6 +17,7 @@
@import './select/styles.scss'; @import './select/styles.scss';
@import './slider/styles.scss'; @import './slider/styles.scss';
@import './checkbox/styles.scss'; @import './checkbox/styles.scss';
@import './circular-progress/styles.scss';
.animate-spin { .animate-spin {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;

View File

@ -19,7 +19,7 @@ const ModalOverlay = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ModalPrimitive.Overlay <ModalPrimitive.Overlay
ref={ref} ref={ref}
className={twMerge(' modal-backdrop', className)} className={twMerge('modal-backdrop', className)}
{...props} {...props}
/> />
)) ))

8
web/.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"semi": false,
"singleQuote": true,
"quoteProps": "consistent",
"trailingComma": "es5",
"endOfLine": "auto",
"plugins": ["prettier-plugin-tailwindcss"]
}

View File

@ -1,19 +1,21 @@
'use client' 'use client'
import { useAtomValue } from 'jotai'
import BaseLayout from '@/containers/Layout' import BaseLayout from '@/containers/Layout'
import { MainViewState } from '@/constants/screens' import { MainViewState } from '@/constants/screens'
import { useMainViewState } from '@/hooks/useMainViewState'
import ChatScreen from '@/screens/Chat' import ChatScreen from '@/screens/Chat'
import ExploreModelsScreen from '@/screens/ExploreModels' import ExploreModelsScreen from '@/screens/ExploreModels'
import LocalServerScreen from '@/screens/LocalServer' import LocalServerScreen from '@/screens/LocalServer'
import SettingsScreen from '@/screens/Settings' import SettingsScreen from '@/screens/Settings'
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
export default function Page() { export default function Page() {
const { mainViewState } = useMainViewState() const mainViewState = useAtomValue(mainViewStateAtom)
let children = null let children = null
switch (mainViewState) { switch (mainViewState) {

View File

@ -38,7 +38,7 @@ export default function CardSidebar({
const [menu, setMenu] = useState<HTMLDivElement | null>(null) const [menu, setMenu] = useState<HTMLDivElement | null>(null)
const [toggle, setToggle] = useState<HTMLDivElement | null>(null) const [toggle, setToggle] = useState<HTMLDivElement | null>(null)
const activeThread = useAtomValue(activeThreadAtom) const activeThread = useAtomValue(activeThreadAtom)
const { onReviewInFinder, onViewJson } = usePath() const { onRevealInFinder, onViewJson } = usePath()
useClickOutside(() => setMore(false), null, [menu, toggle]) useClickOutside(() => setMore(false), null, [menu, toggle])
@ -100,7 +100,7 @@ export default function CardSidebar({
title === 'Model' ? 'items-start' : 'items-center' title === 'Model' ? 'items-start' : 'items-center'
)} )}
onClick={() => { onClick={() => {
onReviewInFinder && onReviewInFinder(title) onRevealInFinder && onRevealInFinder(title)
setMore(false) setMore(false)
}} }}
> >

View File

@ -30,7 +30,6 @@ import { MainViewState } from '@/constants/screens'
import { useActiveModel } from '@/hooks/useActiveModel' import { useActiveModel } from '@/hooks/useActiveModel'
import { useClipboard } from '@/hooks/useClipboard' import { useClipboard } from '@/hooks/useClipboard'
import { useMainViewState } from '@/hooks/useMainViewState'
import useRecommendedModel from '@/hooks/useRecommendedModel' import useRecommendedModel from '@/hooks/useRecommendedModel'
@ -41,6 +40,7 @@ import { toGibibytes } from '@/utils/converter'
import ModelLabel from '../ModelLabel' import ModelLabel from '../ModelLabel'
import OpenAiKeyInput from '../OpenAiKeyInput' import OpenAiKeyInput from '../OpenAiKeyInput'
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom'
import { import {
@ -64,11 +64,13 @@ const DropdownListSidebar = ({
const [isTabActive, setIsTabActive] = useState(0) const [isTabActive, setIsTabActive] = useState(0)
const { stateModel } = useActiveModel() const { stateModel } = useActiveModel()
const [serverEnabled, setServerEnabled] = useAtom(serverEnabledAtom) const [serverEnabled, setServerEnabled] = useAtom(serverEnabledAtom)
const { setMainViewState } = useMainViewState()
const setMainViewState = useSetAtom(mainViewStateAtom)
const [loader, setLoader] = useState(0) const [loader, setLoader] = useState(0)
const { recommendedModel, downloadedModels } = useRecommendedModel() const { recommendedModel, downloadedModels } = useRecommendedModel()
const { updateModelParameter } = useUpdateModelParameters() const { updateModelParameter } = useUpdateModelParameters()
const clipboard = useClipboard({ timeout: 1000 }) const clipboard = useClipboard({ timeout: 1000 })
const [copyId, setCopyId] = useState('') const [copyId, setCopyId] = useState('')
const localModel = downloadedModels.filter( const localModel = downloadedModels.filter(

View File

@ -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 (
<Fragment>
{isImportingModels ? (
<div
className="flex cursor-pointer flex-row items-center space-x-2"
onClick={onClick}
>
<p className="text-xs font-semibold text-[#09090B]">
Importing model ({finishedImportModelCount}/{importingModels.length}
)
</p>
<div className="flex flex-row items-center justify-center space-x-2 rounded-md bg-[#F4F4F5] px-2 py-[2px]">
<Progress
className="h-2 w-24"
value={transferredSize / totalSize}
/>
<span className="text-xs font-bold text-primary">
{progress.toFixed(2)}%
</span>
</div>
</div>
) : null}
</Fragment>
)
}
export default ImportingModelState

View File

@ -25,8 +25,8 @@ const TableActiveModel = () => {
const [serverEnabled, setServerEnabled] = useAtom(serverEnabledAtom) const [serverEnabled, setServerEnabled] = useAtom(serverEnabledAtom)
return ( return (
<div className="flex-shrink-0 m-4 mr-0 w-2/3"> <div className="m-4 mr-0 w-2/3 flex-shrink-0">
<div className="rounded-lg border border-border shadow-sm overflow-hidden"> <div className="overflow-hidden rounded-lg border border-border shadow-sm">
<table className="w-full px-8"> <table className="w-full px-8">
<thead className="w-full border-b border-border bg-secondary"> <thead className="w-full border-b border-border bg-secondary">
<tr> <tr>

View File

@ -57,23 +57,12 @@ const SystemMonitor = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 ( return (
<Fragment> <Fragment>
<div <div
ref={setControl} ref={setControl}
className={twMerge( className={twMerge(
'flex items-center gap-x-2 cursor-pointer p-2 rounded-md hover:bg-secondary', 'flex cursor-pointer items-center gap-x-2 rounded-md p-2 hover:bg-secondary',
systemMonitorCollapse && 'bg-secondary' systemMonitorCollapse && 'bg-secondary'
)} )}
onClick={() => { onClick={() => {
@ -88,29 +77,29 @@ const SystemMonitor = () => {
<div <div
ref={setElementExpand} ref={setElementExpand}
className={twMerge( className={twMerge(
'fixed left-16 bottom-12 bg-background w-[calc(100%-64px)] z-50 border-t border-border flex flex-col flex-shrink-0', 'fixed bottom-12 left-16 z-50 flex w-[calc(100%-64px)] flex-shrink-0 flex-col border-t border-border bg-background',
showFullScreen && 'h-[calc(100%-48px)]' showFullScreen && 'h-[calc(100%-48px)]'
)} )}
> >
<div className="h-12 flex items-center border-b border-border px-4 justify-between flex-shrink-0"> <div className="flex h-12 flex-shrink-0 items-center justify-between border-b border-border px-4">
<h6 className="font-bold">Running Models</h6> <h6 className="font-bold">Running Models</h6>
<div className="flex items-center gap-x-2 unset-drag"> <div className="unset-drag flex items-center gap-x-2">
{showFullScreen ? ( {showFullScreen ? (
<ChevronDown <ChevronDown
size={20} size={20}
className="text-muted-foreground cursor-pointer" className="cursor-pointer text-muted-foreground"
onClick={() => setShowFullScreen(!showFullScreen)} onClick={() => setShowFullScreen(!showFullScreen)}
/> />
) : ( ) : (
<ChevronUp <ChevronUp
size={20} size={20}
className="text-muted-foreground cursor-pointer" className="cursor-pointer text-muted-foreground"
onClick={() => setShowFullScreen(!showFullScreen)} onClick={() => setShowFullScreen(!showFullScreen)}
/> />
)} )}
<XIcon <XIcon
size={16} size={16}
className="text-muted-foreground cursor-pointer" className="cursor-pointer text-muted-foreground"
onClick={() => { onClick={() => {
setSystemMonitorCollapse(false) setSystemMonitorCollapse(false)
setShowFullScreen(false) setShowFullScreen(false)
@ -118,10 +107,10 @@ const SystemMonitor = () => {
/> />
</div> </div>
</div> </div>
<div className="flex gap-4 h-full"> <div className="flex h-full gap-4">
<TableActiveModel /> <TableActiveModel />
<div className="border-l border-border p-4 w-full"> <div className="w-full border-l border-border p-4">
<div className="mb-4 pb-4 border-b border-border"> <div className="mb-4 border-b border-border pb-4">
<h6 className="font-bold">CPU</h6> <h6 className="font-bold">CPU</h6>
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">
<Progress value={cpuUsage} className="h-2" /> <Progress value={cpuUsage} className="h-2" />
@ -130,11 +119,12 @@ const SystemMonitor = () => {
</span> </span>
</div> </div>
</div> </div>
<div className="mb-4 pb-4 border-b border-border"> <div className="mb-4 border-b border-border pb-4">
<div className="flex items-center gap-2"> <div className="flex items-center justify-between gap-2">
<h6 className="font-bold">Memory</h6> <h6 className="font-bold">Memory</h6>
<span className="text-xs text-muted-foreground"> <span className="text-sm text-muted-foreground">
{toGibibytes(usedRam)} of {toGibibytes(totalRam)} used {toGibibytes(usedRam, { hideUnit: true })}/
{toGibibytes(totalRam, { hideUnit: true })} GB
</span> </span>
</div> </div>
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">
@ -148,30 +138,29 @@ const SystemMonitor = () => {
</div> </div>
</div> </div>
{gpus.length > 0 && ( {gpus.length > 0 && (
<div className="mb-4 pb-4 border-b border-border"> <div className="mb-4 border-b border-border pb-4 last:border-none">
<h6 className="font-bold">GPU</h6>
<div className="flex items-center gap-x-4">
<Progress value={calculateUtilization()} className="h-2" />
<span className="flex-shrink-0 text-muted-foreground">
{calculateUtilization()}%
</span>
</div>
{gpus.map((gpu, index) => ( {gpus.map((gpu, index) => (
<div <div key={index} className="mt-4 flex flex-col gap-2">
key={index} <div className="flex w-full items-start justify-between">
className="flex items-start justify-between mt-4 gap-4" <span className="line-clamp-1 w-1/2 font-bold">
> {gpu.name}
<span className="text-muted-foreground font-medium line-clamp-1 w-1/2"> </span>
{gpu.name} <div className="flex gap-x-2">
</span> <div className="text-muted-foreground">
<div className="flex gap-x-2"> <span>
<span className="font-semibold"> {gpu.memoryTotal - gpu.memoryFree}/
{gpu.memoryTotal}
</span>
<span> MB</span>
</div>
</div>
</div>
<div className="flex items-center gap-x-4">
<Progress value={gpu.utilization} className="h-2" />
<span className="flex-shrink-0 text-muted-foreground">
{gpu.utilization}% {gpu.utilization}%
</span> </span>
<div>
<span className="font-semibold">{gpu.vram}</span>
<span>MB VRAM</span>
</div>
</div> </div>
</div> </div>
))} ))}

View File

@ -15,6 +15,7 @@ import ProgressBar from '@/containers/ProgressBar'
import { appDownloadProgress } from '@/containers/Providers/Jotai' import { appDownloadProgress } from '@/containers/Providers/Jotai'
import ImportingModelState from './ImportingModelState'
import SystemMonitor from './SystemMonitor' import SystemMonitor from './SystemMonitor'
const menuLinks = [ const menuLinks = [
@ -41,6 +42,7 @@ const BottomBar = () => {
<ProgressBar total={100} used={progress} /> <ProgressBar total={100} used={progress} />
) : null} ) : null}
</div> </div>
<ImportingModelState />
<DownloadingState /> <DownloadingState />
</div> </div>
<div className="flex items-center gap-x-3"> <div className="flex items-center gap-x-3">

View File

@ -2,6 +2,7 @@ import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
TooltipPortal,
TooltipArrow, TooltipArrow,
} from '@janhq/uikit' } from '@janhq/uikit'
import { motion as m } from 'framer-motion' import { motion as m } from 'framer-motion'
@ -20,13 +21,12 @@ import LogoMark from '@/containers/Brand/Logo/Mark'
import { MainViewState } from '@/constants/screens' 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 { editMessageAtom } from '@/helpers/atoms/ChatMessage.atom'
import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom'
export default function RibbonNav() { export default function RibbonNav() {
const { mainViewState, setMainViewState } = useMainViewState() const [mainViewState, setMainViewState] = useAtom(mainViewStateAtom)
const [serverEnabled] = useAtom(serverEnabledAtom) const [serverEnabled] = useAtom(serverEnabledAtom)
const setEditMessage = useSetAtom(editMessageAtom) const setEditMessage = useSetAtom(editMessageAtom)
@ -119,24 +119,26 @@ export default function RibbonNav() {
/> />
)} )}
</TooltipTrigger> </TooltipTrigger>
{serverEnabled && <TooltipPortal>
primary.state === MainViewState.Thread ? ( {serverEnabled &&
<TooltipContent primary.state === MainViewState.Thread ? (
side="right" <TooltipContent
sideOffset={10} side="right"
className="max-w-[180px]" sideOffset={10}
> className="max-w-[180px]"
<span> >
Threads are disabled while the server is running <span>
</span> Threads are disabled while the server is running
<TooltipArrow /> </span>
</TooltipContent> <TooltipArrow />
) : ( </TooltipContent>
<TooltipContent side="right" sideOffset={10}> ) : (
<span>{primary.name}</span> <TooltipContent side="right" sideOffset={10}>
<TooltipArrow /> <span>{primary.name}</span>
</TooltipContent> <TooltipArrow />
)} </TooltipContent>
)}
</TooltipPortal>
</Tooltip> </Tooltip>
</div> </div>
) )
@ -169,10 +171,12 @@ export default function RibbonNav() {
/> />
)} )}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" sideOffset={10}> <TooltipPortal>
<span>{secondary.name}</span> <TooltipContent side="right" sideOffset={10}>
<TooltipArrow /> <span>{secondary.name}</span>
</TooltipContent> <TooltipArrow />
</TooltipContent>
</TooltipPortal>
</Tooltip> </Tooltip>
</div> </div>
) )

View File

@ -11,7 +11,7 @@ import {
Badge, Badge,
} from '@janhq/uikit' } from '@janhq/uikit'
import { useAtom, useAtomValue } from 'jotai' import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { DatabaseIcon, CpuIcon } from 'lucide-react' import { DatabaseIcon, CpuIcon } from 'lucide-react'
import { showSelectModelModalAtom } from '@/containers/Providers/KeyListener' import { showSelectModelModalAtom } from '@/containers/Providers/KeyListener'
@ -19,13 +19,13 @@ import { showSelectModelModalAtom } from '@/containers/Providers/KeyListener'
import { MainViewState } from '@/constants/screens' import { MainViewState } from '@/constants/screens'
import { useActiveModel } from '@/hooks/useActiveModel' 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 { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom'
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom' import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
export default function CommandListDownloadedModel() { export default function CommandListDownloadedModel() {
const { setMainViewState } = useMainViewState() const setMainViewState = useSetAtom(mainViewStateAtom)
const downloadedModels = useAtomValue(downloadedModelsAtom) const downloadedModels = useAtomValue(downloadedModelsAtom)
const { activeModel, startModel, stopModel } = useActiveModel() const { activeModel, startModel, stopModel } = useActiveModel()
const [serverEnabled] = useAtom(serverEnabledAtom) const [serverEnabled] = useAtom(serverEnabledAtom)

View File

@ -10,20 +10,15 @@ import {
CommandList, CommandList,
} from '@janhq/uikit' } from '@janhq/uikit'
import { useAtom } from 'jotai' import { useAtom, useSetAtom } from 'jotai'
import { import { MessageCircleIcon, SettingsIcon, LayoutGridIcon } from 'lucide-react'
MessageCircleIcon,
SettingsIcon,
LayoutGridIcon,
MonitorIcon,
} from 'lucide-react'
import { showCommandSearchModalAtom } from '@/containers/Providers/KeyListener' import { showCommandSearchModalAtom } from '@/containers/Providers/KeyListener'
import ShortCut from '@/containers/Shortcut' import ShortCut from '@/containers/Shortcut'
import { MainViewState } from '@/constants/screens' import { MainViewState } from '@/constants/screens'
import { useMainViewState } from '@/hooks/useMainViewState' import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
const menus = [ const menus = [
{ {
@ -48,7 +43,7 @@ const menus = [
] ]
export default function CommandSearch() { export default function CommandSearch() {
const { setMainViewState } = useMainViewState() const setMainViewState = useSetAtom(mainViewStateAtom)
const [showCommandSearchModal, setShowCommandSearchModal] = useAtom( const [showCommandSearchModal, setShowCommandSearchModal] = useAtom(
showCommandSearchModalAtom showCommandSearchModalAtom
) )

View File

@ -20,7 +20,6 @@ import { MainViewState } from '@/constants/screens'
import { useClickOutside } from '@/hooks/useClickOutside' import { useClickOutside } from '@/hooks/useClickOutside'
import { useCreateNewThread } from '@/hooks/useCreateNewThread' import { useCreateNewThread } from '@/hooks/useCreateNewThread'
import { useMainViewState } from '@/hooks/useMainViewState'
import { usePath } from '@/hooks/usePath' import { usePath } from '@/hooks/usePath'
@ -28,18 +27,19 @@ import { showRightSideBarAtom } from '@/screens/Chat/Sidebar'
import { openFileTitle } from '@/utils/titleUtils' import { openFileTitle } from '@/utils/titleUtils'
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
import { assistantsAtom } from '@/helpers/atoms/Assistant.atom' import { assistantsAtom } from '@/helpers/atoms/Assistant.atom'
import { activeThreadAtom } from '@/helpers/atoms/Thread.atom' import { activeThreadAtom } from '@/helpers/atoms/Thread.atom'
const TopBar = () => { const TopBar = () => {
const activeThread = useAtomValue(activeThreadAtom) const activeThread = useAtomValue(activeThreadAtom)
const { mainViewState } = useMainViewState() const mainViewState = useAtomValue(mainViewStateAtom)
const { requestCreateNewThread } = useCreateNewThread() const { requestCreateNewThread } = useCreateNewThread()
const assistants = useAtomValue(assistantsAtom) const assistants = useAtomValue(assistantsAtom)
const [showRightSideBar, setShowRightSideBar] = useAtom(showRightSideBarAtom) const [showRightSideBar, setShowRightSideBar] = useAtom(showRightSideBarAtom)
const [showLeftSideBar, setShowLeftSideBar] = useAtom(showLeftSideBarAtom) const [showLeftSideBar, setShowLeftSideBar] = useAtom(showLeftSideBarAtom)
const showing = useAtomValue(showRightSideBarAtom) const showing = useAtomValue(showRightSideBarAtom)
const { onReviewInFinder, onViewJson } = usePath() const { onRevealInFinder, onViewJson } = usePath()
const [more, setMore] = useState(false) const [more, setMore] = useState(false)
const [menu, setMenu] = useState<HTMLDivElement | null>(null) const [menu, setMenu] = useState<HTMLDivElement | null>(null)
const [toggle, setToggle] = useState<HTMLDivElement | null>(null) const [toggle, setToggle] = useState<HTMLDivElement | null>(null)
@ -151,7 +151,7 @@ const TopBar = () => {
<div <div
className="flex cursor-pointer items-center space-x-2 px-4 py-2 hover:bg-secondary" className="flex cursor-pointer items-center space-x-2 px-4 py-2 hover:bg-secondary"
onClick={() => { onClick={() => {
onReviewInFinder('Thread') onRevealInFinder('Thread')
setMore(false) setMore(false)
}} }}
> >
@ -195,7 +195,7 @@ const TopBar = () => {
<div <div
className="flex cursor-pointer items-center space-x-2 px-4 py-2 hover:bg-secondary" className="flex cursor-pointer items-center space-x-2 px-4 py-2 hover:bg-secondary"
onClick={() => { onClick={() => {
onReviewInFinder('Model') onRevealInFinder('Model')
setMore(false) setMore(false)
}} }}
> >

View File

@ -4,6 +4,8 @@ import { useTheme } from 'next-themes'
import { motion as m } from 'framer-motion' import { motion as m } from 'framer-motion'
import { useAtom, useAtomValue } from 'jotai'
import BottomBar from '@/containers/Layout/BottomBar' import BottomBar from '@/containers/Layout/BottomBar'
import RibbonNav from '@/containers/Layout/Ribbon' import RibbonNav from '@/containers/Layout/Ribbon'
@ -11,14 +13,21 @@ import TopBar from '@/containers/Layout/TopBar'
import { MainViewState } from '@/constants/screens' 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 { 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 BaseLayout = (props: PropsWithChildren) => {
const { children } = props const { children } = props
const { mainViewState, setMainViewState } = useMainViewState() const [mainViewState, setMainViewState] = useAtom(mainViewStateAtom)
const importModelStage = useAtomValue(getImportModelStageAtom)
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
useEffect(() => { useEffect(() => {
@ -54,6 +63,11 @@ const BaseLayout = (props: PropsWithChildren) => {
<BottomBar /> <BottomBar />
</div> </div>
</div> </div>
{importModelStage === 'SELECTING_MODEL' && <SelectingModelModal />}
{importModelStage === 'MODEL_SELECTED' && <ImportModelOptionModal />}
{importModelStage === 'IMPORTING_MODEL' && <ImportingModelModal />}
{importModelStage === 'EDIT_MODEL_INFO' && <EditModelInfoModal />}
{importModelStage === 'CONFIRM_CANCEL' && <CancelModelImportModal />}
</div> </div>
) )
} }

View File

@ -69,7 +69,7 @@ const ModalTroubleShooting: React.FC = () => {
> >
Discord Discord
</a> </a>
&nbsp; & send it to #🆘|get-help channel for further support. &nbsp;& send it to #🆘|get-help channel for further support.
</p> </p>
</li> </li>
</ul> </ul>

View File

@ -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 <Fragment>{children}</Fragment>
}
export default AppUpdateListener

View File

@ -1,21 +1,37 @@
'use client' '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 useAssistants from '@/hooks/useAssistants'
import useGetSystemResources from '@/hooks/useGetSystemResources' import useGetSystemResources from '@/hooks/useGetSystemResources'
import useModels from '@/hooks/useModels' import useModels from '@/hooks/useModels'
import useThreads from '@/hooks/useThreads' import useThreads from '@/hooks/useThreads'
import { janDataFolderPathAtom } from '@/helpers/atoms/AppConfig.atom'
type Props = { type Props = {
children: ReactNode children: ReactNode
} }
const DataLoader: React.FC<Props> = ({ children }) => { const DataLoader: React.FC<Props> = ({ children }) => {
const setJanDataFolderPath = useSetAtom(janDataFolderPathAtom)
useModels() useModels()
useThreads() useThreads()
useAssistants() useAssistants()
useGetSystemResources() useGetSystemResources()
useEffect(() => {
window.core?.api
?.getAppConfigurations()
?.then((appConfig: AppConfiguration) => {
setJanDataFolderPath(appConfig.data_folder)
})
}, [setJanDataFolderPath])
console.debug('Load Data...') console.debug('Load Data...')
return <Fragment>{children}</Fragment> return <Fragment>{children}</Fragment>

View File

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { ReactNode, useCallback, useEffect, useRef } from 'react' import { Fragment, ReactNode, useCallback, useEffect, useRef } from 'react'
import { import {
ChatCompletionMessage, ChatCompletionMessage,
@ -302,5 +302,5 @@ export default function EventHandler({ children }: { children: ReactNode }) {
events.off(MessageEvent.OnMessageUpdate, onMessageResponseUpdate) events.off(MessageEvent.OnMessageUpdate, onMessageResponseUpdate)
} }
}, [onNewMessageResponse, onMessageResponseUpdate]) }, [onNewMessageResponse, onMessageResponseUpdate])
return <>{children}</> return <Fragment>{children}</Fragment>
} }

View File

@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { PropsWithChildren, useCallback, useEffect } from 'react' import { PropsWithChildren, useCallback, useEffect } from 'react'
import React from 'react' import React from 'react'
@ -8,13 +7,13 @@ import { useSetAtom } from 'jotai'
import { setDownloadStateAtom } from '@/hooks/useDownloadState' import { setDownloadStateAtom } from '@/hooks/useDownloadState'
import AppUpdateListener from './AppUpdateListener'
import EventHandler from './EventHandler' import EventHandler from './EventHandler'
import { appDownloadProgress } from './Jotai' import ModelImportListener from './ModelImportListener'
const EventListenerWrapper = ({ children }: PropsWithChildren) => { const EventListenerWrapper = ({ children }: PropsWithChildren) => {
const setDownloadState = useSetAtom(setDownloadStateAtom) const setDownloadState = useSetAtom(setDownloadStateAtom)
const setProgress = useSetAtom(appDownloadProgress)
const onFileDownloadUpdate = useCallback( const onFileDownloadUpdate = useCallback(
async (state: DownloadState) => { async (state: DownloadState) => {
@ -42,7 +41,6 @@ const EventListenerWrapper = ({ children }: PropsWithChildren) => {
useEffect(() => { useEffect(() => {
console.debug('EventListenerWrapper: registering event listeners...') console.debug('EventListenerWrapper: registering event listeners...')
events.on(DownloadEvent.onFileDownloadUpdate, onFileDownloadUpdate) events.on(DownloadEvent.onFileDownloadUpdate, onFileDownloadUpdate)
events.on(DownloadEvent.onFileDownloadError, onFileDownloadError) events.on(DownloadEvent.onFileDownloadError, onFileDownloadError)
events.on(DownloadEvent.onFileDownloadSuccess, onFileDownloadSuccess) events.on(DownloadEvent.onFileDownloadSuccess, onFileDownloadSuccess)
@ -55,30 +53,13 @@ const EventListenerWrapper = ({ children }: PropsWithChildren) => {
} }
}, [onFileDownloadUpdate, onFileDownloadError, onFileDownloadSuccess]) }, [onFileDownloadUpdate, onFileDownloadError, onFileDownloadSuccess])
useEffect(() => { return (
if (window && window.electronAPI) { <AppUpdateListener>
window.electronAPI.onAppUpdateDownloadUpdate( <ModelImportListener>
(_event: string, progress: any) => { <EventHandler>{children}</EventHandler>
setProgress(progress.percent) </ModelImportListener>
console.debug('app update progress:', progress.percent) </AppUpdateListener>
} )
)
window.electronAPI.onAppUpdateDownloadError(
(_event: string, callback: any) => {
console.error('Download error', callback)
setProgress(-1)
}
)
window.electronAPI.onAppUpdateDownloadSuccess(() => {
setProgress(-1)
})
}
return () => {}
}, [setDownloadState, setProgress])
return <EventHandler>{children}</EventHandler>
} }
export default EventListenerWrapper export default EventListenerWrapper

View File

@ -6,7 +6,7 @@ import { atom, useSetAtom } from 'jotai'
import { MainViewState } from '@/constants/screens' import { MainViewState } from '@/constants/screens'
import { useMainViewState } from '@/hooks/useMainViewState' import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
type Props = { type Props = {
children: ReactNode children: ReactNode
@ -19,7 +19,7 @@ export const showCommandSearchModalAtom = atom<boolean>(false)
export default function KeyListener({ children }: Props) { export default function KeyListener({ children }: Props) {
const setShowLeftSideBar = useSetAtom(showLeftSideBarAtom) const setShowLeftSideBar = useSetAtom(showLeftSideBarAtom)
const setShowSelectModelModal = useSetAtom(showSelectModelModalAtom) const setShowSelectModelModal = useSetAtom(showSelectModelModalAtom)
const { setMainViewState } = useMainViewState() const setMainViewState = useSetAtom(mainViewStateAtom)
const showCommandSearchModal = useSetAtom(showCommandSearchModalAtom) const showCommandSearchModal = useSetAtom(showCommandSearchModalAtom)
useEffect(() => { useEffect(() => {
@ -48,8 +48,12 @@ export default function KeyListener({ children }: Props) {
} }
document.addEventListener('keydown', onKeyDown) document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown) return () => document.removeEventListener('keydown', onKeyDown)
// eslint-disable-next-line react-hooks/exhaustive-deps }, [
}, []) setMainViewState,
setShowLeftSideBar,
setShowSelectModelModal,
showCommandSearchModal,
])
return <Fragment>{children}</Fragment> return <Fragment>{children}</Fragment>
} }

View File

@ -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 <Fragment>{children}</Fragment>
}
export default ModelImportListener

View File

@ -1,4 +1,4 @@
version: "3.8" version: '3.8'
services: services:
web: web:
@ -14,6 +14,6 @@ services:
- /app/node_modules - /app/node_modules
- /app/.next - /app/.next
ports: ports:
- "3000:3000" - '3000:3000'
environment: environment:
NODE_ENV: development NODE_ENV: development

View File

@ -0,0 +1,5 @@
import { atom } from 'jotai'
import { MainViewState } from '@/constants/screens'
export const mainViewStateAtom = atom<MainViewState>(MainViewState.Thread)

View File

@ -0,0 +1,3 @@
import { atom } from 'jotai'
export const janDataFolderPathAtom = atom('')

View File

@ -0,0 +1,44 @@
import { HuggingFaceRepoData } from '@janhq/core'
import { atom } from 'jotai'
export const repoIDAtom = atom<string | null>(null)
export const loadingAtom = atom<boolean>(false)
export const fetchErrorAtom = atom<Error | null>(null)
export const conversionStatusAtom = atom<
| 'downloading'
| 'converting'
| 'quantizing'
| 'done'
| 'stopping'
| 'generating'
| null
>(null)
export const conversionErrorAtom = atom<Error | null>(null)
const _repoDataAtom = atom<HuggingFaceRepoData | null>(null)
const _unsupportedAtom = atom<boolean>(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))

View File

@ -1,4 +1,4 @@
import { Model } from '@janhq/core' import { ImportingModel, Model } from '@janhq/core'
import { atom } from 'jotai' import { atom } from 'jotai'
export const stateModel = atom({ state: 'start', loading: false, model: '' }) export const stateModel = atom({ state: 'start', loading: false, model: '' })
@ -32,4 +32,81 @@ export const removeDownloadingModelAtom = atom(
export const downloadedModelsAtom = atom<Model[]>([]) export const downloadedModelsAtom = atom<Model[]>([])
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<Model[]>([]) export const configuredModelsAtom = atom<Model[]>([])
/// TODO: move this part to another atom
// store the paths of the models that are being imported
export const importingModelsAtom = atom<ImportingModel[]>([])
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)
}
)

View File

@ -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<HuggingFaceExtension>(
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<HuggingFaceExtension>(
ExtensionTypeEnum.HuggingFace
)
setConversionStatus('stopping')
await extension?.cancelConvert(repoID, repoData)
setConversionStatus(null)
}
return {
convertHuggingFaceModel,
cancelConvertHuggingFaceModel,
}
}

View File

@ -1,28 +1,32 @@
import { useCallback } from 'react'
import { ExtensionTypeEnum, ModelExtension, Model } from '@janhq/core' import { ExtensionTypeEnum, ModelExtension, Model } from '@janhq/core'
import { useAtom } from 'jotai' import { useSetAtom } from 'jotai'
import { toaster } from '@/containers/Toast' import { toaster } from '@/containers/Toast'
import { extensionManager } from '@/extension/ExtensionManager' import { extensionManager } from '@/extension/ExtensionManager'
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom' import { removeDownloadedModelAtom } from '@/helpers/atoms/Model.atom'
export default function useDeleteModel() { export default function useDeleteModel() {
const [downloadedModels, setDownloadedModels] = useAtom(downloadedModelsAtom) const removeDownloadedModel = useSetAtom(removeDownloadedModelAtom)
const deleteModel = async (model: Model) => { const deleteModel = useCallback(
await extensionManager async (model: Model) => {
.get<ModelExtension>(ExtensionTypeEnum.Model) await localDeleteModel(model.id)
?.deleteModel(model.id) removeDownloadedModel(model.id)
toaster({
// reload models title: 'Model Deletion Successful',
setDownloadedModels(downloadedModels.filter((e) => e.id !== model.id)) description: `Model ${model.name} has been successfully deleted.`,
toaster({ type: 'success',
title: 'Model Deletion Successful', })
description: `The model ${model.id} has been successfully deleted.`, },
type: 'success', [removeDownloadedModel]
}) )
}
return { deleteModel } return { deleteModel }
} }
const localDeleteModel = async (id: string) =>
extensionManager.get<ModelExtension>(ExtensionTypeEnum.Model)?.deleteModel(id)

View File

@ -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
}

View File

@ -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<ImportModelStage>('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<Model>) => localUpdateModelInfo(modelInfo),
[]
)
return { importModels, updateModelInfo }
}
const localImportModels = async (
models: ImportingModel[],
optionType: OptionType
): Promise<void> =>
extensionManager
.get<ModelExtension>(ExtensionTypeEnum.Model)
?.importModels(models, optionType)
const localUpdateModelInfo = async (
modelInfo: Partial<Model>
): Promise<Model | undefined> =>
extensionManager
.get<ModelExtension>(ExtensionTypeEnum.Model)
?.updateModelInfo(modelInfo)
export default useImportModel

View File

@ -1,11 +0,0 @@
import { atom, useAtom } from 'jotai'
import { MainViewState } from '@/constants/screens'
const currentMainViewState = atom<MainViewState>(MainViewState.Thread)
export function useMainViewState() {
const [mainViewState, setMainViewState] = useAtom(currentMainViewState)
const viewStateName = MainViewState[mainViewState]
return { mainViewState, setMainViewState, viewStateName }
}

View File

@ -9,7 +9,7 @@ export const usePath = () => {
const activeThread = useAtomValue(activeThreadAtom) const activeThread = useAtomValue(activeThreadAtom)
const selectedModel = useAtomValue(selectedModelAtom) const selectedModel = useAtomValue(selectedModelAtom)
const onReviewInFinder = async (type: string) => { const onRevealInFinder = async (type: string) => {
// TODO: this logic should be refactored. // TODO: this logic should be refactored.
if (type !== 'Model' && !activeThread) return if (type !== 'Model' && !activeThread) return
@ -96,7 +96,7 @@ export const usePath = () => {
} }
return { return {
onReviewInFinder, onRevealInFinder,
onViewJson, onViewJson,
onViewFile, onViewFile,
onViewFileContainer, onViewFileContainer,

View File

@ -32,6 +32,7 @@
"postcss": "8.4.31", "postcss": "8.4.31",
"posthog-js": "^1.95.1", "posthog-js": "^1.95.1",
"react": "18.2.0", "react": "18.2.0",
"react-circular-progressbar": "^2.1.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-hook-form": "^7.47.0", "react-hook-form": "^7.47.0",

View File

@ -4,27 +4,24 @@ import ScrollToBottom from 'react-scroll-to-bottom'
import { InferenceEngine, MessageStatus } from '@janhq/core' import { InferenceEngine, MessageStatus } from '@janhq/core'
import { Button } from '@janhq/uikit' import { Button } from '@janhq/uikit'
import { useAtomValue } from 'jotai' import { useAtomValue, useSetAtom } from 'jotai'
import LogoMark from '@/containers/Brand/Logo/Mark' import LogoMark from '@/containers/Brand/Logo/Mark'
import { MainViewState } from '@/constants/screens' import { MainViewState } from '@/constants/screens'
import { useMainViewState } from '@/hooks/useMainViewState'
import ChatItem from '../ChatItem' import ChatItem from '../ChatItem'
import ErrorMessage from '../ErrorMessage' import ErrorMessage from '../ErrorMessage'
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom' import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
const ChatBody: React.FC = () => { const ChatBody: React.FC = () => {
const messages = useAtomValue(getCurrentChatMessagesAtom) const messages = useAtomValue(getCurrentChatMessagesAtom)
const downloadedModels = useAtomValue(downloadedModelsAtom) const downloadedModels = useAtomValue(downloadedModelsAtom)
const setMainViewState = useSetAtom(mainViewStateAtom)
const { setMainViewState } = useMainViewState()
if (downloadedModels.length === 0) if (downloadedModels.length === 0)
return ( return (

View File

@ -48,7 +48,7 @@ const ErrorMessage = ({ message }: { message: ThreadMessage }) => {
{loadModelError === PORT_NOT_AVAILABLE ? ( {loadModelError === PORT_NOT_AVAILABLE ? (
<div <div
key={message.id} key={message.id}
className="flex flex-col items-center text-center text-sm font-medium text-gray-500 w-full" className="flex w-full flex-col items-center text-center text-sm font-medium text-gray-500"
> >
<p className="w-[90%]"> <p className="w-[90%]">
Port 3928 is currently unavailable. Check for conflicting apps, Port 3928 is currently unavailable. Check for conflicting apps,

View File

@ -2,19 +2,18 @@ import React, { Fragment, useCallback } from 'react'
import { Button } from '@janhq/uikit' import { Button } from '@janhq/uikit'
import { useAtomValue } from 'jotai' import { useAtomValue, useSetAtom } from 'jotai'
import LogoMark from '@/containers/Brand/Logo/Mark' import LogoMark from '@/containers/Brand/Logo/Mark'
import { MainViewState } from '@/constants/screens' import { MainViewState } from '@/constants/screens'
import { useMainViewState } from '@/hooks/useMainViewState' import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom' import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
const RequestDownloadModel: React.FC = () => { const RequestDownloadModel: React.FC = () => {
const downloadedModels = useAtomValue(downloadedModelsAtom) const downloadedModels = useAtomValue(downloadedModelsAtom)
const { setMainViewState } = useMainViewState() const setMainViewState = useSetAtom(mainViewStateAtom)
const onClick = useCallback(() => { const onClick = useCallback(() => {
setMainViewState(MainViewState.Hub) setMainViewState(MainViewState.Hub)

View File

@ -32,6 +32,8 @@ import { usePath } from '@/hooks/usePath'
import { toGibibytes } from '@/utils/converter' import { toGibibytes } from '@/utils/converter'
import { displayDate } from '@/utils/datetime' import { displayDate } from '@/utils/datetime'
import { openFileTitle } from '@/utils/titleUtils'
import EditChatInput from '../EditChatInput' import EditChatInput from '../EditChatInput'
import Icon from '../FileUploadPreview/Icon' import Icon from '../FileUploadPreview/Icon'
import MessageToolbar from '../MessageToolbar' import MessageToolbar from '../MessageToolbar'
@ -207,7 +209,7 @@ const SimpleTextMessage: React.FC<ThreadMessage> = (props) => {
{messages[messages.length - 1]?.id === props.id && {messages[messages.length - 1]?.id === props.id &&
(props.status === MessageStatus.Pending || tokenSpeed > 0) && ( (props.status === MessageStatus.Pending || tokenSpeed > 0) && (
<p className="absolute right-8 text-xs font-medium text-foreground"> <p className="absolute right-8 text-xs font-medium text-foreground">
Token Speed: {Number(tokenSpeed).toFixed(2)}/s Token Speed: {Number(tokenSpeed).toFixed(2)}t/s
</p> </p>
)} )}
</div> </div>
@ -234,7 +236,7 @@ const SimpleTextMessage: React.FC<ThreadMessage> = (props) => {
</TooltipTrigger> </TooltipTrigger>
<TooltipPortal> <TooltipPortal>
<TooltipContent side="top" className="max-w-[154px] px-3"> <TooltipContent side="top" className="max-w-[154px] px-3">
<span>Show in finder</span> <span>{openFileTitle()}</span>
<TooltipArrow /> <TooltipArrow />
</TooltipContent> </TooltipContent>
</TooltipPortal> </TooltipPortal>
@ -261,7 +263,7 @@ const SimpleTextMessage: React.FC<ThreadMessage> = (props) => {
</TooltipTrigger> </TooltipTrigger>
<TooltipPortal> <TooltipPortal>
<TooltipContent side="top" className="max-w-[154px] px-3"> <TooltipContent side="top" className="max-w-[154px] px-3">
<span>Show in finder</span> <span>{openFileTitle()}</span>
<TooltipArrow /> <TooltipArrow />
</TooltipContent> </TooltipContent>
</TooltipPortal> </TooltipPortal>

View File

@ -11,7 +11,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@janhq/uikit' } from '@janhq/uikit'
import { useAtomValue } from 'jotai' import { useAtomValue, useSetAtom } from 'jotai'
import { ChevronDownIcon } from 'lucide-react' import { ChevronDownIcon } from 'lucide-react'
@ -24,10 +24,9 @@ import { MainViewState } from '@/constants/screens'
import { useCreateNewThread } from '@/hooks/useCreateNewThread' import { useCreateNewThread } from '@/hooks/useCreateNewThread'
import useDownloadModel from '@/hooks/useDownloadModel' import useDownloadModel from '@/hooks/useDownloadModel'
import { useMainViewState } from '@/hooks/useMainViewState'
import { toGibibytes } from '@/utils/converter' import { toGibibytes } from '@/utils/converter'
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
import { assistantsAtom } from '@/helpers/atoms/Assistant.atom' import { assistantsAtom } from '@/helpers/atoms/Assistant.atom'
import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom'
@ -70,7 +69,7 @@ const ExploreModelItemHeader: React.FC<Props> = ({ model, onClick, open }) => {
const totalRam = useAtomValue(totalRamAtom) const totalRam = useAtomValue(totalRamAtom)
const nvidiaTotalVram = useAtomValue(nvidiaTotalVramAtom) 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 // Default nvidia returns vram in MB, need to convert to bytes to match the unit of totalRamW
let ram = nvidiaTotalVram * 1024 * 1024 let ram = nvidiaTotalVram * 1024 * 1024

View File

@ -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 (
<>
<div className="flex flex-col items-center justify-center gap-1">
<p className="text-2xl font-bold">Hugging Face Converter</p>
</div>
<div className="flex flex-col items-center justify-center gap-1">
<p className="text-center">
An error occured while {conversionStatus} model {repoData.id}.
</p>
<p>Please close this modal and try again.</p>
</div>
</>
)
}

View File

@ -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 (
<>
<div className="flex flex-col items-center justify-center gap-1">
<p className="text-2xl font-bold">Hugging Face Converter</p>
</div>
{conversionStatus === 'done' ? (
<div className="flex flex-col items-center justify-center gap-1">
<p>Done!</p>
<p>Now you can use the model on Jan as usual. Have fun!</p>
</div>
) : (
<>
<div className="flex flex-col items-center justify-center gap-1">
<p>{status}</p>
</div>
<Button
onClick={onStopClick}
className="w-full"
loading={conversionStatus === 'stopping'}
disabled={conversionStatus === 'stopping'}
themes="danger"
>
{conversionStatus === 'stopping' ? 'Stopping...' : 'Stop'}
</Button>
</>
)}
</>
)
}

View File

@ -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<Parameters<typeof CommandModal>[0], 'children'>) => {
const repoData = useAtomValue(repoDataAtom)
const fetchError = useAtomValue(fetchErrorAtom)
const conversionStatus = useAtomValue(conversionStatusAtom)
const conversionError = useAtomValue(conversionErrorAtom)
const setReset = useSetAtom(resetAtom)
return (
<Modal
{...props}
onOpenChange={(open) => {
if (open === false) {
if (
!repoData ||
['done', 'stopping'].includes(conversionStatus ?? '') ||
conversionError
) {
setReset()
}
}
if (props.onOpenChange) {
props.onOpenChange(open)
}
}}
>
<ModalContent>
<div className="px-2 py-3">
<div className="flex w-full flex-col items-center justify-center gap-4 p-4">
{repoData ? (
conversionStatus ? (
conversionError ? (
<HuggingFaceConvertingErrorModal />
) : (
<HuggingFaceConvertingModal />
)
) : (
<HuggingFaceRepoDataLoadedModal />
)
) : fetchError ? (
<HuggingFaceSearchErrorModal />
) : (
<HuggingFaceSearchModal />
)}
</div>
</div>
</ModalContent>
</Modal>
)
}
export { HuggingFaceModal }

View File

@ -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>(
Quantization.Q4_K_M
)
const { convertHuggingFaceModel } = useConvertHuggingFaceModel()
const onValueSelected = (value: Quantization) => {
setQuantization(value)
}
const onConvertClick = () => {
convertHuggingFaceModel(repoData.id, repoData, quantization)
}
return (
<>
<div className="flex flex-col items-center justify-center gap-1">
<p className="text-2xl font-bold">Hugging Face Converter</p>
<p className="text-gray-500">Found the repository!</p>
</div>
<div className="flex flex-col items-center justify-center gap-1">
<p className="font-bold">{repoData.id}</p>
<p>
{unsupported
? '❌ This model is not supported!'
: '✅ This model is supported!'}
</p>
{repoData.tags.includes('gguf') ? (
<p>...But you can import it manually!</p>
) : null}
</div>
<Select
value={quantization}
onValueChange={onValueSelected}
disabled={unsupported}
>
<SelectTrigger className="relative w-full">
<SelectValue placeholder="Quantization">
<span className={twMerge('relative z-20')}>{quantization}</span>
</SelectValue>
</SelectTrigger>
<SelectPortal>
<SelectContent className="right-2 block w-full min-w-[450px] pr-0">
<div className="border-b border-border" />
<SelectGroup>
{Object.values(Quantization).map((x, i) => (
<SelectItem
key={i}
value={x}
className={twMerge(x === quantization && 'bg-secondary')}
>
<div className="flex w-full justify-between">
<span className="line-clamp-1 block">{x}</span>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</SelectPortal>
</Select>
<Button
onClick={onConvertClick}
className="w-full"
loading={loading}
disabled={unsupported}
themes={loading ? 'ghost' : 'primary'}
>
{loading ? '' : 'Convert'}
</Button>
</>
)
}

View File

@ -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 (
<>
<div className="flex flex-col items-center justify-center gap-1">
<p className="text-2xl font-bold">Error!</p>
<p className="text-gray-500">Fetch error</p>
</div>
<p>{fetchError.message}</p>
<Button
onClick={getRepoData}
className="w-full"
loading={loading}
themes={loading ? 'ghost' : 'danger'}
>
{loading ? '' : 'Try Again'}
</Button>
</>
)
}

View File

@ -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<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
getRepoData()
}
}
return (
<>
<div className="flex flex-col items-center justify-center gap-1">
<p className="text-2xl font-bold">Hugging Face Convertor</p>
<p className="text-gray-500">Type the repository id below</p>
</div>
<Input
placeholder="e.g. username/repo-name"
className="bg-white dark:bg-background"
onChange={(e) => {
setRepoID(e.target.value)
}}
onKeyDown={onKeyDown}
/>
<Button
onClick={getRepoData}
className="w-full"
loading={loading}
themes={loading ? 'ghost' : 'primary'}
>
{loading ? '' : 'OK'}
</Button>
</>
)
}

View File

@ -1,6 +1,5 @@
import { useCallback, useState } from 'react' import { useCallback, useContext, useState } from 'react'
import { openExternalUrl } from '@janhq/core'
import { import {
Input, Input,
ScrollArea, ScrollArea,
@ -10,24 +9,36 @@ import {
SelectContent, SelectContent,
SelectGroup, SelectGroup,
SelectItem, SelectItem,
Button,
} from '@janhq/uikit' } from '@janhq/uikit'
import { useAtomValue } from 'jotai' import { useAtomValue, useSetAtom } from 'jotai'
import { SearchIcon } from 'lucide-react' import { Plus, SearchIcon } from 'lucide-react'
import { FeatureToggleContext } from '@/context/FeatureToggle'
import { setImportModelStageAtom } from '@/hooks/useImportModel'
import ExploreModelList from './ExploreModelList' import ExploreModelList from './ExploreModelList'
import { HuggingFaceModal } from './HuggingFaceModal'
import { import {
configuredModelsAtom, configuredModelsAtom,
downloadedModelsAtom, downloadedModelsAtom,
} from '@/helpers/atoms/Model.atom' } from '@/helpers/atoms/Model.atom'
const sortMenu = ['All Models', 'Recommended', 'Downloaded']
const ExploreModelsScreen = () => { const ExploreModelsScreen = () => {
const configuredModels = useAtomValue(configuredModelsAtom) const configuredModels = useAtomValue(configuredModelsAtom)
const downloadedModels = useAtomValue(downloadedModelsAtom) const downloadedModels = useAtomValue(downloadedModelsAtom)
const [searchValue, setsearchValue] = useState('') const [searchValue, setsearchValue] = useState('')
const [sortSelected, setSortSelected] = useState('All Models') 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) => { const filteredModels = configuredModels.filter((x) => {
if (sortSelected === 'Downloaded') { if (sortSelected === 'Downloaded') {
@ -45,9 +56,13 @@ const ExploreModelsScreen = () => {
} }
}) })
const onHowToImportModelClick = useCallback(() => { const onImportModelClick = useCallback(() => {
openExternalUrl('https://jan.ai/guides/using-models/import-manually/') setImportModelStage('SELECTING_MODEL')
}, []) }, [setImportModelStage])
const onHuggingFaceConverterClick = () => {
setShowHuggingFaceModal(true)
}
return ( return (
<div <div
@ -56,6 +71,10 @@ const ExploreModelsScreen = () => {
> >
<div className="h-full w-full p-4"> <div className="h-full w-full p-4">
<div className="h-full"> <div className="h-full">
<HuggingFaceModal
open={showHuggingFaceModal}
onOpenChange={setShowHuggingFaceModal}
/>
<ScrollArea> <ScrollArea>
<div className="relative"> <div className="relative">
<img <img
@ -63,28 +82,38 @@ const ExploreModelsScreen = () => {
alt="Hub Banner" alt="Hub Banner"
className="w-full object-cover" className="w-full object-cover"
/> />
<div className="absolute left-1/2 top-1/2 w-1/3 -translate-x-1/2 -translate-y-1/2"> <div className="absolute left-1/2 top-1/2 w-1/3 -translate-x-1/2 -translate-y-1/2 space-y-2">
<div className="relative"> <div className="flex flex-row space-x-2">
<SearchIcon <div className="relative">
size={20} <SearchIcon
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" size={20}
/> className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
<Input />
placeholder="Search models" <Input
className="bg-white pl-9 dark:bg-background" placeholder="Search models"
onChange={(e) => { className="bg-white pl-9 dark:bg-background"
setsearchValue(e.target.value) onChange={(e) => setsearchValue(e.target.value)}
}} />
/> </div>
</div> <Button
<div className="mt-2 text-center"> themes={'primary'}
<p className="space-x-2"
onClick={onHowToImportModelClick} onClick={onImportModelClick}
className="cursor-pointer font-semibold text-white underline"
> >
How to manually import models <Plus className="h-3 w-3" />
</p> <p>Import Model</p>
</Button>
</div> </div>
{experimentalFeature && (
<div className="text-center">
<p
onClick={onHuggingFaceConverterClick}
className="cursor-pointer font-semibold text-white underline"
>
Convert from Hugging Face
</p>
</div>
)}
</div> </div>
</div> </div>
<div className="mx-auto w-4/5 py-6"> <div className="mx-auto w-4/5 py-6">

View File

@ -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 { fs, AppConfiguration, isSubdirectory } from '@janhq/core'
import { Button, Input } from '@janhq/uikit' import { Button, Input } from '@janhq/uikit'
import { useSetAtom } from 'jotai' import { useAtomValue, useSetAtom } from 'jotai'
import { PencilIcon, FolderOpenIcon } from 'lucide-react' import { PencilIcon, FolderOpenIcon } from 'lucide-react'
import Loader from '@/containers/Loader' import Loader from '@/containers/Loader'
@ -21,22 +21,17 @@ import ModalErrorSetDestGlobal, {
import ModalSameDirectory, { showSamePathModalAtom } from './ModalSameDirectory' import ModalSameDirectory, { showSamePathModalAtom } from './ModalSameDirectory'
import { janDataFolderPathAtom } from '@/helpers/atoms/AppConfig.atom'
const DataFolder = () => { const DataFolder = () => {
const [janDataFolderPath, setJanDataFolderPath] = useState('')
const [showLoader, setShowLoader] = useState(false) const [showLoader, setShowLoader] = useState(false)
const setShowDirectoryConfirm = useSetAtom(showDirectoryConfirmModalAtom) const setShowDirectoryConfirm = useSetAtom(showDirectoryConfirmModalAtom)
const setShowSameDirectory = useSetAtom(showSamePathModalAtom) const setShowSameDirectory = useSetAtom(showSamePathModalAtom)
const setShowChangeFolderError = useSetAtom(showChangeFolderErrorAtom) const setShowChangeFolderError = useSetAtom(showChangeFolderErrorAtom)
const showDestNotEmptyConfirm = useSetAtom(showDestNotEmptyConfirmAtom) const showDestNotEmptyConfirm = useSetAtom(showDestNotEmptyConfirmAtom)
const [destinationPath, setDestinationPath] = useState(undefined)
useEffect(() => { const [destinationPath, setDestinationPath] = useState(undefined)
window.core?.api const janDataFolderPath = useAtomValue(janDataFolderPathAtom)
?.getAppConfigurations()
?.then((appConfig: AppConfiguration) => {
setJanDataFolderPath(appConfig.data_folder)
})
}, [])
const onChangeFolderClick = useCallback(async () => { const onChangeFolderClick = useCallback(async () => {
const destFolder = await window.core?.api?.selectDirectory() const destFolder = await window.core?.api?.selectDirectory()
@ -56,8 +51,7 @@ const DataFolder = () => {
return return
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any const newDestChildren: string[] = await fs.readdirSync(destFolder)
const newDestChildren: any[] = await fs.readdirSync(destFolder)
const isNotEmpty = const isNotEmpty =
newDestChildren.filter((x) => x !== '.DS_Store').length > 0 newDestChildren.filter((x) => x !== '.DS_Store').length > 0

View File

@ -2,7 +2,6 @@ import React, { useCallback, useState } from 'react'
import { import {
Modal, Modal,
ModalPortal,
ModalContent, ModalContent,
ModalHeader, ModalHeader,
ModalTitle, ModalTitle,
@ -33,7 +32,6 @@ const ModalConfirmReset = () => {
open={modalValidation} open={modalValidation}
onOpenChange={() => setModalValidation(false)} onOpenChange={() => setModalValidation(false)}
> >
<ModalPortal />
<ModalContent> <ModalContent>
<ModalHeader> <ModalHeader>
<ModalTitle> <ModalTitle>

View File

@ -26,6 +26,7 @@ import {
TooltipArrow, TooltipArrow,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
ScrollArea,
} from '@janhq/uikit' } from '@janhq/uikit'
import { AlertTriangleIcon, AlertCircleIcon } from 'lucide-react' import { AlertTriangleIcon, AlertCircleIcon } from 'lucide-react'
@ -138,301 +139,312 @@ const Advanced = () => {
gpuList.length > 0 ? 'Select GPU' : "You don't have any compatible GPU" gpuList.length > 0 ? 'Select GPU' : "You don't have any compatible GPU"
return ( return (
<div className="block w-full"> <ScrollArea className="px-4">
{/* Keyboard shortcut */} <div className="block w-full">
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none"> {/* Keyboard shortcut */}
<div className="flex-shrink-0 space-y-1.5"> <div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-4 last:border-none">
<div className="flex gap-x-2"> <div className="flex-shrink-0 space-y-1.5">
<h6 className="text-sm font-semibold capitalize"> <div className="flex gap-x-2">
Keyboard Shortcuts <h6 className="text-sm font-semibold capitalize">
</h6> Keyboard Shortcuts
</div> </h6>
<p className="leading-relaxed">
Shortcuts that you might find useful in Jan app.
</p>
</div>
<ShortcutModal />
</div>
{/* Experimental */}
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
<div className="flex-shrink-0 space-y-1.5">
<div className="flex gap-x-2">
<h6 className="text-sm font-semibold capitalize">
Experimental Mode
</h6>
</div>
<p className="leading-relaxed">
Enable experimental features that may be unstable tested.
</p>
</div>
<Switch
checked={experimentalFeature}
onCheckedChange={setExperimentalFeature}
/>
</div>
{/* CPU / GPU switching */}
{!isMac && (
<div className="flex w-full flex-col items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
<div className="flex items-start justify-between w-full">
<div className="space-y-1.5">
<div className="flex gap-x-2">
<h6 className="text-sm font-semibold capitalize">
GPU Acceleration
</h6>
</div>
<p className="pr-8 leading-relaxed">
Enable to enhance model performance by utilizing your GPU
devices for acceleration. Read{' '}
<span>
{' '}
<span
className="cursor-pointer text-blue-600"
onClick={() =>
openExternalUrl(
'https://jan.ai/guides/troubleshooting/gpu-not-used/'
)
}
>
troubleshooting guide
</span>{' '}
</span>{' '}
for further assistance.
</p>
</div> </div>
{gpuList.length > 0 && !gpuEnabled && ( <p className="leading-relaxed">
<Tooltip> Shortcuts that you might find useful in Jan app.
<TooltipTrigger> </p>
<AlertCircleIcon size={20} className="mr-2 text-yellow-600" />
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={10}
className="max-w-[240px]"
>
<span>
Disabling NVIDIA GPU Acceleration may result in reduced
performance. It is recommended to keep this enabled for
optimal user experience.
</span>
<TooltipArrow />
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger>
<Switch
disabled={gpuList.length === 0 || vulkanEnabled}
checked={gpuEnabled}
onCheckedChange={(e) => {
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()
}}
/>
</TooltipTrigger>
{gpuList.length === 0 && (
<TooltipContent
side="right"
sideOffset={10}
className="max-w-[240px]"
>
<span>
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.
</span>
<TooltipArrow />
</TooltipContent>
)}
</Tooltip>
</div>
<div className="mt-2 w-full rounded-lg bg-secondary p-4">
<label className="mb-1 inline-block font-medium">
Choose device(s)
</label>
<Select
disabled={gpuList.length === 0 || !gpuEnabled}
value={selectedGpu.join()}
>
<SelectTrigger className="w-[340px] dark:bg-gray-500 bg-white">
<SelectValue placeholder={gpuSelectionPlaceHolder}>
<span className="line-clamp-1 w-full pr-8">
{selectedGpu.join()}
</span>
</SelectValue>
</SelectTrigger>
<SelectPortal>
<SelectContent className="w-[400px] px-1 pb-2">
<SelectGroup>
<SelectLabel>
{vulkanEnabled ? 'Vulkan Supported GPUs' : 'Nvidia'}
</SelectLabel>
<div className="px-4 pb-2">
<div className="rounded-lg bg-secondary p-3">
{gpuList
.filter((gpu) =>
vulkanEnabled
? gpu.name
: gpu.name?.toLowerCase().includes('nvidia')
)
.map((gpu) => (
<div
key={gpu.id}
className="my-1 flex items-center space-x-2"
>
<Checkbox
id={`gpu-${gpu.id}`}
name="gpu-nvidia"
className="bg-white"
value={gpu.id}
checked={gpusInUse.includes(gpu.id)}
onCheckedChange={() => handleGPUChange(gpu.id)}
/>
<label
className="flex w-full items-center justify-between"
htmlFor={`gpu-${gpu.id}`}
>
<span>{gpu.name}</span>
{!vulkanEnabled && (
<span>{gpu.vram}MB VRAM</span>
)}
</label>
</div>
))}
</div>
{/* Warning message */}
{gpuEnabled && gpusInUse.length > 1 && (
<div className="mt-2 flex items-start space-x-2 text-yellow-500">
<AlertTriangleIcon
size={16}
className="flex-shrink-0"
/>
<p className="text-xs leading-relaxed">
If multi-GPU is enabled with different GPU models or
without NVLink, it could impact token speed.
</p>
</div>
)}
</div>
</SelectGroup>
{/* TODO enable this when we support AMD */}
</SelectContent>
</SelectPortal>
</Select>
</div> </div>
<ShortcutModal />
</div> </div>
)}
{/* Vulkan for AMD GPU/ APU and Intel Arc GPU */} {/* Experimental */}
{!isMac && experimentalFeature && (
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none"> <div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
<div className="flex-shrink-0 space-y-1.5"> <div className="flex-shrink-0 space-y-1.5">
<div className="flex gap-x-2"> <div className="flex gap-x-2">
<h6 className="text-sm font-semibold capitalize"> <h6 className="text-sm font-semibold capitalize">
Vulkan Support Experimental Mode
</h6> </h6>
</div> </div>
<p className="text-xs leading-relaxed"> <p className="leading-relaxed">
Enable Vulkan with AMD GPU/APU and Intel Arc GPU for better model Enable experimental features that may be unstable tested.
performance (reload needed).
</p> </p>
</div> </div>
<Switch <Switch
checked={vulkanEnabled} checked={experimentalFeature}
onCheckedChange={(e) => { onCheckedChange={setExperimentalFeature}
toaster({
title: 'Reload',
description:
'Vulkan settings updated. Reload now to apply the changes.',
})
stopModel()
saveSettings({ vulkan: e, gpusInUse: [] })
setVulkanEnabled(e)
}}
/> />
</div> </div>
)}
<DataFolder /> {/* CPU / GPU switching */}
{/* Proxy */} {!isMac && (
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none"> <div className="flex w-full flex-col items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
<div className="flex-shrink-0 space-y-1.5 w-full"> <div className="flex w-full items-start justify-between">
<div className="flex gap-x-2 justify-between w-full"> <div className="space-y-1.5">
<h6 className="text-sm font-semibold capitalize">HTTPS Proxy</h6> <div className="flex gap-x-2">
<h6 className="text-sm font-semibold capitalize">
GPU Acceleration
</h6>
</div>
<p className="pr-8 leading-relaxed">
Enable to enhance model performance by utilizing your GPU
devices for acceleration. Read{' '}
<span>
{' '}
<span
className="cursor-pointer text-blue-600"
onClick={() =>
openExternalUrl(
'https://jan.ai/guides/troubleshooting/gpu-not-used/'
)
}
>
troubleshooting guide
</span>{' '}
</span>{' '}
for further assistance.
</p>
</div>
{gpuList.length > 0 && !gpuEnabled && (
<Tooltip>
<TooltipTrigger>
<AlertCircleIcon
size={20}
className="mr-2 text-yellow-600"
/>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={10}
className="max-w-[240px]"
>
<span>
Disabling NVIDIA GPU Acceleration may result in reduced
performance. It is recommended to keep this enabled for
optimal user experience.
</span>
<TooltipArrow />
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger>
<Switch
disabled={gpuList.length === 0 || vulkanEnabled}
checked={gpuEnabled}
onCheckedChange={(e) => {
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()
}}
/>
</TooltipTrigger>
{gpuList.length === 0 && (
<TooltipContent
side="right"
sideOffset={10}
className="max-w-[240px]"
>
<span>
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.
</span>
<TooltipArrow />
</TooltipContent>
)}
</Tooltip>
</div>
<div className="mt-2 w-full rounded-lg bg-secondary p-4">
<label className="mb-1 inline-block font-medium">
Choose device(s)
</label>
<Select
disabled={gpuList.length === 0 || !gpuEnabled}
value={selectedGpu.join()}
>
<SelectTrigger className="w-[340px] bg-white dark:bg-gray-500">
<SelectValue placeholder={gpuSelectionPlaceHolder}>
<span className="line-clamp-1 w-full pr-8">
{selectedGpu.join()}
</span>
</SelectValue>
</SelectTrigger>
<SelectPortal>
<SelectContent className="w-[400px] px-1 pb-2">
<SelectGroup>
<SelectLabel>
{vulkanEnabled ? 'Vulkan Supported GPUs' : 'Nvidia'}
</SelectLabel>
<div className="px-4 pb-2">
<div className="rounded-lg bg-secondary p-3">
{gpuList
.filter((gpu) =>
vulkanEnabled
? gpu.name
: gpu.name?.toLowerCase().includes('nvidia')
)
.map((gpu) => (
<div
key={gpu.id}
className="my-1 flex items-center space-x-2"
>
<Checkbox
id={`gpu-${gpu.id}`}
name="gpu-nvidia"
className="bg-white"
value={gpu.id}
checked={gpusInUse.includes(gpu.id)}
onCheckedChange={() =>
handleGPUChange(gpu.id)
}
/>
<label
className="flex w-full items-center justify-between"
htmlFor={`gpu-${gpu.id}`}
>
<span>{gpu.name}</span>
{!vulkanEnabled && (
<span>{gpu.vram}MB VRAM</span>
)}
</label>
</div>
))}
</div>
{/* Warning message */}
{gpuEnabled && gpusInUse.length > 1 && (
<div className="mt-2 flex items-start space-x-2 text-yellow-500">
<AlertTriangleIcon
size={16}
className="flex-shrink-0"
/>
<p className="text-xs leading-relaxed">
If multi-GPU is enabled with different GPU models
or without NVLink, it could impact token speed.
</p>
</div>
)}
</div>
</SelectGroup>
{/* TODO enable this when we support AMD */}
</SelectContent>
</SelectPortal>
</Select>
</div>
</div>
)}
{/* Vulkan for AMD GPU/ APU and Intel Arc GPU */}
{!isMac && experimentalFeature && (
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
<div className="flex-shrink-0 space-y-1.5">
<div className="flex gap-x-2">
<h6 className="text-sm font-semibold capitalize">
Vulkan Support
</h6>
</div>
<p className="text-xs leading-relaxed">
Enable Vulkan with AMD GPU/APU and Intel Arc GPU for better
model performance (reload needed).
</p>
</div>
<Switch <Switch
checked={proxyEnabled} checked={vulkanEnabled}
onCheckedChange={(_) => setProxyEnabled(!proxyEnabled)} onCheckedChange={(e) => {
toaster({
title: 'Reload',
description:
'Vulkan settings updated. Reload now to apply the changes.',
})
stopModel()
saveSettings({ vulkan: e, gpusInUse: [] })
setVulkanEnabled(e)
}}
/> />
</div> </div>
<p className="leading-relaxed"> )}
Specify the HTTPS proxy or leave blank (proxy auto-configuration and
SOCKS not supported). <DataFolder />
</p> {/* Proxy */}
<Input <div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
placeholder={'http://<user>:<password>@<domain or IP>:<port>'} <div className="w-full flex-shrink-0 space-y-1.5">
value={partialProxy} <div className="flex w-full justify-between gap-x-2">
onChange={onProxyChange} <h6 className="text-sm font-semibold capitalize">HTTPS Proxy</h6>
className="w-2/3" <Switch
checked={proxyEnabled}
onCheckedChange={() => setProxyEnabled(!proxyEnabled)}
/>
</div>
<p className="leading-relaxed">
Specify the HTTPS proxy or leave blank (proxy auto-configuration
and SOCKS not supported).
</p>
<Input
placeholder={'http://<user>:<password>@<domain or IP>:<port>'}
value={partialProxy}
onChange={onProxyChange}
className="w-2/3"
/>
</div>
</div>
{/* Ignore SSL certificates */}
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
<div className="flex-shrink-0 space-y-1.5">
<div className="flex gap-x-2">
<h6 className="text-sm font-semibold capitalize">
Ignore SSL certificates
</h6>
</div>
<p className="leading-relaxed">
Allow self-signed or unverified certificates - may be required for
certain proxies.
</p>
</div>
<Switch
checked={ignoreSSL}
onCheckedChange={(e) => setIgnoreSSL(e)}
/> />
</div> </div>
</div>
{/* Ignore SSL certificates */} {/* Clear log */}
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none"> <div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
<div className="flex-shrink-0 space-y-1.5"> <div className="flex-shrink-0 space-y-1.5">
<div className="flex gap-x-2"> <div className="flex gap-x-2">
<h6 className="text-sm font-semibold capitalize"> <h6 className="text-sm font-semibold capitalize">Clear logs</h6>
Ignore SSL certificates </div>
</h6> <p className="leading-relaxed">Clear all logs from Jan app.</p>
</div> </div>
<p className="leading-relaxed"> <Button size="sm" themes="secondaryDanger" onClick={clearLogs}>
Allow self-signed or unverified certificates - may be required for Clear
certain proxies. </Button>
</p>
</div> </div>
<Switch checked={ignoreSSL} onCheckedChange={(e) => setIgnoreSSL(e)} />
</div>
{/* Clear log */} {/* Factory Reset */}
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none"> <FactoryReset />
<div className="flex-shrink-0 space-y-1.5">
<div className="flex gap-x-2">
<h6 className="text-sm font-semibold capitalize">Clear logs</h6>
</div>
<p className="leading-relaxed">Clear all logs from Jan app.</p>
</div>
<Button size="sm" themes="secondaryDanger" onClick={clearLogs}>
Clear
</Button>
</div> </div>
</ScrollArea>
{/* Factory Reset */}
<FactoryReset />
</div>
) )
} }

View File

@ -3,7 +3,7 @@ import ToggleTheme from '@/screens/Settings/Appearance/ToggleTheme'
export default function AppearanceOptions() { export default function AppearanceOptions() {
return ( return (
<div className="block w-full"> <div className="m-4 block w-full">
<div className="flex w-full items-center justify-between border-b border-border py-3 first:pt-0 last:border-none"> <div className="flex w-full items-center justify-between border-b border-border py-3 first:pt-0 last:border-none">
<div className="flex-shrink-0 space-y-1"> <div className="flex-shrink-0 space-y-1">
<h6 className="text-sm font-semibold capitalize"> <h6 className="text-sm font-semibold capitalize">

View File

@ -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 (
<Modal open={importModelStage === 'CONFIRM_CANCEL'}>
<ModalContent>
<ModalHeader>
<ModalTitle>Cancel Model Import?</ModalTitle>
</ModalHeader>
<p>
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.
</p>
<ModalFooter>
<div className="flex gap-x-2">
<ModalClose asChild onClick={onContinueClick}>
<Button themes="ghost">Continue</Button>
</ModalClose>
<ModalClose asChild>
<Button autoFocus themes="danger" onClick={onCancelAllClick}>
Cancel All
</Button>
</ModalClose>
</div>
</ModalFooter>
</ModalContent>
</Modal>
)
}
export default React.memo(CancelModelImportModal)

View File

@ -2,7 +2,7 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { Button } from '@janhq/uikit' import { Button, ScrollArea } from '@janhq/uikit'
import { formatExtensionsName } from '@/utils/converter' import { formatExtensionsName } from '@/utils/converter'
@ -68,58 +68,60 @@ const ExtensionCatalog = () => {
} }
return ( return (
<div className="block w-full"> <ScrollArea className="h-full w-full px-4">
{activeExtensions.map((item, i) => { <div className="block w-full">
return ( {activeExtensions.map((item, i) => {
<div return (
key={i} <div
className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none" key={i}
> className="flex w-full items-start justify-between border-b border-border py-4 first:pt-4 last:border-none"
<div className="w-4/5 flex-shrink-0 space-y-1.5"> >
<div className="flex gap-x-2"> <div className="w-4/5 flex-shrink-0 space-y-1.5">
<h6 className="text-sm font-semibold capitalize"> <div className="flex gap-x-2">
{formatExtensionsName(item.name ?? item.description ?? '')} <h6 className="text-sm font-semibold capitalize">
</h6> {formatExtensionsName(item.name ?? item.description ?? '')}
<p className="whitespace-pre-wrap font-semibold leading-relaxed "> </h6>
v{item.version} <p className="whitespace-pre-wrap font-semibold leading-relaxed ">
v{item.version}
</p>
</div>
<p className="whitespace-pre-wrap leading-relaxed ">
{item.description}
</p> </p>
</div> </div>
<p className="whitespace-pre-wrap leading-relaxed ">
{item.description}
</p>
</div> </div>
)
})}
{/* Manual Installation */}
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
<div className="w-4/5 flex-shrink-0 space-y-1.5">
<div className="flex gap-x-2">
<h6 className="text-sm font-semibold capitalize">
Manual Installation
</h6>
</div>
<p className="whitespace-pre-wrap leading-relaxed ">
Select a extension file to install (.tgz)
</p>
</div> </div>
) <div>
})} <input
{/* Manual Installation */} type="file"
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none"> style={{ display: 'none' }}
<div className="w-4/5 flex-shrink-0 space-y-1.5"> ref={fileInputRef}
<div className="flex gap-x-2"> onChange={handleFileChange}
<h6 className="text-sm font-semibold capitalize"> />
Manual Installation <Button
</h6> themes="secondaryBlue"
size="sm"
onClick={() => fileInputRef.current?.click()}
>
Select
</Button>
</div> </div>
<p className="whitespace-pre-wrap leading-relaxed ">
Select a extension file to install (.tgz)
</p>
</div>
<div>
<input
type="file"
style={{ display: 'none' }}
ref={fileInputRef}
onChange={handleFileChange}
/>
<Button
themes="secondaryBlue"
size="sm"
onClick={() => fileInputRef.current?.click()}
>
Select
</Button>
</div> </div>
</div> </div>
</div> </ScrollArea>
) )
} }

View File

@ -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<string | undefined>(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<string[]>([])
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<Model> = {
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 (
<Modal open={importModelStage === 'EDIT_MODEL_INFO'}>
<ModalContent>
<ModalHeader>
<ModalTitle>Edit Model Information</ModalTitle>
</ModalHeader>
<div className="flex flex-row space-x-4 rounded-xl border p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-400">
<Paperclip />
</div>
<div className="flex flex-col">
<p>{editingModel.name}</p>
<div className="flex flex-row">
<span className="mr-2 text-sm text-[#71717A]">
{toGibibytes(editingModel.size)}
</span>
<span className="text-sm font-semibold text-[#71717A]">
Format:{' '}
</span>
<span className="text-sm font-normal text-[#71717A]">
{editingModel.format.toUpperCase()}
</span>
</div>
<div className="mt-1 flex flex-row items-center space-x-2">
<span className="line-clamp-1 text-xs font-normal text-[#71717A]">
{modelFolderPath}
</span>
<Button themes="ghost" onClick={onShowInFinderClick}>
{openFileTitle()}
</Button>
</div>
</div>
</div>
<form className="flex flex-col space-y-4">
<div className="flex flex-col">
<label className="mb-1">Model Name</label>
<Input
value={modelName}
onChange={(e) => {
e.preventDefault()
setModelName(e.target.value)
}}
/>
</div>
<div className="flex flex-col">
<label className="mb-1">Model ID</label>
<Input
disabled
value={modelId}
onChange={(e) => {
e.preventDefault()
setModelId(e.target.value)
}}
/>
</div>
<div className="flex flex-col">
<label className="mb-1">Description</label>
<Textarea
value={description}
onChange={(e) => {
e.preventDefault()
setDescription(e.target.value)
}}
/>
</div>
<div className="flex flex-col">
<label className="mb-1">Tags</label>
<Input />
</div>
</form>
<ModalFooter>
<div className="flex gap-x-2">
<ModalClose asChild onClick={onCancelClick}>
<Button themes="ghost">Cancel</Button>
</ModalClose>
<ModalClose asChild>
<Button autoFocus themes="primary" onClick={onSaveClick}>
Save
</Button>
</ModalClose>
</div>
</ModalFooter>
</ModalContent>
</Modal>
)
}
export default EditModelInfoModal

View File

@ -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<Props> = ({
percentage,
onDeleteModelClick,
}) => {
const [isHovered, setIsHovered] = useState(false)
const onMouseOver = () => {
setIsHovered(true)
}
const onMouseOut = () => {
setIsHovered(false)
}
return (
<div onMouseOver={onMouseOver} onMouseOut={onMouseOut}>
{isHovered ? (
<DeleteIcon onDeleteModelClick={onDeleteModelClick} />
) : (
<ProgressIcon percentage={percentage} />
)}
</div>
)
}
const ProgressIcon: React.FC<Partial<Props>> = ({ percentage }) => (
<div className="h-8 w-8 rounded-full">
<CircularProgressbar value={(percentage ?? 0) * 100} />
</div>
)
const DeleteIcon: React.FC<Partial<Props>> = React.memo(
({ onDeleteModelClick }) => {
const onClick = useCallback(() => {
onDeleteModelClick?.()
}, [onDeleteModelClick])
return (
<div
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-gray-100"
onClick={onClick}
>
<X />
</div>
)
}
)
export default ImportInProgressIcon

View File

@ -0,0 +1,29 @@
import { ModelImportOption, OptionType } from '@janhq/core'
type Props = {
option: ModelImportOption
checked: boolean
setSelectedOptionType: (type: OptionType) => void
}
const ImportModelOptionSelection: React.FC<Props> = ({
option,
checked,
setSelectedOptionType,
}) => (
<div
className="flex cursor-pointer flex-row"
onClick={() => setSelectedOptionType(option.type)}
>
<div className="flex h-5 w-5 items-center justify-center rounded-full border border-[#2563EB]">
{checked && <div className="h-2 w-2 rounded-full bg-primary" />}
</div>
<div className="ml-2 flex-1">
<p className="mb-2 text-sm font-medium">{option.title}</p>
<p className="text-sm font-normal text-[#71717A]">{option.description}</p>
</div>
</div>
)
export default ImportModelOptionSelection

View File

@ -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 (
<Modal
open={importStage === 'MODEL_SELECTED'}
onOpenChange={() => {
if (destinationModal.current === 'NONE') {
setImportStage('NONE')
} else {
onContinueClick()
}
}}
>
<ModalContent>
<ModalHeader>
<ModalTitle>How would you like Jan to handle your models?</ModalTitle>
</ModalHeader>
{importOptions.map((option) => (
<ImportModelOptionSelection
key={option.type}
option={option}
checked={importOption.type === option.type}
setSelectedOptionType={() => setImportOption(option)}
/>
))}
<ModalFooter>
<div className="flex gap-x-2">
<ModalClose asChild onClick={onCancelClick}>
<Button themes="ghost">Cancel</Button>
</ModalClose>
<ModalClose asChild>
<Button
autoFocus
themes="primary"
onClick={() => {
destinationModal.current = 'IMPORTING_MODEL'
}}
>
Continue Importing
</Button>
</ModalClose>
</div>
</ModalFooter>
</ModalContent>
</Modal>
)
}
export default ImportModelOptionModal

View File

@ -0,0 +1,52 @@
import React, { useCallback, useState } from 'react'
import { Check, Pencil } from 'lucide-react'
type Props = {
onEditModelClick: () => void
}
const ImportSuccessIcon: React.FC<Props> = ({ onEditModelClick }) => {
const [isHovered, setIsHovered] = useState(false)
const onMouseOver = () => {
setIsHovered(true)
}
const onMouseOut = () => {
setIsHovered(false)
}
return (
<div onMouseOver={onMouseOver} onMouseOut={onMouseOut}>
{isHovered ? (
<EditIcon onEditModelClick={onEditModelClick} />
) : (
<SuccessIcon />
)}
</div>
)
}
const SuccessIcon: React.FC = React.memo(() => (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary">
<Check color="#FFF" />
</div>
))
const EditIcon: React.FC<Props> = React.memo(({ onEditModelClick }) => {
const onClick = useCallback(() => {
onEditModelClick()
}, [onEditModelClick])
return (
<div
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-gray-100"
onClick={onClick}
>
<Pencil />
</div>
)
})
export default ImportSuccessIcon

Some files were not shown because too many files have changed in this diff Show More