diff --git a/README.md b/README.md index 425ea69be..4d07ad55c 100644 --- a/README.md +++ b/README.md @@ -43,31 +43,31 @@ Jan is an open-source ChatGPT alternative that runs 100% offline on your compute Stable (Recommended) - + jan.exe - + Intel - + M1/M2 - + jan.deb - + jan.AppImage @@ -76,31 +76,31 @@ Jan is an open-source ChatGPT alternative that runs 100% offline on your compute Experimental (Nightly Build) - + jan.exe - + Intel - + M1/M2 - + jan.deb - + jan.AppImage @@ -304,7 +304,7 @@ This will build the app MacOS m1/m2 for production (with code signing already do ```bash # GPU mode with default file system - docker compose --profile gpu up -d + docker compose --profile gpu-fs up -d # GPU mode with S3 file system docker compose --profile gpu-s3fs up -d @@ -319,6 +319,7 @@ This will start the web server and you can access Jan at `http://localhost:3000` Jan builds on top of other open-source projects: - [llama.cpp](https://github.com/ggerganov/llama.cpp) +- [LangChain](https://github.com/langchain-ai) - [TensorRT](https://github.com/NVIDIA/TensorRT) ## Contact diff --git a/core/src/api/index.ts b/core/src/api/index.ts index 676020758..7fb8eeb38 100644 --- a/core/src/api/index.ts +++ b/core/src/api/index.ts @@ -7,6 +7,7 @@ export enum NativeRoute { openAppDirectory = 'openAppDirectory', openFileExplore = 'openFileExplorer', selectDirectory = 'selectDirectory', + selectModelFiles = 'selectModelFiles', relaunch = 'relaunch', } @@ -46,6 +47,13 @@ export enum DownloadEvent { onFileDownloadSuccess = 'onFileDownloadSuccess', } +export enum LocalImportModelEvent { + onLocalImportModelUpdate = 'onLocalImportModelUpdate', + onLocalImportModelFailed = 'onLocalImportModelFailed', + onLocalImportModelSuccess = 'onLocalImportModelSuccess', + onLocalImportModelFinished = 'onLocalImportModelFinished', +} + export enum ExtensionRoute { baseExtensions = 'baseExtensions', getActiveExtensions = 'getActiveExtensions', @@ -67,6 +75,7 @@ export enum FileSystemRoute { } export enum FileManagerRoute { syncFile = 'syncFile', + copyFile = 'copyFile', getJanDataFolderPath = 'getJanDataFolderPath', getResourcePath = 'getResourcePath', getUserHomePath = 'getUserHomePath', @@ -126,4 +135,8 @@ export const CoreRoutes = [ ] export const APIRoutes = [...CoreRoutes, ...Object.values(NativeRoute)] -export const APIEvents = [...Object.values(AppEvent), ...Object.values(DownloadEvent)] +export const APIEvents = [ + ...Object.values(AppEvent), + ...Object.values(DownloadEvent), + ...Object.values(LocalImportModelEvent), +] diff --git a/core/src/core.ts b/core/src/core.ts index 8831c6001..6e2442c2b 100644 --- a/core/src/core.ts +++ b/core/src/core.ts @@ -65,7 +65,7 @@ const joinPath: (paths: string[]) => Promise = (paths) => global.core.ap * @param path - The path to retrieve. * @returns {Promise} A promise that resolves with the basename. */ -const baseName: (paths: string[]) => Promise = (path) => global.core.api?.baseName(path) +const baseName: (paths: string) => Promise = (path) => global.core.api?.baseName(path) /** * Opens an external URL in the default web browser. diff --git a/core/src/extension.ts b/core/src/extension.ts index 3528f581c..3b3edc7b3 100644 --- a/core/src/extension.ts +++ b/core/src/extension.ts @@ -4,6 +4,7 @@ export enum ExtensionTypeEnum { Inference = 'inference', Model = 'model', SystemMonitoring = 'systemMonitoring', + HuggingFace = 'huggingFace', } export interface ExtensionType { diff --git a/core/src/extensions/huggingface.ts b/core/src/extensions/huggingface.ts new file mode 100644 index 000000000..16a1d9b8a --- /dev/null +++ b/core/src/extensions/huggingface.ts @@ -0,0 +1,30 @@ +import { BaseExtension, ExtensionTypeEnum } from '../extension' +import { HuggingFaceInterface, HuggingFaceRepoData, Quantization } from '../types/huggingface' +import { Model } from '../types/model' + +/** + * Hugging Face extension for converting HF models to GGUF. + */ +export abstract class HuggingFaceExtension extends BaseExtension implements HuggingFaceInterface { + interrupted = false + /** + * Hugging Face extension type. + */ + type(): ExtensionTypeEnum | undefined { + return ExtensionTypeEnum.HuggingFace + } + + abstract downloadModelFiles( + repoID: string, + repoData: HuggingFaceRepoData, + network?: { ignoreSSL?: boolean; proxy?: string } + ): Promise + abstract convert(repoID: string): Promise + abstract quantize(repoID: string, quantization: Quantization): Promise + abstract generateMetadata( + repoID: string, + repoData: HuggingFaceRepoData, + quantization: Quantization + ): Promise + abstract cancelConvert(repoID: string, repoData: HuggingFaceRepoData): Promise +} diff --git a/core/src/extensions/index.ts b/core/src/extensions/index.ts index 522334548..c6834482c 100644 --- a/core/src/extensions/index.ts +++ b/core/src/extensions/index.ts @@ -23,3 +23,8 @@ export { AssistantExtension } from './assistant' * Model extension for managing models. */ export { ModelExtension } from './model' + +/** + * Hugging Face extension for converting HF models to GGUF. + */ +export { HuggingFaceExtension } from './huggingface' diff --git a/core/src/extensions/model.ts b/core/src/extensions/model.ts index df7d14f42..79202398b 100644 --- a/core/src/extensions/model.ts +++ b/core/src/extensions/model.ts @@ -1,5 +1,5 @@ import { BaseExtension, ExtensionTypeEnum } from '../extension' -import { Model, ModelInterface } from '../index' +import { ImportingModel, Model, ModelInterface, OptionType } from '../index' /** * Model extension for managing models. @@ -21,4 +21,6 @@ export abstract class ModelExtension extends BaseExtension implements ModelInter abstract saveModel(model: Model): Promise abstract getDownloadedModels(): Promise abstract getConfiguredModels(): Promise + abstract importModels(models: ImportingModel[], optionType: OptionType): Promise + abstract updateModelInfo(modelInfo: Partial): Promise } diff --git a/core/src/fs.ts b/core/src/fs.ts index 0e570d1f5..71538ae9c 100644 --- a/core/src/fs.ts +++ b/core/src/fs.ts @@ -69,14 +69,20 @@ const syncFile: (src: string, dest: string) => Promise = (src, dest) => */ const copyFileSync = (...args: any[]) => global.core.api?.copyFileSync(...args) +const copyFile: (src: string, dest: string) => Promise = (src, dest) => + global.core.api?.copyFile(src, dest) + /** * Gets the file's stats. * * @param path - The path to the file. + * @param outsideJanDataFolder - Whether the file is outside the Jan data folder. * @returns {Promise} - A promise that resolves with the file's stats. */ -const fileStat: (path: string) => Promise = (path) => - global.core.api?.fileStat(path) +const fileStat: (path: string, outsideJanDataFolder?: boolean) => Promise = ( + path, + outsideJanDataFolder +) => global.core.api?.fileStat(path, outsideJanDataFolder) // TODO: Export `dummy` fs functions automatically // Currently adding these manually @@ -90,6 +96,7 @@ export const fs = { unlinkSync, appendFileSync, copyFileSync, + copyFile, syncFile, fileStat, writeBlob, diff --git a/core/src/node/api/processors/download.ts b/core/src/node/api/processors/download.ts index 686ba58a1..bff6f47f0 100644 --- a/core/src/node/api/processors/download.ts +++ b/core/src/node/api/processors/download.ts @@ -50,7 +50,7 @@ export class Downloader implements Processor { fileName, downloadState: 'downloading', } - console.log('progress: ', downloadState) + console.debug('progress: ', downloadState) observer?.(DownloadEvent.onFileDownloadUpdate, downloadState) DownloadManager.instance.downloadProgressMap[modelId] = downloadState }) diff --git a/core/src/node/api/processors/fsExt.ts b/core/src/node/api/processors/fsExt.ts index 71e07ae57..4787da65b 100644 --- a/core/src/node/api/processors/fsExt.ts +++ b/core/src/node/api/processors/fsExt.ts @@ -1,6 +1,5 @@ import { join } from 'path' import fs from 'fs' -import { FileManagerRoute } from '../../../api' import { appResourcePath, normalizeFilePath } from '../../helper/path' import { getJanDataFolderPath, getJanDataFolderPath as getPath } from '../../helper' import { Processor } from './Processor' @@ -48,10 +47,12 @@ export class FSExt implements Processor { } // handle fs is directory here - fileStat(path: string) { + fileStat(path: string, outsideJanDataFolder?: boolean) { const normalizedPath = normalizeFilePath(path) - const fullPath = join(getJanDataFolderPath(), normalizedPath) + const fullPath = outsideJanDataFolder + ? normalizedPath + : join(getJanDataFolderPath(), normalizedPath) const isExist = fs.existsSync(fullPath) if (!isExist) return undefined @@ -75,4 +76,16 @@ export class FSExt implements Processor { console.error(`writeFile ${path} result: ${err}`) } } + + copyFile(src: string, dest: string): Promise { + return new Promise((resolve, reject) => { + fs.copyFile(src, dest, (err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + } } diff --git a/core/src/types/huggingface/huggingfaceEntity.ts b/core/src/types/huggingface/huggingfaceEntity.ts new file mode 100644 index 000000000..c3c320354 --- /dev/null +++ b/core/src/types/huggingface/huggingfaceEntity.ts @@ -0,0 +1,34 @@ +export interface HuggingFaceRepoData { + id: string + author: string + tags: Array<'transformers' | 'pytorch' | 'safetensors' | string> + siblings: { + rfilename: string + }[] + createdAt: string // ISO 8601 timestamp +} + +/* eslint-disable @typescript-eslint/naming-convention */ +export enum Quantization { + Q3_K_S = 'Q3_K_S', + Q3_K_M = 'Q3_K_M', // eslint-disable-line @typescript-eslint/no-duplicate-enum-values + Q3_K_L = 'Q3_K_L', + Q4_K_S = 'Q4_K_S', + Q4_K_M = 'Q4_K_M', // eslint-disable-line @typescript-eslint/no-duplicate-enum-values + Q5_K_S = 'Q5_K_S', + Q5_K_M = 'Q5_K_M', // eslint-disable-line @typescript-eslint/no-duplicate-enum-values + Q4_0 = 'Q4_0', + Q4_1 = 'Q4_1', + Q5_0 = 'Q5_0', + Q5_1 = 'Q5_1', + IQ2_XXS = 'IQ2_XXS', + IQ2_XS = 'IQ2_XS', + Q2_K = 'Q2_K', + Q2_K_S = 'Q2_K_S', + Q6_K = 'Q6_K', + Q8_0 = 'Q8_0', + F16 = 'F16', + F32 = 'F32', + COPY = 'COPY', +} +/* eslint-enable @typescript-eslint/naming-convention */ diff --git a/core/src/types/huggingface/huggingfaceInterface.ts b/core/src/types/huggingface/huggingfaceInterface.ts new file mode 100644 index 000000000..c99b2177d --- /dev/null +++ b/core/src/types/huggingface/huggingfaceInterface.ts @@ -0,0 +1,58 @@ +import { Model } from '../model' +import { HuggingFaceRepoData, Quantization } from './huggingfaceEntity' + +/** + * Hugging Face extension for converting HF models to GGUF. + * @extends BaseExtension + */ +export interface HuggingFaceInterface { + interrupted: boolean + /** + * Downloads a Hugging Face model. + * @param repoID - The repo ID of the model to convert. + * @param repoData - The repo data of the model to convert. + * @param network - Optional object to specify proxy/whether to ignore SSL certificates. + * @returns A promise that resolves when the download is complete. + */ + downloadModelFiles( + repoID: string, + repoData: HuggingFaceRepoData, + network?: { ignoreSSL?: boolean; proxy?: string } + ): Promise + + /** + * Converts a Hugging Face model to GGUF. + * @param repoID - The repo ID of the model to convert. + * @returns A promise that resolves when the conversion is complete. + */ + convert(repoID: string): Promise + + /** + * Quantizes a GGUF model. + * @param repoID - The repo ID of the model to quantize. + * @param quantization - The quantization to use. + * @returns A promise that resolves when the quantization is complete. + */ + quantize(repoID: string, quantization: Quantization): Promise + + /** + * Generates Jan model metadata from a Hugging Face model. + * @param repoID - The repo ID of the model to generate metadata for. + * @param repoData - The repo data of the model to generate metadata for. + * @param quantization - The quantization of the model. + * @returns A promise that resolves when the model metadata generation is complete. + */ + generateMetadata( + repoID: string, + repoData: HuggingFaceRepoData, + quantization: Quantization + ): Promise + + /** + * Cancels the convert of current Hugging Face model. + * @param repoID - The repository ID to cancel. + * @param repoData - The repository data to cancel. + * @returns {Promise} A promise that resolves when the download has been cancelled. + */ + cancelConvert(repoID: string, repoData: HuggingFaceRepoData): Promise +} diff --git a/core/src/types/huggingface/index.ts b/core/src/types/huggingface/index.ts new file mode 100644 index 000000000..c108c55e2 --- /dev/null +++ b/core/src/types/huggingface/index.ts @@ -0,0 +1,2 @@ +export * from './huggingfaceInterface' +export * from './huggingfaceEntity' diff --git a/core/src/types/index.ts b/core/src/types/index.ts index ee6f4ef08..295d054e7 100644 --- a/core/src/types/index.ts +++ b/core/src/types/index.ts @@ -6,4 +6,5 @@ export * from './inference' export * from './monitoring' export * from './file' export * from './config' +export * from './huggingface' export * from './miscellaneous' diff --git a/core/src/types/model/index.ts b/core/src/types/model/index.ts index cba06ea95..fdbf01863 100644 --- a/core/src/types/model/index.ts +++ b/core/src/types/model/index.ts @@ -1,3 +1,4 @@ export * from './modelEntity' export * from './modelInterface' export * from './modelEvent' +export * from './modelImport' diff --git a/core/src/types/model/modelImport.ts b/core/src/types/model/modelImport.ts new file mode 100644 index 000000000..7c72a691b --- /dev/null +++ b/core/src/types/model/modelImport.ts @@ -0,0 +1,23 @@ +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 + error?: string +} diff --git a/docs/docs/acknowledgements.md b/docs/docs/acknowledgements.md new file mode 100644 index 000000000..c68c4ed86 --- /dev/null +++ b/docs/docs/acknowledgements.md @@ -0,0 +1,26 @@ +--- +title: Acknowledgements +description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server. +slug: /acknowledgements +keywords: + [ + Jan AI, + Jan, + ChatGPT alternative, + local AI, + private AI, + conversational AI, + no-subscription fee, + large language model, + acknowledgements, + third-party libraries, + ] +--- + +# Acknowledgements + +We would like to express our gratitude to the following third-party libraries that have made the development of Jan possible. + +- [llama.cpp](https://github.com/ggerganov/llama.cpp/blob/master/LICENSE) +- [LangChain.js](https://github.com/langchain-ai/langchainjs/blob/main/LICENSE) +- [TensorRT](https://github.com/NVIDIA/TensorRT/blob/main/LICENSE) diff --git a/docs/docs/community/community.md b/docs/docs/community/community.mdx similarity index 59% rename from docs/docs/community/community.md rename to docs/docs/community/community.mdx index 24a87daf0..d4866490e 100644 --- a/docs/docs/community/community.md +++ b/docs/docs/community/community.mdx @@ -29,3 +29,18 @@ keywords: ## Careers - [Jobs](https://janai.bamboohr.com/careers) + +## Newsletter + + diff --git a/docs/docs/guides/02-installation/05-docker.md b/docs/docs/guides/02-installation/05-docker.md index 6236ed92e..1cdc829df 100644 --- a/docs/docs/guides/02-installation/05-docker.md +++ b/docs/docs/guides/02-installation/05-docker.md @@ -13,13 +13,13 @@ keywords: no-subscription fee, large language model, docker installation, + cpu mode, + gpu mode, ] --- # Installing Jan using Docker -## Installation - ### Pre-requisites :::note @@ -37,66 +37,87 @@ sudo sh ./get-docker.sh --dry-run - If you intend to run Jan in GPU mode, you need to install `nvidia-driver` and `nvidia-docker2`. Follow the instruction [here](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) for installation. -### Instructions +### Run Jan in Docker Mode -- Run Jan in Docker mode +| Docker compose Profile | Description | +| ---------------------- | -------------------------------------------- | +| `cpu-fs` | Run Jan in CPU mode with default file system | +| `cpu-s3fs` | Run Jan in CPU mode with S3 file system | +| `gpu-fs` | Run Jan in GPU mode with default file system | +| `gpu-s3fs` | Run Jan in GPU mode with S3 file system | - - **Option 1**: Run Jan in CPU mode +| Environment Variable | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------------- | +| `S3_BUCKET_NAME` | S3 bucket name - leave blank for default file system | +| `AWS_ACCESS_KEY_ID` | AWS access key ID - leave blank for default file system | +| `AWS_SECRET_ACCESS_KEY` | AWS secret access key - leave blank for default file system | +| `AWS_ENDPOINT` | AWS endpoint URL - leave blank for default file system | +| `AWS_REGION` | AWS region - leave blank for default file system | +| `API_BASE_URL` | Jan Server URL, please modify it as your public ip address or domain name default http://localhost:1377 | + +- **Option 1**: Run Jan in CPU mode + + ```bash + # cpu mode with default file system + docker compose --profile cpu-fs up -d + + # cpu mode with S3 file system + docker compose --profile cpu-s3fs up -d + ``` + +- **Option 2**: Run Jan in GPU mode + + - **Step 1**: Check CUDA compatibility with your NVIDIA driver by running `nvidia-smi` and check the CUDA version in the output + + ```bash + nvidia-smi + + # Output + +---------------------------------------------------------------------------------------+ + | NVIDIA-SMI 531.18 Driver Version: 531.18 CUDA Version: 12.1 | + |-----------------------------------------+----------------------+----------------------+ + | GPU Name TCC/WDDM | Bus-Id Disp.A | Volatile Uncorr. ECC | + | Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. | + | | | MIG M. | + |=========================================+======================+======================| + | 0 NVIDIA GeForce RTX 4070 Ti WDDM | 00000000:01:00.0 On | N/A | + | 0% 44C P8 16W / 285W| 1481MiB / 12282MiB | 2% Default | + | | | N/A | + +-----------------------------------------+----------------------+----------------------+ + | 1 NVIDIA GeForce GTX 1660 Ti WDDM | 00000000:02:00.0 Off | N/A | + | 0% 49C P8 14W / 120W| 0MiB / 6144MiB | 0% Default | + | | | N/A | + +-----------------------------------------+----------------------+----------------------+ + | 2 NVIDIA GeForce GTX 1660 Ti WDDM | 00000000:05:00.0 Off | N/A | + | 29% 38C P8 11W / 120W| 0MiB / 6144MiB | 0% Default | + | | | N/A | + +-----------------------------------------+----------------------+----------------------+ + + +---------------------------------------------------------------------------------------+ + | Processes: | + | GPU GI CI PID Type Process name GPU Memory | + | ID ID Usage | + |=======================================================================================| + ``` + + - **Step 2**: Visit [NVIDIA NGC Catalog ](https://catalog.ngc.nvidia.com/orgs/nvidia/containers/cuda/tags) and find the smallest minor version of image tag that matches your CUDA version (e.g., 12.1 -> 12.1.0) + + - **Step 3**: Update the `Dockerfile.gpu` line number 5 with the latest minor version of the image tag from step 2 (e.g. change `FROM nvidia/cuda:12.2.0-runtime-ubuntu22.04 AS base` to `FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04 AS base`) + + - **Step 4**: Run command to start Jan in GPU mode ```bash - docker compose --profile cpu up -d + # GPU mode with default file system + docker compose --profile gpu-fs up -d + + # GPU mode with S3 file system + docker compose --profile gpu-s3fs up -d ``` - - **Option 2**: Run Jan in GPU mode - - - **Step 1**: Check CUDA compatibility with your NVIDIA driver by running `nvidia-smi` and check the CUDA version in the output - - ```bash - nvidia-smi - - # Output - +---------------------------------------------------------------------------------------+ - | NVIDIA-SMI 531.18 Driver Version: 531.18 CUDA Version: 12.1 | - |-----------------------------------------+----------------------+----------------------+ - | GPU Name TCC/WDDM | Bus-Id Disp.A | Volatile Uncorr. ECC | - | Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. | - | | | MIG M. | - |=========================================+======================+======================| - | 0 NVIDIA GeForce RTX 4070 Ti WDDM | 00000000:01:00.0 On | N/A | - | 0% 44C P8 16W / 285W| 1481MiB / 12282MiB | 2% Default | - | | | N/A | - +-----------------------------------------+----------------------+----------------------+ - | 1 NVIDIA GeForce GTX 1660 Ti WDDM | 00000000:02:00.0 Off | N/A | - | 0% 49C P8 14W / 120W| 0MiB / 6144MiB | 0% Default | - | | | N/A | - +-----------------------------------------+----------------------+----------------------+ - | 2 NVIDIA GeForce GTX 1660 Ti WDDM | 00000000:05:00.0 Off | N/A | - | 29% 38C P8 11W / 120W| 0MiB / 6144MiB | 0% Default | - | | | N/A | - +-----------------------------------------+----------------------+----------------------+ - - +---------------------------------------------------------------------------------------+ - | Processes: | - | GPU GI CI PID Type Process name GPU Memory | - | ID ID Usage | - |=======================================================================================| - ``` - - - **Step 2**: Visit [NVIDIA NGC Catalog ](https://catalog.ngc.nvidia.com/orgs/nvidia/containers/cuda/tags) and find the smallest minor version of image tag that matches your CUDA version (e.g., 12.1 -> 12.1.0) - - - **Step 3**: Update the `Dockerfile.gpu` line number 5 with the latest minor version of the image tag from step 2 (e.g. change `FROM nvidia/cuda:12.2.0-runtime-ubuntu22.04 AS base` to `FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04 AS base`) - - - **Step 4**: Run command to start Jan in GPU mode - - ```bash - # GPU mode - docker compose --profile gpu up -d - ``` - - This will start the web server and you can access Jan at `http://localhost:3000`. +This will start the web server and you can access Jan at `http://localhost:3000`. :::warning -- Docker mode is currently only suitable for development and localhost. Production is not supported yet, and the RAG feature is not available in Docker mode. +- RAG feature is not supported in Docker mode with s3fs yet. ::: diff --git a/docs/docs/wall-of-love.md b/docs/docs/wall-of-love.md index f196c90e9..f6bfe79d8 100644 --- a/docs/docs/wall-of-love.md +++ b/docs/docs/wall-of-love.md @@ -1,3 +1,95 @@ --- title: Wall of Love ❤️ ---- \ No newline at end of file +--- + +## Twitter + +Check out our amazing users and what they are saying about Jan! + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +Please share your love for Jan on Twitter and tag us [@janframework](https://twitter.com/janframework)! We would love to hear from you! + +## YouTube + +Watch these amazing videos to see how Jan is being used and loved by the community! + +### Run Any Chatbot FREE Locally on Your Computer + +
+ +
+ +

+ +### Jan AI: Run Open Source LLM 100% Local with OpenAI endpoints + +
+ +
+ +

+ +### Setup Tutorial on Jan.ai. JAN AI: Run open source LLM on local Windows PC. 100% offline LLM and AI. + +
+ +
+ +

+ +### Jan.ai: Like Offline ChatGPT on Your Computer 💡 + +
+ +
+ +

+ +### Jan: Bring AI to your Desktop With 100% Offline AI + +
+ +
+ +

+ +### AI on Your Local PC: Install JanAI (ChatGPT alternative) for Enhanced Privacy + +
+ +
+ +

+ +### Install Jan to Run LLM Offline and Local First + +
+ +
diff --git a/docs/sidebars.js b/docs/sidebars.js index 02ea7589f..5f69301be 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -51,6 +51,7 @@ const sidebars = { "how-we-work/website-docs/website-docs", ], }, + "acknowledgements", ], productSidebar: [ { diff --git a/docs/src/containers/Footer/index.js b/docs/src/containers/Footer/index.js index 7cd648149..3e62f579a 100644 --- a/docs/src/containers/Footer/index.js +++ b/docs/src/containers/Footer/index.js @@ -86,6 +86,10 @@ const menus = [ path: "https://janai.bamboohr.com/careers", external: true, }, + { + menu: "Newsletter", + path: "/community#newsletter", + } ], }, ]; diff --git a/docs/src/styles/tweaks/markdown.scss b/docs/src/styles/tweaks/markdown.scss index 1093f2318..ade07e35b 100644 --- a/docs/src/styles/tweaks/markdown.scss +++ b/docs/src/styles/tweaks/markdown.scss @@ -1,4 +1,10 @@ .theme-doc-markdown { + a, + p, + span, + li { + @apply leading-loose; + } a { @apply text-blue-600 dark:text-blue-400; } @@ -10,9 +16,9 @@ } ul, ol { - padding-left: 16px; + padding-left: 28px; li { - @apply leading-normal; + @apply leading-loose; p { margin-bottom: 0; } diff --git a/docs/src/styles/tweaks/sidebar.scss b/docs/src/styles/tweaks/sidebar.scss index 5508a3bfa..02fed8ce8 100644 --- a/docs/src/styles/tweaks/sidebar.scss +++ b/docs/src/styles/tweaks/sidebar.scss @@ -1,12 +1,12 @@ // * Classname from Docusaurus template // * We just overide the styling with applied class from tailwind -[class*="docSidebarContainer_"] { +[class*='docSidebarContainer_'] { margin-top: 0 !important; @apply dark:border-gray-800 border-gray-300; } -[class*="sidebar_"] { +[class*='sidebar_'] { padding-top: 0px !important; } @@ -14,32 +14,40 @@ padding-top: 20px !important; } -[class*="sidebarViewport_"] { +[class*='sidebarViewport_'] { top: 80px !important; // height: unset !important; } -[class*="docItemCol_"] { +[class*='docItemCol_'] { @apply lg:px-8; } // * Including custom sidebar table of content .table-of-contents { - @apply text-base py-0 dark:border-gray-800 border-gray-300; + @apply text-sm py-0 dark:border-gray-800 border-gray-300; } .menu__caret:before { background: var(--ifm-menu-link-sublist-icon) 50% / 1.5rem 1.5rem; } -[class*="codeBlockContainer_"] { +[class*='codeBlockContainer_'] { margin: 4px; } -[class*="codeBlockTitle_"] { +[class*='codeBlockTitle_'] { border-bottom: 1px solid #52525a !important; } -[class*="iconExternalLink_"] { +[class*='iconExternalLink_'] { display: none; } + +[class*='docMainContainer'] { + @media (min-width: 1440px) { + .container { + max-width: var(--ifm-container-width-xl); + } + } +} diff --git a/electron/handlers/native.ts b/electron/handlers/native.ts index 14ead07bd..79fa994bf 100644 --- a/electron/handlers/native.ts +++ b/electron/handlers/native.ts @@ -83,4 +83,22 @@ export function handleAppIPCs() { return filePaths[0] } }) + + ipcMain.handle(NativeRoute.selectModelFiles, async () => { + const mainWindow = WindowManager.instance.currentWindow + if (!mainWindow) { + console.error('No main window found') + return + } + const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, { + title: 'Select model files', + buttonLabel: 'Select', + properties: ['openFile', 'multiSelections'], + }) + if (canceled) { + return + } else { + return filePaths + } + }) } diff --git a/electron/package.json b/electron/package.json index a89803077..7cdb98360 100644 --- a/electron/package.json +++ b/electron/package.json @@ -15,12 +15,14 @@ "build/**/*.{js,map}", "pre-install", "models/**/*", - "docs/**/*" + "docs/**/*", + "scripts/**/*" ], "asarUnpack": [ "pre-install", "models", - "docs" + "docs", + "scripts" ], "publish": [ { diff --git a/electron/utils/dev.ts b/electron/utils/dev.ts index b2a492886..16e5241b6 100644 --- a/electron/utils/dev.ts +++ b/electron/utils/dev.ts @@ -8,10 +8,9 @@ export const setupReactDevTool = async () => { ) // Don't use import on top level, since the installer package is dev-only try { const name = await installExtension(REACT_DEVELOPER_TOOLS) - console.log(`Added Extension: ${name}`) + console.debug(`Added Extension: ${name}`) } catch (err) { - console.log('An error occurred while installing devtools:') - console.error(err) + console.error('An error occurred while installing devtools:', err) // Only log the error and don't throw it because it's not critical } } diff --git a/electron/utils/log.ts b/electron/utils/log.ts index 84c185d75..9dcd4563b 100644 --- a/electron/utils/log.ts +++ b/electron/utils/log.ts @@ -35,7 +35,7 @@ export function cleanLogs( console.error('Error deleting log file:', err) return } - console.log( + console.debug( `Deleted log file due to exceeding size limit: ${filePath}` ) }) @@ -52,7 +52,7 @@ export function cleanLogs( console.error('Error deleting log file:', err) return } - console.log(`Deleted old log file: ${filePath}`) + console.debug(`Deleted old log file: ${filePath}`) }) } } diff --git a/extensions/huggingface-extension/.gitignore b/extensions/huggingface-extension/.gitignore new file mode 100644 index 000000000..bdf39cc7f --- /dev/null +++ b/extensions/huggingface-extension/.gitignore @@ -0,0 +1,3 @@ +bin +scripts/convert* +scripts/gguf-py diff --git a/extensions/huggingface-extension/.prettierrc b/extensions/huggingface-extension/.prettierrc new file mode 100644 index 000000000..46f1abcb0 --- /dev/null +++ b/extensions/huggingface-extension/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": false, + "singleQuote": true, + "quoteProps": "consistent", + "trailingComma": "es5", + "endOfLine": "auto", + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/extensions/huggingface-extension/README.md b/extensions/huggingface-extension/README.md new file mode 100644 index 000000000..ae70eb4ec --- /dev/null +++ b/extensions/huggingface-extension/README.md @@ -0,0 +1,73 @@ +# Create a Jan Plugin using Typescript + +Use this template to bootstrap the creation of a TypeScript Jan plugin. 🚀 + +## Create Your Own Plugin + +To create your own plugin, you can use this repository as a template! Just follow the below instructions: + +1. Click the Use this template button at the top of the repository +2. Select Create a new repository +3. Select an owner and name for your new repository +4. Click Create repository +5. Clone your new repository + +## Initial Setup + +After you've cloned the repository to your local machine or codespace, you'll need to perform some initial setup steps before you can develop your plugin. + +> [!NOTE] +> +> You'll need to have a reasonably modern version of +> [Node.js](https://nodejs.org) handy. If you are using a version manager like +> [`nodenv`](https://github.com/nodenv/nodenv) or +> [`nvm`](https://github.com/nvm-sh/nvm), you can run `nodenv install` in the +> root of your repository to install the version specified in +> [`package.json`](./package.json). Otherwise, 20.x or later should work! + +1. :hammer_and_wrench: Install the dependencies + + ```bash + npm install + ``` + +1. :building_construction: Package the TypeScript for distribution + + ```bash + npm run bundle + ``` + +1. :white_check_mark: Check your artifact + + There will be a tgz file in your plugin directory now + +## Update the Plugin Metadata + +The [`package.json`](package.json) file defines metadata about your plugin, such as +plugin name, main entry, description and version. + +When you copy this repository, update `package.json` with the name, description for your plugin. + +## Update the Plugin Code + +The [`src/`](./src/) directory is the heart of your plugin! This contains the +source code that will be run when your plugin extension functions are invoked. You can replace the +contents of this directory with your own code. + +There are a few things to keep in mind when writing your plugin code: + +- Most Jan Plugin Extension functions are processed asynchronously. + In `index.ts`, you will see that the extension function will return a `Promise`. + + ```typescript + import { core } from "@janhq/core"; + + function onStart(): Promise { + return core.invokePluginFunc(MODULE_PATH, "run", 0); + } + ``` + + For more information about the Jan Plugin Core module, see the + [documentation](https://github.com/janhq/jan/blob/main/core/README.md). + +So, what are you waiting for? Go ahead and start customizing your plugin! diff --git a/extensions/huggingface-extension/bin/mac-arm64/quantize b/extensions/huggingface-extension/bin/mac-arm64/quantize new file mode 100755 index 000000000..f8a149b10 Binary files /dev/null and b/extensions/huggingface-extension/bin/mac-arm64/quantize differ diff --git a/extensions/huggingface-extension/download.bat b/extensions/huggingface-extension/download.bat new file mode 100644 index 000000000..de055cb80 --- /dev/null +++ b/extensions/huggingface-extension/download.bat @@ -0,0 +1,3 @@ +@echo off +set /p LLAMA_CPP_VERSION=<./scripts/version.txt +.\node_modules\.bin\download https://github.com/ggerganov/llama.cpp/archive/refs/tags/%LLAMA_CPP_VERSION%.tar.gz -o . --filename ./scripts/llama.cpp.tar.gz && tar -xzf .\scripts\llama.cpp.tar.gz "llama.cpp-%LLAMA_CPP_VERSION%/convert.py" "llama.cpp-%LLAMA_CPP_VERSION%/convert-hf-to-gguf.py" "llama.cpp-%LLAMA_CPP_VERSION%/gguf-py" && cpx "./llama.cpp-%LLAMA_CPP_VERSION%/**" "scripts" && rimraf "./scripts/llama.cpp.tar.gz" && rimraf "./llama.cpp-%LLAMA_CPP_VERSION%" \ No newline at end of file diff --git a/extensions/huggingface-extension/package.json b/extensions/huggingface-extension/package.json new file mode 100644 index 000000000..e71dc7406 --- /dev/null +++ b/extensions/huggingface-extension/package.json @@ -0,0 +1,57 @@ +{ + "name": "@janhq/huggingface-extension", + "version": "1.0.0", + "description": "Hugging Face extension for converting HF models to GGUF", + "main": "dist/index.js", + "node": "dist/node/index.cjs.js", + "author": "Jan ", + "license": "AGPL-3.0", + "scripts": { + "build": "tsc --module commonjs && rollup -c rollup.config.ts --configPlugin @rollup/plugin-typescript --bundleConfigAsCjs", + "download:llama": "run-script-os", + "download:llama:linux": "LLAMA_CPP_VERSION=$(cat ./scripts/version.txt) && download https://github.com/ggerganov/llama.cpp/archive/refs/tags/${LLAMA_CPP_VERSION}.tar.gz -o . --filename ./scripts/llama.cpp.tar.gz && tar -xzf ./scripts/llama.cpp.tar.gz --wildcards '*/convert.py' '*/convert-hf-to-gguf.py' '*/gguf-py' && cpx \"./llama.cpp-$LLAMA_CPP_VERSION/**\" \"scripts\" && rimraf \"./scripts/llama.cpp.tar.gz\" && rimraf \"./llama.cpp-$LLAMA_CPP_VERSION\"", + "download:llama:darwin": "LLAMA_CPP_VERSION=$(cat ./scripts/version.txt) && download https://github.com/ggerganov/llama.cpp/archive/refs/tags/${LLAMA_CPP_VERSION}.tar.gz -o . --filename ./scripts/llama.cpp.tar.gz && tar -xzf ./scripts/llama.cpp.tar.gz '*/convert.py' '*/convert-hf-to-gguf.py' '*/gguf-py' && cpx \"./llama.cpp-$LLAMA_CPP_VERSION/**\" \"scripts\" && rimraf \"./scripts/llama.cpp.tar.gz\" && rimraf \"./llama.cpp-$LLAMA_CPP_VERSION\"", + "download:llama:win32": "download.bat", + "build:publish:linux": "rimraf *.tgz --glob && npm run build && npm run download:llama && cpx \"scripts/**\" \"dist/scripts\" && cpx \"bin/**\" \"dist/bin\" && npm pack && cpx *.tgz ../../pre-install", + "build:publish:darwin": "rimraf *.tgz --glob && npm run build && npm run download:llama && cpx \"scripts/**\" \"dist/scripts\" && cpx \"bin/**\" \"dist/bin\" && ../../.github/scripts/auto-sign.sh && npm pack && cpx *.tgz ../../pre-install", + "build:publish:win32": "rimraf *.tgz --glob && npm run build && npm run download:llama && cpx \"scripts/**\" \"dist/scripts\" && cpx \"bin/**\" \"dist/bin\" && npm pack && cpx *.tgz ../../pre-install", + "build:publish": "run-script-os" + }, + "exports": { + ".": "./dist/index.js", + "./main": "./dist/node/index.cjs.js" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-replace": "^5.0.5", + "@rollup/plugin-typescript": "^11.1.6", + "@types/node": "^20.11.16", + "cpx": "^1.5.0", + "download-cli": "^1.1.1", + "rimraf": "^5.0.5", + "rollup": "^4.9.6", + "rollup-plugin-sourcemaps": "^0.6.3", + "rollup-plugin-typescript2": "^0.36.0", + "run-script-os": "^1.1.6", + "typescript": "^5.3.3" + }, + "dependencies": { + "@janhq/core": "file:../../core", + "hyllama": "^0.1.2", + "python-shell": "^5.0.0", + "ts-loader": "^9.5.0" + }, + "bundledDependencies": [ + "python-shell" + ], + "engines": { + "node": ">=18.0.0" + }, + "files": [ + "dist/*", + "package.json", + "README.md" + ] +} diff --git a/extensions/huggingface-extension/rollup.config.ts b/extensions/huggingface-extension/rollup.config.ts new file mode 100644 index 000000000..7ae2c5781 --- /dev/null +++ b/extensions/huggingface-extension/rollup.config.ts @@ -0,0 +1,72 @@ +import resolve from '@rollup/plugin-node-resolve' +import commonjs from '@rollup/plugin-commonjs' +import sourceMaps from 'rollup-plugin-sourcemaps' +import typescript from 'rollup-plugin-typescript2' +import json from '@rollup/plugin-json' +import replace from '@rollup/plugin-replace' + +const packageJson = require('./package.json') + +export default [ + { + input: `src/index.ts`, + output: [{ file: packageJson.main, format: 'es', sourcemap: true }], + // Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash') + external: [], + watch: { + include: 'src/**', + }, + plugins: [ + replace({ + EXTENSION_NAME: JSON.stringify(packageJson.name), + NODE_MODULE_PATH: JSON.stringify( + `${packageJson.name}/${packageJson.node}` + ), + }), + // Allow json resolution + json(), + // Compile TypeScript files + typescript({ useTsconfigDeclarationDir: true }), + // Compile TypeScript files + // Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs) + commonjs(), + // Allow node_modules resolution, so you can use 'external' to control + // which external modules to include in the bundle + // https://github.com/rollup/rollup-plugin-node-resolve#usage + resolve({ + extensions: ['.js', '.ts'], + }), + + // Resolve source maps to the original source + sourceMaps(), + ], + }, + { + input: `src/node/index.ts`, + output: [ + { file: 'dist/node/index.cjs.js', format: 'cjs', sourcemap: true }, + ], + // Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash') + external: [], + watch: { + include: 'src/node/**', + }, + plugins: [ + // Allow json resolution + json(), + // Compile TypeScript files + typescript({ useTsconfigDeclarationDir: true }), + // Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs) + commonjs(), + // Allow node_modules resolution, so you can use 'external' to control + // which external modules to include in the bundle + // https://github.com/rollup/rollup-plugin-node-resolve#usage + resolve({ + extensions: ['.ts', '.js', '.json'], + }), + + // Resolve source maps to the original source + sourceMaps(), + ], + }, +] diff --git a/extensions/huggingface-extension/scripts/install_deps.py b/extensions/huggingface-extension/scripts/install_deps.py new file mode 100644 index 000000000..2dfabed07 --- /dev/null +++ b/extensions/huggingface-extension/scripts/install_deps.py @@ -0,0 +1,14 @@ +import subprocess +import sys + +deps = [ + 'numpy~=1.24.4', + 'sentencepiece~=0.1.98', + 'transformers>=4.35.2,<5.0.0', + 'gguf>=0.1.0', + 'protobuf>=4.21.0,<5.0.0', + 'torch~=2.1.1', + 'packaging>=20.0', + 'tiktoken~=0.5.0' +] +subprocess.check_call([sys.executable, '-m', 'pip', 'install', '--upgrade', '--force-reinstall', *deps]) diff --git a/extensions/huggingface-extension/scripts/version.txt b/extensions/huggingface-extension/scripts/version.txt new file mode 100644 index 000000000..f743d6c4a --- /dev/null +++ b/extensions/huggingface-extension/scripts/version.txt @@ -0,0 +1 @@ +b2106 \ No newline at end of file diff --git a/extensions/huggingface-extension/src/@types/global.d.ts b/extensions/huggingface-extension/src/@types/global.d.ts new file mode 100644 index 000000000..495ecf00e --- /dev/null +++ b/extensions/huggingface-extension/src/@types/global.d.ts @@ -0,0 +1,2 @@ +declare const EXTENSION_NAME: string +declare const NODE_MODULE_PATH: string diff --git a/extensions/huggingface-extension/src/index.ts b/extensions/huggingface-extension/src/index.ts new file mode 100644 index 000000000..d8f755080 --- /dev/null +++ b/extensions/huggingface-extension/src/index.ts @@ -0,0 +1,396 @@ +import { + fs, + downloadFile, + abortDownload, + joinPath, + HuggingFaceExtension, + HuggingFaceRepoData, + executeOnMain, + Quantization, + Model, + InferenceEngine, + getJanDataFolderPath, + events, + DownloadEvent, + log, +} from '@janhq/core' +import { ggufMetadata } from 'hyllama' + +declare global { + interface Window { + electronAPI?: any + } +} + +/** + * A extension for models + */ +export default class JanHuggingFaceExtension extends HuggingFaceExtension { + private static readonly _safetensorsRegexs = [ + /model\.safetensors$/, + /model-[0-9]+-of-[0-9]+\.safetensors$/, + ] + private static readonly _pytorchRegexs = [ + /pytorch_model\.bin$/, + /consolidated\.[0-9]+\.pth$/, + /pytorch_model-[0-9]+-of-[0-9]+\.bin$/, + /.*\.pt$/, + ] + interrupted = false + + /** + * Called when the extension is loaded. + * @override + */ + onLoad() {} + + /** + * Called when the extension is unloaded. + * @override + */ + onUnload(): void {} + + private getFileList(repoData: HuggingFaceRepoData): string[] { + // SafeTensors first, if not, then PyTorch + const modelFiles = repoData.siblings + .map((file) => file.rfilename) + .filter((file) => + JanHuggingFaceExtension._safetensorsRegexs.some((regex) => + regex.test(file) + ) + ) + if (modelFiles.length === 0) { + repoData.siblings.forEach((file) => { + if ( + JanHuggingFaceExtension._pytorchRegexs.some((regex) => + regex.test(file.rfilename) + ) + ) { + modelFiles.push(file.rfilename) + } + }) + } + + const vocabFiles = [ + 'tokenizer.model', + 'vocab.json', + 'tokenizer.json', + ].filter((file) => + repoData.siblings.some((sibling) => sibling.rfilename === file) + ) + + const etcFiles = repoData.siblings + .map((file) => file.rfilename) + .filter( + (file) => + (file.endsWith('.json') && !vocabFiles.includes(file)) || + file.endsWith('.txt') || + file.endsWith('.py') || + file.endsWith('.tiktoken') + ) + + return [...modelFiles, ...vocabFiles, ...etcFiles] + } + + private async getModelDirPath(repoID: string): Promise { + const modelName = repoID.split('/').slice(1).join('/') + return joinPath([await getJanDataFolderPath(), 'models', modelName]) + } + private async getConvertedModelPath(repoID: string): Promise { + const modelName = repoID.split('/').slice(1).join('/') + const modelDirPath = await this.getModelDirPath(repoID) + return joinPath([modelDirPath, modelName + '.gguf']) + } + private async getQuantizedModelPath( + repoID: string, + quantization: Quantization + ): Promise { + const modelName = repoID.split('/').slice(1).join('/') + const modelDirPath = await this.getModelDirPath(repoID) + return joinPath([ + modelDirPath, + modelName + `-${quantization.toLowerCase()}.gguf`, + ]) + } + private getCtxLength(config: { + max_sequence_length?: number + max_position_embeddings?: number + n_ctx?: number + }): number { + if (config.max_sequence_length) return config.max_sequence_length + if (config.max_position_embeddings) return config.max_position_embeddings + if (config.n_ctx) return config.n_ctx + return 4096 + } + + /** + * Downloads a Hugging Face model. + * @param repoID - The repo ID of the model to convert. + * @param repoData - The repo data of the model to convert. + * @param network - Optional object to specify proxy/whether to ignore SSL certificates. + * @returns A promise that resolves when the download is complete. + */ + async downloadModelFiles( + repoID: string, + repoData: HuggingFaceRepoData, + network?: { ignoreSSL?: boolean; proxy?: string } + ): Promise { + if (this.interrupted) return + const modelDirPath = await this.getModelDirPath(repoID) + if (!(await fs.existsSync(modelDirPath))) await fs.mkdirSync(modelDirPath) + const files = this.getFileList(repoData) + const filePaths: string[] = [] + + for (const file of files) { + const filePath = file + const localPath = await joinPath([modelDirPath, filePath]) + const url = `https://huggingface.co/${repoID}/resolve/main/${filePath}` + + if (this.interrupted) return + if (!(await fs.existsSync(localPath))) { + downloadFile(url, localPath, network) + filePaths.push(filePath) + } + } + + await new Promise((resolve, reject) => { + if (filePaths.length === 0) resolve() + const onDownloadSuccess = async ({ fileName }: { fileName: string }) => { + if (filePaths.includes(fileName)) { + filePaths.splice(filePaths.indexOf(fileName), 1) + if (filePaths.length === 0) { + events.off(DownloadEvent.onFileDownloadSuccess, onDownloadSuccess) + events.off(DownloadEvent.onFileDownloadError, onDownloadError) + resolve() + } + } + } + + const onDownloadError = async ({ + fileName, + error, + }: { + fileName: string + error: Error + }) => { + if (filePaths.includes(fileName)) { + this.cancelConvert(repoID, repoData) + events.off(DownloadEvent.onFileDownloadSuccess, onDownloadSuccess) + events.off(DownloadEvent.onFileDownloadError, onDownloadError) + reject(error) + } + } + + events.on(DownloadEvent.onFileDownloadSuccess, onDownloadSuccess) + events.on(DownloadEvent.onFileDownloadError, onDownloadError) + }) + } + + /** + * Converts a Hugging Face model to GGUF. + * @param repoID - The repo ID of the model to convert. + * @returns A promise that resolves when the conversion is complete. + */ + async convert(repoID: string): Promise { + if (this.interrupted) return + const modelDirPath = await this.getModelDirPath(repoID) + const modelOutPath = await this.getConvertedModelPath(repoID) + if (!(await fs.existsSync(modelDirPath))) { + throw new Error('Model dir not found') + } + if (await fs.existsSync(modelOutPath)) return + + await executeOnMain(NODE_MODULE_PATH, 'installDeps') + if (this.interrupted) return + + try { + await executeOnMain( + NODE_MODULE_PATH, + 'convertHf', + modelDirPath, + modelOutPath + '.temp' + ) + } catch (err) { + log(`[Conversion]::Debug: Error using hf-to-gguf.py, trying convert.py`) + + let ctx = 4096 + try { + const config = await fs.readFileSync( + await joinPath([modelDirPath, 'config.json']), + 'utf8' + ) + const configParsed = JSON.parse(config) + ctx = this.getCtxLength(configParsed) + configParsed.max_sequence_length = ctx + await fs.writeFileSync( + await joinPath([modelDirPath, 'config.json']), + JSON.stringify(configParsed, null, 2) + ) + } catch (err) { + log(`${err}`) + // ignore missing config.json + } + + const bpe = await fs.existsSync( + await joinPath([modelDirPath, 'vocab.json']) + ) + + await executeOnMain( + NODE_MODULE_PATH, + 'convert', + modelDirPath, + modelOutPath + '.temp', + { + ctx, + bpe, + } + ) + } + await executeOnMain( + NODE_MODULE_PATH, + 'renameSync', + modelOutPath + '.temp', + modelOutPath + ) + + for (const file of await fs.readdirSync(modelDirPath)) { + if ( + modelOutPath.endsWith(file) || + (file.endsWith('config.json') && !file.endsWith('_config.json')) + ) + continue + await fs.unlinkSync(await joinPath([modelDirPath, file])) + } + } + + /** + * Quantizes a GGUF model. + * @param repoID - The repo ID of the model to quantize. + * @param quantization - The quantization to use. + * @returns A promise that resolves when the quantization is complete. + */ + async quantize(repoID: string, quantization: Quantization): Promise { + if (this.interrupted) return + const modelDirPath = await this.getModelDirPath(repoID) + const modelOutPath = await this.getQuantizedModelPath(repoID, quantization) + if (!(await fs.existsSync(modelDirPath))) { + throw new Error('Model dir not found') + } + if (await fs.existsSync(modelOutPath)) return + + await executeOnMain( + NODE_MODULE_PATH, + 'quantize', + await this.getConvertedModelPath(repoID), + modelOutPath + '.temp', + quantization + ) + await executeOnMain( + NODE_MODULE_PATH, + 'renameSync', + modelOutPath + '.temp', + modelOutPath + ) + + await fs.unlinkSync(await this.getConvertedModelPath(repoID)) + } + + /** + * Generates Jan model metadata from a Hugging Face model. + * @param repoID - The repo ID of the model to generate metadata for. + * @param repoData - The repo data of the model to generate metadata for. + * @param quantization - The quantization of the model. + * @returns A promise that resolves when the model metadata generation is complete. + */ + async generateMetadata( + repoID: string, + repoData: HuggingFaceRepoData, + quantization: Quantization + ): Promise { + const modelName = repoID.split('/').slice(1).join('/') + const filename = `${modelName}-${quantization.toLowerCase()}.gguf` + const modelDirPath = await this.getModelDirPath(repoID) + const modelPath = await this.getQuantizedModelPath(repoID, quantization) + const modelConfigPath = await joinPath([modelDirPath, 'model.json']) + if (!(await fs.existsSync(modelPath))) { + throw new Error('Model not found') + } + + const size = await executeOnMain(NODE_MODULE_PATH, 'getSize', modelPath) + let ctx = 4096 + try { + const config = await fs.readFileSync( + await joinPath([modelDirPath, 'config.json']), + 'utf8' + ) + ctx = this.getCtxLength(JSON.parse(config)) + fs.unlinkSync(await joinPath([modelDirPath, 'config.json'])) + } catch (err) { + // ignore missing config.json + } + // maybe later, currently it's gonna use too much memory + // const buffer = await fs.readFileSync(quantizedModelPath) + // const ggufData = ggufMetadata(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)) + + const metadata: Model = { + object: 'model', + version: 1, + format: 'gguf', + sources: [ + { + url: `https://huggingface.co/${repoID}`, // i think this is just for download but not sure, + filename, + }, + ], + id: modelName, + name: modelName, + created: Date.now(), + description: `Auto converted from Hugging Face model: ${repoID}`, + settings: { + ctx_len: ctx, + prompt_template: '', + llama_model_path: modelName, + }, + parameters: { + temperature: 0.7, + top_p: 0.95, + stream: true, + max_tokens: 4096, + // stop: [''], seems like we dont really need this..? + frequency_penalty: 0, + presence_penalty: 0, + }, + metadata: { + author: repoData.author, + tags: repoData.tags, + size, + }, + engine: InferenceEngine.nitro, + } + + await fs.writeFileSync(modelConfigPath, JSON.stringify(metadata, null, 2)) + } + + /** + * Cancels the convert of current Hugging Face model. + * @param repoID - The repository ID to cancel. + * @param repoData - The repository data to cancel. + * @returns {Promise} A promise that resolves when the download has been cancelled. + */ + async cancelConvert( + repoID: string, + repoData: HuggingFaceRepoData + ): Promise { + this.interrupted = true + const modelDirPath = await this.getModelDirPath(repoID) + const files = this.getFileList(repoData) + for (const file of files) { + const filePath = file + const localPath = await joinPath([modelDirPath, filePath]) + await abortDownload(localPath) + } + // ;(await fs.existsSync(modelDirPath)) && (await fs.rmdirSync(modelDirPath)) + + executeOnMain(NODE_MODULE_PATH, 'killProcesses') + } +} diff --git a/extensions/huggingface-extension/src/node/index.ts b/extensions/huggingface-extension/src/node/index.ts new file mode 100644 index 000000000..cd36c1ab9 --- /dev/null +++ b/extensions/huggingface-extension/src/node/index.ts @@ -0,0 +1,187 @@ +import { PythonShell } from 'python-shell' +import { spawn, ChildProcess } from 'child_process' +import { resolve as presolve, join as pjoin } from 'path' +import type { Quantization } from '@janhq/core' +import { log } from '@janhq/core/node' +import { statSync } from 'fs' +export { renameSync } from 'fs' + +let pythonShell: PythonShell | undefined = undefined +let quantizeProcess: ChildProcess | undefined = undefined + +export const getSize = (path: string): number => statSync(path).size + +export const killProcesses = () => { + if (pythonShell) { + pythonShell.kill() + pythonShell = undefined + } + if (quantizeProcess) { + quantizeProcess.kill() + quantizeProcess = undefined + } +} + +export const getQuantizeExecutable = (): string => { + let binaryFolder = pjoin(__dirname, '..', 'bin') // Current directory by default + let binaryName = 'quantize' + /** + * The binary folder is different for each platform. + */ + if (process.platform === 'win32') { + binaryFolder = pjoin(binaryFolder, 'win') + binaryName = 'quantize.exe' + } else if (process.platform === 'darwin') { + /** + * For MacOS: mac-arm64 (Silicon), mac-x64 (InteL) + */ + if (process.arch === 'arm64') { + binaryFolder = pjoin(binaryFolder, 'mac-arm64') + } else { + binaryFolder = pjoin(binaryFolder, 'mac-x64') + } + } else { + binaryFolder = pjoin(binaryFolder, 'linux-cpu') + } + return pjoin(binaryFolder, binaryName) +} + +export const installDeps = (): Promise => { + return new Promise((resolve, reject) => { + const _pythonShell = new PythonShell( + presolve(__dirname, '..', 'scripts', 'install_deps.py') + ) + _pythonShell.on('message', (message) => { + log(`[Install Deps]::Debug: ${message}`) + }) + _pythonShell.on('stderr', (stderr) => { + log(`[Install Deps]::Error: ${stderr}`) + }) + _pythonShell.on('error', (err) => { + pythonShell = undefined + log(`[Install Deps]::Error: ${err}`) + reject(err) + }) + _pythonShell.on('close', () => { + const exitCode = _pythonShell.exitCode + pythonShell = undefined + log( + `[Install Deps]::Debug: Deps installation exited with code: ${exitCode}` + ) + exitCode === 0 ? resolve() : reject(exitCode) + }) + }) +} + +export const convertHf = async ( + modelDirPath: string, + outPath: string +): Promise => { + return await new Promise((resolve, reject) => { + const _pythonShell = new PythonShell( + presolve(__dirname, '..', 'scripts', 'convert-hf-to-gguf.py'), + { + args: [modelDirPath, '--outfile', outPath], + } + ) + pythonShell = _pythonShell + _pythonShell.on('message', (message) => { + log(`[Conversion]::Debug: ${message}`) + }) + _pythonShell.on('stderr', (stderr) => { + log(`[Conversion]::Error: ${stderr}`) + }) + _pythonShell.on('error', (err) => { + pythonShell = undefined + log(`[Conversion]::Error: ${err}`) + reject(err) + }) + _pythonShell.on('close', () => { + const exitCode = _pythonShell.exitCode + pythonShell = undefined + if (exitCode !== 0) { + log(`[Conversion]::Debug: Conversion exited with code: ${exitCode}`) + reject(exitCode) + } else { + resolve() + } + }) + }) +} + +export const convert = async ( + modelDirPath: string, + outPath: string, + { ctx, bpe }: { ctx?: number; bpe?: boolean } +): Promise => { + const args = [modelDirPath, '--outfile', outPath] + if (ctx) { + args.push('--ctx') + args.push(ctx.toString()) + } + if (bpe) { + args.push('--vocab-type') + args.push('bpe') + } + return await new Promise((resolve, reject) => { + const _pythonShell = new PythonShell( + presolve(__dirname, '..', 'scripts', 'convert.py'), + { + args, + } + ) + _pythonShell.on('message', (message) => { + log(`[Conversion]::Debug: ${message}`) + }) + _pythonShell.on('stderr', (stderr) => { + log(`[Conversion]::Error: ${stderr}`) + }) + _pythonShell.on('error', (err) => { + pythonShell = undefined + log(`[Conversion]::Error: ${err}`) + reject(err) + }) + _pythonShell.on('close', () => { + const exitCode = _pythonShell.exitCode + pythonShell = undefined + if (exitCode !== 0) { + log(`[Conversion]::Debug: Conversion exited with code: ${exitCode}`) + reject(exitCode) + } else { + resolve() + } + }) + }) +} + +export const quantize = async ( + modelPath: string, + outPath: string, + quantization: Quantization +): Promise => { + return await new Promise((resolve, reject) => { + const quantizeExecutable = getQuantizeExecutable() + const _quantizeProcess = spawn(quantizeExecutable, [ + modelPath, + outPath, + quantization, + ]) + quantizeProcess = _quantizeProcess + + _quantizeProcess.stdout?.on('data', (data) => { + log(`[Quantization]::Debug: ${data}`) + }) + _quantizeProcess.stderr?.on('data', (data) => { + log(`[Quantization]::Error: ${data}`) + }) + + _quantizeProcess.on('close', (code) => { + if (code !== 0) { + log(`[Quantization]::Debug: Quantization exited with code: ${code}`) + reject(code) + } else { + resolve() + } + }) + }) +} diff --git a/extensions/huggingface-extension/tsconfig.json b/extensions/huggingface-extension/tsconfig.json new file mode 100644 index 000000000..a42f31602 --- /dev/null +++ b/extensions/huggingface-extension/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "target": "es2020", + "module": "ES2020", + "lib": ["es2015", "es2016", "es2017", "dom"], + "strict": true, + "sourceMap": true, + "declaration": true, + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "declarationDir": "dist/types", + "outDir": "dist", + "importHelpers": true, + "typeRoots": ["node_modules/@types"], + "resolveJsonModule": true, + }, + "include": ["src"], +} diff --git a/extensions/inference-nitro-extension/bin/version.txt b/extensions/inference-nitro-extension/bin/version.txt index 0b9c01996..e4737652c 100644 --- a/extensions/inference-nitro-extension/bin/version.txt +++ b/extensions/inference-nitro-extension/bin/version.txt @@ -1 +1 @@ -0.3.12 +0.3.13 diff --git a/extensions/inference-nitro-extension/src/node/accelerator.ts b/extensions/inference-nitro-extension/src/node/accelerator.ts index 972f88681..1ffdbc5bd 100644 --- a/extensions/inference-nitro-extension/src/node/accelerator.ts +++ b/extensions/inference-nitro-extension/src/node/accelerator.ts @@ -23,10 +23,7 @@ const DEFALT_SETTINGS = { gpus_in_use: [], is_initial: true, // TODO: This needs to be set based on user toggle in settings - vulkan: { - enabled: true, - gpu_in_use: '1', - }, + vulkan: false } /** @@ -152,7 +149,7 @@ export function updateCudaExistence( data['cuda'].exist = cudaExists data['cuda'].version = cudaVersion - console.log(data['is_initial'], data['gpus_in_use']) + console.debug(data['is_initial'], data['gpus_in_use']) if (cudaExists && data['is_initial'] && data['gpus_in_use'].length > 0) { data.run_mode = 'gpu' } diff --git a/extensions/inference-nitro-extension/src/node/execute.ts b/extensions/inference-nitro-extension/src/node/execute.ts index 08baba0d5..f9a668507 100644 --- a/extensions/inference-nitro-extension/src/node/execute.ts +++ b/extensions/inference-nitro-extension/src/node/execute.ts @@ -67,7 +67,7 @@ export const executableNitroFile = (): NitroExecutableOptions => { if (gpuInfo['vulkan'] === true) { binaryFolder = path.join(__dirname, '..', 'bin') - binaryFolder = path.join(binaryFolder, 'win-vulkan') + binaryFolder = path.join(binaryFolder, 'linux-vulkan') vkVisibleDevices = gpuInfo['gpus_in_use'].toString() } } diff --git a/extensions/model-extension/src/index.ts b/extensions/model-extension/src/index.ts index 926e65ee5..fb1f26885 100644 --- a/extensions/model-extension/src/index.ts +++ b/extensions/model-extension/src/index.ts @@ -13,6 +13,10 @@ import { DownloadRoute, ModelEvent, DownloadState, + OptionType, + ImportingModel, + LocalImportModelEvent, + baseName, } from '@janhq/core' import { extractFileName } from './helpers/path' @@ -158,18 +162,18 @@ export default class JanModelExtension extends ModelExtension { /** * Cancels the download of a specific machine learning model. + * * @param {string} modelId - The ID of the model whose download is to be cancelled. * @returns {Promise} A promise that resolves when the download has been cancelled. */ async cancelModelDownload(modelId: string): Promise { - const model = await this.getConfiguredModels() - return abortDownload( - await joinPath([JanModelExtension._homeDir, modelId, modelId]) - ).then(async () => { - fs.unlinkSync( - await joinPath([JanModelExtension._homeDir, modelId, modelId]) - ) - }) + const path = await joinPath([JanModelExtension._homeDir, modelId, modelId]) + try { + await abortDownload(path) + await fs.unlinkSync(path) + } catch (e) { + console.error(e) + } } /** @@ -180,6 +184,20 @@ export default class JanModelExtension extends ModelExtension { async deleteModel(modelId: string): Promise { try { const dirPath = await joinPath([JanModelExtension._homeDir, modelId]) + const jsonFilePath = await joinPath([ + dirPath, + JanModelExtension._modelMetadataFileName, + ]) + const modelInfo = JSON.parse( + await this.readModelMetadata(jsonFilePath) + ) as Model + + const isUserImportModel = + modelInfo.metadata?.author?.toLowerCase() === 'user' + if (isUserImportModel) { + // just delete the folder + return fs.rmdirSync(dirPath) + } // remove all files under dirPath except model.json const files = await fs.readdirSync(dirPath) @@ -389,7 +407,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 +473,189 @@ export default class JanModelExtension extends ModelExtension { ) } } + + private async importModelSymlink( + modelBinaryPath: string, + modelFolderName: string, + modelFolderPath: string + ): Promise { + const fileStats = await fs.fileStat(modelBinaryPath, true) + const binaryFileSize = fileStats.size + + // Just need to generate model.json there + const defaultModel = (await this.getDefaultModel()) as Model + if (!defaultModel) { + console.error('Unable to find default model') + return + } + + const binaryFileName = await baseName(modelBinaryPath) + + const model: Model = { + ...defaultModel, + id: modelFolderName, + name: modelFolderName, + sources: [ + { + url: modelBinaryPath, + filename: binaryFileName, + }, + ], + settings: { + ...defaultModel.settings, + llama_model_path: binaryFileName, + }, + created: Date.now(), + description: '', + metadata: { + size: binaryFileSize, + author: 'User', + tags: [], + }, + } + + const modelFilePath = await joinPath([ + modelFolderPath, + JanModelExtension._modelMetadataFileName, + ]) + + await fs.writeFileSync(modelFilePath, JSON.stringify(model, null, 2)) + + return model + } + + async updateModelInfo(modelInfo: Partial): Promise { + const modelId = modelInfo.id + if (modelInfo.id == null) throw new Error('Model ID is required') + + const janDataFolderPath = await getJanDataFolderPath() + const jsonFilePath = await joinPath([ + janDataFolderPath, + 'models', + modelId, + JanModelExtension._modelMetadataFileName, + ]) + const model = JSON.parse( + await this.readModelMetadata(jsonFilePath) + ) as Model + + const updatedModel: Model = { + ...model, + ...modelInfo, + metadata: { + ...model.metadata, + tags: modelInfo.metadata?.tags ?? [], + }, + } + + await fs.writeFileSync(jsonFilePath, JSON.stringify(updatedModel, null, 2)) + return updatedModel + } + + private async importModel( + model: ImportingModel, + optionType: OptionType + ): Promise { + const binaryName = (await baseName(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 = await baseName(modelFolderPath) + const modelBinaryFile = binaryName.endsWith( + JanModelExtension._supportedModelFormat + ) + ? binaryName + : `${binaryName}${JanModelExtension._supportedModelFormat}` + + const binaryPath = await joinPath([modelFolderPath, modelBinaryFile]) + + if (optionType === 'SYMLINK') { + return this.importModelSymlink( + model.path, + uniqueFolderName, + modelFolderPath + ) + } + + const srcStat = await fs.fileStat(model.path, true) + + // interval getting the file size to calculate the percentage + const interval = setInterval(async () => { + const destStats = await fs.fileStat(binaryPath, true) + const percentage = destStats.size / srcStat.size + events.emit(LocalImportModelEvent.onLocalImportModelUpdate, { + ...model, + percentage, + }) + }, 1000) + + await fs.copyFile(model.path, binaryPath) + + clearInterval(interval) + + // generate model json + return this.generateModelMetadata(uniqueFolderName) + } + + private async getModelFolderName( + modelFolderName: string, + count?: number + ): Promise { + const newModelFolderName = count + ? `${modelFolderName}-${count}` + : modelFolderName + + const janDataFolderPath = await getJanDataFolderPath() + const modelFolderPath = await joinPath([ + janDataFolderPath, + 'models', + newModelFolderName, + ]) + + const isFolderExist = await fs.existsSync(modelFolderPath) + if (!isFolderExist) { + return modelFolderPath + } else { + const newCount = (count ?? 0) + 1 + return this.getModelFolderName(modelFolderName, newCount) + } + } + + async importModels( + models: ImportingModel[], + optionType: OptionType + ): Promise { + const importedModels: Model[] = [] + + for (const model of models) { + events.emit(LocalImportModelEvent.onLocalImportModelUpdate, model) + try { + const importedModel = await this.importModel(model, optionType) + events.emit(LocalImportModelEvent.onLocalImportModelSuccess, { + ...model, + modelId: importedModel.id, + }) + importedModels.push(importedModel) + } catch (err) { + events.emit(LocalImportModelEvent.onLocalImportModelFailed, { + ...model, + error: err, + }) + } + } + + events.emit( + LocalImportModelEvent.onLocalImportModelFinished, + importedModels + ) + } } diff --git a/server/middleware/s3.ts b/server/middleware/s3.ts index 28971a42b..3024285a3 100644 --- a/server/middleware/s3.ts +++ b/server/middleware/s3.ts @@ -38,7 +38,7 @@ export const s3 = (req: any, reply: any, done: any) => { reply.status(200).send(result) return } catch (ex) { - console.log(ex) + console.error(ex) } } } diff --git a/uikit/src/button/styles.scss b/uikit/src/button/styles.scss index 003df5b4d..c97bec9e0 100644 --- a/uikit/src/button/styles.scss +++ b/uikit/src/button/styles.scss @@ -5,11 +5,11 @@ @apply disabled:pointer-events-none disabled:bg-zinc-100 disabled:text-zinc-400; &-primary { - @apply bg-primary hover:bg-primary/90 text-white; + @apply bg-blue-600 text-white hover:bg-blue-600/90; } &-secondary-blue { - @apply bg-blue-200 text-blue-600 hover:bg-blue-300/50 dark:hover:bg-blue-200/80; + @apply bg-blue-200 text-blue-600 hover:bg-blue-300/50; } &-danger { @@ -17,7 +17,7 @@ } &-secondary-danger { - @apply bg-red-200 text-red-600 hover:bg-red-300/50 dark:hover:bg-red-200/80; + @apply bg-red-200 text-red-600 hover:bg-red-300/50; } &-outline { @@ -66,7 +66,7 @@ [type='reset'], [type='submit'] { &.btn-primary { - @apply bg-primary hover:bg-primary/90; + @apply bg-blue-600 hover:bg-blue-600/90; @apply disabled:pointer-events-none disabled:bg-zinc-100 disabled:text-zinc-400; } &.btn-secondary { diff --git a/uikit/src/checkbox/styles.scss b/uikit/src/checkbox/styles.scss index 33610f837..cf35ed5ca 100644 --- a/uikit/src/checkbox/styles.scss +++ b/uikit/src/checkbox/styles.scss @@ -1,5 +1,5 @@ .checkbox { - @apply border-border data-[state=checked]:bg-primary h-5 w-5 flex-shrink-0 rounded-md border data-[state=checked]:text-white; + @apply border-border h-5 w-5 flex-shrink-0 rounded-md border data-[state=checked]:bg-blue-600 data-[state=checked]:text-white; &--icon { @apply h-4 w-4; diff --git a/uikit/src/circular-progress/styles.scss b/uikit/src/circular-progress/styles.scss new file mode 100644 index 000000000..093cd435f --- /dev/null +++ b/uikit/src/circular-progress/styles.scss @@ -0,0 +1,66 @@ +/* + * react-circular-progressbar styles + * All of the styles in this file are configurable! + */ + +.CircularProgressbar { + /* + * This fixes an issue where the CircularProgressbar svg has + * 0 width inside a "display: flex" container, and thus not visible. + */ + width: 100%; + /* + * This fixes a centering issue with CircularProgressbarWithChildren: + * https://github.com/kevinsqi/react-circular-progressbar/issues/94 + */ + vertical-align: middle; +} + +.CircularProgressbar .CircularProgressbar-path { + stroke: #3e98c7; + stroke-linecap: round; + transition: stroke-dashoffset 0.5s ease 0s; +} + +.CircularProgressbar .CircularProgressbar-trail { + stroke: #d6d6d6; + /* Used when trail is not full diameter, i.e. when props.circleRatio is set */ + stroke-linecap: round; +} + +.CircularProgressbar .CircularProgressbar-text { + fill: #3e98c7; + font-size: 20px; + dominant-baseline: middle; + text-anchor: middle; +} + +.CircularProgressbar .CircularProgressbar-background { + fill: #d6d6d6; +} + +/* + * Sample background styles. Use these with e.g.: + * + * + */ +.CircularProgressbar.CircularProgressbar-inverted + .CircularProgressbar-background { + fill: #3e98c7; +} + +.CircularProgressbar.CircularProgressbar-inverted .CircularProgressbar-text { + fill: #fff; +} + +.CircularProgressbar.CircularProgressbar-inverted .CircularProgressbar-path { + stroke: #fff; +} + +.CircularProgressbar.CircularProgressbar-inverted .CircularProgressbar-trail { + stroke: transparent; +} diff --git a/uikit/src/input/styles.scss b/uikit/src/input/styles.scss index e649f494d..51efd8e57 100644 --- a/uikit/src/input/styles.scss +++ b/uikit/src/input/styles.scss @@ -1,6 +1,6 @@ .input { @apply border-border placeholder:text-muted-foreground flex h-9 w-full rounded-lg border bg-transparent px-3 py-1 transition-colors; - @apply disabled:text-muted-foreground disabled:cursor-not-allowed disabled:bg-zinc-100 disabled:dark:bg-zinc-800 disabled:dark:text-zinc-600; + @apply disabled:text-muted-foreground disabled:cursor-not-allowed disabled:bg-zinc-100; @apply focus-within:outline-none focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1; @apply file:border-0 file:bg-transparent file:font-medium; } diff --git a/uikit/src/main.scss b/uikit/src/main.scss index c1326ba19..f3294e12e 100644 --- a/uikit/src/main.scss +++ b/uikit/src/main.scss @@ -17,6 +17,7 @@ @import './select/styles.scss'; @import './slider/styles.scss'; @import './checkbox/styles.scss'; +@import './circular-progress/styles.scss'; .animate-spin { animation: spin 1s linear infinite; diff --git a/uikit/src/modal/index.tsx b/uikit/src/modal/index.tsx index c41909843..1c0586637 100644 --- a/uikit/src/modal/index.tsx +++ b/uikit/src/modal/index.tsx @@ -19,7 +19,7 @@ const ModalOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( )) diff --git a/uikit/src/progress/styles.scss b/uikit/src/progress/styles.scss index 0b7078f48..1a8483c47 100644 --- a/uikit/src/progress/styles.scss +++ b/uikit/src/progress/styles.scss @@ -1,7 +1,7 @@ .progress { - @apply bg-secondary relative h-4 w-full overflow-hidden rounded-full; + @apply relative h-4 w-full overflow-hidden rounded-full bg-gray-100; &-indicator { - @apply bg-primary h-full w-full flex-1 transition-all; + @apply h-full w-full flex-1 bg-blue-600 transition-all; } } diff --git a/uikit/src/select/styles.scss b/uikit/src/select/styles.scss index 90485723a..99db49766 100644 --- a/uikit/src/select/styles.scss +++ b/uikit/src/select/styles.scss @@ -1,6 +1,6 @@ .select { @apply placeholder:text-muted-foreground border-border flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm disabled:cursor-not-allowed [&>span]:line-clamp-1; - @apply disabled:text-muted-foreground disabled:cursor-not-allowed disabled:bg-zinc-100 disabled:dark:bg-zinc-800 disabled:dark:text-zinc-600; + @apply disabled:text-muted-foreground disabled:cursor-not-allowed disabled:bg-zinc-100; @apply focus-within:outline-none focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1; &-caret { diff --git a/uikit/src/slider/styles.scss b/uikit/src/slider/styles.scss index 718972efb..465392419 100644 --- a/uikit/src/slider/styles.scss +++ b/uikit/src/slider/styles.scss @@ -2,7 +2,7 @@ @apply relative flex w-full touch-none select-none items-center; &-track { - @apply relative h-1.5 w-full grow overflow-hidden rounded-full bg-gray-200 dark:bg-gray-800; + @apply relative h-1.5 w-full grow overflow-hidden rounded-full bg-gray-200; [data-disabled] { @apply cursor-not-allowed opacity-50; } @@ -13,6 +13,6 @@ } &-thumb { - @apply border-primary/50 bg-background focus-visible:ring-ring block h-4 w-4 rounded-full border shadow transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50; + @apply bg-background focus-visible:ring-ring block h-4 w-4 rounded-full border border-blue-600/50 shadow transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50; } } diff --git a/uikit/src/switch/styles.scss b/uikit/src/switch/styles.scss index c8a12cdf5..57fa128ba 100644 --- a/uikit/src/switch/styles.scss +++ b/uikit/src/switch/styles.scss @@ -1,7 +1,7 @@ .switch { @apply inline-flex h-[20px] w-[36px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent; @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2; - @apply data-[state=checked]:bg-primary data-[state=unchecked]:bg-input; + @apply data-[state=unchecked]:bg-input data-[state=checked]:bg-blue-600; @apply disabled:cursor-not-allowed disabled:opacity-50; &-toggle { diff --git a/uikit/src/tooltip/styles.scss b/uikit/src/tooltip/styles.scss index 8ae645cee..169e081b7 100644 --- a/uikit/src/tooltip/styles.scss +++ b/uikit/src/tooltip/styles.scss @@ -1,6 +1,6 @@ .tooltip { - @apply dark:bg-input dark:text-foreground z-50 overflow-hidden rounded-md bg-gray-950 px-2 py-1.5 text-xs font-medium text-gray-200 shadow-md; + @apply z-50 overflow-hidden rounded-md bg-gray-950 px-2 py-1.5 text-xs font-medium text-gray-200 shadow-md; &-arrow { - @apply dark:fill-input fill-gray-950; + @apply fill-gray-950; } } diff --git a/web/.prettierrc b/web/.prettierrc new file mode 100644 index 000000000..46f1abcb0 --- /dev/null +++ b/web/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": false, + "singleQuote": true, + "quoteProps": "consistent", + "trailingComma": "es5", + "endOfLine": "auto", + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 6c6fc65ab..37bcdf53e 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -15,7 +15,7 @@ export const metadata: Metadata = { export default function RootLayout({ children }: PropsWithChildren) { return ( - +
{children} diff --git a/web/app/page.tsx b/web/app/page.tsx index 92d654528..ab619f061 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,19 +1,21 @@ 'use client' +import { useAtomValue } from 'jotai' + import BaseLayout from '@/containers/Layout' import { MainViewState } from '@/constants/screens' -import { useMainViewState } from '@/hooks/useMainViewState' - import ChatScreen from '@/screens/Chat' import ExploreModelsScreen from '@/screens/ExploreModels' import LocalServerScreen from '@/screens/LocalServer' import SettingsScreen from '@/screens/Settings' +import { mainViewStateAtom } from '@/helpers/atoms/App.atom' + export default function Page() { - const { mainViewState } = useMainViewState() + const mainViewState = useAtomValue(mainViewStateAtom) let children = null switch (mainViewState) { diff --git a/web/containers/CardSidebar/index.tsx b/web/containers/CardSidebar/index.tsx index 89ff60e66..3013360e9 100644 --- a/web/containers/CardSidebar/index.tsx +++ b/web/containers/CardSidebar/index.tsx @@ -38,14 +38,14 @@ export default function CardSidebar({ const [menu, setMenu] = useState(null) const [toggle, setToggle] = useState(null) const activeThread = useAtomValue(activeThreadAtom) - const { onReviewInFinder, onViewJson } = usePath() + const { onRevealInFinder, onViewJson } = usePath() useClickOutside(() => setMore(false), null, [menu, toggle]) return (
@@ -61,7 +61,7 @@ export default function CardSidebar({ if (!children) return setShow(!show) }} - className="flex w-full flex-1 items-center space-x-2 rounded-lg bg-zinc-100 py-2 pr-2 dark:bg-zinc-900" + className="flex w-full flex-1 items-center space-x-2 rounded-lg bg-zinc-100 py-2 pr-2" > setMore(!more)} > @@ -100,7 +100,7 @@ export default function CardSidebar({ title === 'Model' ? 'items-start' : 'items-center' )} onClick={() => { - onReviewInFinder && onReviewInFinder(title) + onRevealInFinder && onRevealInFinder(title) setMore(false) }} > @@ -114,7 +114,7 @@ export default function CardSidebar({ <> {title === 'Model' ? (
- + {openFileTitle()} @@ -122,7 +122,7 @@ export default function CardSidebar({
) : ( - + {openFileTitle()} )} @@ -141,7 +141,7 @@ export default function CardSidebar({ /> <>
- + Edit Global Defaults for{' '} diff --git a/web/containers/Checkbox/index.tsx b/web/containers/Checkbox/index.tsx index a545771b6..1ced3e19d 100644 --- a/web/containers/Checkbox/index.tsx +++ b/web/containers/Checkbox/index.tsx @@ -34,12 +34,10 @@ const Checkbox: React.FC = ({ return (
-

- {title} -

+

{title}

- + diff --git a/web/containers/DropdownListSidebar/index.tsx b/web/containers/DropdownListSidebar/index.tsx index 191c7bcbe..dc5ee2605 100644 --- a/web/containers/DropdownListSidebar/index.tsx +++ b/web/containers/DropdownListSidebar/index.tsx @@ -30,7 +30,6 @@ import { MainViewState } from '@/constants/screens' import { useActiveModel } from '@/hooks/useActiveModel' import { useClipboard } from '@/hooks/useClipboard' -import { useMainViewState } from '@/hooks/useMainViewState' import useRecommendedModel from '@/hooks/useRecommendedModel' @@ -41,6 +40,7 @@ import { toGibibytes } from '@/utils/converter' import ModelLabel from '../ModelLabel' import OpenAiKeyInput from '../OpenAiKeyInput' +import { mainViewStateAtom } from '@/helpers/atoms/App.atom' import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' import { @@ -64,11 +64,13 @@ const DropdownListSidebar = ({ const [isTabActive, setIsTabActive] = useState(0) const { stateModel } = useActiveModel() const [serverEnabled, setServerEnabled] = useAtom(serverEnabledAtom) - const { setMainViewState } = useMainViewState() + + const setMainViewState = useSetAtom(mainViewStateAtom) const [loader, setLoader] = useState(0) const { recommendedModel, downloadedModels } = useRecommendedModel() const { updateModelParameter } = useUpdateModelParameters() const clipboard = useClipboard({ timeout: 1000 }) + const [copyId, setCopyId] = useState('') const localModel = downloadedModels.filter( @@ -201,15 +203,14 @@ const DropdownListSidebar = ({ isTabActive === 1 && '[&_.select-scroll-down-button]:hidden' )} > -
-
    +
    +
      {engineOptions.map((name, i) => { return (
    • setIsTabActive(i)} @@ -228,8 +229,7 @@ const DropdownListSidebar = ({ {name} diff --git a/web/containers/GPUDriverPromptModal/index.tsx b/web/containers/GPUDriverPromptModal/index.tsx index bdcf1b2f8..8d11b4efa 100644 --- a/web/containers/GPUDriverPromptModal/index.tsx +++ b/web/containers/GPUDriverPromptModal/index.tsx @@ -60,7 +60,7 @@ const GPUDriverPrompt: React.FC = () => { id="default-checkbox" type="checkbox" onChange={onDoNotShowAgainChange} - className="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-blue-600" + className="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-2 focus:ring-blue-500" /> Don't show again
    diff --git a/web/containers/Layout/BottomBar/DownloadingState/index.tsx b/web/containers/Layout/BottomBar/DownloadingState/index.tsx index dcebacd3c..4c3d596b0 100644 --- a/web/containers/Layout/BottomBar/DownloadingState/index.tsx +++ b/web/containers/Layout/BottomBar/DownloadingState/index.tsx @@ -47,7 +47,7 @@ export default function DownloadingState() { { + const importingModels = useAtomValue(importingModelsAtom) + const setImportModelStage = useSetAtom(setImportModelStageAtom) + + const isImportingModels = + importingModels.filter((m) => m.status === 'IMPORTING').length > 0 + + const finishedImportModelCount = importingModels.filter( + (model) => model.status === 'IMPORTED' || model.status === 'FAILED' + ).length + + let transferredSize = 0 + importingModels.forEach((model) => { + transferredSize += (model.percentage ?? 0) * 100 * model.size + }) + + const totalSize = importingModels.reduce((acc, model) => acc + model.size, 0) + + const progress = totalSize === 0 ? 0 : transferredSize / totalSize + + const onClick = useCallback(() => { + setImportModelStage('IMPORTING_MODEL') + }, [setImportModelStage]) + + return ( + + {isImportingModels ? ( +
    +

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

    + +
    + + + {progress.toFixed(2)}% + +
    +
    + ) : null} +
    + ) +} + +export default ImportingModelState diff --git a/web/containers/Layout/BottomBar/SystemMonitor/TableActiveModel/index.tsx b/web/containers/Layout/BottomBar/SystemMonitor/TableActiveModel/index.tsx index a73ec687f..8bcccdba2 100644 --- a/web/containers/Layout/BottomBar/SystemMonitor/TableActiveModel/index.tsx +++ b/web/containers/Layout/BottomBar/SystemMonitor/TableActiveModel/index.tsx @@ -25,8 +25,8 @@ const TableActiveModel = () => { const [serverEnabled, setServerEnabled] = useAtom(serverEnabledAtom) return ( -
    -
    +
    +
    diff --git a/web/containers/Layout/BottomBar/SystemMonitor/index.tsx b/web/containers/Layout/BottomBar/SystemMonitor/index.tsx index 5b7853698..989ae7777 100644 --- a/web/containers/Layout/BottomBar/SystemMonitor/index.tsx +++ b/web/containers/Layout/BottomBar/SystemMonitor/index.tsx @@ -57,23 +57,12 @@ const SystemMonitor = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - const calculateUtilization = () => { - let sum = 0 - const util = gpus.map((x) => { - return Number(x['utilization']) - }) - util.forEach((num) => { - sum += num - }) - return sum - } - return (
    { @@ -88,29 +77,29 @@ const SystemMonitor = () => {
    -
    -
    Running Models
    -
    +
    +
    Running Models
    +
    {showFullScreen ? ( setShowFullScreen(!showFullScreen)} /> ) : ( setShowFullScreen(!showFullScreen)} /> )} { setSystemMonitorCollapse(false) setShowFullScreen(false) @@ -118,10 +107,10 @@ const SystemMonitor = () => { />
    -
    +
    -
    -
    +
    +
    CPU
    @@ -130,11 +119,12 @@ const SystemMonitor = () => {
    -
    -
    +
    +
    Memory
    - - {toGibibytes(usedRam)} of {toGibibytes(totalRam)} used + + {toGibibytes(usedRam, { hideUnit: true })}/ + {toGibibytes(totalRam, { hideUnit: true })} GB
    @@ -148,30 +138,29 @@ const SystemMonitor = () => {
    {gpus.length > 0 && ( -
    -
    GPU
    -
    - - - {calculateUtilization()}% - -
    +
    {gpus.map((gpu, index) => ( -
    - - {gpu.name} - -
    - +
    +
    + + {gpu.name} + +
    +
    + + {gpu.memoryTotal - gpu.memoryFree}/ + {gpu.memoryTotal} + + MB +
    +
    +
    + +
    + + {gpu.utilization}% -
    - {gpu.vram} - MB VRAM -
    ))} diff --git a/web/containers/Layout/BottomBar/index.tsx b/web/containers/Layout/BottomBar/index.tsx index 61e984c99..66c089744 100644 --- a/web/containers/Layout/BottomBar/index.tsx +++ b/web/containers/Layout/BottomBar/index.tsx @@ -15,6 +15,7 @@ import ProgressBar from '@/containers/ProgressBar' import { appDownloadProgress } from '@/containers/Providers/Jotai' +import ImportingModelState from './ImportingModelState' import SystemMonitor from './SystemMonitor' const menuLinks = [ @@ -34,13 +35,14 @@ const BottomBar = () => { const progress = useAtomValue(appDownloadProgress) return ( -
    +
    {progress && progress > 0 ? ( ) : null}
    +
    diff --git a/web/containers/Layout/Ribbon/index.tsx b/web/containers/Layout/Ribbon/index.tsx index 8a3c4a3a3..dc6191f09 100644 --- a/web/containers/Layout/Ribbon/index.tsx +++ b/web/containers/Layout/Ribbon/index.tsx @@ -2,6 +2,7 @@ import { Tooltip, TooltipContent, TooltipTrigger, + TooltipPortal, TooltipArrow, } from '@janhq/uikit' import { motion as m } from 'framer-motion' @@ -20,13 +21,12 @@ import LogoMark from '@/containers/Brand/Logo/Mark' import { MainViewState } from '@/constants/screens' -import { useMainViewState } from '@/hooks/useMainViewState' - +import { mainViewStateAtom } from '@/helpers/atoms/App.atom' import { editMessageAtom } from '@/helpers/atoms/ChatMessage.atom' import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' export default function RibbonNav() { - const { mainViewState, setMainViewState } = useMainViewState() + const [mainViewState, setMainViewState] = useAtom(mainViewStateAtom) const [serverEnabled] = useAtom(serverEnabledAtom) const setEditMessage = useSetAtom(editMessageAtom) @@ -45,7 +45,7 @@ export default function RibbonNav() { size={20} className={twMerge( 'flex-shrink-0 text-muted-foreground', - serverEnabled && 'text-gray-300 dark:text-gray-700' + serverEnabled && 'text-gray-300' )} /> ), @@ -114,29 +114,31 @@ export default function RibbonNav() {
    {isActive && ( )} - {serverEnabled && - primary.state === MainViewState.Thread ? ( - - - Threads are disabled while the server is running - - - - ) : ( - - {primary.name} - - - )} + + {serverEnabled && + primary.state === MainViewState.Thread ? ( + + + Threads are disabled while the server is running + + + + ) : ( + + {primary.name} + + + )} +
    ) @@ -164,15 +166,17 @@ export default function RibbonNav() {
    {isActive && ( )} - - {secondary.name} - - + + + {secondary.name} + + +
    ) diff --git a/web/containers/Layout/TopBar/CommandListDownloadedModel/index.tsx b/web/containers/Layout/TopBar/CommandListDownloadedModel/index.tsx index ac5756e9f..ecec5c758 100644 --- a/web/containers/Layout/TopBar/CommandListDownloadedModel/index.tsx +++ b/web/containers/Layout/TopBar/CommandListDownloadedModel/index.tsx @@ -11,7 +11,7 @@ import { Badge, } from '@janhq/uikit' -import { useAtom, useAtomValue } from 'jotai' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { DatabaseIcon, CpuIcon } from 'lucide-react' import { showSelectModelModalAtom } from '@/containers/Providers/KeyListener' @@ -19,13 +19,13 @@ import { showSelectModelModalAtom } from '@/containers/Providers/KeyListener' import { MainViewState } from '@/constants/screens' import { useActiveModel } from '@/hooks/useActiveModel' -import { useMainViewState } from '@/hooks/useMainViewState' +import { mainViewStateAtom } from '@/helpers/atoms/App.atom' import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom' export default function CommandListDownloadedModel() { - const { setMainViewState } = useMainViewState() + const setMainViewState = useSetAtom(mainViewStateAtom) const downloadedModels = useAtomValue(downloadedModelsAtom) const { activeModel, startModel, stopModel } = useActiveModel() const [serverEnabled] = useAtom(serverEnabledAtom) diff --git a/web/containers/Layout/TopBar/CommandSearch/index.tsx b/web/containers/Layout/TopBar/CommandSearch/index.tsx index 17887763e..d92c7297b 100644 --- a/web/containers/Layout/TopBar/CommandSearch/index.tsx +++ b/web/containers/Layout/TopBar/CommandSearch/index.tsx @@ -10,20 +10,15 @@ import { CommandList, } from '@janhq/uikit' -import { useAtom } from 'jotai' -import { - MessageCircleIcon, - SettingsIcon, - LayoutGridIcon, - MonitorIcon, -} from 'lucide-react' +import { useAtom, useSetAtom } from 'jotai' +import { MessageCircleIcon, SettingsIcon, LayoutGridIcon } from 'lucide-react' import { showCommandSearchModalAtom } from '@/containers/Providers/KeyListener' import ShortCut from '@/containers/Shortcut' import { MainViewState } from '@/constants/screens' -import { useMainViewState } from '@/hooks/useMainViewState' +import { mainViewStateAtom } from '@/helpers/atoms/App.atom' const menus = [ { @@ -48,7 +43,7 @@ const menus = [ ] export default function CommandSearch() { - const { setMainViewState } = useMainViewState() + const setMainViewState = useSetAtom(mainViewStateAtom) const [showCommandSearchModal, setShowCommandSearchModal] = useAtom( showCommandSearchModalAtom ) diff --git a/web/containers/Layout/TopBar/index.tsx b/web/containers/Layout/TopBar/index.tsx index 525cd97de..9686a7fd9 100644 --- a/web/containers/Layout/TopBar/index.tsx +++ b/web/containers/Layout/TopBar/index.tsx @@ -20,7 +20,6 @@ import { MainViewState } from '@/constants/screens' import { useClickOutside } from '@/hooks/useClickOutside' import { useCreateNewThread } from '@/hooks/useCreateNewThread' -import { useMainViewState } from '@/hooks/useMainViewState' import { usePath } from '@/hooks/usePath' @@ -28,18 +27,19 @@ import { showRightSideBarAtom } from '@/screens/Chat/Sidebar' import { openFileTitle } from '@/utils/titleUtils' +import { mainViewStateAtom } from '@/helpers/atoms/App.atom' import { assistantsAtom } from '@/helpers/atoms/Assistant.atom' import { activeThreadAtom } from '@/helpers/atoms/Thread.atom' const TopBar = () => { const activeThread = useAtomValue(activeThreadAtom) - const { mainViewState } = useMainViewState() + const mainViewState = useAtomValue(mainViewStateAtom) const { requestCreateNewThread } = useCreateNewThread() const assistants = useAtomValue(assistantsAtom) const [showRightSideBar, setShowRightSideBar] = useAtom(showRightSideBarAtom) const [showLeftSideBar, setShowLeftSideBar] = useAtom(showLeftSideBarAtom) const showing = useAtomValue(showRightSideBarAtom) - const { onReviewInFinder, onViewJson } = usePath() + const { onRevealInFinder, onViewJson } = usePath() const [more, setMore] = useState(false) const [menu, setMenu] = useState(null) const [toggle, setToggle] = useState(null) @@ -151,7 +151,7 @@ const TopBar = () => {
    { - onReviewInFinder('Thread') + onRevealInFinder('Thread') setMore(false) }} > @@ -159,7 +159,7 @@ const TopBar = () => { size={16} className="text-muted-foreground" /> - + {openFileTitle()}
    @@ -175,7 +175,7 @@ const TopBar = () => { className="mt-0.5 flex-shrink-0 text-muted-foreground" />
    - + Edit Threads Settings @@ -195,7 +195,7 @@ const TopBar = () => {
    { - onReviewInFinder('Model') + onRevealInFinder('Model') setMore(false) }} > @@ -204,7 +204,7 @@ const TopBar = () => { className="text-muted-foreground" />
    - + {openFileTitle()}
    diff --git a/web/containers/Layout/index.tsx b/web/containers/Layout/index.tsx index 77a1fe971..7e3ad38ab 100644 --- a/web/containers/Layout/index.tsx +++ b/web/containers/Layout/index.tsx @@ -4,6 +4,8 @@ import { useTheme } from 'next-themes' import { motion as m } from 'framer-motion' +import { useAtom, useAtomValue } from 'jotai' + import BottomBar from '@/containers/Layout/BottomBar' import RibbonNav from '@/containers/Layout/Ribbon' @@ -11,14 +13,21 @@ import TopBar from '@/containers/Layout/TopBar' import { MainViewState } from '@/constants/screens' -import { useMainViewState } from '@/hooks/useMainViewState' +import { getImportModelStageAtom } from '@/hooks/useImportModel' import { SUCCESS_SET_NEW_DESTINATION } from '@/screens/Settings/Advanced/DataFolder' +import CancelModelImportModal from '@/screens/Settings/CancelModelImportModal' +import EditModelInfoModal from '@/screens/Settings/EditModelInfoModal' +import ImportModelOptionModal from '@/screens/Settings/ImportModelOptionModal' +import ImportingModelModal from '@/screens/Settings/ImportingModelModal' +import SelectingModelModal from '@/screens/Settings/SelectingModelModal' + +import { mainViewStateAtom } from '@/helpers/atoms/App.atom' const BaseLayout = (props: PropsWithChildren) => { const { children } = props - const { mainViewState, setMainViewState } = useMainViewState() - + const [mainViewState, setMainViewState] = useAtom(mainViewStateAtom) + const importModelStage = useAtomValue(getImportModelStageAtom) const { theme, setTheme } = useTheme() useEffect(() => { @@ -54,6 +63,11 @@ const BaseLayout = (props: PropsWithChildren) => {
    + {importModelStage === 'SELECTING_MODEL' && } + {importModelStage === 'MODEL_SELECTED' && } + {importModelStage === 'IMPORTING_MODEL' && } + {importModelStage === 'EDIT_MODEL_INFO' && } + {importModelStage === 'CONFIRM_CANCEL' && }
    ) } diff --git a/web/containers/Loader/index.tsx b/web/containers/Loader/index.tsx index dcf1bec65..cf6604fc8 100644 --- a/web/containers/Loader/index.tsx +++ b/web/containers/Loader/index.tsx @@ -7,12 +7,12 @@ export default function Loader({ description }: Props) {
    -

    {description}

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

    Hugging Face Converter

    +

    Found the repository!

    +
    +
    +

    {repoData.id}

    +

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

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

    ...But you can import it manually!

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

    Error!

    +

    Fetch error

    +
    +

    {fetchError.message}

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

    Hugging Face Convertor

    +

    Type the repository id below

    +
    + { + setRepoID(e.target.value) + }} + onKeyDown={onKeyDown} + /> + + + ) +} diff --git a/web/screens/ExploreModels/index.tsx b/web/screens/ExploreModels/index.tsx index 7002c60b7..e7fd3a9dc 100644 --- a/web/screens/ExploreModels/index.tsx +++ b/web/screens/ExploreModels/index.tsx @@ -1,6 +1,5 @@ -import { useCallback, useState } from 'react' +import { useCallback, useContext, useState } from 'react' -import { openExternalUrl } from '@janhq/core' import { Input, ScrollArea, @@ -10,24 +9,36 @@ import { SelectContent, SelectGroup, SelectItem, + Button, } from '@janhq/uikit' -import { useAtomValue } from 'jotai' -import { SearchIcon } from 'lucide-react' +import { useAtomValue, useSetAtom } from 'jotai' +import { UploadIcon, SearchIcon } from 'lucide-react' + +import { FeatureToggleContext } from '@/context/FeatureToggle' + +import { setImportModelStageAtom } from '@/hooks/useImportModel' import ExploreModelList from './ExploreModelList' +import { HuggingFaceModal } from './HuggingFaceModal' import { configuredModelsAtom, downloadedModelsAtom, } from '@/helpers/atoms/Model.atom' +const sortMenu = ['All Models', 'Recommended', 'Downloaded'] + const ExploreModelsScreen = () => { const configuredModels = useAtomValue(configuredModelsAtom) const downloadedModels = useAtomValue(downloadedModelsAtom) const [searchValue, setsearchValue] = useState('') const [sortSelected, setSortSelected] = useState('All Models') - const sortMenu = ['All Models', 'Recommended', 'Downloaded'] + + const [showHuggingFaceModal, setShowHuggingFaceModal] = useState(false) + const setImportModelStage = useSetAtom(setImportModelStageAtom) + + const { experimentalFeature } = useContext(FeatureToggleContext) const filteredModels = configuredModels.filter((x) => { if (sortSelected === 'Downloaded') { @@ -45,9 +56,13 @@ const ExploreModelsScreen = () => { } }) - const onHowToImportModelClick = useCallback(() => { - openExternalUrl('https://jan.ai/guides/using-models/import-manually/') - }, []) + const onImportModelClick = useCallback(() => { + setImportModelStage('SELECTING_MODEL') + }, [setImportModelStage]) + + const onHuggingFaceConverterClick = () => { + setShowHuggingFaceModal(true) + } return (
    { >
    +
    { alt="Hub Banner" className="w-full object-cover" /> -
    -
    - - { - setsearchValue(e.target.value) - }} - /> -
    -
    -

    +

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

    + Convert from Hugging Face +

    +
    + )}
    diff --git a/web/screens/LocalServer/index.tsx b/web/screens/LocalServer/index.tsx index f9c2cf719..3a8668770 100644 --- a/web/screens/LocalServer/index.tsx +++ b/web/screens/LocalServer/index.tsx @@ -181,7 +181,7 @@ const LocalServerScreen = () => {
    -

    +

    Server Options

    @@ -231,15 +231,12 @@ const LocalServerScreen = () => {