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">
<td style="text-align:center"><b>Stable (Recommended)</b></td>
<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" />
<b>jan.exe</b>
</a>
</td>
<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" />
<b>Intel</b>
</a>
</td>
<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" />
<b>M1/M2</b>
</a>
</td>
<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" />
<b>jan.deb</b>
</a>
</td>
<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" />
<b>jan.AppImage</b>
</a>
@ -76,31 +76,31 @@ Jan is an open-source ChatGPT alternative that runs 100% offline on your compute
<tr style="text-align:center">
<td style="text-align:center"><b>Experimental (Nightly Build)</b></td>
<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" />
<b>jan.exe</b>
</a>
</td>
<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" />
<b>Intel</b>
</a>
</td>
<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" />
<b>M1/M2</b>
</a>
</td>
<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" />
<b>jan.deb</b>
</a>
</td>
<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" />
<b>jan.AppImage</b>
</a>
@ -304,7 +304,7 @@ This will build the app MacOS m1/m2 for production (with code signing already do
```bash
# GPU mode with default file system
docker compose --profile gpu up -d
docker compose --profile gpu-fs up -d
# GPU mode with S3 file system
docker compose --profile gpu-s3fs up -d
@ -319,6 +319,7 @@ This will start the web server and you can access Jan at `http://localhost:3000`
Jan builds on top of other open-source projects:
- [llama.cpp](https://github.com/ggerganov/llama.cpp)
- [LangChain](https://github.com/langchain-ai)
- [TensorRT](https://github.com/NVIDIA/TensorRT)
## Contact

View File

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

View File

@ -4,6 +4,7 @@ export enum ExtensionTypeEnum {
Inference = 'inference',
Model = 'model',
SystemMonitoring = 'systemMonitoring',
HuggingFace = 'huggingFace',
}
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.
*/
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 { Model, ModelInterface } from '../index'
import { ImportingModel, Model, ModelInterface, OptionType } from '../index'
/**
* Model extension for managing models.
@ -21,4 +21,6 @@ export abstract class ModelExtension extends BaseExtension implements ModelInter
abstract saveModel(model: Model): Promise<void>
abstract getDownloadedModels(): 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 copyFile: (src: string, dest: string) => Promise<void> = (src, dest) =>
global.core.api?.copyFile(src, dest)
/**
* Gets the file's stats.
*
* @param path - The path to the file.
* @param outsideJanDataFolder - Whether the file is outside the Jan data folder.
* @returns {Promise<FileStat>} - A promise that resolves with the file's stats.
*/
const fileStat: (path: string) => Promise<FileStat | undefined> = (path) =>
global.core.api?.fileStat(path)
const fileStat: (path: string, outsideJanDataFolder?: boolean) => Promise<FileStat | undefined> = (
path,
outsideJanDataFolder
) => global.core.api?.fileStat(path, outsideJanDataFolder)
// TODO: Export `dummy` fs functions automatically
// Currently adding these manually
@ -90,6 +96,7 @@ export const fs = {
unlinkSync,
appendFileSync,
copyFileSync,
copyFile,
syncFile,
fileStat,
writeBlob,

View File

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

View File

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

View File

@ -1,3 +1,4 @@
export * from './modelEntity'
export * from './modelInterface'
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,
large language model,
docker installation,
cpu mode,
gpu mode,
]
---
# Installing Jan using Docker
## Installation
### Pre-requisites
:::note
@ -37,66 +37,87 @@ sudo sh ./get-docker.sh --dry-run
- If you intend to run Jan in GPU mode, you need to install `nvidia-driver` and `nvidia-docker2`. Follow the instruction [here](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) for installation.
### Instructions
### Run Jan in Docker Mode
- Run Jan in Docker mode
| Docker compose Profile | Description |
| ---------------------- | -------------------------------------------- |
| `cpu-fs` | Run Jan in CPU mode with default file system |
| `cpu-s3fs` | Run Jan in CPU mode with S3 file system |
| `gpu-fs` | Run Jan in GPU mode with default file system |
| `gpu-s3fs` | Run Jan in GPU mode with S3 file system |
- **Option 1**: Run Jan in CPU mode
| Environment Variable | Description |
| ----------------------- | ------------------------------------------------------------------------------------------------------- |
| `S3_BUCKET_NAME` | S3 bucket name - leave blank for default file system |
| `AWS_ACCESS_KEY_ID` | AWS access key ID - leave blank for default file system |
| `AWS_SECRET_ACCESS_KEY` | AWS secret access key - leave blank for default file system |
| `AWS_ENDPOINT` | AWS endpoint URL - leave blank for default file system |
| `AWS_REGION` | AWS region - leave blank for default file system |
| `API_BASE_URL` | Jan Server URL, please modify it as your public ip address or domain name default http://localhost:1377 |
- **Option 1**: Run Jan in CPU mode
```bash
# cpu mode with default file system
docker compose --profile cpu-fs up -d
# cpu mode with S3 file system
docker compose --profile cpu-s3fs up -d
```
- **Option 2**: Run Jan in GPU mode
- **Step 1**: Check CUDA compatibility with your NVIDIA driver by running `nvidia-smi` and check the CUDA version in the output
```bash
nvidia-smi
# Output
+---------------------------------------------------------------------------------------+
| NVIDIA-SMI 531.18 Driver Version: 531.18 CUDA Version: 12.1 |
|-----------------------------------------+----------------------+----------------------+
| GPU Name TCC/WDDM | Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|=========================================+======================+======================|
| 0 NVIDIA GeForce RTX 4070 Ti WDDM | 00000000:01:00.0 On | N/A |
| 0% 44C P8 16W / 285W| 1481MiB / 12282MiB | 2% Default |
| | | N/A |
+-----------------------------------------+----------------------+----------------------+
| 1 NVIDIA GeForce GTX 1660 Ti WDDM | 00000000:02:00.0 Off | N/A |
| 0% 49C P8 14W / 120W| 0MiB / 6144MiB | 0% Default |
| | | N/A |
+-----------------------------------------+----------------------+----------------------+
| 2 NVIDIA GeForce GTX 1660 Ti WDDM | 00000000:05:00.0 Off | N/A |
| 29% 38C P8 11W / 120W| 0MiB / 6144MiB | 0% Default |
| | | N/A |
+-----------------------------------------+----------------------+----------------------+
+---------------------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=======================================================================================|
```
- **Step 2**: Visit [NVIDIA NGC Catalog ](https://catalog.ngc.nvidia.com/orgs/nvidia/containers/cuda/tags) and find the smallest minor version of image tag that matches your CUDA version (e.g., 12.1 -> 12.1.0)
- **Step 3**: Update the `Dockerfile.gpu` line number 5 with the latest minor version of the image tag from step 2 (e.g. change `FROM nvidia/cuda:12.2.0-runtime-ubuntu22.04 AS base` to `FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04 AS base`)
- **Step 4**: Run command to start Jan in GPU mode
```bash
docker compose --profile cpu up -d
# GPU mode with default file system
docker compose --profile gpu up -d
# GPU mode with S3 file system
docker compose --profile gpu-s3fs up -d
```
- **Option 2**: Run Jan in GPU mode
- **Step 1**: Check CUDA compatibility with your NVIDIA driver by running `nvidia-smi` and check the CUDA version in the output
```bash
nvidia-smi
# Output
+---------------------------------------------------------------------------------------+
| NVIDIA-SMI 531.18 Driver Version: 531.18 CUDA Version: 12.1 |
|-----------------------------------------+----------------------+----------------------+
| GPU Name TCC/WDDM | Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|=========================================+======================+======================|
| 0 NVIDIA GeForce RTX 4070 Ti WDDM | 00000000:01:00.0 On | N/A |
| 0% 44C P8 16W / 285W| 1481MiB / 12282MiB | 2% Default |
| | | N/A |
+-----------------------------------------+----------------------+----------------------+
| 1 NVIDIA GeForce GTX 1660 Ti WDDM | 00000000:02:00.0 Off | N/A |
| 0% 49C P8 14W / 120W| 0MiB / 6144MiB | 0% Default |
| | | N/A |
+-----------------------------------------+----------------------+----------------------+
| 2 NVIDIA GeForce GTX 1660 Ti WDDM | 00000000:05:00.0 Off | N/A |
| 29% 38C P8 11W / 120W| 0MiB / 6144MiB | 0% Default |
| | | N/A |
+-----------------------------------------+----------------------+----------------------+
+---------------------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=======================================================================================|
```
- **Step 2**: Visit [NVIDIA NGC Catalog ](https://catalog.ngc.nvidia.com/orgs/nvidia/containers/cuda/tags) and find the smallest minor version of image tag that matches your CUDA version (e.g., 12.1 -> 12.1.0)
- **Step 3**: Update the `Dockerfile.gpu` line number 5 with the latest minor version of the image tag from step 2 (e.g. change `FROM nvidia/cuda:12.2.0-runtime-ubuntu22.04 AS base` to `FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04 AS base`)
- **Step 4**: Run command to start Jan in GPU mode
```bash
# GPU mode
docker compose --profile gpu up -d
```
This will start the web server and you can access Jan at `http://localhost:3000`.
This will start the web server and you can access Jan at `http://localhost:3000`.
:::warning
- Docker mode is currently only suitable for development and localhost. Production is not supported yet, and the RAG feature is not available in Docker mode.
- RAG feature is not supported in Docker mode with s3fs yet.
:::

View File

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

View File

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

View File

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

View File

@ -83,4 +83,22 @@ export function handleAppIPCs() {
return filePaths[0]
}
})
ipcMain.handle(NativeRoute.selectModelFiles, async () => {
const mainWindow = WindowManager.instance.currentWindow
if (!mainWindow) {
console.error('No main window found')
return
}
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
title: 'Select model files',
buttonLabel: 'Select',
properties: ['openFile', 'multiSelections'],
})
if (canceled) {
return
} else {
return filePaths
}
})
}

View File

@ -15,12 +15,14 @@
"build/**/*.{js,map}",
"pre-install",
"models/**/*",
"docs/**/*"
"docs/**/*",
"scripts/**/*"
],
"asarUnpack": [
"pre-install",
"models",
"docs"
"docs",
"scripts"
],
"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
try {
const name = await installExtension(REACT_DEVELOPER_TOOLS)
console.log(`Added Extension: ${name}`)
console.debug(`Added Extension: ${name}`)
} catch (err) {
console.log('An error occurred while installing devtools:')
console.error(err)
console.error('An error occurred while installing devtools:', err)
// Only log the error and don't throw it because it's not critical
}
}

View File

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

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

View File

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

View File

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

View File

@ -19,7 +19,7 @@ const ModalOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<ModalPrimitive.Overlay
ref={ref}
className={twMerge(' modal-backdrop', className)}
className={twMerge('modal-backdrop', className)}
{...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'
import { useAtomValue } from 'jotai'
import BaseLayout from '@/containers/Layout'
import { MainViewState } from '@/constants/screens'
import { useMainViewState } from '@/hooks/useMainViewState'
import ChatScreen from '@/screens/Chat'
import ExploreModelsScreen from '@/screens/ExploreModels'
import LocalServerScreen from '@/screens/LocalServer'
import SettingsScreen from '@/screens/Settings'
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
export default function Page() {
const { mainViewState } = useMainViewState()
const mainViewState = useAtomValue(mainViewStateAtom)
let children = null
switch (mainViewState) {

View File

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

View File

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

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)
return (
<div className="flex-shrink-0 m-4 mr-0 w-2/3">
<div className="rounded-lg border border-border shadow-sm overflow-hidden">
<div className="m-4 mr-0 w-2/3 flex-shrink-0">
<div className="overflow-hidden rounded-lg border border-border shadow-sm">
<table className="w-full px-8">
<thead className="w-full border-b border-border bg-secondary">
<tr>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -69,7 +69,7 @@ const ModalTroubleShooting: React.FC = () => {
>
Discord
</a>
&nbsp; & send it to #🆘|get-help channel for further support.
&nbsp;& send it to #🆘|get-help channel for further support.
</p>
</li>
</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'
import { Fragment, ReactNode } from 'react'
import { Fragment, ReactNode, useEffect } from 'react'
import { AppConfiguration } from '@janhq/core/.'
import { useSetAtom } from 'jotai'
import useAssistants from '@/hooks/useAssistants'
import useGetSystemResources from '@/hooks/useGetSystemResources'
import useModels from '@/hooks/useModels'
import useThreads from '@/hooks/useThreads'
import { janDataFolderPathAtom } from '@/helpers/atoms/AppConfig.atom'
type Props = {
children: ReactNode
}
const DataLoader: React.FC<Props> = ({ children }) => {
const setJanDataFolderPath = useSetAtom(janDataFolderPathAtom)
useModels()
useThreads()
useAssistants()
useGetSystemResources()
useEffect(() => {
window.core?.api
?.getAppConfigurations()
?.then((appConfig: AppConfiguration) => {
setJanDataFolderPath(appConfig.data_folder)
})
}, [setJanDataFolderPath])
console.debug('Load Data...')
return <Fragment>{children}</Fragment>

View File

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

View File

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

View File

@ -6,7 +6,7 @@ import { atom, useSetAtom } from 'jotai'
import { MainViewState } from '@/constants/screens'
import { useMainViewState } from '@/hooks/useMainViewState'
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
type Props = {
children: ReactNode
@ -19,7 +19,7 @@ export const showCommandSearchModalAtom = atom<boolean>(false)
export default function KeyListener({ children }: Props) {
const setShowLeftSideBar = useSetAtom(showLeftSideBarAtom)
const setShowSelectModelModal = useSetAtom(showSelectModelModalAtom)
const { setMainViewState } = useMainViewState()
const setMainViewState = useSetAtom(mainViewStateAtom)
const showCommandSearchModal = useSetAtom(showCommandSearchModalAtom)
useEffect(() => {
@ -48,8 +48,12 @@ export default function KeyListener({ children }: Props) {
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [
setMainViewState,
setShowLeftSideBar,
setShowSelectModelModal,
showCommandSearchModal,
])
return <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:
web:
@ -14,6 +14,6 @@ services:
- /app/node_modules
- /app/.next
ports:
- "3000:3000"
- '3000:3000'
environment:
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'
export const stateModel = atom({ state: 'start', loading: false, model: '' })
@ -32,4 +32,81 @@ export const removeDownloadingModelAtom = atom(
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[]>([])
/// 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 { useAtom } from 'jotai'
import { useSetAtom } from 'jotai'
import { toaster } from '@/containers/Toast'
import { extensionManager } from '@/extension/ExtensionManager'
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
import { removeDownloadedModelAtom } from '@/helpers/atoms/Model.atom'
export default function useDeleteModel() {
const [downloadedModels, setDownloadedModels] = useAtom(downloadedModelsAtom)
const removeDownloadedModel = useSetAtom(removeDownloadedModelAtom)
const deleteModel = async (model: Model) => {
await extensionManager
.get<ModelExtension>(ExtensionTypeEnum.Model)
?.deleteModel(model.id)
// reload models
setDownloadedModels(downloadedModels.filter((e) => e.id !== model.id))
toaster({
title: 'Model Deletion Successful',
description: `The model ${model.id} has been successfully deleted.`,
type: 'success',
})
}
const deleteModel = useCallback(
async (model: Model) => {
await localDeleteModel(model.id)
removeDownloadedModel(model.id)
toaster({
title: 'Model Deletion Successful',
description: `Model ${model.name} has been successfully deleted.`,
type: 'success',
})
},
[removeDownloadedModel]
)
return { deleteModel }
}
const localDeleteModel = async (id: string) =>
extensionManager.get<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 selectedModel = useAtomValue(selectedModelAtom)
const onReviewInFinder = async (type: string) => {
const onRevealInFinder = async (type: string) => {
// TODO: this logic should be refactored.
if (type !== 'Model' && !activeThread) return
@ -96,7 +96,7 @@ export const usePath = () => {
}
return {
onReviewInFinder,
onRevealInFinder,
onViewJson,
onViewFile,
onViewFileContainer,

View File

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

View File

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

View File

@ -48,7 +48,7 @@ const ErrorMessage = ({ message }: { message: ThreadMessage }) => {
{loadModelError === PORT_NOT_AVAILABLE ? (
<div
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%]">
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 { useAtomValue } from 'jotai'
import { useAtomValue, useSetAtom } from 'jotai'
import LogoMark from '@/containers/Brand/Logo/Mark'
import { MainViewState } from '@/constants/screens'
import { useMainViewState } from '@/hooks/useMainViewState'
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
const RequestDownloadModel: React.FC = () => {
const downloadedModels = useAtomValue(downloadedModelsAtom)
const { setMainViewState } = useMainViewState()
const setMainViewState = useSetAtom(mainViewStateAtom)
const onClick = useCallback(() => {
setMainViewState(MainViewState.Hub)

View File

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

View File

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

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 {
Input,
ScrollArea,
@ -10,24 +9,36 @@ import {
SelectContent,
SelectGroup,
SelectItem,
Button,
} from '@janhq/uikit'
import { useAtomValue } from 'jotai'
import { SearchIcon } from 'lucide-react'
import { useAtomValue, useSetAtom } from 'jotai'
import { Plus, SearchIcon } from 'lucide-react'
import { FeatureToggleContext } from '@/context/FeatureToggle'
import { setImportModelStageAtom } from '@/hooks/useImportModel'
import ExploreModelList from './ExploreModelList'
import { HuggingFaceModal } from './HuggingFaceModal'
import {
configuredModelsAtom,
downloadedModelsAtom,
} from '@/helpers/atoms/Model.atom'
const sortMenu = ['All Models', 'Recommended', 'Downloaded']
const ExploreModelsScreen = () => {
const configuredModels = useAtomValue(configuredModelsAtom)
const downloadedModels = useAtomValue(downloadedModelsAtom)
const [searchValue, setsearchValue] = useState('')
const [sortSelected, setSortSelected] = useState('All Models')
const sortMenu = ['All Models', 'Recommended', 'Downloaded']
const [showHuggingFaceModal, setShowHuggingFaceModal] = useState(false)
const setImportModelStage = useSetAtom(setImportModelStageAtom)
const { experimentalFeature } = useContext(FeatureToggleContext)
const filteredModels = configuredModels.filter((x) => {
if (sortSelected === 'Downloaded') {
@ -45,9 +56,13 @@ const ExploreModelsScreen = () => {
}
})
const onHowToImportModelClick = useCallback(() => {
openExternalUrl('https://jan.ai/guides/using-models/import-manually/')
}, [])
const onImportModelClick = useCallback(() => {
setImportModelStage('SELECTING_MODEL')
}, [setImportModelStage])
const onHuggingFaceConverterClick = () => {
setShowHuggingFaceModal(true)
}
return (
<div
@ -56,6 +71,10 @@ const ExploreModelsScreen = () => {
>
<div className="h-full w-full p-4">
<div className="h-full">
<HuggingFaceModal
open={showHuggingFaceModal}
onOpenChange={setShowHuggingFaceModal}
/>
<ScrollArea>
<div className="relative">
<img
@ -63,28 +82,38 @@ const ExploreModelsScreen = () => {
alt="Hub Banner"
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="relative">
<SearchIcon
size={20}
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder="Search models"
className="bg-white pl-9 dark:bg-background"
onChange={(e) => {
setsearchValue(e.target.value)
}}
/>
</div>
<div className="mt-2 text-center">
<p
onClick={onHowToImportModelClick}
className="cursor-pointer font-semibold text-white underline"
<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="flex flex-row space-x-2">
<div className="relative">
<SearchIcon
size={20}
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder="Search models"
className="bg-white pl-9 dark:bg-background"
onChange={(e) => setsearchValue(e.target.value)}
/>
</div>
<Button
themes={'primary'}
className="space-x-2"
onClick={onImportModelClick}
>
How to manually import models
</p>
<Plus className="h-3 w-3" />
<p>Import Model</p>
</Button>
</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 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 { Button, Input } from '@janhq/uikit'
import { useSetAtom } from 'jotai'
import { useAtomValue, useSetAtom } from 'jotai'
import { PencilIcon, FolderOpenIcon } from 'lucide-react'
import Loader from '@/containers/Loader'
@ -21,22 +21,17 @@ import ModalErrorSetDestGlobal, {
import ModalSameDirectory, { showSamePathModalAtom } from './ModalSameDirectory'
import { janDataFolderPathAtom } from '@/helpers/atoms/AppConfig.atom'
const DataFolder = () => {
const [janDataFolderPath, setJanDataFolderPath] = useState('')
const [showLoader, setShowLoader] = useState(false)
const setShowDirectoryConfirm = useSetAtom(showDirectoryConfirmModalAtom)
const setShowSameDirectory = useSetAtom(showSamePathModalAtom)
const setShowChangeFolderError = useSetAtom(showChangeFolderErrorAtom)
const showDestNotEmptyConfirm = useSetAtom(showDestNotEmptyConfirmAtom)
const [destinationPath, setDestinationPath] = useState(undefined)
useEffect(() => {
window.core?.api
?.getAppConfigurations()
?.then((appConfig: AppConfiguration) => {
setJanDataFolderPath(appConfig.data_folder)
})
}, [])
const [destinationPath, setDestinationPath] = useState(undefined)
const janDataFolderPath = useAtomValue(janDataFolderPathAtom)
const onChangeFolderClick = useCallback(async () => {
const destFolder = await window.core?.api?.selectDirectory()
@ -56,8 +51,7 @@ const DataFolder = () => {
return
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newDestChildren: any[] = await fs.readdirSync(destFolder)
const newDestChildren: string[] = await fs.readdirSync(destFolder)
const isNotEmpty =
newDestChildren.filter((x) => x !== '.DS_Store').length > 0

View File

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

View File

@ -26,6 +26,7 @@ import {
TooltipArrow,
TooltipContent,
TooltipTrigger,
ScrollArea,
} from '@janhq/uikit'
import { AlertTriangleIcon, AlertCircleIcon } from 'lucide-react'
@ -138,301 +139,312 @@ const Advanced = () => {
gpuList.length > 0 ? 'Select GPU' : "You don't have any compatible GPU"
return (
<div className="block w-full">
{/* Keyboard shortcut */}
<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">
Keyboard Shortcuts
</h6>
</div>
<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>
<ScrollArea className="px-4">
<div className="block w-full">
{/* Keyboard shortcut */}
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-4 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">
Keyboard Shortcuts
</h6>
</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] 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>
<p className="leading-relaxed">
Shortcuts that you might find useful in Jan app.
</p>
</div>
<ShortcutModal />
</div>
)}
{/* Vulkan for AMD GPU/ APU and Intel Arc GPU */}
{!isMac && experimentalFeature && (
{/* 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">
Vulkan Support
Experimental Mode
</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 className="leading-relaxed">
Enable experimental features that may be unstable tested.
</p>
</div>
<Switch
checked={vulkanEnabled}
onCheckedChange={(e) => {
toaster({
title: 'Reload',
description:
'Vulkan settings updated. Reload now to apply the changes.',
})
stopModel()
saveSettings({ vulkan: e, gpusInUse: [] })
setVulkanEnabled(e)
}}
checked={experimentalFeature}
onCheckedChange={setExperimentalFeature}
/>
</div>
)}
<DataFolder />
{/* Proxy */}
<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 w-full">
<div className="flex gap-x-2 justify-between w-full">
<h6 className="text-sm font-semibold capitalize">HTTPS Proxy</h6>
{/* 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 w-full items-start justify-between">
<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>
{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
checked={proxyEnabled}
onCheckedChange={(_) => setProxyEnabled(!proxyEnabled)}
checked={vulkanEnabled}
onCheckedChange={(e) => {
toaster({
title: 'Reload',
description:
'Vulkan settings updated. Reload now to apply the changes.',
})
stopModel()
saveSettings({ vulkan: e, gpusInUse: [] })
setVulkanEnabled(e)
}}
/>
</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"
)}
<DataFolder />
{/* Proxy */}
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
<div className="w-full flex-shrink-0 space-y-1.5">
<div className="flex w-full justify-between gap-x-2">
<h6 className="text-sm font-semibold capitalize">HTTPS Proxy</h6>
<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>
{/* 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>
{/* 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-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>
<p className="leading-relaxed">
Allow self-signed or unverified certificates - may be required for
certain proxies.
</p>
<Button size="sm" themes="secondaryDanger" onClick={clearLogs}>
Clear
</Button>
</div>
<Switch checked={ignoreSSL} onCheckedChange={(e) => setIgnoreSSL(e)} />
</div>
{/* 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-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>
{/* Factory Reset */}
<FactoryReset />
</div>
{/* Factory Reset */}
<FactoryReset />
</div>
</ScrollArea>
)
}

View File

@ -3,7 +3,7 @@ import ToggleTheme from '@/screens/Settings/Appearance/ToggleTheme'
export default function AppearanceOptions() {
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-shrink-0 space-y-1">
<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 { Button } from '@janhq/uikit'
import { Button, ScrollArea } from '@janhq/uikit'
import { formatExtensionsName } from '@/utils/converter'
@ -68,58 +68,60 @@ const ExtensionCatalog = () => {
}
return (
<div className="block w-full">
{activeExtensions.map((item, i) => {
return (
<div
key={i}
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">
{formatExtensionsName(item.name ?? item.description ?? '')}
</h6>
<p className="whitespace-pre-wrap font-semibold leading-relaxed ">
v{item.version}
<ScrollArea className="h-full w-full px-4">
<div className="block w-full">
{activeExtensions.map((item, i) => {
return (
<div
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">
<h6 className="text-sm font-semibold capitalize">
{formatExtensionsName(item.name ?? item.description ?? '')}
</h6>
<p className="whitespace-pre-wrap font-semibold leading-relaxed ">
v{item.version}
</p>
</div>
<p className="whitespace-pre-wrap leading-relaxed ">
{item.description}
</p>
</div>
<p className="whitespace-pre-wrap leading-relaxed ">
{item.description}
</p>
</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>
)
})}
{/* 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>
<input
type="file"
style={{ display: 'none' }}
ref={fileInputRef}
onChange={handleFileChange}
/>
<Button
themes="secondaryBlue"
size="sm"
onClick={() => fileInputRef.current?.click()}
>
Select
</Button>
</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>
</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