diff --git a/.github/workflows/jan-electron-build-nightly.yml b/.github/workflows/jan-electron-build-nightly.yml index 60a8a951c..f961ccd6f 100644 --- a/.github/workflows/jan-electron-build-nightly.yml +++ b/.github/workflows/jan-electron-build-nightly.yml @@ -16,7 +16,6 @@ on: jobs: set-public-provider: runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' outputs: public_provider: ${{ steps.set-public-provider.outputs.public_provider }} ref: ${{ steps.set-public-provider.outputs.ref }} @@ -69,9 +68,9 @@ jobs: if: github.event_name == 'schedule' uses: ./.github/workflows/template-noti-discord-and-update-url-readme.yml with: - ref: ${{ needs.set-public-provider.outputs.ref }} + ref: refs/heads/dev build_reason: Nightly - push_to_branch: main + push_to_branch: dev new_version: ${{ needs.get-update-version.outputs.new_version }} noti-discord-manual-and-update-url-readme: @@ -80,7 +79,7 @@ jobs: if: github.event_name == 'workflow_dispatch' && github.event.inputs.public_provider == 'cloudflare-r2' uses: ./.github/workflows/template-noti-discord-and-update-url-readme.yml with: - ref: ${{ needs.set-public-provider.outputs.ref }} + ref: refs/heads/dev build_reason: Manual - push_to_branch: main + push_to_branch: dev new_version: ${{ needs.get-update-version.outputs.new_version }} diff --git a/.github/workflows/jan-electron-build-pre-release.yml b/.github/workflows/jan-electron-build-pre-release.yml index c3580105b..d37cda5ab 100644 --- a/.github/workflows/jan-electron-build-pre-release.yml +++ b/.github/workflows/jan-electron-build-pre-release.yml @@ -46,7 +46,7 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: ./.github/workflows/template-noti-discord-and-update-url-readme.yml with: - ref: refs/heads/main + ref: refs/heads/dev build_reason: Nightly - push_to_branch: main + push_to_branch: dev new_version: ${{ needs.get-update-version.outputs.new_version }} diff --git a/.github/workflows/template-build-linux-x64.yml b/.github/workflows/template-build-linux-x64.yml index f39f2302e..c6d1eac97 100644 --- a/.github/workflows/template-build-linux-x64.yml +++ b/.github/workflows/template-build-linux-x64.yml @@ -58,7 +58,7 @@ jobs: mv /tmp/package.json electron/package.json jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json mv /tmp/package.json web/package.json - jq '.build.publish = [{"provider": "s3", "bucket": "${{ secrets.CLOUDFLARE_R2_BUCKET_NAME }}", "region": "auto", "endpoint": "https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com", "path": "${{ inputs.cloudflare_r2_path }}", "channel": "latest"}]' electron/package.json > /tmp/package.json + jq '.build.publish = [{"provider": "generic", "url": "${{ secrets.CLOUDFLARE_R2_PUBLIC_URL }}", "channel": "latest"}, {"provider": "s3", "bucket": "${{ secrets.CLOUDFLARE_R2_BUCKET_NAME }}", "region": "auto", "endpoint": "https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com", "path": "${{ inputs.cloudflare_r2_path }}", "channel": "latest"}]' electron/package.json > /tmp/package.json mv /tmp/package.json electron/package.json cat electron/package.json diff --git a/.github/workflows/template-build-macos.yml b/.github/workflows/template-build-macos.yml index 8295947af..bc48e6c21 100644 --- a/.github/workflows/template-build-macos.yml +++ b/.github/workflows/template-build-macos.yml @@ -1,4 +1,4 @@ -name: build-linux-x64 +name: build-macos on: workflow_call: inputs: @@ -70,7 +70,7 @@ jobs: jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json mv /tmp/package.json web/package.json - jq '.build.publish = [{"provider": "s3", "bucket": "${{ secrets.CLOUDFLARE_R2_BUCKET_NAME }}", "region": "auto", "endpoint": "https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com", "path": "${{ inputs.cloudflare_r2_path }}", "channel": "latest"}]' electron/package.json > /tmp/package.json + jq '.build.publish = [{"provider": "generic", "url": "${{ secrets.CLOUDFLARE_R2_PUBLIC_URL }}", "channel": "latest"}, {"provider": "s3", "bucket": "${{ secrets.CLOUDFLARE_R2_BUCKET_NAME }}", "region": "auto", "endpoint": "https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com", "path": "${{ inputs.cloudflare_r2_path }}", "channel": "latest"}]' electron/package.json > /tmp/package.json mv /tmp/package.json electron/package.json cat electron/package.json diff --git a/.github/workflows/template-build-windows-x64.yml b/.github/workflows/template-build-windows-x64.yml index 83ca10e95..5d96b3f49 100644 --- a/.github/workflows/template-build-windows-x64.yml +++ b/.github/workflows/template-build-windows-x64.yml @@ -71,7 +71,7 @@ jobs: jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json mv /tmp/package.json web/package.json - jq '.build.publish = [{"provider": "s3", "bucket": "${{ secrets.CLOUDFLARE_R2_BUCKET_NAME }}", "region": "auto", "endpoint": "https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com", "path": "${{ inputs.cloudflare_r2_path }}", "channel": "latest"}]' electron/package.json > /tmp/package.json + jq '.build.publish = [{"provider": "generic", "url": "${{ secrets.CLOUDFLARE_R2_PUBLIC_URL }}", "channel": "latest"}, {"provider": "s3", "bucket": "${{ secrets.CLOUDFLARE_R2_BUCKET_NAME }}", "region": "auto", "endpoint": "https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com", "path": "${{ inputs.cloudflare_r2_path }}", "channel": "latest"}]' electron/package.json > /tmp/package.json mv /tmp/package.json electron/package.json jq '.build.win.sign = "./sign.js"' electron/package.json > /tmp/package.json diff --git a/.gitignore b/.gitignore index dbf94335a..e3e4635fc 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,6 @@ extensions/inference-nitro-extension/bin/*/*.metal extensions/inference-nitro-extension/bin/*/*.exe extensions/inference-nitro-extension/bin/*/*.dll extensions/inference-nitro-extension/bin/*/*.exp -extensions/inference-nitro-extension/bin/*/*.lib \ No newline at end of file +extensions/inference-nitro-extension/bin/*/*.lib +extensions/inference-nitro-extension/bin/saved-* +extensions/inference-nitro-extension/bin/*.tar.gz diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 000000000..0da96d6ba --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx pretty-quick --staged diff --git a/Makefile b/Makefile index 65e21897d..905a68321 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,7 @@ lint: check-file-counts # Testing test: lint yarn build:test + yarn test:unit yarn test # Builds and publishes the app diff --git a/README.md b/README.md index 703171090..faa04e70a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/core/.gitignore b/core/.gitignore index d626d098e..2227931f3 100644 --- a/core/.gitignore +++ b/core/.gitignore @@ -6,7 +6,4 @@ coverage .vscode .idea dist -compiled -.awcache -.rpt2_cache docs diff --git a/core/.prettierrc b/core/.prettierrc new file mode 100644 index 000000000..a4207953b --- /dev/null +++ b/core/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": false, + "singleQuote": true, + "quoteProps": "consistent", + "trailingComma": "es5", + "endOfLine": "auto" +} diff --git a/core/README.md b/core/README.md index 5a7dd3993..925ffaf7b 100644 --- a/core/README.md +++ b/core/README.md @@ -1,348 +1,69 @@ ## @janhq/core -> The module includes functions for communicating with core APIs, registering plugin extensions, and exporting type definitions. +> This module includes functions for communicating with core APIs, registering app extensions, and exporting type definitions. ## Usage ### Import the package ```js -// javascript -const core = require("@janhq/core"); - -// typescript +// Web / extension runtime import * as core from "@janhq/core"; + +// Node runtime +import * as node from "@janhq/core/node"; ``` -### Register Plugin Extensions - -Every plugin must define an `init` function in its main entry file to initialize the plugin and register its extensions with the Jan platform. - -You can `register` any function as a plugin extension using `CoreServiceAPI` below. For example, the `DataService.GetConversations` entry name can be used to register a function that retrieves conversations. - -Once the extension is registered, it can be used by other plugins or components in the Jan platform. For example, a UI component might use the DataService.GetConversations extension to retrieve a list of conversations to display to the user. - -```js -import { RegisterExtensionPoint, DataService } from "@janhq/core"; - -function getConversations() { - // Your logic here -} - -export function init({ register }: { register: RegisterExtensionPoint }) { - register(DataService.GetConversations, getConversations.name, getConversations); -} -``` - -### Interact with Local Data Storage - -The Core API allows you to interact with local data storage. Here are a couple of examples of how you can use it: - -#### Insert Data - -You can use the store.insertOne function to insert data into a specific collection in the local data store. - -```js -import { store } from "@janhq/core"; - -function insertData() { - store.insertOne("conversations", { name: "meow" }); - // Insert a new document with { name: "meow" } into the "conversations" collection. -} -``` - -#### Get Data - -To retrieve data from a collection in the local data store, you can use the `store.findOne` or `store.findMany` function. It allows you to filter and retrieve documents based on specific criteria. - -store.getOne(collectionName, key) retrieves a single document that matches the provided key in the specified collection. -store.getMany(collectionName, selector, sort) retrieves multiple documents that match the provided selector in the specified collection. - -```js -import { store } from "@janhq/core"; - -function getData() { - const selector = { name: "meow" }; - const data = store.findMany("conversations", selector); - // Retrieve documents from the "conversations" collection that match the filter. -} -``` - -#### Update Data - -You can update data in the local store using these functions: - -store.updateOne(collectionName, key, update) updates a single document that matches the provided key in the specified collection. -store.updateMany(collectionName, selector, update) updates multiple documents that match the provided selector in the specified collection. - -```js -function updateData() { - const selector = { name: "meow" }; - const update = { name: "newName" }; - store.updateOne("conversations", selector, update); - // Update a document in the "conversations" collection. -} -``` - -#### Delete Data - -You can delete data from the local data store using these functions: - -store.deleteOne(collectionName, key) deletes a single document that matches the provided key in the specified collection. -store.deleteMany(collectionName, selector) deletes multiple documents that match the provided selector in the specified collection. - -```js -function deleteData() { - const selector = { name: "meow" }; - store.deleteOne("conversations", selector); - // Delete a document from the "conversations" collection. -} -``` - -### Events - -You can subscribe to NewMessageRequest events by defining a function to handle the event and registering it with the events object: - -```js -import { events } from "@janhq/core"; - -function handleMessageRequest(message: NewMessageRequest) { - // Your logic here. For example: - // const response = openai.createChatCompletion({...}) -} -function registerListener() { - events.on(EventName.OnNewMessageRequest, handleMessageRequest); -} -// Register the listener function with the relevant extension points. -export function init({ register }) { - registerListener(); -} -``` - -In this example, we're defining a function called handleMessageRequest that takes a NewMessageRequest object as its argument. We're also defining a function called registerListener that registers the handleMessageRequest function as a listener for NewMessageRequest events using the on method of the events object. - -```js -import { events } from "@janhq/core"; - -function handleMessageRequest(data: NewMessageRequest) { - // Your logic here. For example: - const response = openai.createChatCompletion({...}) - const message: NewMessageResponse = { - ...data, - message: response.data.choices[0].message.content - } - // Now emit event so the app can display in the conversation - events.emit(EventName.OnNewMessageResponse, message) -} -``` - -### Preferences - -To register plugin preferences, you can use the preferences object from the @janhq/core package. Here's an example of how to register and retrieve plugin preferences: - -```js -import { PluginService, preferences } from "@janhq/core"; - -const pluginName = "your-first-plugin"; -const preferenceKey = ""; -const preferenceName = "Your First Preference"; -const preferenceDescription = "This is for example only"; -const defaultValue = ""; - -export function init({ register }: { register: RegisterExtensionPoint }) { - // Register preference update handlers. E.g. update plugin instance with new configuration - register(PluginService.OnPreferencesUpdate, pluginName, onPreferencesUpdate); - - // Register plugin preferences. E.g. Plugin need apiKey to connect to your service - preferences.registerPreferences < - string > - (register, pluginName, preferenceKey, preferenceName, preferenceDescription, defaultValue); -} -``` - -In this example, we're registering preference update handlers and plugin preferences using the preferences object. We're also defining a PluginName constant to use as the name of the plugin. - -To retrieve the values of the registered preferences, we're using the get method of the preferences object and passing in the name of the plugin and the name of the preference. - -```js -import { preferences } from "@janhq/core"; - -const pluginName = "your-first-plugin"; -const preferenceKey = "apiKey"; - -const setup = async () => { - // Retrieve apiKey - const apiKey: string = (await preferences.get(pluginName, preferenceKey)) ?? ""; -}; -``` - -### Access Core API - -To access the Core API in your plugin, you can follow the code examples and explanations provided below. - -##### Import Core API and Store Module - -In your main entry code (e.g., `index.ts`), start by importing the necessary modules and functions from the `@janhq/core` library. - -```js -// index.ts -import * as core from "@janhq/core"; -``` - -#### Perform File Operations - -The Core API also provides functions to perform file operations. Here are a couple of examples: - -#### Download a File - -You can download a file from a specified URL and save it with a given file name using the core.downloadFile function. - -```js -function downloadModel(url: string, fileName: string) { - core.downloadFile(url, fileName); -} -``` - -#### Delete a File - -To delete a file, you can use the core.deleteFile function, providing the path to the file you want to delete. - -```js -function deleteModel(filePath: string) { - core.deleteFile(path); -} -``` - -#### Execute plugin module in main process - -To execute a plugin module in the main process of your application, you can follow the steps outlined below. - -##### Import the `core` Object - -In your main process code (e.g., `index.ts`), start by importing the `core` object from the `@janhq/core` library. - -```js -// index.ts -import * as core from "@janhq/core"; -``` - -##### Define the Module Path - -Specify the path to the plugin module you want to execute. This path should lead to the module file (e.g., module.js) that contains the functions you wish to call. - -```js -// index.ts -const MODULE_PATH = "data-plugin/dist/module.js"; -``` - -##### Define the Function to Execute - -Create a function that will execute a function defined in your plugin module. In the example provided, the function `getConversationMessages` is created to invoke the `getConvMessages` function from the plugin module. - -```js -// index.ts -function getConversationMessages(id: number) { - return core.invokePluginFunc(MODULE_PATH, "getConvMessages", id); -} - -export function init({ register }: { register: RegisterExtensionPoint }) { - register(DataService.GetConversationMessages, getConversationMessages.name, getConversationMessages); -} -``` - -##### Define Your Plugin Module - -In your plugin module (e.g., module.ts), define the logic for the function you wish to execute. In the example, the function getConvMessages is defined with a placeholder comment indicating where your logic should be implemented. - -```js -// module.ts -function getConvMessages(id: number) { - // Your logic here -} - -module.exports = { - getConvMessages, -}; -``` - -## CoreService API - -The `CoreService` type is an exported union type that includes: - -- `StoreService` -- `DataService` -- `InferenceService` -- `ModelManagementService` -- `SystemMonitoringService` -- `PreferenceService` - -## StoreService - -The `StoreService` enum represents available methods for managing the database store. It includes the following methods: - -- `CreateCollection`: Creates a new collection in the data store. -- `DeleteCollection`: Deletes an existing collection from the data store. -- `InsertOne`: Inserts a new value into an existing collection in the data store. -- `UpdateOne`: Updates an existing value in an existing collection in the data store. -- `UpdateMany`: Updates multiple records in a collection in the data store. -- `DeleteOne`: Deletes an existing value from an existing collection in the data store. -- `DeleteMany`: Deletes multiple records in a collection in the data store. -- `FindMany`: Retrieves multiple records from a collection in the data store. -- `FindOne`: Retrieves a single record from a collection in the data store. - -## DataService - -The `DataService` enum represents methods related to managing conversations and messages. It includes the following methods: - -- `GetConversations`: Gets a list of conversations from the data store. -- `CreateConversation`: Creates a new conversation in the data store. -- `DeleteConversation`: Deletes an existing conversation from the data store. -- `CreateMessage`: Creates a new message in an existing conversation in the data store. -- `UpdateMessage`: Updates an existing message in an existing conversation in the data store. -- `GetConversationMessages`: Gets a list of messages for an existing conversation from the data store. - -## InferenceService - -The `InferenceService` enum exports: - -- `InitModel`: Initializes a model for inference. -- `StopModel`: Stops a running inference model. - -## ModelManagementService - -The `ModelManagementService` enum provides methods for managing models: - -- `GetDownloadedModels`: Gets a list of downloaded models. -- `GetAvailableModels`: Gets a list of available models from data store. -- `DeleteModel`: Deletes a downloaded model. -- `DownloadModel`: Downloads a model from the server. -- `SearchModels`: Searches for models on the server. -- `GetConfiguredModels`: Gets configured models from the data store. -- `StoreModel`: Stores a model in the data store. -- `UpdateFinishedDownloadAt`: Updates the finished download time for a model in the data store. -- `GetUnfinishedDownloadModels`: Gets a list of unfinished download models from the data store. -- `GetFinishedDownloadModels`: Gets a list of finished download models from the data store. -- `DeleteDownloadModel`: Deletes a downloaded model from the data store. -- `GetModelById`: Gets a model by its ID from the data store. - -## PreferenceService - -The `PreferenceService` enum provides methods for managing plugin preferences: - -- `ExperimentComponent`: Represents the UI experiment component for a testing function. - -## SystemMonitoringService - -The `SystemMonitoringService` enum includes methods for monitoring system resources: - -- `GetResourcesInfo`: Gets information about system resources. -- `GetCurrentLoad`: Gets the current system load. - -## PluginService - -The `PluginService` enum includes plugin cycle handlers: - -- `OnStart`: Handler for starting. E.g. Create a collection. -- `OnPreferencesUpdate`: Handler for preferences update. E.g. Update instances with new configurations. - -For more detailed information on each of these components, please refer to the source code. +## Build an Extension + +1. Download an extension template, for example, [https://github.com/janhq/extension-template](https://github.com/janhq/extension-template). + +2. Update the source code: + 1. Open `index.ts` in your code editor. + 2. Rename the extension class from `SampleExtension` to your preferred extension name. + 3. Import modules from the core package. + ```ts + import * as core from "@janhq/core"; + ``` + 4. In the `onLoad()` method, add your code: + ```ts + // Example of listening to app events and providing customized inference logic: + import * as core from "@janhq/core"; + + export default class MyExtension extends BaseExtension { + // On extension load + onLoad() { + core.events.on(MessageEvent.OnMessageSent, (data) => MyExtension.inference(data, this)); + } + + // Customized inference logic + private static inference(incomingMessage: MessageRequestData) { + + // Prepare customized message content + const content: ThreadContent = { + type: ContentType.Text, + text: { + value: "I'm Jan Assistant!", + annotations: [], + }, + }; + + // Modify message and send out + const outGoingMessage: ThreadMessage = { + ...incomingMessage, + content + }; + } + } + ``` +3. Build the extension: + 1. Navigate to the extension directory. + 2. Install dependencies. + ```bash + yarn install + ``` + 3. Compile the source code. The following command keeps running in the terminal and rebuilds the extension when you modify the source code. + ```bash + yarn build + ``` + 4. Select the generated .tgz from Jan > Settings > Extension > Manual Installation. \ No newline at end of file diff --git a/core/jest.config.js b/core/jest.config.js new file mode 100644 index 000000000..fb03768fe --- /dev/null +++ b/core/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + moduleNameMapper: { + '@/(.*)': '/src/$1', + }, +} \ No newline at end of file diff --git a/core/package.json b/core/package.json index ce0714c27..437e6d0a6 100644 --- a/core/package.json +++ b/core/package.json @@ -12,16 +12,10 @@ "module": "dist/core.es5.js", "typings": "dist/types/index.d.ts", "files": [ - "dist" + "dist", + "types" ], "author": "Jan ", - "repository": { - "type": "git", - "url": "" - }, - "engines": { - "node": ">=6.0.0" - }, "exports": { ".": "./dist/core.umd.js", "./sdk": "./dist/core.umd.js", @@ -45,66 +39,23 @@ }, "scripts": { "lint": "tslint --project tsconfig.json -t codeFrame 'src/**/*.ts' 'test/**/*.ts'", + "test": "jest", "prebuild": "rimraf dist", "build": "tsc --module commonjs && rollup -c rollup.config.ts", "start": "rollup -c rollup.config.ts -w" }, - "lint-staged": { - "{src,test}/**/*.ts": [ - "prettier --write", - "git add" - ] - }, - "config": { - "commitizen": { - "path": "node_modules/cz-conventional-changelog" - } - }, - "jest": { - "transform": { - ".(ts|tsx)": "ts-jest" - }, - "testEnvironment": "node", - "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", - "moduleFileExtensions": [ - "ts", - "tsx", - "js" - ], - "coveragePathIgnorePatterns": [ - "/node_modules/", - "/test/" - ], - "coverageThreshold": { - "global": { - "branches": 90, - "functions": 95, - "lines": 95, - "statements": 95 - } - }, - "collectCoverageFrom": [ - "src/*.{js,ts}" - ] - }, - "prettier": { - "semi": false, - "singleQuote": true - }, - "commitlint": { - "extends": [ - "@commitlint/config-conventional" - ] - }, "devDependencies": { + "jest": "^25.4.0", + "@types/jest": "^29.5.11", "@types/node": "^12.0.2", + "eslint-plugin-jest": "^23.8.2", "rollup": "^2.38.5", "rollup-plugin-commonjs": "^9.1.8", "rollup-plugin-json": "^3.1.0", "rollup-plugin-node-resolve": "^5.2.0", "rollup-plugin-sourcemaps": "^0.6.3", "rollup-plugin-typescript2": "^0.36.0", - "ts-node": "^7.0.1", + "ts-jest": "^26.1.1", "tslib": "^2.6.2", "typescript": "^5.2.2" } diff --git a/core/src/api/index.ts b/core/src/api/index.ts index 3cf70a105..a3d0361e7 100644 --- a/core/src/api/index.ts +++ b/core/src/api/index.ts @@ -7,12 +7,16 @@ export enum AppRoute { openExternalUrl = 'openExternalUrl', openAppDirectory = 'openAppDirectory', openFileExplore = 'openFileExplorer', + selectDirectory = 'selectDirectory', + getAppConfigurations = 'getAppConfigurations', + updateAppConfiguration = 'updateAppConfiguration', relaunch = 'relaunch', joinPath = 'joinPath', baseName = 'baseName', startServer = 'startServer', stopServer = 'stopServer', - log = 'log' + log = 'log', + logServer = 'logServer', } export enum AppEvent { @@ -55,7 +59,7 @@ export enum FileSystemRoute { } export enum FileManagerRoute { syncFile = 'syncFile', - getUserSpace = 'getUserSpace', + getJanDataFolderPath = 'getJanDataFolderPath', getResourcePath = 'getResourcePath', fileStat = 'fileStat', } diff --git a/core/src/core.ts b/core/src/core.ts index 4480697f6..aa545e10e 100644 --- a/core/src/core.ts +++ b/core/src/core.ts @@ -19,10 +19,12 @@ const executeOnMain: (extension: string, method: string, ...args: any[]) => Prom * Downloads a file from a URL and saves it to the local file system. * @param {string} url - The URL of the file to download. * @param {string} fileName - The name to use for the downloaded file. + * @param {object} network - Optional object to specify proxy/whether to ignore SSL certificates. * @returns {Promise} A promise that resolves when the file is downloaded. */ -const downloadFile: (url: string, fileName: string) => Promise = (url, fileName) => - global.core?.api?.downloadFile(url, fileName) +const downloadFile: (url: string, fileName: string, network?: { proxy?: string, ignoreSSL?: boolean }) => Promise = (url, fileName, network) => { + return global.core?.api?.downloadFile(url, fileName, network) +} /** * Aborts the download of a specific file. @@ -33,10 +35,11 @@ const abortDownload: (fileName: string) => Promise = (fileName) => global.core.api?.abortDownload(fileName) /** - * Gets the user space path. - * @returns {Promise} A Promise that resolves with the user space path. + * Gets Jan's data folder path. + * + * @returns {Promise} A Promise that resolves with Jan's data folder path. */ -const getUserSpace = (): Promise => global.core.api?.getUserSpace() +const getJanDataFolderPath = (): Promise => global.core.api?.getJanDataFolderPath() /** * Opens the file explorer at a specific path. @@ -101,12 +104,12 @@ export { executeOnMain, downloadFile, abortDownload, - getUserSpace, + getJanDataFolderPath, openFileExplorer, getResourcePath, joinPath, openExternalUrl, baseName, log, - FileStat + FileStat, } diff --git a/core/src/events.ts b/core/src/events.ts index 7d3877046..700807b0c 100644 --- a/core/src/events.ts +++ b/core/src/events.ts @@ -1,28 +1,3 @@ -// TODO: refactor EventName to use the events defined in /types -/** - * The `EventName` enumeration contains the names of all the available events in the Jan platform. - */ -export enum EventName { - /** The `OnMessageSent` event is emitted when a message is sent. */ - OnMessageSent = 'OnMessageSent', - /** The `OnMessageResponse` event is emitted when a message is received. */ - OnMessageResponse = 'OnMessageResponse', - /** The `OnMessageUpdate` event is emitted when a message is updated. */ - OnMessageUpdate = 'OnMessageUpdate', - /** The `OnModelInit` event is emitted when a model inits. */ - OnModelInit = 'OnModelInit', - /** The `OnModelReady` event is emitted when a model ready. */ - OnModelReady = 'OnModelReady', - /** The `OnModelFail` event is emitted when a model fails loading. */ - OnModelFail = 'OnModelFail', - /** The `OnModelStop` event is emitted when a model start to stop. */ - OnModelStop = 'OnModelStop', - /** The `OnModelStopped` event is emitted when a model stopped ok. */ - OnModelStopped = 'OnModelStopped', - /** The `OnInferenceStopped` event is emitted when a inference is stopped. */ - OnInferenceStopped = 'OnInferenceStopped', -} - /** * Adds an observer for an event. * diff --git a/core/src/extension.ts b/core/src/extension.ts index fc1031a53..0b7f9b7fc 100644 --- a/core/src/extension.ts +++ b/core/src/extension.ts @@ -1,4 +1,4 @@ -export enum ExtensionType { +export enum ExtensionTypeEnum { Assistant = "assistant", Conversational = "conversational", Inference = "inference", @@ -6,17 +6,22 @@ export enum ExtensionType { SystemMonitoring = "systemMonitoring", } +export interface ExtensionType { + type(): ExtensionTypeEnum | undefined; +} /** * Represents a base extension. * This class should be extended by any class that represents an extension. */ -export abstract class BaseExtension { +export abstract class BaseExtension implements ExtensionType { /** * Returns the type of the extension. * @returns {ExtensionType} The type of the extension * Undefined means its not extending any known extension by the application. */ - abstract type(): ExtensionType | undefined; + type(): ExtensionTypeEnum | undefined { + return undefined; + } /** * Called when the extension is loaded. * Any initialization logic for the extension should be put here. diff --git a/core/src/extensions/assistant.ts b/core/src/extensions/assistant.ts index 96646e374..ba345711a 100644 --- a/core/src/extensions/assistant.ts +++ b/core/src/extensions/assistant.ts @@ -1,12 +1,19 @@ -import { Assistant, AssistantInterface } from '../index' -import { BaseExtension } from '../extension' +import { Assistant, AssistantInterface } from "../index"; +import { BaseExtension, ExtensionTypeEnum } from "../extension"; /** * Assistant extension for managing assistants. * @extends BaseExtension */ export abstract class AssistantExtension extends BaseExtension implements AssistantInterface { - abstract createAssistant(assistant: Assistant): Promise - abstract deleteAssistant(assistant: Assistant): Promise - abstract getAssistants(): Promise + /** + * Assistant extension type. + */ + type(): ExtensionTypeEnum | undefined { + return ExtensionTypeEnum.Assistant; + } + + abstract createAssistant(assistant: Assistant): Promise; + abstract deleteAssistant(assistant: Assistant): Promise; + abstract getAssistants(): Promise; } diff --git a/core/src/extensions/conversational.ts b/core/src/extensions/conversational.ts index 673ea9c11..4319784c3 100644 --- a/core/src/extensions/conversational.ts +++ b/core/src/extensions/conversational.ts @@ -1,5 +1,5 @@ import { Thread, ThreadInterface, ThreadMessage, MessageInterface } from '../index' -import { BaseExtension } from '../extension' +import { BaseExtension, ExtensionTypeEnum } from '../extension' /** * Conversational extension. Persists and retrieves conversations. @@ -10,6 +10,13 @@ export abstract class ConversationalExtension extends BaseExtension implements ThreadInterface, MessageInterface { + /** + * Conversation extension type. + */ + type(): ExtensionTypeEnum | undefined { + return ExtensionTypeEnum.Conversational; + } + abstract getThreads(): Promise abstract saveThread(thread: Thread): Promise abstract deleteThread(threadId: string): Promise diff --git a/core/src/extensions/inference.ts b/core/src/extensions/inference.ts index f3654934a..c551d108f 100644 --- a/core/src/extensions/inference.ts +++ b/core/src/extensions/inference.ts @@ -1,9 +1,16 @@ -import { InferenceInterface, MessageRequest, ThreadMessage } from '../index' -import { BaseExtension } from '../extension' +import { InferenceInterface, MessageRequest, ThreadMessage } from "../index"; +import { BaseExtension, ExtensionTypeEnum } from "../extension"; /** * Inference extension. Start, stop and inference models. */ export abstract class InferenceExtension extends BaseExtension implements InferenceInterface { - abstract inference(data: MessageRequest): Promise + /** + * Inference extension type. + */ + type(): ExtensionTypeEnum | undefined { + return ExtensionTypeEnum.Inference; + } + + abstract inference(data: MessageRequest): Promise; } diff --git a/core/src/extensions/model.ts b/core/src/extensions/model.ts index cac9d9d89..30aa5b6ba 100644 --- a/core/src/extensions/model.ts +++ b/core/src/extensions/model.ts @@ -1,14 +1,24 @@ -import { BaseExtension } from '../extension' -import { Model, ModelInterface } from '../index' +import { BaseExtension, ExtensionTypeEnum } from "../extension"; +import { Model, ModelInterface } from "../index"; /** * Model extension for managing models. */ export abstract class ModelExtension extends BaseExtension implements ModelInterface { - abstract downloadModel(model: Model): Promise - abstract cancelModelDownload(modelId: string): Promise - abstract deleteModel(modelId: string): Promise - abstract saveModel(model: Model): Promise - abstract getDownloadedModels(): Promise - abstract getConfiguredModels(): Promise + /** + * Model extension type. + */ + type(): ExtensionTypeEnum | undefined { + return ExtensionTypeEnum.Model; + } + + abstract downloadModel( + model: Model, + network?: { proxy: string; ignoreSSL?: boolean }, + ): Promise; + abstract cancelModelDownload(modelId: string): Promise; + abstract deleteModel(modelId: string): Promise; + abstract saveModel(model: Model): Promise; + abstract getDownloadedModels(): Promise; + abstract getConfiguredModels(): Promise; } diff --git a/core/src/extensions/monitoring.ts b/core/src/extensions/monitoring.ts index 94f437f86..2de9b9ae5 100644 --- a/core/src/extensions/monitoring.ts +++ b/core/src/extensions/monitoring.ts @@ -1,11 +1,18 @@ -import { BaseExtension } from '../extension' -import { MonitoringInterface } from '../index' +import { BaseExtension, ExtensionTypeEnum } from "../extension"; +import { MonitoringInterface } from "../index"; /** * Monitoring extension for system monitoring. * @extends BaseExtension */ export abstract class MonitoringExtension extends BaseExtension implements MonitoringInterface { - abstract getResourcesInfo(): Promise - abstract getCurrentLoad(): Promise + /** + * Monitoring extension type. + */ + type(): ExtensionTypeEnum | undefined { + return ExtensionTypeEnum.SystemMonitoring; + } + + abstract getResourcesInfo(): Promise; + abstract getCurrentLoad(): Promise; } diff --git a/core/src/node/api/common/builder.ts b/core/src/node/api/common/builder.ts index 3291e4217..14946f415 100644 --- a/core/src/node/api/common/builder.ts +++ b/core/src/node/api/common/builder.ts @@ -2,13 +2,10 @@ import fs from 'fs' import { JanApiRouteConfiguration, RouteConfiguration } from './configuration' import { join } from 'path' import { ContentType, MessageStatus, Model, ThreadMessage } from './../../../index' - -const os = require('os') - -const path = join(os.homedir(), 'jan') +import { getJanDataFolderPath } from '../../utils' export const getBuilder = async (configuration: RouteConfiguration) => { - const directoryPath = join(path, configuration.dirName) + const directoryPath = join(getJanDataFolderPath(), configuration.dirName) try { if (!fs.existsSync(directoryPath)) { console.debug('model folder not found') @@ -72,7 +69,7 @@ export const deleteBuilder = async (configuration: RouteConfiguration, id: strin } } - const directoryPath = join(path, configuration.dirName) + const directoryPath = join(getJanDataFolderPath(), configuration.dirName) try { const data = await retrieveBuilder(configuration, id) if (!data) { @@ -94,7 +91,7 @@ export const deleteBuilder = async (configuration: RouteConfiguration, id: strin } export const getMessages = async (threadId: string): Promise => { - const threadDirPath = join(path, 'threads', threadId) + const threadDirPath = join(getJanDataFolderPath(), 'threads', threadId) const messageFile = 'messages.jsonl' try { const files: string[] = fs.readdirSync(threadDirPath) @@ -155,7 +152,7 @@ export const createThread = async (thread: any) => { created: Date.now(), updated: Date.now(), } - const threadDirPath = join(path, 'threads', updatedThread.id) + const threadDirPath = join(getJanDataFolderPath(), 'threads', updatedThread.id) const threadJsonPath = join(threadDirPath, threadMetadataFileName) if (!fs.existsSync(threadDirPath)) { @@ -189,7 +186,7 @@ export const updateThread = async (threadId: string, thread: any) => { updated: Date.now(), } try { - const threadDirPath = join(path, 'threads', updatedThread.id) + const threadDirPath = join(getJanDataFolderPath(), 'threads', updatedThread.id) const threadJsonPath = join(threadDirPath, threadMetadataFileName) await fs.writeFileSync(threadJsonPath, JSON.stringify(updatedThread, null, 2)) @@ -231,7 +228,7 @@ export const createMessage = async (threadId: string, message: any) => { ], } - const threadDirPath = join(path, 'threads', threadId) + const threadDirPath = join(getJanDataFolderPath(), 'threads', threadId) const threadMessagePath = join(threadDirPath, threadMessagesFileName) if (!fs.existsSync(threadDirPath)) { @@ -246,7 +243,12 @@ export const createMessage = async (threadId: string, message: any) => { } } -export const downloadModel = async (modelId: string) => { +export const downloadModel = async ( + modelId: string, + network?: { proxy?: string; ignoreSSL?: boolean } +) => { + const strictSSL = !network?.ignoreSSL + const proxy = network?.proxy?.startsWith('http') ? network.proxy : undefined const model = await retrieveBuilder(JanApiRouteConfiguration.models, modelId) if (!model || model.object !== 'model') { return { @@ -254,7 +256,7 @@ export const downloadModel = async (modelId: string) => { } } - const directoryPath = join(path, 'models', modelId) + const directoryPath = join(getJanDataFolderPath(), 'models', modelId) if (!fs.existsSync(directoryPath)) { fs.mkdirSync(directoryPath) } @@ -263,7 +265,7 @@ export const downloadModel = async (modelId: string) => { const modelBinaryPath = join(directoryPath, modelId) const request = require('request') - const rq = request(model.source_url) + const rq = request({ url: model.source_url, strictSSL, proxy }) const progress = require('request-progress') progress(rq, {}) .on('progress', function (state: any) { @@ -314,7 +316,8 @@ export const chatCompletions = async (request: any, reply: any) => { reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - Connection: 'keep-alive', + 'Connection': 'keep-alive', + "Access-Control-Allow-Origin": "*" }) const headers: Record = { @@ -345,7 +348,7 @@ const getEngineConfiguration = async (engineId: string) => { if (engineId !== 'openai') { return undefined } - const directoryPath = join(path, 'engines') + const directoryPath = join(getJanDataFolderPath(), 'engines') const filePath = join(directoryPath, `${engineId}.json`) const data = await fs.readFileSync(filePath, 'utf-8') return JSON.parse(data) diff --git a/core/src/node/api/routes/common.ts b/core/src/node/api/routes/common.ts index 184ca131d..a6c65a382 100644 --- a/core/src/node/api/routes/common.ts +++ b/core/src/node/api/routes/common.ts @@ -27,7 +27,7 @@ export const commonRouter = async (app: HttpServer) => { // Download Model Routes app.get(`/models/download/:modelId`, async (request: any) => - downloadModel(request.params.modelId), + downloadModel(request.params.modelId, { ignoreSSL: request.query.ignoreSSL === 'true', proxy: request.query.proxy }), ) // Chat Completion Routes diff --git a/core/src/node/api/routes/download.ts b/core/src/node/api/routes/download.ts index f62ee0258..b4e11f957 100644 --- a/core/src/node/api/routes/download.ts +++ b/core/src/node/api/routes/download.ts @@ -1,56 +1,58 @@ import { DownloadRoute } from '../../../api' import { join } from 'path' -import { userSpacePath } from '../../extension/manager' import { DownloadManager } from '../../download' import { HttpServer } from '../HttpServer' import { createWriteStream } from 'fs' +import { getJanDataFolderPath } from '../../utils' +import { normalizeFilePath } from "../../path"; export const downloadRouter = async (app: HttpServer) => { app.post(`/${DownloadRoute.downloadFile}`, async (req, res) => { - const body = JSON.parse(req.body as any) + const strictSSL = !(req.query.ignoreSSL === "true"); + const proxy = req.query.proxy?.startsWith("http") ? req.query.proxy : undefined; + const body = JSON.parse(req.body as any); const normalizedArgs = body.map((arg: any) => { - if (typeof arg === 'string' && arg.includes('file:/')) { - return join(userSpacePath, arg.replace('file:/', '')) + if (typeof arg === "string") { + return join(getJanDataFolderPath(), normalizeFilePath(arg)); } - return arg - }) + return arg; + }); - const localPath = normalizedArgs[1] - const fileName = localPath.split('/').pop() ?? '' + const localPath = normalizedArgs[1]; + const fileName = localPath.split("/").pop() ?? ""; - const request = require('request') - const progress = require('request-progress') - - const rq = request(normalizedArgs[0]) + const request = require("request"); + const progress = require("request-progress"); + + const rq = request({ url: normalizedArgs[0], strictSSL, proxy }); progress(rq, {}) - .on('progress', function (state: any) { - console.log('download onProgress', state) + .on("progress", function (state: any) { + console.log("download onProgress", state); }) - .on('error', function (err: Error) { - console.log('download onError', err) + .on("error", function (err: Error) { + console.log("download onError", err); }) - .on('end', function () { - console.log('download onEnd') + .on("end", function () { + console.log("download onEnd"); }) - .pipe(createWriteStream(normalizedArgs[1])) + .pipe(createWriteStream(normalizedArgs[1])); - DownloadManager.instance.setRequest(fileName, rq) - }) + DownloadManager.instance.setRequest(fileName, rq); + }); app.post(`/${DownloadRoute.abortDownload}`, async (req, res) => { - const body = JSON.parse(req.body as any) + const body = JSON.parse(req.body as any); const normalizedArgs = body.map((arg: any) => { - if (typeof arg === 'string' && arg.includes('file:/')) { - return join(userSpacePath, arg.replace('file:/', '')) + if (typeof arg === "string") { + return join(getJanDataFolderPath(), normalizeFilePath(arg)); } - return arg - }) + return arg; + }); - const localPath = normalizedArgs[0] - const fileName = localPath.split('/').pop() ?? '' - console.debug('fileName', fileName) - const rq = DownloadManager.instance.networkRequests[fileName] - DownloadManager.instance.networkRequests[fileName] = undefined - rq?.abort() - }) -} + const localPath = normalizedArgs[0]; + const fileName = localPath.split("/").pop() ?? ""; + const rq = DownloadManager.instance.networkRequests[fileName]; + DownloadManager.instance.networkRequests[fileName] = undefined; + rq?.abort(); + }); +}; diff --git a/core/src/node/api/routes/extension.ts b/core/src/node/api/routes/extension.ts index a3a3a2e19..02bc54eb3 100644 --- a/core/src/node/api/routes/extension.ts +++ b/core/src/node/api/routes/extension.ts @@ -1,20 +1,20 @@ import { join, extname } from 'path' import { ExtensionRoute } from '../../../api/index' -import { userSpacePath } from '../../extension/manager' import { ModuleManager } from '../../module' import { getActiveExtensions, installExtensions } from '../../extension/store' import { HttpServer } from '../HttpServer' import { readdirSync } from 'fs' +import { getJanExtensionsPath } from '../../utils' export const extensionRouter = async (app: HttpServer) => { // TODO: Share code between node projects - app.post(`/${ExtensionRoute.getActiveExtensions}`, async (req, res) => { + app.post(`/${ExtensionRoute.getActiveExtensions}`, async (_req, res) => { const activeExtensions = await getActiveExtensions() res.status(200).send(activeExtensions) }) - app.post(`/${ExtensionRoute.baseExtensions}`, async (req, res) => { + app.post(`/${ExtensionRoute.baseExtensions}`, async (_req, res) => { const baseExtensionPath = join(__dirname, '..', '..', '..', 'pre-install') const extensions = readdirSync(baseExtensionPath) .filter((file) => extname(file) === '.tgz') @@ -23,7 +23,7 @@ export const extensionRouter = async (app: HttpServer) => { res.status(200).send(extensions) }) - app.post(`/${ExtensionRoute.installExtension}`, async (req, res) => { + app.post(`/${ExtensionRoute.installExtension}`, async (req) => { const extensions = req.body as any const installed = await installExtensions(JSON.parse(extensions)[0]) return JSON.parse(JSON.stringify(installed)) @@ -32,7 +32,7 @@ export const extensionRouter = async (app: HttpServer) => { app.post(`/${ExtensionRoute.invokeExtensionFunc}`, async (req, res) => { const args = JSON.parse(req.body as any) console.debug(args) - const module = await import(join(userSpacePath, 'extensions', args[0])) + const module = await import(join(getJanExtensionsPath(), args[0])) ModuleManager.instance.setModule(args[0], module) const method = args[1] diff --git a/core/src/node/api/routes/fileManager.ts b/core/src/node/api/routes/fileManager.ts index 04ab1913b..159c23a0c 100644 --- a/core/src/node/api/routes/fileManager.ts +++ b/core/src/node/api/routes/fileManager.ts @@ -4,7 +4,7 @@ import { HttpServer } from '../../index' export const fsRouter = async (app: HttpServer) => { app.post(`/app/${FileManagerRoute.syncFile}`, async (request: any, reply: any) => {}) - app.post(`/app/${FileManagerRoute.getUserSpace}`, async (request: any, reply: any) => {}) + app.post(`/app/${FileManagerRoute.getJanDataFolderPath}`, async (request: any, reply: any) => {}) app.post(`/app/${FileManagerRoute.getResourcePath}`, async (request: any, reply: any) => {}) diff --git a/core/src/node/api/routes/fs.ts b/core/src/node/api/routes/fs.ts index 9c34498b6..5f511af27 100644 --- a/core/src/node/api/routes/fs.ts +++ b/core/src/node/api/routes/fs.ts @@ -1,7 +1,7 @@ import { FileSystemRoute } from '../../../api' import { join } from 'path' import { HttpServer } from '../HttpServer' -import { userSpacePath } from '../../extension/manager' +import { getJanDataFolderPath } from '../../utils' export const fsRouter = async (app: HttpServer) => { const moduleName = 'fs' @@ -14,7 +14,7 @@ export const fsRouter = async (app: HttpServer) => { return mdl[route]( ...body.map((arg: any) => typeof arg === 'string' && arg.includes('file:/') - ? join(userSpacePath, arg.replace('file:/', '')) + ? join(getJanDataFolderPath(), arg.replace('file:/', '')) : arg, ), ) diff --git a/core/src/node/extension/extension.ts b/core/src/node/extension/extension.ts index 070703688..aeb0277c0 100644 --- a/core/src/node/extension/extension.ts +++ b/core/src/node/extension/extension.ts @@ -103,7 +103,7 @@ export default class Extension { const pacote = await import('pacote') await pacote.extract( this.specifier, - join(ExtensionManager.instance.extensionsPath ?? '', this.name ?? ''), + join(ExtensionManager.instance.getExtensionsPath() ?? '', this.name ?? ''), this.installOptions, ) @@ -166,9 +166,9 @@ export default class Extension { * @returns the latest available version if a new version is available or false if not. */ async isUpdateAvailable() { - return import('pacote').then((pacote) => { - if (this.origin) { - return pacote.manifest(this.origin).then((mnf) => { + return import('pacote').then((pacote) => { + if (this.origin) { + return pacote.manifest(this.origin).then((mnf) => { return mnf.version !== this.version ? mnf.version : false }) } @@ -179,8 +179,9 @@ export default class Extension { * Remove extension and refresh renderers. * @returns {Promise} */ - async uninstall() { - const extPath = resolve(ExtensionManager.instance.extensionsPath ?? '', this.name ?? '') + async uninstall(): Promise { + const path = ExtensionManager.instance.getExtensionsPath() + const extPath = resolve(path ?? '', this.name ?? '') await rmdirSync(extPath, { recursive: true }) this.emitUpdate() diff --git a/core/src/node/extension/index.ts b/core/src/node/extension/index.ts index e685fc0ae..ed8544773 100644 --- a/core/src/node/extension/index.ts +++ b/core/src/node/extension/index.ts @@ -35,17 +35,17 @@ async function registerExtensionProtocol() { let electron: any = undefined try { - const moduleName = "electron" + const moduleName = 'electron' electron = await import(moduleName) } catch (err) { console.error('Electron is not available') } - + const extensionPath = ExtensionManager.instance.getExtensionsPath() if (electron) { return electron.protocol.registerFileProtocol('extension', (request: any, callback: any) => { const entry = request.url.substr('extension://'.length - 1) - const url = normalize(ExtensionManager.instance.extensionsPath + entry) + const url = normalize(extensionPath + entry) callback({ path: url }) }) } @@ -120,7 +120,7 @@ function loadExtension(ext: any) { * @returns {extensionManager} A set of functions used to manage the extension lifecycle. */ export function getStore() { - if (!ExtensionManager.instance.extensionsPath) { + if (!ExtensionManager.instance.getExtensionsFile()) { throw new Error( 'The extension path has not yet been set up. Please run useExtensions before accessing the store', ) @@ -133,4 +133,4 @@ export function getStore() { getActiveExtensions, removeExtension, } -} \ No newline at end of file +} diff --git a/core/src/node/extension/manager.ts b/core/src/node/extension/manager.ts index abfe916c0..c66d7b163 100644 --- a/core/src/node/extension/manager.ts +++ b/core/src/node/extension/manager.ts @@ -1,44 +1,45 @@ -import { join, resolve } from "path"; +import { join, resolve } from 'path' + +import { existsSync, mkdirSync, writeFileSync } from 'fs' -import { existsSync, mkdirSync, writeFileSync } from "fs"; -import { homedir } from "os" /** * Manages extension installation and migration. */ -export const userSpacePath = join(homedir(), "jan"); - export class ExtensionManager { - public static instance: ExtensionManager = new ExtensionManager(); + public static instance: ExtensionManager = new ExtensionManager() - extensionsPath: string | undefined = join(userSpacePath, "extensions"); + private extensionsPath: string | undefined constructor() { if (ExtensionManager.instance) { - return ExtensionManager.instance; + return ExtensionManager.instance } } + getExtensionsPath(): string | undefined { + return this.extensionsPath + } + setExtensionsPath(extPath: string) { // Create folder if it does not exist - let extDir; + let extDir try { - extDir = resolve(extPath); - if (extDir.length < 2) throw new Error(); + extDir = resolve(extPath) + if (extDir.length < 2) throw new Error() - if (!existsSync(extDir)) mkdirSync(extDir); + if (!existsSync(extDir)) mkdirSync(extDir) - const extensionsJson = join(extDir, "extensions.json"); - if (!existsSync(extensionsJson)) - writeFileSync(extensionsJson, "{}"); + const extensionsJson = join(extDir, 'extensions.json') + if (!existsSync(extensionsJson)) writeFileSync(extensionsJson, '{}') - this.extensionsPath = extDir; + this.extensionsPath = extDir } catch (error) { - throw new Error("Invalid path provided to the extensions folder"); + throw new Error('Invalid path provided to the extensions folder') } } getExtensionsFile() { - return join(this.extensionsPath ?? "", "extensions.json"); + return join(this.extensionsPath ?? '', 'extensions.json') } } diff --git a/core/src/node/index.ts b/core/src/node/index.ts index 50651d1fd..10385ecfc 100644 --- a/core/src/node/index.ts +++ b/core/src/node/index.ts @@ -6,3 +6,5 @@ export * from './download' export * from './module' export * from './api' export * from './log' +export * from './utils' +export * from './path' diff --git a/core/src/node/log.ts b/core/src/node/log.ts index 6c9712b6a..8a5155d8d 100644 --- a/core/src/node/log.ts +++ b/core/src/node/log.ts @@ -1,22 +1,35 @@ import fs from 'fs' import util from 'util' -import path from 'path' -import os from 'os' +import { getAppLogPath, getServerLogPath } from './utils' -export const logDir = path.join(os.homedir(), 'jan', 'logs') - -export const log = function (message: string, fileName: string = 'app.log') { - if (!fs.existsSync(logDir)) { - fs.mkdirSync(logDir, { recursive: true }) - } +export const log = function (message: string) { + const appLogPath = getAppLogPath() if (!message.startsWith('[')) { message = `[APP]::${message}` } message = `${new Date().toISOString()} ${message}` - if (fs.existsSync(logDir)) { - var log_file = fs.createWriteStream(path.join(logDir, fileName), { + if (fs.existsSync(appLogPath)) { + var log_file = fs.createWriteStream(appLogPath, { + flags: 'a', + }) + log_file.write(util.format(message) + '\n') + log_file.close() + console.debug(message) + } +} + +export const logServer = function (message: string) { + const serverLogPath = getServerLogPath() + if (!message.startsWith('[')) { + message = `[SERVER]::${message}` + } + + message = `${new Date().toISOString()} ${message}` + + if (fs.existsSync(serverLogPath)) { + var log_file = fs.createWriteStream(serverLogPath, { flags: 'a', }) log_file.write(util.format(message) + '\n') diff --git a/core/src/node/path.ts b/core/src/node/path.ts new file mode 100644 index 000000000..adbc38c6c --- /dev/null +++ b/core/src/node/path.ts @@ -0,0 +1,9 @@ +/** + * Normalize file path + * Remove all file protocol prefix + * @param path + * @returns + */ +export function normalizeFilePath(path: string): string { + return path.replace(/^(file:[\\/]+)([^:\s]+)$/, "$2"); +} diff --git a/core/src/node/utils/index.ts b/core/src/node/utils/index.ts new file mode 100644 index 000000000..00db04c9b --- /dev/null +++ b/core/src/node/utils/index.ts @@ -0,0 +1,103 @@ +import { AppConfiguration } from "../../types"; +import { join } from "path"; +import fs from "fs"; +import os from "os"; + +// TODO: move this to core +const configurationFileName = "settings.json"; + +// TODO: do no specify app name in framework module +const defaultJanDataFolder = join(os.homedir(), "jan"); +const defaultAppConfig: AppConfiguration = { + data_folder: defaultJanDataFolder, +}; + +/** + * Getting App Configurations. + * + * @returns {AppConfiguration} The app configurations. + */ +export const getAppConfigurations = (): AppConfiguration => { + // Retrieve Application Support folder path + // Fallback to user home directory if not found + const configurationFile = getConfigurationFilePath(); + + if (!fs.existsSync(configurationFile)) { + // create default app config if we don't have one + console.debug(`App config not found, creating default config at ${configurationFile}`); + fs.writeFileSync(configurationFile, JSON.stringify(defaultAppConfig)); + return defaultAppConfig; + } + + try { + const appConfigurations: AppConfiguration = JSON.parse( + fs.readFileSync(configurationFile, "utf-8"), + ); + return appConfigurations; + } catch (err) { + console.error(`Failed to read app config, return default config instead! Err: ${err}`); + return defaultAppConfig; + } +}; + +const getConfigurationFilePath = () => + join( + global.core?.appPath() || process.env[process.platform == "win32" ? "USERPROFILE" : "HOME"], + configurationFileName, + ); + +export const updateAppConfiguration = (configuration: AppConfiguration): Promise => { + const configurationFile = getConfigurationFilePath(); + console.debug("updateAppConfiguration, configurationFile: ", configurationFile); + + fs.writeFileSync(configurationFile, JSON.stringify(configuration)); + return Promise.resolve(); +}; + +/** + * Utility function to get server log path + * + * @returns {string} The log path. + */ +export const getServerLogPath = (): string => { + const appConfigurations = getAppConfigurations(); + const logFolderPath = join(appConfigurations.data_folder, "logs"); + if (!fs.existsSync(logFolderPath)) { + fs.mkdirSync(logFolderPath, { recursive: true }); + } + return join(logFolderPath, "server.log"); +}; + +/** + * Utility function to get app log path + * + * @returns {string} The log path. + */ +export const getAppLogPath = (): string => { + const appConfigurations = getAppConfigurations(); + const logFolderPath = join(appConfigurations.data_folder, "logs"); + if (!fs.existsSync(logFolderPath)) { + fs.mkdirSync(logFolderPath, { recursive: true }); + } + return join(logFolderPath, "app.log"); +}; + +/** + * Utility function to get data folder path + * + * @returns {string} The data folder path. + */ +export const getJanDataFolderPath = (): string => { + const appConfigurations = getAppConfigurations(); + return appConfigurations.data_folder; +}; + +/** + * Utility function to get extension path + * + * @returns {string} The extensions path. + */ +export const getJanExtensionsPath = (): string => { + const appConfigurations = getAppConfigurations(); + return join(appConfigurations.data_folder, "extensions"); +}; diff --git a/core/src/types/config/appConfigEntity.ts b/core/src/types/config/appConfigEntity.ts new file mode 100644 index 000000000..81ea0b30f --- /dev/null +++ b/core/src/types/config/appConfigEntity.ts @@ -0,0 +1,3 @@ +export type AppConfiguration = { + data_folder: string +} diff --git a/core/src/types/config/index.ts b/core/src/types/config/index.ts new file mode 100644 index 000000000..0fa3645aa --- /dev/null +++ b/core/src/types/config/index.ts @@ -0,0 +1 @@ +export * from './appConfigEntity' diff --git a/core/src/types/index.ts b/core/src/types/index.ts index 5fb4448f9..3bdcb5421 100644 --- a/core/src/types/index.ts +++ b/core/src/types/index.ts @@ -5,3 +5,4 @@ export * from './message' export * from './inference' export * from './monitoring' export * from './file' +export * from './config' diff --git a/core/src/types/model/modelEntity.ts b/core/src/types/model/modelEntity.ts index 23d27935e..80adc9e96 100644 --- a/core/src/types/model/modelEntity.ts +++ b/core/src/types/model/modelEntity.ts @@ -104,6 +104,9 @@ export type ModelSettingParams = { n_parallel?: number cpu_threads?: number prompt_template?: string + system_prompt?: string + ai_prompt?: string + user_prompt?: string } /** diff --git a/core/src/types/model/modelInterface.ts b/core/src/types/model/modelInterface.ts index 19b5e6051..74a479f3c 100644 --- a/core/src/types/model/modelInterface.ts +++ b/core/src/types/model/modelInterface.ts @@ -7,9 +7,10 @@ export interface ModelInterface { /** * Downloads a model. * @param model - The model to download. + * @param network - Optional object to specify proxy/whether to ignore SSL certificates. * @returns A Promise that resolves when the model has been downloaded. */ - downloadModel(model: Model): Promise + downloadModel(model: Model, network?: { ignoreSSL?: boolean, proxy?: string }): Promise /** * Cancels the download of a specific model. diff --git a/core/tests/node/path.test.ts b/core/tests/node/path.test.ts new file mode 100644 index 000000000..9f8a557bb --- /dev/null +++ b/core/tests/node/path.test.ts @@ -0,0 +1,12 @@ +import { normalizeFilePath } from "../../src/node/path"; + +describe("Test file normalize", () => { + test("returns no file protocol prefix on Unix", async () => { + expect(normalizeFilePath("file://test.txt")).toBe("test.txt"); + expect(normalizeFilePath("file:/test.txt")).toBe("test.txt"); + }); + test("returns no file protocol prefix on Windows", async () => { + expect(normalizeFilePath("file:\\\\test.txt")).toBe("test.txt"); + expect(normalizeFilePath("file:\\test.txt")).toBe("test.txt"); + }); +}); diff --git a/core/tsconfig.json b/core/tsconfig.json index 81991f46a..b112079d2 100644 --- a/core/tsconfig.json +++ b/core/tsconfig.json @@ -13,7 +13,7 @@ "declarationDir": "dist/types", "outDir": "dist/lib", "importHelpers": true, - "typeRoots": ["node_modules/@types"] + "types": ["@types/jest"] }, "include": ["src"] } diff --git a/docs/blog/2024-01-15-cultivating-culture-in-open-source-contributions-beyond-code.md b/docs/blog/2024-01-15-cultivating-culture-in-open-source-contributions-beyond-code.md deleted file mode 100644 index 9d5b081b2..000000000 --- a/docs/blog/2024-01-15-cultivating-culture-in-open-source-contributions-beyond-code.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -title: Cultivating Culture in Open Source Contributions Beyond Code -description: Discover how to contribute to open-source projects without coding skills. This Jan post guides you through the essentials of non-code contributions, from documentation to community engagement. Learn practical ways to impact open-source initiatives, even if you're new to the field. -slug: contributions -keywords: - [ - Jan framework, - Jan AI, - Jan, - ChatGPT alternative, - local AI, - private AI, - conversational AI, - no-subscription fee, - large language model, - how to contribute to opensource, - open source, - contributing to open source, - ] -authors: - - name: Jan Team -tags: [contribution, jan] -hide_table_of_contents: false ---- - -Ever wondered what ‘contribution’ really means in open-source communities? Calling back to John Picozzi’s presentation at DrupalCon 2022 and his article on [Opensource.com,](https://opensource.com/article/22/8/non-code-contribution-powers-open-source) we look at how his views on contributions align with ours at [Jan](https://jan.ai) - -![how to contribute opensource non-code](./assets/01-how-to-contribute-to-open-source-non-code.png) - -[https://jan.ai/about/#open-source](https://jan.ai/about/#open-source) - -#### Broadening the Definition of Contribution - how it benefits everyone - -Contribution in open source isn’t just about coding, and Picozzi nails it with this point. - -At Jan, we’re all about embracing different skills — because that’s what fuels innovation and growth. - -> Contribution? It’s when you take time to push forward ideas, vision, and awareness. Here are some solid ideas and how they’ve been executed before. - -#### Documentation - One Pillar of Contribution - -Remember those ancient storytellers and their crucial role in history? That’s what modern documentation does in tech. It’s the backbone of knowledge transfer. Without it, we’d be lost in translating what our products are all about. - -![](./assets/01-credit-janhq-repo.png)credit- Janhq/jan repo - -#### Writing and Editing - -Got a knack for words? Awesome! Making complex stuff easy and friendly is a real game-changer. Search around and see if your favourite open-source project has a tone of voice guide or style guide. (We’re currently making one!) - -#### Translating - -Speak another language? You could jump in and help translate documents. You’ll be flinging open doors for heaps of non-native language speakers and will scale awareness and adoption in a pretty big way. - -#### Creating Tutorials and Guides - -Projects thrive on collective knowledge being shared. Maybe your way of contributing is helping newbies find their feet by showcasing how you use the product? - -### Spreading the Word- Storytelling and ‘Marketing - -In the OS world, marketing is more about storytelling and community building than selling. - -Here’s what can make an impact that isn’t code- - -- Social media, blogs, forums- When the folks running open source projects are knee-deep in code, spreading the word might slip their minds. Your tweets, blogs, and comments? They’re more impactful than you think. - -* Content creation -If you’re into making videos or infographics, your skills can tell the project’s story in ways that really stick. - -#### Event Planning and Meetups - -Hosting online meetups or webinars? It’s a great way to share knowledge, connect people, and gather feedback as to how people are connecting with the project. - -### Contributing via ‘Dot Connecting’ - -Know people who might be interested or can chip in? Bringing in new energy and ideas is what keeps open source buzzing. Spot an opportunity for a partnership? Go for it! - -Linking up with educational bodies, non-profits, or businesses can work wonders depending on the projects end goals. - -### Why People Contribute to Open Source - -As Picozzi points out, it’s all about embracing every kind of skill. At Jan, we celebrate each team member’s unique contributions. - -It’s about being part of something bigger, honing new skills, and feeling that sense of belonging. - -#### What the Company/Project Gets Out of It - -By valuing non-code contributions, open-source projects, and companies tap into a broader skill set, leading to more well-rounded development and problem-solving. A diverse community is key to Jan’s success, just like Picozzi emphasizes. - -### Challenges in Non-Code Contributing - -Imposter syndrome? We get it, and we’re tackling it by creating a welcoming space for all contributions. Jan’s big on balancing work, life, and contribution — integrating it into our culture without overwhelming the team. - -#### How Do You Start Contributing to OpenSource? - -Just like Nike’s “just do it”, we’re all about making it easy for our team to dive into areas they’re passionate about. Here’s our [labelled issues for contributors](https://github.com/janhq/jan/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) - -#### Here’s the [Jan readme](https://github.com/janhq/jan?tab=readme-ov-file#readme) - -#### [Join us on Discord](https://discord.gg/JPcNRaJyzJ) diff --git a/docs/blog/README.md b/docs/blog/README.md new file mode 100644 index 000000000..464090415 --- /dev/null +++ b/docs/blog/README.md @@ -0,0 +1 @@ +# TODO diff --git a/docs/blog/assets/01-credit-janhq-repo.png b/docs/blog/assets/01-credit-janhq-repo.png deleted file mode 100644 index baef85c22..000000000 Binary files a/docs/blog/assets/01-credit-janhq-repo.png and /dev/null differ diff --git a/docs/blog/assets/01-how-to-contribute-to-open-source-non-code.png b/docs/blog/assets/01-how-to-contribute-to-open-source-non-code.png deleted file mode 100644 index a6af8b1c1..000000000 Binary files a/docs/blog/assets/01-how-to-contribute-to-open-source-non-code.png and /dev/null differ diff --git a/docs/docs/about/about.md b/docs/docs/about/01-README.md similarity index 99% rename from docs/docs/about/about.md rename to docs/docs/about/01-README.md index 16e0e2b84..3b2759513 100644 --- a/docs/docs/about/about.md +++ b/docs/docs/about/01-README.md @@ -1,5 +1,6 @@ --- title: About Jan +slug: /about description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server. keywords: [ diff --git a/docs/docs/handbook/11-onboarding.md b/docs/docs/about/02-onboarding/README.md similarity index 99% rename from docs/docs/handbook/11-onboarding.md rename to docs/docs/about/02-onboarding/README.md index 921534dc1..bf232c07d 100644 --- a/docs/docs/handbook/11-onboarding.md +++ b/docs/docs/about/02-onboarding/README.md @@ -1,6 +1,7 @@ --- title: Onboarding description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server. +slug: /onboarding keywords: [ Jan AI, @@ -14,6 +15,8 @@ keywords: ] --- +# Onboarding + Welcome to Jan! We’re really excited to bring you onboard. ## Expectations diff --git a/docs/docs/about/03-engineering/01-ci-cd.md b/docs/docs/about/03-engineering/01-ci-cd.md new file mode 100644 index 000000000..34454e850 --- /dev/null +++ b/docs/docs/about/03-engineering/01-ci-cd.md @@ -0,0 +1,10 @@ +--- +title: CI & CD +slug: /engineering/ci-cd +--- + +## Gitflow + +Previously we were trunk based. Now we use the following Gitflow: + +TODO: @van to include her Mermaid diagram diff --git a/docs/docs/about/03-engineering/02-qa.mdx b/docs/docs/about/03-engineering/02-qa.mdx new file mode 100644 index 000000000..f43caae4a --- /dev/null +++ b/docs/docs/about/03-engineering/02-qa.mdx @@ -0,0 +1,80 @@ +--- +title: QA +description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server. +slug: /engineering/qa +keywords: + [ + Jan AI, + Jan, + ChatGPT alternative, + local AI, + private AI, + conversational AI, + no-subscription fee, + large language model, + ] +--- + +### Phase 1: Planning + +#### Definition of Ready (DoR): + +- [ ] **Scope Defined:** The features to be implemented are clearly defined and scoped out. +- [ ] **Requirements Gathered:** Gather and document all the necessary requirements for the feature. +- [ ] **Stakeholder Input:** Ensure relevant stakeholders have provided input on the document scope and content. + +#### Definition of Done (DoD): + +- [ ] **Document Complete:** All sections of the document are filled out with relevant information. +- [ ] **Reviewed by Stakeholders:** The document has been reviewed and approved by stakeholders. +- [ ] **Ready for Development:** The document is in a state where developers can use it to begin implementation. + +### Phase 2: Development + +#### Definition of Ready (DoR): + +- [ ] **Task Breakdown:** The development team has broken down tasks based on the document. +- [ ] **Communication Plan:** A plan is in place for communication between developers and writers if clarification is needed during implementation. +- [ ] **Developer Understanding:** Developers have a clear understanding of the document content. + +#### Definition of Done (DoD): + +- [ ] **Code Implementation:** The feature is implemented according to the document specifications. +- [ ] **Developer Testing:** + - Unit tests and basic integration tests are completed + - Developer also completed self-testing for the feature (please add this as a comment in the ticket, with the tested OS and as much info as possible to reduce overlaping effort). + - (AC -> Code Changes -> Impacted scenarios) +- [ ] **Communication with Writers:** Developers have communicated any changes or challenges to the writers, and necessary adjustments are made in the document. (Can be through a note in the PR of the feature for writers to take care, or create a separate PR with the change you made for the docs, for writers to review) + +### Phase 3: QA for feature + +#### Definition of Ready (DoR): + +- [ ] **Test Note Defined:** The test note is prepared outlining the testing items. +- [ ] **Environment Ready:** PR merged to nightly build, Nightly build notes updated (automatically from pipeline after merged). +- [ ] **Status:** Ticket moved to the column Testing and assigning to QA/writers to review. +- [ ] **Test Data Prepared:** Relevant test data is prepared for testing the scenarios. + +#### Definition of Done (DoD): + +- [ ] **Test Executed:** All identified test items are executed on different OS, along with exploratory testing. +- [ ] **Defects Logged:** Any defects found during testing are resolved / appropriately logged (and approved for future fix). +- [ ] **Test Sign-Off:** QA team provides sign-off indicating the completion of testing. + +### Phase 4: Release (DoR) + +- [ ] **Pre-release wait time:** Code change to pre-release version should be frozen for at least X (hrs/days) for Regression testing purpose. + - Pre-release cut off on Thu morning for the team to regression test. + - Release to production (Stable) during working hour on Mon morning (if no blocker) or Tue morning. + - During the release cut off, the nightly build will be paused, to leave room for pre-release build. The build version used for regression test will be notified. +- [ ] **Pre-release testing:** A review of the implemented feature has been conducted, a long with regression test (check-list) by the team. + - Release checklist cloned from the templat for different OS (with hackMD link) + - New key test items from new feature added to the checklist. + - Split 3 OS to different team members for testing. +- [ ] **Document Updated:** The document is updated based on the review and feedback on any discrepancies or modification needed for this release. +- [ ] **Reviewed by Stakeholders:** New feature and the updated document is reviewed and approved by stakeholders. The document is in its final version, reflecting the implemented feature accurately. + +### Notes (WIP) + +- [ ] **API collection run:** to run along with nightly build daily, for critical API validation +- [ ] **Automation run:** for regression testing purpose, to reduce manual testing effort for the same items each release on multiple OS. diff --git a/docs/docs/handbook/10-engineering.md b/docs/docs/about/03-engineering/03-mlops.md similarity index 98% rename from docs/docs/handbook/10-engineering.md rename to docs/docs/about/03-engineering/03-mlops.md index 0b68d0949..d3e768a58 100644 --- a/docs/docs/handbook/10-engineering.md +++ b/docs/docs/about/03-engineering/03-mlops.md @@ -1,6 +1,7 @@ --- -title: Engineering +title: MLOps description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server. +slug: /engineering/mlops keywords: [ Jan AI, @@ -16,6 +17,8 @@ keywords: ## Connecting to Rigs +We have a small data rig you can remote into for R&D and CI. + ### Pritunl Setup 1. **Install Pritunl**: [Download here](https://client.pritunl.com/#install) @@ -137,7 +140,7 @@ cd examples/llama && rm -rf ./llama/7B && mkdir -p ./llama/7B && git clone https python build.py --model_dir ./llama/7B/ --dtype float16 --remove_input_padding --use_gpt_attention_plugin float16 --enable_context_fmha --use_gemm_plugin float16 --use_weight_only --output_dir ./llama/7B/trt_engines/weight_only/1-gpu/ ``` -4. Run Inference: +4. **Run Inference:** ```bash python3 run.py --max_output_len=2048 --tokenizer_dir ./llama/7B/ --engine_dir=./llama/7B/trt_engines/weight_only/1-gpu/ --input_text "Writing a thesis proposal can be done in 10 simple steps:\nStep 1:" diff --git a/docs/docs/handbook/00-overview.md b/docs/docs/about/03-engineering/04-rd.md similarity index 73% rename from docs/docs/handbook/00-overview.md rename to docs/docs/about/03-engineering/04-rd.md index a1127f7d0..f173d27de 100644 --- a/docs/docs/handbook/00-overview.md +++ b/docs/docs/about/03-engineering/04-rd.md @@ -1,7 +1,7 @@ --- -title: Overview -slug: /handbook +title: R&D description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server. +slug: /engineering/research keywords: [ Jan AI, @@ -15,4 +15,6 @@ keywords: ] --- -Welcome to Jan Handbook! We’re really excited to bring you onboard. +## Foundry Best Practices + +@alan/rex TODO diff --git a/docs/docs/about/03-engineering/05-postmortems/01-january-10-2024-bitdefender-false-positive-flag.mdx b/docs/docs/about/03-engineering/05-postmortems/01-january-10-2024-bitdefender-false-positive-flag.mdx new file mode 100644 index 000000000..4d0189c60 --- /dev/null +++ b/docs/docs/about/03-engineering/05-postmortems/01-january-10-2024-bitdefender-false-positive-flag.mdx @@ -0,0 +1,136 @@ +--- +title: | + 10/1/24: Bitdefender False Positive Flag (Resolved) +slug: /postmortems/january-10-2024-bitdefender-false-positive-flag +description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server. +keywords: + [ + Jan AI, + Jan, + ChatGPT alternative, + local AI, + private AI, + conversational AI, + no-subscription fee, + large language model, + postmortem, + incident, + flagging issue, + ] +--- + +Following the recent incident related to Jan version 0.4.4 triggering Bitdefender on Windows with Gen:Variant.Tedy.258323 on January 10, 2024, we wanted to provide a comprehensive postmortem and outline the necessary follow-up actions. + +## Incident Overview + +### Bug Description + +Jan 0.4.4 installation on Windows triggered Bitdefender to flag it as infected with Gen:Variant.Tedy.258323, leading to automatic quarantine. + +### Affected Antivirus + +- McAfee / Microsoft Defender was unaffected +- Bitdefender consistently flagged the issue. + +### Incident Timeline + +- _10 Jan, 2:18 am SGT:_ Hawke flags up Malware antivirus errors for 0.4.4 installation on Windows computers. +- _10 Jan, 2:21 am SGT:_ @0xSage responds in Discord. +- _10 Jan, 2:35 am SGT:_ Hawke confirms multiple people have experienced this error on fresh installs. +- _10 Jan, 2:41 am SGT:_ @louis-jan and @dan-jan revert 0.4.4 out of an abundance of caution. +- _Incident ongoing:_ To triage and investigate the next day. +- _10 Jan, 11:36 am SGT:_ @Hien has investigated all versions of Nitro and conducted scans using Bitdefender. Only the 2 latest versions raised warnings (0.2.7, 0.2.8). +- _10 Jan, 12:44 pm SGT:_ @Hien tested again for the 0.2.6 and suggested using 0.2.6 for now, the 2 remaining Nitro version (0.2.7, 0.2.8) will under further investigation. +- The team started testing on the fixed build. +- _10 Jan, 3:22 pm SGT:_ Diagnosis found that it's most likely a false positive. @Hien has only found a solution by attempting to build Nitro Windows CPU on a GitHub-hosted runner and hasn't identified the root cause yet. +- _10 Jan, 5:24 pm SGT:_ @Hien testing two scenarios and still trying to understand the workings of Bitdefender. +- _11 Jan, 5:46 pm SGT:_ Postmortem meeting + +## Investigation Update + +- @Hien has investigated all versions of Nitro and conducted scans using Bitdefender. and only the 2 latest versions raised warnings from Bitdefender. Nitro 0.2.6, which is the highest version without the issue, was tested again, and it no longer triggers a warning from Bitdefender. +- We have observed that Nitro versions up to 0.2.6 remain unaffected. However, Bitdefender flags versions 0.2.7 and 0.2.8 as infected, leading to the deletion. In order to proceed with the current release, Hien suggests downgrading Nitro to version 0.2.6 and conducting tests with this version. Simultaneously, he will investigate why Bitdefender is flagging versions 0.2.7 and 0.2.8. +- It's essential to note that between versions 0.2.6, 0.2.7, and 0.2.8, only minor changes were made, which should not trigger a malicious code warning. We can refer to the changelog between 0.2.7 and 0.2.8 to pinpoint these changes. +- Our primary message is to convey that we did not introduce malicious code into Jan (indicating a false positive), and the investigation aims to understand the root cause behind Bitdefender flagging versions 0.2.7 and 0.2.8. +- The current diagnosis looks like a false positive but it's still under investigation. Reference link: [here](https://stackoverflow.com/questions/75886428/fake-positive-bit-defender-problem-genvariant-tedy-304469), [here](https://stackoverflow.com/questions/58010466/bitdefender-detects-my-console-application-as-genvariant-ursu-56053), and [here](https://www.cisa.gov/sites/default/files/2023-06/mar-10365227.r1.v1.clear_.pdf). +- @Hien testing two scenarios and still trying to understand the workings of Bitdefender. Still under investigation: is the issue with the code or the CI? + - In Case 1, using the same CI agent for tags 0.2.6 and 0.2.8, after PRs by Alan and myself, Bitdefender flagged the Nitro CPU binary build. Naturally, one would conclude this is due to the code. + - However, I proceeded with a further experiment: for the 0.2.8 code, instead of using our CI agent, I used a GitHub hosted agent. This time, Bitdefender did not flag our binary build. +- We've identified the Bitdefender warning was not an attack. There is no malicious code +- We've isolated the event to originate from a CI agent, which resulted in a BitDefender false positive alert. + +## Follow-ups and Action Items + +1. **Reproduce Bitdefender Flag in Controlled Environment [Done]:** + + - _Objective:_ To replicate the issue in a controlled environment to understand the triggers and specifics of Bitdefender's detection. + +2. **Investigate Malicious Code or False Positive:** + + - _Objective:_ Determine whether the flagged issue is a result of actual malicious code or a false positive. If it's a false positive, work towards resolution while communicating with Bitdefender. + +3. **Supply Chain Attack Assessment:** + + - _Objective:_ Evaluate the possibility of a supply chain attack. Investigate whether the Nitro 0.4.4 distribution was compromised or tampered with during the release process. + +4. **Testing after the Hotfix:** + + - _Objective:_ In addition to verifying the issue after the fix, it is essential to conduct comprehensive testing across related areas, ensuring compatibility across different operating systems and antivirus software (latest version / free version only). + +5. **Process Improvement for Future Releases:** + + - _Objective:_ Identify and implement improvements to our release process to prevent similar incidents in the future. This may include enhanced testing procedures, code analysis, and collaboration with antivirus software providers during the pre-release phase. Additionally, we should add verifying the latest antivirus software in the release checklist. + +6. **Documentation of Tested Antivirus Versions:** + - _Objective:_ Create a document that outlines the testing conducted, including a matrix that correlates Jan versions with the tested antivirus versions. + - _Sample list:_ for consideration purpose + - Bitdefender + - McAfee + - Avira + - Kaspersky + - Norton + - Microsoft defender + - AVG + - TotalAV + +## Next Steps + +- The team should follow up on each action item with clear ownership priority, and deadlines. +- Communicate progress transparently with the community and clients through appropriate channels. If any insights or suggestions, share them within the dedicated channels. +- Update internal documentation and procedures based on the lessons learned from this incident. + +## Lessons Learned + +1. **Antivirus Compatibility Awareness:** + + - _Observation:_ The incident underscored the significance of recognizing and testing for antivirus compatibility, particularly with widely-used solutions like Bitdefender. + - _Lesson Learned:_ In the future, we will integrate comprehensive checks for compatibility with various antivirus software, including both antivirus and "Malicious Code Detection," into our CI or QA checklist. This proactive measure aims to minimize false positive detections during the release and testing processes. + +2. **Cross-Platform Testing:** + + - _Observation:_ The problem did not occur on MacOS and Linux systems, implying a potential oversight in cross-platform testing during our release procedures. + - _Lesson Learned:_ Clarification — This observation is not directly related to antivirus testing. Instead, it underscores the necessity to improve our testing protocols, encompassing multiple operating systems. This ensures a thorough evaluation of potential issues on diverse platforms, considering the various antivirus software and differences in architectures on Mac and Linux systems. + +3. **User Communication and Documentation:** + + - _Observation:_ Due to the timely response from Nicole, who was still active on Discord and Github at 2 am, this quick response facilitated our ability to assess the impact accurately. + - _Lesson Learned:_ While our communication with users was effective in this instance, it was mainly due to Nicole's presence during the incident. To improve our overall response capability, we should prioritize "24/7 rapid triage and response." This involves ensuring continuous availability or establishing a reliable rotation of team members for swift user communication and issue documentation, further enhancing our incident response efficiency. + +4. **Proactive Incident Response:** + + - _Observation:_ The incident response, while involving a prompt version rollback, experienced a slight delay due to the release occurring at midnight. This delay postponed the initiation of the investigation until the next working hours. + - _Lesson Learned:_ Recognizing the importance of swift incident response, particularly in time-sensitive situations, we acknowledge that releasing updates during off-hours can impact the immediacy of our actions. Moving forward, we will strive to optimize our release schedules to minimize delays and ensure that investigations can commence promptly regardless of the time of day. This may involve considering alternative release windows or implementing automated responses to critical incidents, ensuring a more proactive and timely resolution. + +5. **Supply Chain Security Measures:** + + - _Observation:_ While the incident prompted consideration of a potential supply chain attack, it's crucial to emphasize that this was not the case. Nonetheless, the incident underscored the importance of reviewing our supply chain security measures. + - _Lesson Learned:_ Going forward, we should strengthen supply chain security by introducing additional verification steps to uphold the integrity of our release process. Collaborating with distribution channels is essential for enhancing security checks and ensuring a robust supply chain. + - _Longer-term:_ Exploring options for checking Jan for malicious code and incorporating antivirus as part of our CI/CD pipeline should be considered for a more comprehensive and proactive approach. + +6. **User Education on False Positives:** + - _Observation:_ Users reported Bitdefender automatically "disinfecting" the flagged Nitro version without allowing any user actions. + - _Lesson Learned:_ Educate users about the possibility of false positives and guide them on how to whitelist or report such incidents to their antivirus provider (if possible). Provide clear communication on steps users can take in such situations. + +These lessons learned will serve as a foundation for refining our processes and ensuring a more resilient release and incident response framework in the future. Continuous improvement is key to maintaining the reliability and security of our software. + +Thank you for your dedication and cooperation in resolving this matter promptly. diff --git a/docs/docs/handbook/05-what-we-do/README.mdx b/docs/docs/about/03-engineering/05-postmortems/README.md similarity index 73% rename from docs/docs/handbook/05-what-we-do/README.mdx rename to docs/docs/about/03-engineering/05-postmortems/README.md index a7e6b7476..fda1e6c2f 100644 --- a/docs/docs/handbook/05-what-we-do/README.mdx +++ b/docs/docs/about/03-engineering/05-postmortems/README.md @@ -1,7 +1,7 @@ --- -title: What We Do -slug: /handbook/what-we-do +title: Postmortems description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server. +slug: /postmortems keywords: [ Jan AI, @@ -12,10 +12,9 @@ keywords: conversational AI, no-subscription fee, large language model, - handbook, ] --- import DocCardList from "@theme/DocCardList"; - + diff --git a/docs/docs/handbook/03-products-and-innovations/README.mdx b/docs/docs/about/03-engineering/README.md similarity index 53% rename from docs/docs/handbook/03-products-and-innovations/README.mdx rename to docs/docs/about/03-engineering/README.md index 795814975..1db5c3912 100644 --- a/docs/docs/handbook/03-products-and-innovations/README.mdx +++ b/docs/docs/about/03-engineering/README.md @@ -1,7 +1,7 @@ --- -title: Our Products and Innovations -slug: /handbook/products-and-innovations +title: Engineering description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server. +slug: /engineering keywords: [ Jan AI, @@ -12,10 +12,10 @@ keywords: conversational AI, no-subscription fee, large language model, - handbook, ] --- -import DocCardList from "@theme/DocCardList"; +## Prerequisites - +- [Requirements](https://github.com/janhq/jan?tab=readme-ov-file#requirements-for-running-jan) +- [Setting up local env](https://github.com/janhq/jan?tab=readme-ov-file#contributing) diff --git a/docs/docs/handbook/09-contributing-to-jan/assets/01-get-help.png b/docs/docs/about/03-engineering/assets/01-get-help.png similarity index 100% rename from docs/docs/handbook/09-contributing-to-jan/assets/01-get-help.png rename to docs/docs/about/03-engineering/assets/01-get-help.png diff --git a/docs/docs/handbook/12-product.md b/docs/docs/about/04-product/01-management.md similarity index 95% rename from docs/docs/handbook/12-product.md rename to docs/docs/about/04-product/01-management.md index bd579b93b..c231801c0 100644 --- a/docs/docs/handbook/12-product.md +++ b/docs/docs/about/04-product/01-management.md @@ -1,8 +1,9 @@ --- -title: Product +title: Project Management +slug: /product/management --- -We use the [Jan Monorepo Project](https://github.com/orgs/janhq/projects/5) in Github for 100% of our product / project management. +We use the [Jan Monorepo Project](https://github.com/orgs/janhq/projects/5) in Github to manage our roadmap and sprint Kanbans. As much as possible, everyone owns their respective `epics` and `tasks`. diff --git a/docs/docs/about/04-product/README.md b/docs/docs/about/04-product/README.md new file mode 100644 index 000000000..c07109800 --- /dev/null +++ b/docs/docs/about/04-product/README.md @@ -0,0 +1,9 @@ +--- +title: Product +slug: /product +--- + +## Prerequisites + +- [Figma](https://figma.com) +- [ScreenStudio](https://www.screen.studio/) diff --git a/docs/docs/about/05-community/README.md b/docs/docs/about/05-community/README.md new file mode 100644 index 000000000..849cac61e --- /dev/null +++ b/docs/docs/about/05-community/README.md @@ -0,0 +1,31 @@ +--- +title: Community +slug: /community +description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server. +keywords: + [ + Jan AI, + Jan, + ChatGPT alternative, + local AI, + private AI, + conversational AI, + no-subscription fee, + large language model, + ] +--- + +## Socials + +- [Discord](https://discord.gg/SH3DGmUs6b) +- [X](https://twitter.com/janframework) +- [HuggingFace](https://huggingface.co/janhq) +- [LinkedIn](https://www.linkedin.com/company/janframework/) + +## Community Run + +- [Reddit](https://www.reddit.com/r/janframework/) + +## Careers + +- [Jobs](https://janai.bamboohr.com/careers) diff --git a/docs/docs/handbook/02-meet-jan/README.mdx b/docs/docs/about/05-community/events/README.mdx similarity index 74% rename from docs/docs/handbook/02-meet-jan/README.mdx rename to docs/docs/about/05-community/events/README.mdx index c8f02812d..92d9cd5f1 100644 --- a/docs/docs/handbook/02-meet-jan/README.mdx +++ b/docs/docs/about/05-community/events/README.mdx @@ -1,6 +1,6 @@ --- -title: Meet Jan -slug: /handbook/meet-jan +title: Events +slug: /events description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server. keywords: [ @@ -12,10 +12,9 @@ keywords: conversational AI, no-subscription fee, large language model, - handbook, ] --- import DocCardList from "@theme/DocCardList"; - + \ No newline at end of file diff --git a/docs/docs/events/hcmc-oct23.md b/docs/docs/about/05-community/events/hcmc-oct23.md similarity index 100% rename from docs/docs/events/hcmc-oct23.md rename to docs/docs/about/05-community/events/hcmc-oct23.md diff --git a/docs/docs/events/nvidia-llm-day-nov-23.md b/docs/docs/about/05-community/events/nvidia-llm-day-nov-23.md similarity index 100% rename from docs/docs/events/nvidia-llm-day-nov-23.md rename to docs/docs/about/05-community/events/nvidia-llm-day-nov-23.md diff --git a/docs/docs/handbook/04-core-contributors/README.mdx b/docs/docs/about/06-careers/README.md similarity index 61% rename from docs/docs/handbook/04-core-contributors/README.mdx rename to docs/docs/about/06-careers/README.md index bb0057e35..a0a579e02 100644 --- a/docs/docs/handbook/04-core-contributors/README.mdx +++ b/docs/docs/about/06-careers/README.md @@ -1,6 +1,6 @@ --- -title: Our Contributors -slug: /handbook/core-contributors +title: Careers +slug: /careers description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server. keywords: [ @@ -12,10 +12,9 @@ keywords: conversational AI, no-subscription fee, large language model, - handbook, ] --- -import DocCardList from "@theme/DocCardList"; +## We're hiring - +[Careers on Bamboo](https://janai.bamboohr.com/careers) diff --git a/docs/docs/community/community.md b/docs/docs/community/community.md deleted file mode 100644 index 5defbf30c..000000000 --- a/docs/docs/community/community.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: Community -description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server. -keywords: [Jan AI, Jan, ChatGPT alternative, local AI, private AI, conversational AI, no-subscription fee, large language model ] ---- - -- [ ] Social media links \ No newline at end of file diff --git a/docs/docs/developer/04-build-extension/01-your-first-extension.md b/docs/docs/developer/04-build-extension/01-your-first-extension.md index 6ffe2de2f..f89f34053 100644 --- a/docs/docs/developer/04-build-extension/01-your-first-extension.md +++ b/docs/docs/developer/04-build-extension/01-your-first-extension.md @@ -20,3 +20,69 @@ keywords: :::caution This is currently under development. ::: + +In this guide, we'll walk you through the process of building your first extension and integrating it into Jan. + +## Steps to Create Your First Extension + +To create your own extension, you can follow the steps below: + +1. Click the **Use this template** button at the top of the [extension-template repository](https://github.com/janhq/extension-template). +2. Select **Create a new repository**. +3. Choose an owner and name for your new repository. +4. Click **Create repository**. +5. Clone your new repository to your local machine. + +## Initial Setup + +After you have cloned the repository to your local machine or codespace, you will need to perform some initial setup steps before you can develop your extension. + +:::info + +You will 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`](https://github.com/janhq/extension-template/blob/main/package.json). Otherwise, 20.x or later should work! + +::: + +1. :hammer_and_wrench: Install the dependencies + +```bash +npm install +``` + +2. :building_construction: Package the TypeScript for distribution + +```bash +npm run bundle +``` + +3. :white_check_mark: Check your artifact + +There will be a `.tgz` file in your extension directory now. This is the file you will need to import into Jan. You can import this file into Jan by following the instructions in the [Import Extension](https://jan.ai/guides/using-extensions/import-extensions/) guide. + +## Update the Extension Metadata + +The [`package.json`](https://github.com/janhq/extension-template/blob/main/package.json) file defines metadata about your extension, such as extension name, main entry, description and version. + +When you copy this repository, update `package.json` with the name, and description for your extension. + +## Update the Extension Code + +The [`src/`](https://github.com/janhq/extension-template/tree/main/src) directory is the heart of your extension! This contains the source code that will be run when your extension 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 extension code: + +- Most Jan 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 Extension Core module, see the [documentation](https://github.com/janhq/jan/blob/main/core/README.md). + +Now, go ahead and start customizing your extension! Happy coding! \ No newline at end of file diff --git a/docs/docs/guides/02-installation/06-hardware.md b/docs/docs/guides/02-installation/06-hardware.md index 4fe21490d..b91722456 100644 --- a/docs/docs/guides/02-installation/06-hardware.md +++ b/docs/docs/guides/02-installation/06-hardware.md @@ -1,5 +1,6 @@ --- title: Hardware Requirements +slug: /guides/install/hardware description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server. keywords: [ diff --git a/docs/docs/guides/02-installation/07-antivirus-compatibility-testing.md b/docs/docs/guides/02-installation/07-antivirus-compatibility-testing.md new file mode 100644 index 000000000..6dd82138b --- /dev/null +++ b/docs/docs/guides/02-installation/07-antivirus-compatibility-testing.md @@ -0,0 +1,33 @@ +--- +title: Antivirus Compatibility Testing +slug: /guides/install/antivirus-compatibility-testing +description: Antivirus compatibility testing documentation for the Jan App v0.4.4 release. +keywords: + [ + Jan AI, + Jan, + ChatGPT alternative, + local AI, + private AI, + conversational AI, + no-subscription fee, + large language model, + antivirus compatibility, + ] +--- + +This documentation outlines the antivirus compatibility testing conducted for the Jan App v0.4.4 release. This documentation includes a matrix that correlates the Jan App version with the tested antivirus versions. + +## Tested Antivirus Versions + +The Jan App v0.4.4 release has undergone automatic testing through CI with a selection of popular antivirus software to ensure compatibility and safety. The following summarizes the testing results: + +| Antivirus | Version | Result | +| ------------------ | ------------ | -------------------------------- | +| Bitdefender | 27.0.27.125 | Scanned and 0 threat(s) detected | +| McAfee | 4.21.0.0 | Scanned and 0 threat(s) detected | +| Microsoft Defender | 1.403.2259.0 | Scanned and 0 threat(s) detected | + +## Conclusion + +The testing indicates that Jan App v0.4.4 is compatible with Bitdefender, Microsoft Defender, and McAfee. Any updates or changes to compatibility status will be promptly documented. diff --git a/docs/docs/guides/06-using-extensions/01-extension-settings.md b/docs/docs/guides/06-using-extensions/01-extension-settings.md new file mode 100644 index 000000000..4e36415dd --- /dev/null +++ b/docs/docs/guides/06-using-extensions/01-extension-settings.md @@ -0,0 +1,133 @@ +--- +title: Extension Settings +slug: /guides/using-extensions/extension-settings/ +description: Configure settings for extensions. +keywords: + [ + Jan AI, + Jan, + ChatGPT alternative, + local AI, + private AI, + conversational AI, + no-subscription fee, + large language model, + extension settings, + ] +--- + +The current Jan Desktop Client has some default extensions built on top of this framework to enhance the user experience. In this guide, we will show you the list of default extensions and how to configure extension settings. + +## Default Extensions + +You can find the default extensions in the `Settings` > `Extensions`. + +![Extensions](./assets/01-extension-settings.png) + +### List of Default Extensions + +| Extension Name | Version | Description | Source Code Link | +| ---------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | +| Assistant Extension | v1.0.0 | This extension enables assistants, including Jan, a default assistant that can call all downloaded models. | [Link to Source](https://github.com/janhq/jan/tree/main/extensions/assistant-extension) | +| Conversational Extension | v1.0.0 | This extension enables conversations and state persistence via your filesystem. | [Link to Source](https://github.com/janhq/jan/tree/main/extensions/conversational-extension) | +| Inference Nitro Extension | v1.0.0 | This extension embeds Nitro, a lightweight (3mb) inference engine written in C++. See [nitro.jan.ai](nitro.jan.ai) | [Link to Source](https://github.com/janhq/jan/tree/main/extensions/inference-nitro-extension) | +| Inference Openai Extension | v1.0.0 | This extension enables OpenAI chat completion API calls | [Link to Source](https://github.com/janhq/jan/tree/main/extensions/inference-openai-extension) | +| Inference Triton Trt Llm Extension | v1.0.0 | This extension enables Nvidia's TensorRT-LLM as an inference engine option. | [Link to Source](https://github.com/janhq/jan/tree/main/extensions/inference-triton-trtllm-extension) | +| Model Extension | v1.0.22 | Model Management Extension provides model exploration and seamless downloads. | [Link to Source](https://github.com/janhq/jan/tree/main/extensions/model-extension) | +| Monitoring Extension | v1.0.9 | This extension provides system health and OS level data. | [Link to Source](https://github.com/janhq/jan/tree/main/extensions/monitoring-extension) | + +## Configure Extension Settings + +You can configure the extension settings by modifying the `extensions.json` file under the `~/jan/extensions` directory including the following configurations: + +- `_active`: true means the extension is enabled. If you want to disable an extension, you can set it to false. +- `listeners`: {} is the default value for listeners. +- `origin`: the path to the extension file. +- `installOptions`: configure the installOptions with version and fullMetadata. +- `name`: the name of the extension. +- `version`: the version of the extension. +- `main`: the path to the main file of the extension. +- `description`: the description of the extension. +- `url`: the url of the extension. + +```json title="~/jan/extensions/extensions.json" +{ + "@janhq/assistant-extension": { + "_active": true, + "listeners": {}, + "origin": "/Applications/Jan.app/Contents/Resources/app.asar.unpacked/pre-install/janhq-assistant-extension-1.0.0.tgz", + "installOptions": { "version": false, "fullMetadata": false }, + "name": "@janhq/assistant-extension", + "version": "1.0.0", + "main": "dist/index.js", + "description": "This extension enables assistants, including Jan, a default assistant that can call all downloaded models", + "url": "extension://@janhq/assistant-extension/dist/index.js" + }, + "@janhq/conversational-extension": { + "_active": true, + "listeners": {}, + "origin": "/Applications/Jan.app/Contents/Resources/app.asar.unpacked/pre-install/janhq-conversational-extension-1.0.0.tgz", + "installOptions": { "version": false, "fullMetadata": false }, + "name": "@janhq/conversational-extension", + "version": "1.0.0", + "main": "dist/index.js", + "description": "This extension enables conversations and state persistence via your filesystem", + "url": "extension://@janhq/conversational-extension/dist/index.js" + }, + "@janhq/inference-nitro-extension": { + "_active": true, + "listeners": {}, + "origin": "/Applications/Jan.app/Contents/Resources/app.asar.unpacked/pre-install/janhq-inference-nitro-extension-1.0.0.tgz", + "installOptions": { "version": false, "fullMetadata": false }, + "name": "@janhq/inference-nitro-extension", + "version": "1.0.0", + "main": "dist/index.js", + "description": "This extension embeds Nitro, a lightweight (3mb) inference engine written in C++. See nitro.jan.ai", + "url": "extension://@janhq/inference-nitro-extension/dist/index.js" + }, + "@janhq/inference-openai-extension": { + "_active": true, + "listeners": {}, + "origin": "/Applications/Jan.app/Contents/Resources/app.asar.unpacked/pre-install/janhq-inference-openai-extension-1.0.0.tgz", + "installOptions": { "version": false, "fullMetadata": false }, + "name": "@janhq/inference-openai-extension", + "version": "1.0.0", + "main": "dist/index.js", + "description": "This extension enables OpenAI chat completion API calls", + "url": "extension://@janhq/inference-openai-extension/dist/index.js" + }, + "@janhq/inference-triton-trt-llm-extension": { + "_active": true, + "listeners": {}, + "origin": "/Applications/Jan.app/Contents/Resources/app.asar.unpacked/pre-install/janhq-inference-triton-trt-llm-extension-1.0.0.tgz", + "installOptions": { "version": false, "fullMetadata": false }, + "name": "@janhq/inference-triton-trt-llm-extension", + "version": "1.0.0", + "main": "dist/index.js", + "description": "This extension enables Nvidia's TensorRT-LLM as an inference engine option", + "url": "extension://@janhq/inference-triton-trt-llm-extension/dist/index.js" + }, + "@janhq/model-extension": { + "_active": true, + "listeners": {}, + "origin": "/Applications/Jan.app/Contents/Resources/app.asar.unpacked/pre-install/janhq-model-extension-1.0.22.tgz", + "installOptions": { "version": false, "fullMetadata": false }, + "name": "@janhq/model-extension", + "version": "1.0.22", + "main": "dist/index.js", + "description": "Model Management Extension provides model exploration and seamless downloads", + "url": "extension://@janhq/model-extension/dist/index.js" + }, + "@janhq/monitoring-extension": { + "_active": true, + "listeners": {}, + "origin": "/Applications/Jan.app/Contents/Resources/app.asar.unpacked/pre-install/janhq-monitoring-extension-1.0.9.tgz", + "installOptions": { "version": false, "fullMetadata": false }, + "name": "@janhq/monitoring-extension", + "version": "1.0.9", + "main": "dist/index.js", + "description": "This extension provides system health and OS level data", + "url": "extension://@janhq/monitoring-extension/dist/index.js" + } +} +``` diff --git a/docs/docs/guides/06-using-extensions/01-import-extensions.md b/docs/docs/guides/06-using-extensions/01-import-extensions.md deleted file mode 100644 index 52e26c7d0..000000000 --- a/docs/docs/guides/06-using-extensions/01-import-extensions.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: Import Extensions -slug: /guides/using-extensions/import-extensions/ -description: Import extensions into Jan. -keywords: - [ - Jan AI, - Jan, - ChatGPT alternative, - local AI, - private AI, - conversational AI, - no-subscription fee, - large language model, - using-models, - ] ---- \ No newline at end of file diff --git a/docs/docs/guides/06-using-extensions/02-extension-settings.md b/docs/docs/guides/06-using-extensions/02-extension-settings.md deleted file mode 100644 index d5b053b72..000000000 --- a/docs/docs/guides/06-using-extensions/02-extension-settings.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: Extension Settings -slug: /guides/using-extensions/extension-settings/ -description: Configure settings for extensions. -keywords: - [ - Jan AI, - Jan, - ChatGPT alternative, - local AI, - private AI, - conversational AI, - no-subscription fee, - large language model, - using-models, - ] ---- \ No newline at end of file diff --git a/docs/docs/guides/06-using-extensions/02-import-extensions.md b/docs/docs/guides/06-using-extensions/02-import-extensions.md new file mode 100644 index 000000000..91b0eab7c --- /dev/null +++ b/docs/docs/guides/06-using-extensions/02-import-extensions.md @@ -0,0 +1,29 @@ +--- +title: Import Extensions +slug: /guides/using-extensions/import-extensions/ +description: Import extensions into Jan. +keywords: + [ + Jan AI, + Jan, + ChatGPT alternative, + local AI, + private AI, + conversational AI, + no-subscription fee, + large language model, + import extensions, + ] +--- + +Beside default extensions, you can import extensions into Jan by navigate to `Settings` > `Extensions` > `Manual Installation`. Then, the `~/jan/extensions/extensions.json` file will be updated automatically. + +:::caution + +You need to prepare the extension file in `.tgz` format to install. + +::: + +![Manual Installation](./assets/02-import-extensions.gif) + +If you want to build your own extension, please refer to the [Build Your First Extension | Developer Documentation](/developer/build-extension/your-first-extension/). diff --git a/docs/docs/guides/06-using-extensions/assets/01-extension-settings.png b/docs/docs/guides/06-using-extensions/assets/01-extension-settings.png new file mode 100644 index 000000000..29f4c9d08 Binary files /dev/null and b/docs/docs/guides/06-using-extensions/assets/01-extension-settings.png differ diff --git a/docs/docs/guides/06-using-extensions/assets/02-import-extensions.gif b/docs/docs/guides/06-using-extensions/assets/02-import-extensions.gif new file mode 100644 index 000000000..d2436b947 Binary files /dev/null and b/docs/docs/guides/06-using-extensions/assets/02-import-extensions.gif differ diff --git a/docs/docs/guides/07-integrations/02-integrate-openrouter.mdx b/docs/docs/guides/07-integrations/02-integrate-openrouter.mdx index 5d563c92d..8623a1a4a 100644 --- a/docs/docs/guides/07-integrations/02-integrate-openrouter.mdx +++ b/docs/docs/guides/07-integrations/02-integrate-openrouter.mdx @@ -37,7 +37,7 @@ You can find your API keys in the [OpenRouter API Key](https://openrouter.ai/key } ``` -### 2. Mofidy a Model JSON +### 2. Modify a Model JSON Navigate to the `~/jan/models` folder. Create a folder named ``, for example, `openrouter-dolphin-mixtral-8x7b` and create a `model.json` file inside the folder including the following configurations: diff --git a/docs/docs/guides/07-integrations/03-integrate-azure-openai-service.mdx b/docs/docs/guides/07-integrations/03-integrate-azure-openai-service.mdx new file mode 100644 index 000000000..827a8583a --- /dev/null +++ b/docs/docs/guides/07-integrations/03-integrate-azure-openai-service.mdx @@ -0,0 +1,91 @@ +--- +title: Integrate Azure OpenAI Service with Jan +slug: /guides/integrations/azure-openai-service +description: Guide to integrate Azure OpenAI Service with Jan +keywords: + [ + Jan AI, + Jan, + ChatGPT alternative, + local AI, + private AI, + conversational AI, + no-subscription fee, + large language model, + integration, + Azure OpenAI Service, + ] +--- + +## Quick Introduction + +[Azure OpenAI Service](https://learn.microsoft.com/en-us/azure/ai-services/openai/overview?source=docs) provides a set of powerful APIs that enable you to easily integrate the OpenAI's language models. + +In this guide, we will show you how to integrate Azure OpenAI Service with Jan. + +## Steps to Integrate Azure OpenAI Service with Jan + +### 1. Configure Azure OpenAI Service API key + +Once you completed setting up and deploying the Azure OpenAI Service, you can find the endpoint and API key in the [Azure OpenAI Studio](https://oai.azure.com/) by navigating to `Chat` > `View code`. + +![View-code](./assets/03-viewcode.png) + +

+ +![AzureOpenAIKeyandEndpoint](./assets/03-azureopenai-endpoint-key.png) + +Set the Azure OpenAI Service endpoint and API key in the `~/jan/engines/openai.json` file. + +```json title="~/jan/engines/openai.json" +{ + // https://hieujan.openai.azure.com/openai/deployments/gpt-35-hieu-jan/chat/completions?api-version=2023-07-01-preview + // highlight-start + "full_url": "https://.openai.azure.com/openai/deployments//chat/completions?api-version=", + "api_key": "" + // highlight-end +} +``` + +### 2. Modify a Model JSON + +Navigate to the `~/jan/models` folder. Create a folder named ``, for example, `gpt-35-hieu-jan` and create a `model.json` file inside the folder including the following configurations: + +- Ensure the filename must be `model.json`. +- Ensure the `id` property is set to the same as the folder name and your deployment name. +- Ensure the `format` property is set to `api`. +- Ensure the `engine` property is set to `openai`. +- Ensure the `state` property is set to `ready`. + +```json title="~/jan/models/gpt-35-hieu-jan/model.json" +{ + "source_url": "https://hieujan.openai.azure.com", + // highlight-next-line + "id": "gpt-35-hieu-jan", + "object": "model", + "name": "Azure OpenAI GPT 3.5", + "version": "1.0", + "description": "Azure Open AI GPT 3.5 model is extremely good", + // highlight-next-line + "format": "api", + "settings": {}, + "parameters": {}, + "metadata": { + "author": "OpenAI", + "tags": ["General", "Big Context Length"] + }, + // highlight-start + "engine": "openai", + "state": "ready" + // highlight-end +} +``` + +### 3. Start the Model + +Restart Jan and navigate to the Hub. Locate your model and click the Use button. +![StartModel](./assets/03-start-model.png) + +### 4. Try Out the Integration of Jan and Azure OpenAI Service + +![Integration Demo](./assets/03-azureopenai-integration-demo.gif) diff --git a/docs/docs/guides/07-integrations/assets/03-azureopenai-endpoint-key.png b/docs/docs/guides/07-integrations/assets/03-azureopenai-endpoint-key.png new file mode 100644 index 000000000..b762da3d2 Binary files /dev/null and b/docs/docs/guides/07-integrations/assets/03-azureopenai-endpoint-key.png differ diff --git a/docs/docs/guides/07-integrations/assets/03-azureopenai-integration-demo.gif b/docs/docs/guides/07-integrations/assets/03-azureopenai-integration-demo.gif new file mode 100644 index 000000000..05cdb1147 Binary files /dev/null and b/docs/docs/guides/07-integrations/assets/03-azureopenai-integration-demo.gif differ diff --git a/docs/docs/guides/07-integrations/assets/03-start-model.png b/docs/docs/guides/07-integrations/assets/03-start-model.png new file mode 100644 index 000000000..65f44734b Binary files /dev/null and b/docs/docs/guides/07-integrations/assets/03-start-model.png differ diff --git a/docs/docs/guides/07-integrations/assets/03-viewcode.png b/docs/docs/guides/07-integrations/assets/03-viewcode.png new file mode 100644 index 000000000..6613499dd Binary files /dev/null and b/docs/docs/guides/07-integrations/assets/03-viewcode.png differ diff --git a/docs/docs/guides/08-troubleshooting/02-somethings-amiss.mdx b/docs/docs/guides/08-troubleshooting/02-somethings-amiss.mdx index 749b56ea9..a5669e36d 100644 --- a/docs/docs/guides/08-troubleshooting/02-somethings-amiss.mdx +++ b/docs/docs/guides/08-troubleshooting/02-somethings-amiss.mdx @@ -1,8 +1,11 @@ --- -title: Something's amiss +title: Something's Amiss slug: /troubleshooting/somethings-amiss description: Troubleshooting "Something's amiss". keywords: [ + jan ai failed to fetch, + failed to fetch error, + jan ai error, Jan AI, Jan, ChatGPT alternative, @@ -16,6 +19,12 @@ keywords: [ ] --- +{/* Imports */} +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; + +Previously labelled "Failed to fetch" error. + You may receive a "Something's amiss" response when you first start chatting with a selected model. This may occur due to several reasons. Please follow these steps to resolve it: @@ -30,8 +39,45 @@ This may occur due to several reasons. Please follow these steps to resolve it: 3. Install the latest [Nightly release](https://jan.ai/install/nightly/) - - If you are re-installing Jan, it can help to [clear the application cache](https://jan.ai/troubleshooting/stuck-on-broken-build/) + - If you are re-installing Jan, it can help to [clear the application cache](https://jan.ai/troubleshooting/stuck-on-broken-build/). -4. Ensure your V/RAM is accessible by the application (some people have virtual RAM) +4. Ensure your V/RAM is accessible by the application (some people have virtual RAM). -5. If you are on Nvidia GPUs, please download [Cuda](https://developer.nvidia.com/cuda-downloads) +5. If you are on Nvidia GPUs, please download [Cuda](https://developer.nvidia.com/cuda-downloads). + +6. When [checking app logs](https://jan.ai/troubleshooting/how-to-get-error-logs/), if you encounter the error log `Bind address failed at 127.0.0.1:3928`, it indicates that the port used by Nitro might already be in use. Use the following commands to check the port status: + + + + + ```bash + netstat -an | grep 3928 + ``` + + + + + ```sh + netstat -ano | find "3928" + tasklist /fi "PID eq 3928" + ``` + + + + + ```sh + netstat -anpe | grep "3928" + ``` + + + + +:::tip + +Jan uses the following ports: + +- Nitro: 3928 +- Jan API Server: 1337 +- Jan Documentation: 3001 + +::: diff --git a/docs/docs/guides/08-troubleshooting/05-permission-denied.mdx b/docs/docs/guides/08-troubleshooting/05-permission-denied.mdx new file mode 100644 index 000000000..07452d822 --- /dev/null +++ b/docs/docs/guides/08-troubleshooting/05-permission-denied.mdx @@ -0,0 +1,32 @@ +--- +title: Permission Denied +slug: /troubleshooting/permission-denied +description: Permission denied. +keywords: + [ + Jan AI, + Jan, + ChatGPT alternative, + local AI, + private AI, + conversational AI, + no-subscription fee, + large language model, + troubleshooting, + permission denied, + ] +--- + +When you run Jan, you may encounter the following error: + +```bash +Uncaught (in promise) Error: Error invoking layout-480796bff433a3a3.js:538 remote method 'installExtension': +Error Package /Applications/Jan.app/Contents/Resources/app.asar.unpacked/pre-install/janhq-assistant-extension-1.0.0.tgz does not contain a valid manifest: +Error EACCES: permission denied, mkdtemp '/Users/username/.npm/_cacache/tmp/ueCMn4' +``` + +This error indicates a permission issue during the installation process. To fix this issue, you can run the following command to change ownership of the `~/.npm` directory to the current user: + +```bash +sudo chown -R $(whoami) ~/.npm +``` diff --git a/docs/docs/guides/08-troubleshooting/06-unexpected-token.mdx b/docs/docs/guides/08-troubleshooting/06-unexpected-token.mdx new file mode 100644 index 000000000..973001f1b --- /dev/null +++ b/docs/docs/guides/08-troubleshooting/06-unexpected-token.mdx @@ -0,0 +1,20 @@ +--- +title: Unexpected Token +slug: /troubleshooting/unexpected-token +description: Unexpected token is not a valid JSON +keywords: + [ + Jan AI, + Jan, + ChatGPT alternative, + local AI, + private AI, + conversational AI, + no-subscription fee, + large language model, + troubleshooting, + unexpected token, + ] +--- + +1. You may receive an error response `Error occurred: Unexpected token '<', " diff --git a/docs/docs/handbook/07-product-and-community/01-how-do-we-know-what-to-work-on.mdx b/docs/docs/handbook/07-product-and-community/01-how-do-we-know-what-to-work-on.mdx deleted file mode 100644 index a4c772f27..000000000 --- a/docs/docs/handbook/07-product-and-community/01-how-do-we-know-what-to-work-on.mdx +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: How Do We Know What to Work On? -slug: /handbook/product-and-community/how-dowe-know-what-to-work-on -description: How Do We Know What to Work On? -keywords: - [ - Jan AI, - Jan, - ChatGPT alternative, - local AI, - private AI, - conversational AI, - no-subscription fee, - large language model, - handbook, - ] ---- diff --git a/docs/docs/handbook/07-product-and-community/02-our-OKRs.mdx b/docs/docs/handbook/07-product-and-community/02-our-OKRs.mdx deleted file mode 100644 index 79370f2a7..000000000 --- a/docs/docs/handbook/07-product-and-community/02-our-OKRs.mdx +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: Our OKRs -slug: /handbook/product-and-community/our-okrs -description: Our OKRs -keywords: - [ - Jan AI, - Jan, - ChatGPT alternative, - local AI, - private AI, - conversational AI, - no-subscription fee, - large language model, - handbook, - ] ---- diff --git a/docs/docs/handbook/07-product-and-community/03-approaches-to-beta-testing-and-user-engagement.mdx b/docs/docs/handbook/07-product-and-community/03-approaches-to-beta-testing-and-user-engagement.mdx deleted file mode 100644 index f77a68803..000000000 --- a/docs/docs/handbook/07-product-and-community/03-approaches-to-beta-testing-and-user-engagement.mdx +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: Approaches to Beta Testing and User Engagement -slug: /handbook/product-and-community/approaches-to-beta-testing-and-user-engagement -description: Approaches to Beta Testing and User Engagement -keywords: - [ - Jan AI, - Jan, - ChatGPT alternative, - local AI, - private AI, - conversational AI, - no-subscription fee, - large language model, - handbook, - ] ---- diff --git a/docs/docs/handbook/07-product-and-community/README.mdx b/docs/docs/handbook/07-product-and-community/README.mdx deleted file mode 100644 index c61d77395..000000000 --- a/docs/docs/handbook/07-product-and-community/README.mdx +++ /dev/null @@ -1,21 +0,0 @@ ---- -title: Product and Community -slug: /handbook/product-and-community -description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server. -keywords: - [ - Jan AI, - Jan, - ChatGPT alternative, - local AI, - private AI, - conversational AI, - no-subscription fee, - large language model, - handbook, - ] ---- - -import DocCardList from "@theme/DocCardList"; - - diff --git a/docs/docs/handbook/08-from-spaghetti-flinging-to-strategy/01-jan-pivot-and-journey-so-far.mdx b/docs/docs/handbook/08-from-spaghetti-flinging-to-strategy/01-jan-pivot-and-journey-so-far.mdx deleted file mode 100644 index dc78e2764..000000000 --- a/docs/docs/handbook/08-from-spaghetti-flinging-to-strategy/01-jan-pivot-and-journey-so-far.mdx +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: Jan’s Pivot and Journey So Far -slug: /handbook/from-spaghetti-flinging-to-strategy/jan-pivot-and-journey-so-far -description: Jan’s Pivot and Journey So Far -keywords: - [ - Jan AI, - Jan, - ChatGPT alternative, - local AI, - private AI, - conversational AI, - no-subscription fee, - large language model, - handbook, - ] ---- diff --git a/docs/docs/handbook/08-from-spaghetti-flinging-to-strategy/02-ESOP-philosophy.mdx b/docs/docs/handbook/08-from-spaghetti-flinging-to-strategy/02-ESOP-philosophy.mdx deleted file mode 100644 index 81d4af6dc..000000000 --- a/docs/docs/handbook/08-from-spaghetti-flinging-to-strategy/02-ESOP-philosophy.mdx +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: ESOP Philosophy -slug: /handbook/from-spaghetti-flinging-to-strategy/esop-philosophy -description: ESOP Philosophy -keywords: - [ - Jan AI, - Jan, - ChatGPT alternative, - local AI, - private AI, - conversational AI, - no-subscription fee, - large language model, - handbook, - ] ---- diff --git a/docs/docs/handbook/08-from-spaghetti-flinging-to-strategy/03-how-we-GTM.mdx b/docs/docs/handbook/08-from-spaghetti-flinging-to-strategy/03-how-we-GTM.mdx deleted file mode 100644 index fa5bb43fb..000000000 --- a/docs/docs/handbook/08-from-spaghetti-flinging-to-strategy/03-how-we-GTM.mdx +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: How We GTM -slug: /handbook/from-spaghetti-flinging-to-strategy/how-we-gtm -description: How We GTM -keywords: - [ - Jan AI, - Jan, - ChatGPT alternative, - local AI, - private AI, - conversational AI, - no-subscription fee, - large language model, - handbook, - ] ---- diff --git a/docs/docs/handbook/08-from-spaghetti-flinging-to-strategy/README.mdx b/docs/docs/handbook/08-from-spaghetti-flinging-to-strategy/README.mdx deleted file mode 100644 index bdc9f9283..000000000 --- a/docs/docs/handbook/08-from-spaghetti-flinging-to-strategy/README.mdx +++ /dev/null @@ -1,21 +0,0 @@ ---- -title: From Spaghetti Flinging to Strategy -slug: /handbook/from-spaghetti-flinging-to-strategy -description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server. -keywords: - [ - Jan AI, - Jan, - ChatGPT alternative, - local AI, - private AI, - conversational AI, - no-subscription fee, - large language model, - handbook, - ] ---- - -import DocCardList from "@theme/DocCardList"; - - diff --git a/docs/docs/handbook/09-contributing-to-jan/01-how-to-get-involved-and-FAQ.mdx b/docs/docs/handbook/09-contributing-to-jan/01-how-to-get-involved-and-FAQ.mdx deleted file mode 100644 index 9c3ba6ad1..000000000 --- a/docs/docs/handbook/09-contributing-to-jan/01-how-to-get-involved-and-FAQ.mdx +++ /dev/null @@ -1,90 +0,0 @@ ---- -title: How to Get Involved and FAQ -slug: /handbook/contributing-to-jan/how-to-get-involved-and-faq -description: How to Get Involved and FAQ -keywords: - [ - Jan AI, - Jan, - ChatGPT alternative, - local AI, - private AI, - conversational AI, - no-subscription fee, - large language model, - handbook, - contributing to Jan, - FAQ, - ] ---- - -_We get a lot of questions about Jan (and why wouldn't we, she’s pretty interesting!). Some key points if you’re looking for help - please note your build and OS!_ - -- Read this FAQ -- Read our [Docs](https://jan.ai/docs/) -- Search our [Discord](https://discord.gg/z9Z8hdNdvN) -- If you can’t find an answer - create a post in `#get-help`. - -![Get-help](./assets/01-get-help.png) - -## Common Jan Questions - -### What’s the Best Model For? Or Will Jan Choose the Best Model for Me? - -[Soon!](https://discord.com/channels/1107178041848909847/1193811810332184606/1193814713264521226) - -We get asked a lot what the “best model” is. Depending on your goals, there’s a model that will fit best. Check out our descriptions in the Model Hub - -### Does the Size of a Model Improve Accuracy? - -No, the accuracy is determined by the quantize version - -### Can I Set a Custom Path for Models? Or Can I Install in Different Folders? - -Sort of - [you can create a symbolic link](https://discord.com/channels/1107178041848909847/1110389363809976361/1193919109180706866). - -### Can I use the local LLMs in Jan like Copilot/ChatGPT in VS Code? - -Yes! [See here](https://github.com/continuedev/continue) about using Continue and [Jan’s server](https://jan.ai/guides/using-server/server/) - -### Can I Use Jan Desktop as a Second Brain? - -You sure can. [RAG is on the way](https://github.com/janhq/jan/issues/1076). You can also do do fine-tuning/LoRAs. - -### Can I Set Up My Remote Server with Jan and Use Jan on My Phone or Tablet? - -You can! Here’s [how we integrate with remote servers](https://jan.ai/guides/using-models/integrate-with-remote-server/) - -### Can I Report a Bug! - -Yup! Please do check our Discord to see if we’re currently working on it (reducing duplication) - -### What’s a Nightly Build? - -Nightly releases are cutting-edge versions that include the latest features. However, they are highly unstable and may contain bugs. Please follow this [guide](https://jan.ai/install/nightly/) to install a nightly build. - -### How Can I Benchmark Models in Jan? - -### Can I Download Jan, Host It and White Label It? - -Please get in touch [here] (https://form.fillout.com/t/bxeHrdHhiZus) - -### When Will My GPU Be Used? - -Jan will use your GPU when the model is loaded. If you unload the model there’s no use afterwards. - -### What Makes Jan Unique in AI and Productivity? - -Jan specializes in open-source GPT and offline local AI, making advanced AI technology accessible and reliable for a variety of uses. - -### What Are Jan’s Model Training Objectives? - -Our goal is to develop LLMs that are not only theoretically impressive but also provide practical productivity enhancements and are tailored to specific industries. - -### How Is Jan Impacting Education and Healthcare with AI? - -Jan is developing domain-specific LLMs to revolutionise education and healthcare, providing expert-level assistance in these key sectors. - -### What Distinguishes Jan’s Approach to AI Development? - -Jan's commitment to open-source  and offline local AI ensures our technology is not just advanced but also practical and accessible for diverse applications. diff --git a/docs/docs/handbook/09-contributing-to-jan/02-feedback-channels.mdx b/docs/docs/handbook/09-contributing-to-jan/02-feedback-channels.mdx deleted file mode 100644 index 20a33b507..000000000 --- a/docs/docs/handbook/09-contributing-to-jan/02-feedback-channels.mdx +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: Feedback Channels/ Where to Get Help/ Use Your Voice -slug: /handbook/contributing-to-jan/feedback-channels -description: Feedback Channels/ Where to Get Help/ Use Your Voice -keywords: - [ - Jan AI, - Jan, - ChatGPT alternative, - local AI, - private AI, - conversational AI, - no-subscription fee, - large language model, - handbook, - ] ---- diff --git a/docs/docs/handbook/09-contributing-to-jan/README.mdx b/docs/docs/handbook/09-contributing-to-jan/README.mdx deleted file mode 100644 index fa5e60515..000000000 --- a/docs/docs/handbook/09-contributing-to-jan/README.mdx +++ /dev/null @@ -1,21 +0,0 @@ ---- -title: Contributing to Jan -slug: /handbook/contributing-to-jan -description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server. -keywords: - [ - Jan AI, - Jan, - ChatGPT alternative, - local AI, - private AI, - conversational AI, - no-subscription fee, - large language model, - handbook, - ] ---- - -import DocCardList from "@theme/DocCardList"; - - diff --git a/docs/docs/template/QA_script.md b/docs/docs/template/QA_script.md new file mode 100644 index 000000000..05dbed2b4 --- /dev/null +++ b/docs/docs/template/QA_script.md @@ -0,0 +1,210 @@ +# [Release Version] QA Script + +**Release Version:** + +**Operating System:** + +--- + +## A. Installation, Update, and Uninstallation + +### 1. Users install app + +- [ ] :key: Test for clear user installation instructions. +- [ ] :key: Verify that the installation path is correct for each OS. +- [ ] Check that the installation package is not corrupted and passes all security checks. +- [ ] Validate that the app is correctly installed in the default or user-specified directory. +- [ ] Ensure that all necessary dependencies are installed along with the app. +- [ ] :key: :rocket: Confirm that the app launches successfully after installation. + +### 2. Users update app + +- [ ] :key: Test that the updated version includes the new features or fixes outlined in the update notes. +- [ ] :key: Validate that the update does not corrupt user data or settings. +- [ ] :key: Confirm that the app restarts or prompts the user to restart after an update. + +### 3. Users uninstall app + +- [ ] :key: Check that the uninstallation process removes all components of the app from the system. +- [ ] Clean the Jan root directory and open the app to check if it creates all the necessary folders, especially models and extensions. +- [ ] When updating the app, check if the `/models` directory has any JSON files that change according to the update. +- [ ] Verify if updating the app also updates extensions correctly (test functionality changes; support notifications for necessary tests with each version related to extensions update). + +### 4. Users close app + +- [ ] :key: Ensure that after closing the app, all models are unloaded. + +## B. Overview + +### 1. Users use shortcut keys + +- [ ] :key: Test each shortcut key to confirm it works as described (My models, navigating, opening, closing, etc.). + +### 2. Users check the memory usage and CPU usage + +- [ ] :key: Ensure that the interface presents the correct numbers for memory and CPU usage. + +### 3. Users check the `active model` + +- [ ] :key: Verify that the app correctly displays the state of the loading model (e.g., loading, ready, error). +- [ ] :key: Confirm that the app allows users to switch between models if multiple are available. +- [ ] Check that the app provides feedback or instructions if the model fails to load. + +## C. Thread + +### 1. Users can chat with Jan, the default assistant + +- [ ] Verify that the input box for messages is present and functional. +- [ ] :key: Check if typing a message and hitting `Send` results in the message appearing in the chat window. +- [ ] :key: Confirm that Jan, the default assistant, replies to user inputs. +- [ ] :key: Ensure that the conversation thread is maintained without any loss of data upon sending multiple messages. +- [ ] Test for the ability to send different types of messages (e.g., text, emojis, code blocks). +- [ ] :key: Validate the scroll functionality in the chat window for lengthy conversations. +- [ ] Check if the user can renew responses multiple times. +- [ ] Check if the user can copy the response. +- [ ] Check if the user can delete responses. +- [ ] :warning: Test if the user deletes the message midway, then the assistant stops that response. +- [ ] :key: Check the `clear message` button works. +- [ ] :key: Check the `delete entire chat` works. +- [ ] :warning: Check if deleting all the chat retains the system prompt. +- [ ] Check the output format of the AI (code blocks, JSON, markdown, ...). +- [ ] :key: Validate that there is appropriate error handling and messaging if the assistant fails to respond. +- [ ] Test assistant's ability to maintain context over multiple exchanges. +- [ ] :key: Check the `create new chat` button works correctly +- [ ] Confirm that by changing `models` mid-thread the app can still handle it. +- [ ] Check that by changing `instructions` mid-thread the app can still handle it. +- [ ] Check the `regenerate` button renews the response. +- [ ] Check the `Instructions` update correctly after the user updates it midway. + +### 2. Users can customize chat settings like model parameters via both the GUI & thread.json + +- [ ] :key: Confirm that the chat settings options are accessible via the GUI. +- [ ] Test the functionality to adjust model parameters (e.g., Temperature, Top K, Top P) from the GUI and verify they are reflected in the chat behavior. +- [ ] :key: Ensure that changes can be saved and persisted between sessions. +- [ ] Validate that users can access and modify the thread.json file. +- [ ] :key: Check that changes made in thread.json are correctly applied to the chat session upon reload or restart. +- [ ] Verify if there is a revert option to go back to previous settings after changes are made. +- [ ] Test for user feedback or confirmation after saving changes to settings. +- [ ] Check the maximum and minimum limits of the adjustable parameters and how they affect the assistant's responses. +- [ ] :key: Validate user permissions for those who can change settings and persist them. +- [ ] :key: Ensure that users switch between threads with different models, the app can handle it. + +### 3. Users can click on a history thread + +- [ ] Test the ability to click on any thread in the history panel. +- [ ] :key: Verify that clicking a thread brings up the past conversation in the main chat window. +- [ ] :key: Ensure that the selected thread is highlighted or otherwise indicated in the history panel. +- [ ] Confirm that the chat window displays the entire conversation from the selected history thread without any missing messages. +- [ ] :key: Check the performance and accuracy of the history feature when dealing with a large number of threads. +- [ ] Validate that historical threads reflect the exact state of the chat at that time, including settings. +- [ ] :key: :warning: Test the search functionality within the history panel for quick navigation. +- [ ] :key: Verify the ability to delete or clean old threads. +- [ ] :key: Confirm that changing the title of the thread updates correctly. + +### 4. Users can config instructions for the assistant. + +- [ ] Ensure there is a clear interface to input or change instructions for the assistant. +- [ ] Test if the instructions set by the user are being followed by the assistant in subsequent conversations. +- [ ] :key: Validate that changes to instructions are updated in real time and do not require a restart of the application or session. +- [ ] :key: Confirm that the assistant's behavior changes in accordance with the new instructions provided. +- [ ] :key: Check for the ability to reset instructions to default or clear them completely. +- [ ] :key: Test the feature that allows users to save custom sets of instructions for different scenarios. +- [ ] Validate that instructions can be saved with descriptive names for easy retrieval. +- [ ] :key: Check if the assistant can handle conflicting instructions and how it resolves them. +- [ ] Ensure that instruction configurations are documented for user reference. + +## D. Hub + +### 1. Users can discover recommended models (Jan ships with a few preconfigured model.json files) + +- [ ] :key: Verify that recommended models are displayed prominently on the main page. +- [ ] :key: Ensure that each model's recommendations are consistent with the user’s activity and preferences. +- [ ] Test the functionality of any filters that refine model recommendations. + +### 2. Users can download models suitable for their devices, e.g. compatible with their RAM + +- [ ] Display the best model for their RAM at the top. +- [ ] :key: Ensure that models are labeled with RAM requirements and compatibility. +- [ ] :key: Validate that the download function is disabled for models that exceed the user's system capabilities. +- [ ] Test that the platform provides alternative recommendations for models not suitable due to RAM limitations. +- [ ] :key: Check the download model functionality and validate if the cancel download feature works correctly. + +### 3. Users can download models via a HuggingFace URL (coming soon) + +- [ ] :key: Have the warning/status when the user enters the URL. +- [ ] :key: Check the progress bar reflects the right process. +- [ ] Validate the error handling for invalid or inaccessible URLs. + +### 4. Users can add a new model to the Hub + +- [ ] :key: Have clear instructions so users can do their own. +- [ ] :key: Ensure the new model updates after restarting the app. +- [ ] Ensure it raises clear errors for users to fix the problem while adding a new model. + +### 5. Users can use the model as they want + +- [ ] :key: Check `start` button response exactly what it does. +- [ ] :key: Check `stop` button response exactly what it does. +- [ ] :key: Check `delete` button response exactly what it does. +- [ ] Check if starting another model stops the other model entirely. +- [ ] Check the `Explore models` navigate correctly to the model panel. +- [ ] :key: Check when deleting a model it will delete all the files on the user's computer. +- [ ] The recommended tags should present right for the user's hardware. +- [ ] Assess that the descriptions of models are accurate and informative. + +## E. System Monitor + +### 1. Users can see disk and RAM utilization + +- [ ] :key: Verify that the RAM and VRAM utilization graphs display accurate information. +- [ ] :key: Check that the CPU usage is accurately reported in real time. +- [ ] :key: Validate that the utilization percentages reflect the actual usage compared to the system's total available resources. +- [ ] :key: Ensure that the system monitors updates dynamically as the models run and stop. + +### 2. Users can start and stop models based on system health + +- [ ] :key: Test the 'Start' action for a model to ensure it initiates and the system resource usage reflects this change. +- [ ] :key: Verify the 'Stop' action for a model to confirm it ceases operation and frees up the system resources accordingly. +- [ ] :key: Check the functionality that allows starting a model based on available system resources. +- [ ] :key: Validate that the system prevents starting a new model if it exceeds safe resource utilization thresholds. +- [ ] Ensure that the system provides warnings or recommendations when resource utilization is high before starting new models. +- [ ] Test the ease of accessing model settings from the system monitor for resource management. +- [ ] Confirm that any changes in model status (start/stop) are logged or reported to the user for transparency. + +## F. Settings + +### 1. Users can set color themes and dark/ light modes + +- [ ] Verify that the theme setting is easily accessible in the `Appearance` tab. +- [ ] :key: Check that the theme change is reflected immediately upon selection. +- [ ] :key: Test the `Light`, `Dark`, and `System` theme settings to ensure they are functioning as expected. +- [ ] Confirm that the application saves the theme preference and persists it across sessions. +- [ ] Validate that all elements of the UI are compatible with the theme changes and maintain legibility and contrast. + +### 2. Users change the extensions + +- [ ] Confirm that the `Extensions` tab lists all available plugins. +- [ ] :key: Test the toggle switch for each plugin to ensure it enables or disables the plugin correctly. +- [ ] Verify that plugin changes take effect without needing to restart the application unless specified. +- [ ] :key: Check that the plugin's status (`Installed the latest version`) updates accurately after any changes. +- [ ] Validate the `Manual Installation` process by selecting and installing a plugin file. +- [ ] Test for proper error handling and user feedback when a plugin installation fails. + +### 3. Users change the advanced settings + +- [ ] :key: Test the `Experimental Mode` toggle to confirm it enables or disables experimental features as intended. +- [ ] :key: Check the functionality of `Open App Directory` to ensure it opens the correct folder in the system file explorer. +- [ ] Validate that changes in advanced settings are applied immediately or provide appropriate instructions if a restart is needed. +- [ ] Test the application's stability when experimental features are enabled. + +### 4. Users can add custom plugins via manual installation [TBU] + +- [ ] Verify that the `Manual Installation` option is clearly visible and accessible in the `Extensions` section. +- [ ] Test the functionality of the `Select` button within the `Manual Installation` area. +- [ ] :warning: Check that the file picker dialog allows for the correct plugin file types (e.g., .tgz). +- [ ] :key: Validate that the selected plugin file installs correctly and the plugin becomes functional. +- [ ] Ensure that there is a progress indicator or confirmation message once the installation is complete. +- [ ] Confirm that if the installation is interrupted or fails, the user is given a clear error message. +- [ ] :key: Test that the application prevents the installation of incompatible or corrupt plugin files. +- [ ] :key: Check that the user can uninstall or disable custom plugins as easily as pre-installed ones. +- [ ] Verify that the application's performance remains stable after the installation of custom plugins. diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index d2169a63b..4ed7f0bea 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -155,8 +155,8 @@ const config = { algolia: { appId: process.env.ALGOLIA_APP_ID || "XXX", apiKey: process.env.ALGOLIA_API_KEY || "XXX", - indexName: "jan", - contextualSearch: false, + indexName: "jan_docs", + contextualSearch: true, insights: true, }, // SEO Docusarus @@ -263,12 +263,6 @@ const config = { label: "Framework", }, // Navbar right - { - type: "docSidebar", - position: "right", - sidebarId: "communitySidebar", - label: "Community", - }, { to: "blog", label: "Blog", diff --git a/docs/openapi/jan.yaml b/docs/openapi/jan.yaml index e3463c94c..bfff0ad73 100644 --- a/docs/openapi/jan.yaml +++ b/docs/openapi/jan.yaml @@ -14,7 +14,7 @@ license: name: AGPLv3 url: https://github.com/janhq/nitro/blob/main/LICENSE servers: - - url: http://localhost:1337/v1/ + - url: /v1 tags: - name: Models description: List and describe the various models available in the API. @@ -642,4 +642,4 @@ x-webhooks: content: application/json: schema: - $ref: specs/threads.yaml#/components/schemas/ThreadObject \ No newline at end of file + $ref: specs/threads.yaml#/components/schemas/ThreadObject diff --git a/docs/sidebars.js b/docs/sidebars.js index a50c9ed49..00eb8d40d 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -34,46 +34,10 @@ const sidebars = { }, ], - communitySidebar: [ - "community/community", - { - type: "category", - label: "Events", - collapsible: true, - collapsed: true, - items: [ - "events/nvidia-llm-day-nov-23", - { - type: "doc", - label: "Oct 23: HCMC Hacker House", - id: "events/hcmc-oct23", - }, - ], - }, - ], - aboutSidebar: [ - { - type: "doc", - label: "About Jan", - id: "about/about", - }, - { - type: "doc", - label: "Company Handbook", - id: "handbook/overview", - }, - { - type: "link", - label: "Careers", - href: "https://janai.bamboohr.com/careers", - }, - ], - - handbookSidebar: [ { type: "autogenerated", - dirName: "handbook", + dirName: "about", }, ], }; diff --git a/docs/src/containers/Footer/index.js b/docs/src/containers/Footer/index.js index 02d97eeeb..80b5a2d4b 100644 --- a/docs/src/containers/Footer/index.js +++ b/docs/src/containers/Footer/index.js @@ -24,7 +24,7 @@ const menus = [ child: [ { menu: "Documentation", - path: "/intro", + path: "/developer", }, { menu: "Hardware", diff --git a/electron/.eslintrc.js b/electron/.eslintrc.js index 25a98348f..d252ec42b 100644 --- a/electron/.eslintrc.js +++ b/electron/.eslintrc.js @@ -34,5 +34,5 @@ module.exports = { { name: 'Link', linkAttribute: 'to' }, ], }, - ignorePatterns: ['build', 'renderer', 'node_modules'], + ignorePatterns: ['build', 'renderer', 'node_modules', '@global'], } diff --git a/electron/@global/index.ts b/electron/@global/index.ts new file mode 100644 index 000000000..b2d55fc1c --- /dev/null +++ b/electron/@global/index.ts @@ -0,0 +1,10 @@ +export {} + +declare global { + namespace NodeJS { + interface Global { + core: any + } + } + var core: any | undefined +} diff --git a/electron/handlers/app.ts b/electron/handlers/app.ts index 2a84b0de9..bdb70047a 100644 --- a/electron/handlers/app.ts +++ b/electron/handlers/app.ts @@ -1,10 +1,19 @@ -import { app, ipcMain, shell } from 'electron' +import { app, ipcMain, dialog, shell } from 'electron' import { join, basename } from 'path' import { WindowManager } from './../managers/window' -import { getResourcePath, userSpacePath } from './../utils/path' -import { AppRoute } from '@janhq/core' -import { ModuleManager, init, log } from '@janhq/core/node' -import { startServer, stopServer } from '@janhq/server' +import { getResourcePath } from './../utils/path' +import { AppRoute, AppConfiguration } from '@janhq/core' +import { ServerConfig, startServer, stopServer } from '@janhq/server' +import { + ModuleManager, + getJanDataFolderPath, + getJanExtensionsPath, + init, + log, + logServer, + getAppConfigurations, + updateAppConfiguration, +} from '@janhq/core/node' export function handleAppIPCs() { /** @@ -13,7 +22,7 @@ export function handleAppIPCs() { * @param _event - The IPC event object. */ ipcMain.handle(AppRoute.openAppDirectory, async (_event) => { - shell.openPath(userSpacePath) + shell.openPath(getJanDataFolderPath()) }) /** @@ -51,26 +60,32 @@ export function handleAppIPCs() { /** * Start Jan API Server. */ - ipcMain.handle(AppRoute.startServer, async (_event) => - startServer( - app.isPackaged + ipcMain.handle(AppRoute.startServer, async (_event, configs?: ServerConfig) => + startServer({ + host: configs?.host, + port: configs?.port, + isCorsEnabled: configs?.isCorsEnabled, + isVerboseEnabled: configs?.isVerboseEnabled, + schemaPath: app.isPackaged ? join(getResourcePath(), 'docs', 'openapi', 'jan.yaml') : undefined, - app.isPackaged ? join(getResourcePath(), 'docs', 'openapi') : undefined - ) + baseDir: app.isPackaged + ? join(getResourcePath(), 'docs', 'openapi') + : undefined, + }) ) /** * Stop Jan API Server. */ - ipcMain.handle(AppRoute.stopServer, async (_event) => stopServer()) + ipcMain.handle(AppRoute.stopServer, stopServer) /** * Relaunches the app in production - reload window in development. * @param _event - The IPC event object. * @param url - The URL to reload. */ - ipcMain.handle(AppRoute.relaunch, async (_event, url) => { + ipcMain.handle(AppRoute.relaunch, async (_event) => { ModuleManager.instance.clearImportedModules() if (app.isPackaged) { @@ -79,7 +94,7 @@ export function handleAppIPCs() { } else { for (const modulePath in ModuleManager.instance.requiredModules) { delete require.cache[ - require.resolve(join(userSpacePath, 'extensions', modulePath)) + require.resolve(join(getJanExtensionsPath(), modulePath)) ] } init({ @@ -88,7 +103,7 @@ export function handleAppIPCs() { return true }, // Path to install extension to - extensionsPath: join(userSpacePath, 'extensions'), + extensionsPath: getJanExtensionsPath(), }) WindowManager.instance.currentWindow?.reload() } @@ -97,7 +112,41 @@ export function handleAppIPCs() { /** * Log message to log file. */ - ipcMain.handle(AppRoute.log, async (_event, message, fileName) => - log(message, fileName) + ipcMain.handle(AppRoute.log, async (_event, message) => log(message)) + + /** + * Log message to log file. + */ + ipcMain.handle(AppRoute.logServer, async (_event, message) => + logServer(message) + ) + + ipcMain.handle(AppRoute.selectDirectory, async () => { + const mainWindow = WindowManager.instance.currentWindow + if (!mainWindow) { + console.error('No main window found') + return + } + const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, { + title: 'Select a folder', + buttonLabel: 'Select Folder', + properties: ['openDirectory', 'createDirectory'], + }) + if (canceled) { + return + } else { + return filePaths[0] + } + }) + + ipcMain.handle(AppRoute.getAppConfigurations, async () => + getAppConfigurations() + ) + + ipcMain.handle( + AppRoute.updateAppConfiguration, + async (_event, appConfiguration: AppConfiguration) => { + await updateAppConfiguration(appConfiguration) + } ) } diff --git a/electron/handlers/download.ts b/electron/handlers/download.ts index e8867b055..f63e56f6b 100644 --- a/electron/handlers/download.ts +++ b/electron/handlers/download.ts @@ -1,11 +1,11 @@ -import { app, ipcMain } from 'electron' -import { resolve, join } from 'path' +import { ipcMain } from 'electron' +import { resolve } from 'path' import { WindowManager } from './../managers/window' import request from 'request' import { createWriteStream, renameSync } from 'fs' import { DownloadEvent, DownloadRoute } from '@janhq/core' const progress = require('request-progress') -import { DownloadManager } from '@janhq/core/node' +import { DownloadManager, getJanDataFolderPath, normalizeFilePath } from '@janhq/core/node' export function handleDownloaderIPCs() { /** @@ -54,64 +54,68 @@ export function handleDownloaderIPCs() { * @param url - The URL to download the file from. * @param fileName - The name to give the downloaded file. */ - ipcMain.handle(DownloadRoute.downloadFile, async (_event, url, fileName) => { - const userDataPath = join(app.getPath('home'), 'jan') - if ( - typeof fileName === 'string' && - (fileName.includes('file:/') || fileName.includes('file:\\')) - ) { - fileName = fileName.replace('file:/', '').replace('file:\\', '') - } - const destination = resolve(userDataPath, fileName) - const rq = request(url) + ipcMain.handle( + DownloadRoute.downloadFile, + async (_event, url, fileName, network) => { + const strictSSL = !network?.ignoreSSL + const proxy = network?.proxy?.startsWith('http') + ? network.proxy + : undefined - // Put request to download manager instance - DownloadManager.instance.setRequest(fileName, rq) + if (typeof fileName === 'string') { + fileName = normalizeFilePath(fileName) + } + const destination = resolve(getJanDataFolderPath(), fileName) + const rq = request({ url, strictSSL, proxy }) - // Downloading file to a temp file first - const downloadingTempFile = `${destination}.download` + // Put request to download manager instance + DownloadManager.instance.setRequest(fileName, rq) - progress(rq, {}) - .on('progress', function (state: any) { - WindowManager?.instance.currentWindow?.webContents.send( - DownloadEvent.onFileDownloadUpdate, - { - ...state, - fileName, - } - ) - }) - .on('error', function (err: Error) { - WindowManager?.instance.currentWindow?.webContents.send( - DownloadEvent.onFileDownloadError, - { - fileName, - err, - } - ) - }) - .on('end', function () { - if (DownloadManager.instance.networkRequests[fileName]) { - // Finished downloading, rename temp file to actual file - renameSync(downloadingTempFile, destination) + // Downloading file to a temp file first + const downloadingTempFile = `${destination}.download` + progress(rq, {}) + .on('progress', function (state: any) { WindowManager?.instance.currentWindow?.webContents.send( - DownloadEvent.onFileDownloadSuccess, + DownloadEvent.onFileDownloadUpdate, { + ...state, fileName, } ) - DownloadManager.instance.setRequest(fileName, undefined) - } else { + }) + .on('error', function (err: Error) { WindowManager?.instance.currentWindow?.webContents.send( DownloadEvent.onFileDownloadError, { fileName, - err: { message: 'aborted' }, + err, } ) - } - }) - .pipe(createWriteStream(downloadingTempFile)) - }) + }) + .on('end', function () { + if (DownloadManager.instance.networkRequests[fileName]) { + // Finished downloading, rename temp file to actual file + renameSync(downloadingTempFile, destination) + + WindowManager?.instance.currentWindow?.webContents.send( + DownloadEvent.onFileDownloadSuccess, + { + fileName, + } + ) + DownloadManager.instance.setRequest(fileName, undefined) + } else { + WindowManager?.instance.currentWindow?.webContents.send( + DownloadEvent.onFileDownloadError, + { + fileName, + err: { message: 'aborted' }, + } + ) + } + }) + .pipe(createWriteStream(downloadingTempFile)) + } + ) } diff --git a/electron/handlers/extension.ts b/electron/handlers/extension.ts index ad8b59a99..763c4cdec 100644 --- a/electron/handlers/extension.ts +++ b/electron/handlers/extension.ts @@ -7,10 +7,11 @@ import { getExtension, removeExtension, getActiveExtensions, - ModuleManager + ModuleManager, + getJanExtensionsPath, } from '@janhq/core/node' -import { getResourcePath, userSpacePath } from './../utils/path' +import { getResourcePath } from './../utils/path' import { ExtensionRoute } from '@janhq/core' export function handleExtensionIPCs() { @@ -27,7 +28,7 @@ export function handleExtensionIPCs() { ExtensionRoute.invokeExtensionFunc, async (_event, modulePath, method, ...args) => { const module = require( - /* webpackIgnore: true */ join(userSpacePath, 'extensions', modulePath) + /* webpackIgnore: true */ join(getJanExtensionsPath(), modulePath) ) ModuleManager.instance.setModule(modulePath, module) diff --git a/electron/handlers/fileManager.ts b/electron/handlers/fileManager.ts index bfbd9af45..f41286934 100644 --- a/electron/handlers/fileManager.ts +++ b/electron/handlers/fileManager.ts @@ -2,11 +2,11 @@ import { ipcMain } from 'electron' // @ts-ignore import reflect from '@alumna/reflect' -import { FileManagerRoute } from '@janhq/core' -import { userSpacePath, getResourcePath } from './../utils/path' +import { FileManagerRoute, FileStat } from '@janhq/core' +import { getResourcePath } from './../utils/path' import fs from 'fs' import { join } from 'path' -import { FileStat } from '@janhq/core' +import { getJanDataFolderPath, normalizeFilePath } from '@janhq/core/node' /** * Handles file system extensions operations. @@ -27,10 +27,10 @@ export function handleFileMangerIPCs() { } ) - // Handles the 'getUserSpace' IPC event. This event is triggered to get the user space path. + // Handles the 'getJanDataFolderPath' IPC event. This event is triggered to get the user space path. ipcMain.handle( - FileManagerRoute.getUserSpace, - (): Promise => Promise.resolve(userSpacePath) + FileManagerRoute.getJanDataFolderPath, + (): Promise => Promise.resolve(getJanDataFolderPath()) ) // Handles the 'getResourcePath' IPC event. This event is triggered to get the resource path. @@ -42,13 +42,9 @@ export function handleFileMangerIPCs() { ipcMain.handle( FileManagerRoute.fileStat, async (_event, path: string): Promise => { - const normalizedPath = path - .replace(`file://`, '') - .replace(`file:/`, '') - .replace(`file:\\\\`, '') - .replace(`file:\\`, '') + const normalizedPath = normalizeFilePath(path) - const fullPath = join(userSpacePath, normalizedPath) + const fullPath = join(getJanDataFolderPath(), normalizedPath) const isExist = fs.existsSync(fullPath) if (!isExist) return undefined diff --git a/electron/handlers/fs.ts b/electron/handlers/fs.ts index 8f7e434cc..408a5fd10 100644 --- a/electron/handlers/fs.ts +++ b/electron/handlers/fs.ts @@ -1,8 +1,9 @@ import { ipcMain } from 'electron' import { FileSystemRoute } from '@janhq/core' -import { userSpacePath } from '../utils/path' import { join } from 'path' +import { getJanDataFolderPath, normalizeFilePath } from '@janhq/core/node' + /** * Handles file system operations. */ @@ -15,14 +16,7 @@ export function handleFsIPCs() { ...args.map((arg) => typeof arg === 'string' && (arg.includes(`file:/`) || arg.includes(`file:\\`)) - ? join( - userSpacePath, - arg - .replace(`file://`, '') - .replace(`file:/`, '') - .replace(`file:\\\\`, '') - .replace(`file:\\`, '') - ) + ? join(getJanDataFolderPath(), normalizeFilePath(arg)) : arg ) ) diff --git a/electron/main.ts b/electron/main.ts index 75ba9b062..fb7066cd0 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,12 +1,10 @@ import { app, BrowserWindow } from 'electron' import { join } from 'path' -import { setupMenu } from './utils/menu' -import { createUserSpace } from './utils/path' /** * Managers **/ import { WindowManager } from './managers/window' -import { log, ModuleManager } from '@janhq/core/node' +import { log } from '@janhq/core/node' /** * IPC Handlers @@ -21,12 +19,16 @@ import { handleFsIPCs } from './handlers/fs' /** * Utils **/ +import { setupMenu } from './utils/menu' +import { createUserSpace } from './utils/path' import { migrateExtensions } from './utils/migration' import { cleanUpAndQuit } from './utils/clean' import { setupExtensions } from './utils/extension' +import { setupCore } from './utils/setup' app .whenReady() + .then(setupCore) .then(createUserSpace) .then(migrateExtensions) .then(setupExtensions) @@ -94,9 +96,8 @@ function handleIPCs() { } /* -** Suppress Node error messages -*/ + ** Suppress Node error messages + */ process.on('uncaughtException', function (err) { - // TODO: Write error to log file in #1447 log(`Error: ${err}`) }) diff --git a/electron/utils/extension.ts b/electron/utils/extension.ts index 20bb50b46..e055411a6 100644 --- a/electron/utils/extension.ts +++ b/electron/utils/extension.ts @@ -1,13 +1,12 @@ -import { init, userSpacePath } from '@janhq/core/node' -import path from 'path' +import { getJanExtensionsPath, init } from '@janhq/core/node' -export const setupExtensions = () => { +export const setupExtensions = async () => { init({ // Function to check from the main process that user wants to install a extension confirmInstall: async (_extensions: string[]) => { return true }, // Path to install extension to - extensionsPath: path.join(userSpacePath, 'extensions'), + extensionsPath: getJanExtensionsPath(), }) } diff --git a/electron/utils/menu.ts b/electron/utils/menu.ts index 97a2afd98..7721b7c78 100644 --- a/electron/utils/menu.ts +++ b/electron/utils/menu.ts @@ -1,110 +1,96 @@ // @ts-nocheck -import { app, Menu, dialog, shell } from "electron"; -const isMac = process.platform === "darwin"; -const { autoUpdater } = require("electron-updater"); -import { compareSemanticVersions } from "./versionDiff"; +import { app, Menu, dialog, shell } from 'electron' +const isMac = process.platform === 'darwin' +import { autoUpdater } from 'electron-updater' +import { compareSemanticVersions } from './versionDiff' const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [ - ...(isMac - ? [ - { - label: app.name, - submenu: [ - { role: "about" }, - { - label: "Check for Updates...", - click: () => - autoUpdater.checkForUpdatesAndNotify().then((e) => { - if ( - !e || - compareSemanticVersions( - app.getVersion(), - e.updateInfo.version - ) >= 0 - ) - dialog.showMessageBox({ - message: `There are currently no updates available.`, - }); - }), - }, - { type: "separator" }, - { role: "services" }, - { type: "separator" }, - { role: "hide" }, - { role: "hideOthers" }, - { role: "unhide" }, - { type: "separator" }, - { role: "quit" }, - ], - }, - ] - : []), { - label: "Edit", + label: app.name, submenu: [ - { role: "undo" }, - { role: "redo" }, - { type: "separator" }, - { role: "cut" }, - { role: "copy" }, - { role: "paste" }, + { role: 'about' }, + { + label: 'Check for Updates...', + click: () => + // Check for updates and notify user if there are any + autoUpdater.checkForUpdatesAndNotify(), + }, + { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' }, + ], + }, + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, ...(isMac ? [ - { role: "pasteAndMatchStyle" }, - { role: "delete" }, - { role: "selectAll" }, - { type: "separator" }, + { role: 'pasteAndMatchStyle' }, + { role: 'delete' }, + { role: 'selectAll' }, + { type: 'separator' }, { - label: "Speech", - submenu: [{ role: "startSpeaking" }, { role: "stopSpeaking" }], + label: 'Speech', + submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }], }, ] - : [{ role: "delete" }, { type: "separator" }, { role: "selectAll" }]), + : [{ role: 'delete' }, { type: 'separator' }, { role: 'selectAll' }]), ], }, { - label: "View", + label: 'View', submenu: [ - { role: "reload" }, - { role: "forceReload" }, - { role: "toggleDevTools" }, - { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn" }, - { role: "zoomOut" }, - { type: "separator" }, - { role: "togglefullscreen" }, + { role: 'reload' }, + { role: 'forceReload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' }, ], }, { - label: "Window", + label: 'Window', submenu: [ - { role: "minimize" }, - { role: "zoom" }, + { role: 'minimize' }, + { role: 'zoom' }, ...(isMac ? [ - { type: "separator" }, - { role: "front" }, - { type: "separator" }, - { role: "window" }, + { type: 'separator' }, + { role: 'front' }, + { type: 'separator' }, + { role: 'window' }, ] - : [{ role: "close" }]), + : [{ role: 'close' }]), ], }, { - role: "help", + role: 'help', submenu: [ { - label: "Learn More", + label: 'Learn More', click: async () => { - await shell.openExternal("https://jan.ai/"); + await shell.openExternal('https://jan.ai/guides/') }, }, ], }, -]; +] export const setupMenu = () => { - const menu = Menu.buildFromTemplate(template); - Menu.setApplicationMenu(menu); -}; + const menu = Menu.buildFromTemplate(template) + Menu.setApplicationMenu(menu) +} diff --git a/electron/utils/migration.ts b/electron/utils/migration.ts index cf66270c6..399b362f4 100644 --- a/electron/utils/migration.ts +++ b/electron/utils/migration.ts @@ -1,9 +1,9 @@ import { app } from 'electron' -import { join } from 'path' import { rmdir } from 'fs' import Store from 'electron-store' -import { userSpacePath } from './path' +import { getJanExtensionsPath } from '@janhq/core/node' + /** * Migrates the extensions by deleting the `extensions` directory in the user data path. * If the `migrated_version` key in the `Store` object does not match the current app version, @@ -15,9 +15,8 @@ export function migrateExtensions() { const store = new Store() if (store.get('migrated_version') !== app.getVersion()) { console.debug('start migration:', store.get('migrated_version')) - const fullPath = join(userSpacePath, 'extensions') - rmdir(fullPath, { recursive: true }, function (err) { + rmdir(getJanExtensionsPath(), { recursive: true }, function (err) { if (err) console.error(err) store.set('migrated_version', app.getVersion()) console.debug('migrate extensions done') diff --git a/electron/utils/path.ts b/electron/utils/path.ts index 8f3092561..4e47cc312 100644 --- a/electron/utils/path.ts +++ b/electron/utils/path.ts @@ -1,13 +1,22 @@ import { join } from 'path' import { app } from 'electron' import { mkdir } from 'fs-extra' +import { existsSync } from 'fs' +import { getJanDataFolderPath } from '@janhq/core/node' export async function createUserSpace(): Promise { - return mkdir(userSpacePath).catch(() => {}) + const janDataFolderPath = getJanDataFolderPath() + if (!existsSync(janDataFolderPath)) { + try { + await mkdir(janDataFolderPath) + } catch (err) { + console.error( + `Unable to create Jan data folder at ${janDataFolderPath}: ${err}` + ) + } + } } -export const userSpacePath = join(app.getPath('home'), 'jan') - export function getResourcePath() { let appPath = join(app.getAppPath(), '..', 'app.asar.unpacked') diff --git a/electron/utils/setup.ts b/electron/utils/setup.ts new file mode 100644 index 000000000..887c3c2b7 --- /dev/null +++ b/electron/utils/setup.ts @@ -0,0 +1,9 @@ +import { app } from 'electron' + +export const setupCore = async () => { + // Setup core api for main process + global.core = { + // Define appPath function for app to retrieve app path globaly + appPath: () => app.getPath('userData') + } +} \ No newline at end of file diff --git a/extensions/assistant-extension/src/index.ts b/extensions/assistant-extension/src/index.ts index 0c5745b3d..96de33b7b 100644 --- a/extensions/assistant-extension/src/index.ts +++ b/extensions/assistant-extension/src/index.ts @@ -1,14 +1,10 @@ -import { ExtensionType, fs, Assistant } from "@janhq/core"; +import { fs, Assistant } from "@janhq/core"; import { AssistantExtension } from "@janhq/core"; import { join } from "path"; -export default class JanAssistantExtension implements AssistantExtension { +export default class JanAssistantExtension extends AssistantExtension { private static readonly _homeDir = "file://assistants"; - type(): ExtensionType { - return ExtensionType.Assistant; - } - async onLoad() { // making the assistant directory if (!(await fs.existsSync(JanAssistantExtension._homeDir))) diff --git a/extensions/conversational-extension/src/index.ts b/extensions/conversational-extension/src/index.ts index 70eb9f851..66becb748 100644 --- a/extensions/conversational-extension/src/index.ts +++ b/extensions/conversational-extension/src/index.ts @@ -1,5 +1,4 @@ import { - ExtensionType, fs, joinPath, ConversationalExtension, @@ -12,19 +11,12 @@ import { * functionality for managing threads. */ export default class JSONConversationalExtension - implements ConversationalExtension + extends ConversationalExtension { private static readonly _homeDir = 'file://threads' private static readonly _threadInfoFileName = 'thread.json' private static readonly _threadMessagesFileName = 'messages.jsonl' - /** - * Returns the type of the extension. - */ - type(): ExtensionType { - return ExtensionType.Conversational - } - /** * Called when the extension is loaded. */ diff --git a/extensions/inference-nitro-extension/bin/version.txt b/extensions/inference-nitro-extension/bin/version.txt index b0032849c..f2722b133 100644 --- a/extensions/inference-nitro-extension/bin/version.txt +++ b/extensions/inference-nitro-extension/bin/version.txt @@ -1 +1 @@ -0.2.7 +0.2.12 diff --git a/extensions/inference-nitro-extension/package.json b/extensions/inference-nitro-extension/package.json index 6366ad4b8..9379e194b 100644 --- a/extensions/inference-nitro-extension/package.json +++ b/extensions/inference-nitro-extension/package.json @@ -3,11 +3,11 @@ "version": "1.0.0", "description": "This extension embeds Nitro, a lightweight (3mb) inference engine written in C++. See nitro.jan.ai", "main": "dist/index.js", - "module": "dist/module.js", + "node": "dist/node/index.cjs.js", "author": "Jan ", "license": "AGPL-3.0", "scripts": { - "build": "tsc -b . && webpack --config webpack.config.js", + "build": "tsc --module commonjs && rollup -c rollup.config.ts", "downloadnitro:linux": "NITRO_VERSION=$(cat ./bin/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-linux-amd64.tar.gz -e --strip 1 -o ./bin/linux-cpu && chmod +x ./bin/linux-cpu/nitro && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-linux-amd64-cuda-12-0.tar.gz -e --strip 1 -o ./bin/linux-cuda-12-0 && chmod +x ./bin/linux-cuda-12-0/nitro && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-linux-amd64-cuda-11-7.tar.gz -e --strip 1 -o ./bin/linux-cuda-11-7 && chmod +x ./bin/linux-cuda-11-7/nitro", "downloadnitro:darwin": "NITRO_VERSION=$(cat ./bin/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-mac-arm64.tar.gz -e --strip 1 -o ./bin/mac-arm64 && chmod +x ./bin/mac-arm64/nitro && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-mac-amd64.tar.gz -e --strip 1 -o ./bin/mac-x64 && chmod +x ./bin/mac-x64/nitro", "downloadnitro:win32": "download.bat", @@ -19,24 +19,31 @@ }, "exports": { ".": "./dist/index.js", - "./main": "./dist/module.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", + "@types/node": "^20.11.4", + "@types/tcp-port-used": "^1.0.4", "cpx": "^1.5.0", + "download-cli": "^1.1.1", "rimraf": "^3.0.2", + "rollup": "^2.38.5", + "rollup-plugin-define": "^1.0.1", + "rollup-plugin-sourcemaps": "^0.6.3", + "rollup-plugin-typescript2": "^0.36.0", "run-script-os": "^1.1.6", - "webpack": "^5.88.2", - "webpack-cli": "^5.1.4" + "typescript": "^5.3.3" }, "dependencies": { "@janhq/core": "file:../../core", - "download-cli": "^1.1.1", + "@rollup/plugin-replace": "^5.0.5", "fetch-retry": "^5.0.6", - "os-utils": "^0.0.14", "path-browserify": "^1.0.1", "rxjs": "^7.8.1", "tcp-port-used": "^1.0.2", - "ts-loader": "^9.5.0", "ulid": "^2.3.0" }, "engines": { @@ -50,7 +57,6 @@ "bundleDependencies": [ "tcp-port-used", "fetch-retry", - "os-utils", "@janhq/core" ] } diff --git a/extensions/inference-nitro-extension/rollup.config.ts b/extensions/inference-nitro-extension/rollup.config.ts new file mode 100644 index 000000000..374a054cd --- /dev/null +++ b/extensions/inference-nitro-extension/rollup.config.ts @@ -0,0 +1,77 @@ +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"); + +const pkg = require("./package.json"); + +export default [ + { + input: `src/index.ts`, + output: [{ file: pkg.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({ + NODE: JSON.stringify(`${packageJson.name}/${packageJson.node}`), + INFERENCE_URL: JSON.stringify( + process.env.INFERENCE_URL || + "http://127.0.0.1:3928/inferences/llamacpp/chat_completion" + ), + TROUBLESHOOTING_URL: JSON.stringify( + "https://jan.ai/guides/troubleshooting" + ), + }), + // 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", ".svelte"], + }), + + // 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: ["@janhq/core/node"], + 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/inference-nitro-extension/src/@types/global.d.ts b/extensions/inference-nitro-extension/src/@types/global.d.ts index 6bcdc4adc..5fb41f0f8 100644 --- a/extensions/inference-nitro-extension/src/@types/global.d.ts +++ b/extensions/inference-nitro-extension/src/@types/global.d.ts @@ -1,4 +1,4 @@ -declare const MODULE: string; +declare const NODE: string; declare const INFERENCE_URL: string; declare const TROUBLESHOOTING_URL: string; diff --git a/extensions/inference-nitro-extension/src/index.ts b/extensions/inference-nitro-extension/src/index.ts index b6c63f59a..735383a61 100644 --- a/extensions/inference-nitro-extension/src/index.ts +++ b/extensions/inference-nitro-extension/src/index.ts @@ -9,10 +9,8 @@ import { ChatCompletionRole, ContentType, - EventName, MessageRequest, MessageStatus, - ExtensionType, ThreadContent, ThreadMessage, events, @@ -23,17 +21,19 @@ import { InferenceExtension, log, InferenceEngine, + MessageEvent, + ModelEvent, + InferenceEvent, } from "@janhq/core"; import { requestInference } from "./helpers/sse"; import { ulid } from "ulid"; -import { join } from "path"; /** * A class that implements the InferenceExtension interface from the @janhq/core package. * The class provides methods for initializing and stopping a model, and for making inference requests. * It also subscribes to events emitted by the @janhq/core package and handles new message requests. */ -export default class JanInferenceNitroExtension implements InferenceExtension { +export default class JanInferenceNitroExtension extends InferenceExtension { private static readonly _homeDir = "file://engines"; private static readonly _settingsDir = "file://settings"; private static readonly _engineMetadataFileName = "nitro.json"; @@ -43,7 +43,7 @@ export default class JanInferenceNitroExtension implements InferenceExtension { */ private static readonly _intervalHealthCheck = 5 * 1000; - private _currentModel: Model; + private _currentModel: Model | undefined; private _engineSettings: EngineSettings = { ctx_len: 2048, @@ -67,14 +67,6 @@ export default class JanInferenceNitroExtension implements InferenceExtension { */ private nitroProcessInfo: any = undefined; - /** - * Returns the type of the extension. - * @returns {ExtensionType} The type of the extension. - */ - type(): ExtensionType { - return ExtensionType.Inference; - } - /** * Subscribes to events emitted by the @janhq/core package. */ @@ -82,7 +74,7 @@ export default class JanInferenceNitroExtension implements InferenceExtension { if (!(await fs.existsSync(JanInferenceNitroExtension._homeDir))) { await fs .mkdirSync(JanInferenceNitroExtension._homeDir) - .catch((err) => console.debug(err)); + .catch((err: Error) => console.debug(err)); } if (!(await fs.existsSync(JanInferenceNitroExtension._settingsDir))) @@ -90,16 +82,24 @@ export default class JanInferenceNitroExtension implements InferenceExtension { this.writeDefaultEngineSettings(); // Events subscription - events.on(EventName.OnMessageSent, (data) => this.onMessageRequest(data)); + events.on(MessageEvent.OnMessageSent, (data: MessageRequest) => + this.onMessageRequest(data) + ); - events.on(EventName.OnModelInit, (model: Model) => this.onModelInit(model)); + events.on(ModelEvent.OnModelInit, (model: Model) => + this.onModelInit(model) + ); - events.on(EventName.OnModelStop, (model: Model) => this.onModelStop(model)); + events.on(ModelEvent.OnModelStop, (model: Model) => + this.onModelStop(model) + ); - events.on(EventName.OnInferenceStopped, () => this.onInferenceStopped()); + events.on(InferenceEvent.OnInferenceStopped, () => + this.onInferenceStopped() + ); // Attempt to fetch nvidia info - await executeOnMain(MODULE, "updateNvidiaInfo", {}); + await executeOnMain(NODE, "updateNvidiaInfo", {}); } /** @@ -109,10 +109,10 @@ export default class JanInferenceNitroExtension implements InferenceExtension { private async writeDefaultEngineSettings() { try { - const engineFile = join( + const engineFile = await joinPath([ JanInferenceNitroExtension._homeDir, - JanInferenceNitroExtension._engineMetadataFileName - ); + JanInferenceNitroExtension._engineMetadataFileName, + ]); if (await fs.existsSync(engineFile)) { const engine = await fs.readFileSync(engineFile, "utf-8"); this._engineSettings = @@ -133,18 +133,18 @@ export default class JanInferenceNitroExtension implements InferenceExtension { const modelFullPath = await joinPath(["models", model.id]); - const nitroInitResult = await executeOnMain(MODULE, "initModel", { - modelFullPath: modelFullPath, - model: model, + const nitroInitResult = await executeOnMain(NODE, "runModel", { + modelFullPath, + model, }); - if (nitroInitResult.error === null) { - events.emit(EventName.OnModelFail, model); + if (nitroInitResult?.error) { + events.emit(ModelEvent.OnModelFail, model); return; } this._currentModel = model; - events.emit(EventName.OnModelReady, model); + events.emit(ModelEvent.OnModelReady, model); this.getNitroProcesHealthIntervalId = setInterval( () => this.periodicallyGetNitroHealth(), @@ -155,12 +155,11 @@ export default class JanInferenceNitroExtension implements InferenceExtension { private async onModelStop(model: Model) { if (model.engine !== "nitro") return; - await executeOnMain(MODULE, "stopModel"); - events.emit(EventName.OnModelStopped, {}); + await executeOnMain(NODE, "stopModel"); + events.emit(ModelEvent.OnModelStopped, {}); // stop the periocally health check if (this.getNitroProcesHealthIntervalId) { - console.debug("Stop calling Nitro process health check"); clearInterval(this.getNitroProcesHealthIntervalId); this.getNitroProcesHealthIntervalId = undefined; } @@ -170,12 +169,12 @@ export default class JanInferenceNitroExtension implements InferenceExtension { * Periodically check for nitro process's health. */ private async periodicallyGetNitroHealth(): Promise { - const health = await executeOnMain(MODULE, "getCurrentNitroProcessInfo"); + const health = await executeOnMain(NODE, "getCurrentNitroProcessInfo"); const isRunning = this.nitroProcessInfo?.isRunning ?? false; if (isRunning && health.isRunning === false) { console.debug("Nitro process is stopped"); - events.emit(EventName.OnModelStopped, {}); + events.emit(ModelEvent.OnModelStopped, {}); } this.nitroProcessInfo = health; } @@ -204,12 +203,14 @@ export default class JanInferenceNitroExtension implements InferenceExtension { }; return new Promise(async (resolve, reject) => { + if (!this._currentModel) return Promise.reject("No model loaded"); + requestInference(data.messages ?? [], this._currentModel).subscribe({ - next: (_content) => {}, + next: (_content: any) => {}, complete: async () => { resolve(message); }, - error: async (err) => { + error: async (err: any) => { reject(err); }, }); @@ -223,7 +224,9 @@ export default class JanInferenceNitroExtension implements InferenceExtension { * @param {MessageRequest} data - The data for the new message request. */ private async onMessageRequest(data: MessageRequest) { - if (data.model.engine !== "nitro") return; + if (data.model?.engine !== InferenceEngine.nitro || !this._currentModel) { + return; + } const timestamp = Date.now(); const message: ThreadMessage = { @@ -237,17 +240,18 @@ export default class JanInferenceNitroExtension implements InferenceExtension { updated: timestamp, object: "thread.message", }; - events.emit(EventName.OnMessageResponse, message); + events.emit(MessageEvent.OnMessageResponse, message); this.isCancelled = false; this.controller = new AbortController(); - requestInference( - data.messages ?? [], - { ...this._currentModel, ...data.model }, - this.controller - ).subscribe({ - next: (content) => { + // @ts-ignore + const model: Model = { + ...(this._currentModel || {}), + ...(data.model || {}), + }; + requestInference(data.messages ?? [], model, this.controller).subscribe({ + next: (content: any) => { const messageContent: ThreadContent = { type: ContentType.Text, text: { @@ -256,22 +260,22 @@ export default class JanInferenceNitroExtension implements InferenceExtension { }, }; message.content = [messageContent]; - events.emit(EventName.OnMessageUpdate, message); + events.emit(MessageEvent.OnMessageUpdate, message); }, complete: async () => { message.status = message.content.length ? MessageStatus.Ready : MessageStatus.Error; - events.emit(EventName.OnMessageUpdate, message); + events.emit(MessageEvent.OnMessageUpdate, message); }, - error: async (err) => { + error: async (err: any) => { if (this.isCancelled || message.content.length) { message.status = MessageStatus.Stopped; - events.emit(EventName.OnMessageUpdate, message); + events.emit(MessageEvent.OnMessageUpdate, message); return; } message.status = MessageStatus.Error; - events.emit(EventName.OnMessageUpdate, message); + events.emit(MessageEvent.OnMessageUpdate, message); log(`[APP]::Error: ${err.message}`); }, }); diff --git a/extensions/inference-nitro-extension/src/module.ts b/extensions/inference-nitro-extension/src/module.ts deleted file mode 100644 index 6907f244a..000000000 --- a/extensions/inference-nitro-extension/src/module.ts +++ /dev/null @@ -1,514 +0,0 @@ -const fs = require("fs"); -const path = require("path"); -const { exec, spawn } = require("child_process"); -const tcpPortUsed = require("tcp-port-used"); -const fetchRetry = require("fetch-retry")(global.fetch); -const osUtils = require("os-utils"); -const { readFileSync, writeFileSync, existsSync } = require("fs"); -const { log } = require("@janhq/core/node"); - -// The PORT to use for the Nitro subprocess -const PORT = 3928; -const LOCAL_HOST = "127.0.0.1"; -const NITRO_HTTP_SERVER_URL = `http://${LOCAL_HOST}:${PORT}`; -const NITRO_HTTP_LOAD_MODEL_URL = `${NITRO_HTTP_SERVER_URL}/inferences/llamacpp/loadmodel`; -const NITRO_HTTP_VALIDATE_MODEL_URL = `${NITRO_HTTP_SERVER_URL}/inferences/llamacpp/modelstatus`; -const NITRO_HTTP_KILL_URL = `${NITRO_HTTP_SERVER_URL}/processmanager/destroy`; -const SUPPORTED_MODEL_FORMAT = ".gguf"; -const NVIDIA_INFO_FILE = path.join( - require("os").homedir(), - "jan", - "settings", - "settings.json" -); - -// The subprocess instance for Nitro -let subprocess = undefined; -let currentModelFile: string = undefined; -let currentSettings = undefined; - -let nitroProcessInfo = undefined; - -/** - * Default GPU settings - **/ -const DEFALT_SETTINGS = { - notify: true, - run_mode: "cpu", - nvidia_driver: { - exist: false, - version: "", - }, - cuda: { - exist: false, - version: "", - }, - gpus: [], - gpu_highest_vram: "", -}; - -/** - * Stops a Nitro subprocess. - * @param wrapper - The model wrapper. - * @returns A Promise that resolves when the subprocess is terminated successfully, or rejects with an error message if the subprocess fails to terminate. - */ -function stopModel(): Promise { - return killSubprocess(); -} - -/** - * Initializes a Nitro subprocess to load a machine learning model. - * @param wrapper - The model wrapper. - * @returns A Promise that resolves when the model is loaded successfully, or rejects with an error message if the model is not found or fails to load. - * TODO: Should pass absolute of the model file instead of just the name - So we can modurize the module.ts to npm package - * TODO: Should it be startModel instead? - */ -async function initModel(wrapper: any): Promise { - currentModelFile = wrapper.modelFullPath; - const janRoot = path.join(require("os").homedir(), "jan"); - if (!currentModelFile.includes(janRoot)) { - currentModelFile = path.join(janRoot, currentModelFile); - } - const files: string[] = fs.readdirSync(currentModelFile); - - // Look for GGUF model file - const ggufBinFile = files.find( - (file) => - file === path.basename(currentModelFile) || - file.toLowerCase().includes(SUPPORTED_MODEL_FORMAT) - ); - - currentModelFile = path.join(currentModelFile, ggufBinFile); - - if (wrapper.model.engine !== "nitro") { - return Promise.resolve({ error: "Not a nitro model" }); - } else { - const nitroResourceProbe = await getResourcesInfo(); - // Convert settings.prompt_template to system_prompt, user_prompt, ai_prompt - if (wrapper.model.settings.prompt_template) { - const promptTemplate = wrapper.model.settings.prompt_template; - const prompt = promptTemplateConverter(promptTemplate); - if (prompt.error) { - return Promise.resolve({ error: prompt.error }); - } - wrapper.model.settings.system_prompt = prompt.system_prompt; - wrapper.model.settings.user_prompt = prompt.user_prompt; - wrapper.model.settings.ai_prompt = prompt.ai_prompt; - } - - currentSettings = { - llama_model_path: currentModelFile, - ...wrapper.model.settings, - // This is critical and requires real system information - cpu_threads: nitroResourceProbe.numCpuPhysicalCore, - }; - return loadModel(nitroResourceProbe); - } -} - -async function loadModel(nitroResourceProbe: any | undefined) { - // Gather system information for CPU physical cores and memory - if (!nitroResourceProbe) nitroResourceProbe = await getResourcesInfo(); - return killSubprocess() - .then(() => tcpPortUsed.waitUntilFree(PORT, 300, 5000)) - .then(() => { - /** - * There is a problem with Windows process manager - * Should wait for awhile to make sure the port is free and subprocess is killed - * The tested threshold is 500ms - **/ - if (process.platform === "win32") { - return new Promise((resolve) => setTimeout(resolve, 500)); - } else { - return Promise.resolve(); - } - }) - .then(() => spawnNitroProcess(nitroResourceProbe)) - .then(() => loadLLMModel(currentSettings)) - .then(validateModelStatus) - .catch((err) => { - log(`[NITRO]::Error: ${err}`); - // TODO: Broadcast error so app could display proper error message - return { error: err, currentModelFile }; - }); -} - -function promptTemplateConverter(promptTemplate) { - // Split the string using the markers - const systemMarker = "{system_message}"; - const promptMarker = "{prompt}"; - - if ( - promptTemplate.includes(systemMarker) && - promptTemplate.includes(promptMarker) - ) { - // Find the indices of the markers - const systemIndex = promptTemplate.indexOf(systemMarker); - const promptIndex = promptTemplate.indexOf(promptMarker); - - // Extract the parts of the string - const system_prompt = promptTemplate.substring(0, systemIndex); - const user_prompt = promptTemplate.substring( - systemIndex + systemMarker.length, - promptIndex - ); - const ai_prompt = promptTemplate.substring( - promptIndex + promptMarker.length - ); - - // Return the split parts - return { system_prompt, user_prompt, ai_prompt }; - } else if (promptTemplate.includes(promptMarker)) { - // Extract the parts of the string for the case where only promptMarker is present - const promptIndex = promptTemplate.indexOf(promptMarker); - const user_prompt = promptTemplate.substring(0, promptIndex); - const ai_prompt = promptTemplate.substring( - promptIndex + promptMarker.length - ); - const system_prompt = ""; - - // Return the split parts - return { system_prompt, user_prompt, ai_prompt }; - } - - // Return an error if none of the conditions are met - return { error: "Cannot split prompt template" }; -} - -/** - * Loads a LLM model into the Nitro subprocess by sending a HTTP POST request. - * @returns A Promise that resolves when the model is loaded successfully, or rejects with an error message if the model is not found or fails to load. - */ -function loadLLMModel(settings): Promise { - log(`[NITRO]::Debug: Loading model with params ${JSON.stringify(settings)}`); - return fetchRetry(NITRO_HTTP_LOAD_MODEL_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(settings), - retries: 3, - retryDelay: 500, - }).catch((err) => { - log(`[NITRO]::Error: Load model failed with error ${err}`); - }); -} - -/** - * Validates the status of a model. - * @returns {Promise} A promise that resolves to an object. - * If the model is loaded successfully, the object is empty. - * If the model is not loaded successfully, the object contains an error message. - */ -async function validateModelStatus(): Promise { - // Send a GET request to the validation URL. - // Retry the request up to 3 times if it fails, with a delay of 500 milliseconds between retries. - return fetchRetry(NITRO_HTTP_VALIDATE_MODEL_URL, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - retries: 5, - retryDelay: 500, - }).then(async (res: Response) => { - // If the response is OK, check model_loaded status. - if (res.ok) { - const body = await res.json(); - // If the model is loaded, return an empty object. - // Otherwise, return an object with an error message. - if (body.model_loaded) { - return { error: undefined }; - } - } - return { error: "Model loading failed" }; - }); -} - -/** - * Terminates the Nitro subprocess. - * @returns A Promise that resolves when the subprocess is terminated successfully, or rejects with an error message if the subprocess fails to terminate. - */ -async function killSubprocess(): Promise { - const controller = new AbortController(); - setTimeout(() => controller.abort(), 5000); - log(`[NITRO]::Debug: Request to kill Nitro`); - - return fetch(NITRO_HTTP_KILL_URL, { - method: "DELETE", - signal: controller.signal, - }) - .then(() => { - subprocess?.kill(); - subprocess = undefined; - }) - .catch(() => {}) - .then(() => tcpPortUsed.waitUntilFree(PORT, 300, 5000)) - .then(() => log(`[NITRO]::Debug: Nitro process is terminated`)); -} - -/** - * Spawns a Nitro subprocess. - * @param nitroResourceProbe - The Nitro resource probe. - * @returns A promise that resolves when the Nitro subprocess is started. - */ -function spawnNitroProcess(nitroResourceProbe: any): Promise { - log(`[NITRO]::Debug: Spawning Nitro subprocess...`); - - return new Promise(async (resolve, reject) => { - let binaryFolder = path.join(__dirname, "bin"); // Current directory by default - let cudaVisibleDevices = ""; - let binaryName; - if (process.platform === "win32") { - let nvidiaInfo = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf-8")); - if (nvidiaInfo["run_mode"] === "cpu") { - binaryFolder = path.join(binaryFolder, "win-cpu"); - } else { - if (nvidiaInfo["cuda"].version === "12") { - binaryFolder = path.join(binaryFolder, "win-cuda-12-0"); - } else { - binaryFolder = path.join(binaryFolder, "win-cuda-11-7"); - } - cudaVisibleDevices = nvidiaInfo["gpu_highest_vram"]; - } - binaryName = "nitro.exe"; - } else if (process.platform === "darwin") { - if (process.arch === "arm64") { - binaryFolder = path.join(binaryFolder, "mac-arm64"); - } else { - binaryFolder = path.join(binaryFolder, "mac-x64"); - } - binaryName = "nitro"; - } else { - let nvidiaInfo = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf-8")); - if (nvidiaInfo["run_mode"] === "cpu") { - binaryFolder = path.join(binaryFolder, "linux-cpu"); - } else { - if (nvidiaInfo["cuda"].version === "12") { - binaryFolder = path.join(binaryFolder, "linux-cuda-12-0"); - } else { - binaryFolder = path.join(binaryFolder, "linux-cuda-11-7"); - } - cudaVisibleDevices = nvidiaInfo["gpu_highest_vram"]; - } - binaryName = "nitro"; - } - - const binaryPath = path.join(binaryFolder, binaryName); - // Execute the binary - subprocess = spawn(binaryPath, ["1", LOCAL_HOST, PORT.toString()], { - cwd: binaryFolder, - env: { - ...process.env, - CUDA_VISIBLE_DEVICES: cudaVisibleDevices, - }, - }); - - // Handle subprocess output - subprocess.stdout.on("data", (data) => { - log(`[NITRO]::Debug: ${data}`); - }); - - subprocess.stderr.on("data", (data) => { - log(`[NITRO]::Error: ${data}`); - }); - - subprocess.on("close", (code) => { - log(`[NITRO]::Debug: Nitro exited with code: ${code}`); - subprocess = null; - reject(`child process exited with code ${code}`); - }); - - tcpPortUsed.waitUntilUsed(PORT, 300, 30000).then(() => { - resolve(nitroResourceProbe); - }); - }); -} - -/** - * Get the system resources information - * TODO: Move to Core so that it can be reused - */ -function getResourcesInfo(): Promise { - return new Promise(async (resolve) => { - const cpu = await osUtils.cpuCount(); - log(`[NITRO]::CPU informations - ${cpu}`); - const response: ResourcesInfo = { - numCpuPhysicalCore: cpu, - memAvailable: 0, - }; - resolve(response); - }); -} - -/** - * This will retrive GPU informations and persist settings.json - * Will be called when the extension is loaded to turn on GPU acceleration if supported - */ -async function updateNvidiaInfo() { - if (process.platform !== "darwin") { - await Promise.all([ - updateNvidiaDriverInfo(), - updateCudaExistence(), - updateGpuInfo(), - ]); - } -} - -/** - * Retrieve current nitro process - */ -const getCurrentNitroProcessInfo = (): Promise => { - nitroProcessInfo = { - isRunning: subprocess != null, - }; - return nitroProcessInfo; -}; - -/** - * Every module should have a dispose function - * This will be called when the extension is unloaded and should clean up any resources - * Also called when app is closed - */ -function dispose() { - // clean other registered resources here - killSubprocess(); -} - -/** - * Validate nvidia and cuda for linux and windows - */ -async function updateNvidiaDriverInfo(): Promise { - exec( - "nvidia-smi --query-gpu=driver_version --format=csv,noheader", - (error, stdout) => { - let data; - try { - data = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf-8")); - } catch (error) { - data = DEFALT_SETTINGS; - } - - if (!error) { - const firstLine = stdout.split("\n")[0].trim(); - data["nvidia_driver"].exist = true; - data["nvidia_driver"].version = firstLine; - } else { - data["nvidia_driver"].exist = false; - } - - writeFileSync(NVIDIA_INFO_FILE, JSON.stringify(data, null, 2)); - Promise.resolve(); - } - ); -} - -/** - * Check if file exists in paths - */ -function checkFileExistenceInPaths(file: string, paths: string[]): boolean { - return paths.some((p) => existsSync(path.join(p, file))); -} - -/** - * Validate cuda for linux and windows - */ -function updateCudaExistence() { - let filesCuda12: string[]; - let filesCuda11: string[]; - let paths: string[]; - let cudaVersion: string = ""; - - if (process.platform === "win32") { - filesCuda12 = ["cublas64_12.dll", "cudart64_12.dll", "cublasLt64_12.dll"]; - filesCuda11 = ["cublas64_11.dll", "cudart64_11.dll", "cublasLt64_11.dll"]; - paths = process.env.PATH ? process.env.PATH.split(path.delimiter) : []; - } else { - filesCuda12 = ["libcudart.so.12", "libcublas.so.12", "libcublasLt.so.12"]; - filesCuda11 = ["libcudart.so.11.0", "libcublas.so.11", "libcublasLt.so.11"]; - paths = process.env.LD_LIBRARY_PATH - ? process.env.LD_LIBRARY_PATH.split(path.delimiter) - : []; - paths.push("/usr/lib/x86_64-linux-gnu/"); - } - - let cudaExists = filesCuda12.every( - (file) => existsSync(file) || checkFileExistenceInPaths(file, paths) - ); - - if (!cudaExists) { - cudaExists = filesCuda11.every( - (file) => existsSync(file) || checkFileExistenceInPaths(file, paths) - ); - if (cudaExists) { - cudaVersion = "11"; - } - } else { - cudaVersion = "12"; - } - - let data; - try { - data = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf-8")); - } catch (error) { - data = DEFALT_SETTINGS; - } - - data["cuda"].exist = cudaExists; - data["cuda"].version = cudaVersion; - if (cudaExists) { - data.run_mode = "gpu"; - } - writeFileSync(NVIDIA_INFO_FILE, JSON.stringify(data, null, 2)); -} - -/** - * Get GPU information - */ -async function updateGpuInfo(): Promise { - exec( - "nvidia-smi --query-gpu=index,memory.total --format=csv,noheader,nounits", - (error, stdout) => { - let data; - try { - data = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf-8")); - } catch (error) { - data = DEFALT_SETTINGS; - } - - if (!error) { - // Get GPU info and gpu has higher memory first - let highestVram = 0; - let highestVramId = "0"; - let gpus = stdout - .trim() - .split("\n") - .map((line) => { - let [id, vram] = line.split(", "); - vram = vram.replace(/\r/g, ""); - if (parseFloat(vram) > highestVram) { - highestVram = parseFloat(vram); - highestVramId = id; - } - return { id, vram }; - }); - - data["gpus"] = gpus; - data["gpu_highest_vram"] = highestVramId; - } else { - data["gpus"] = []; - } - - writeFileSync(NVIDIA_INFO_FILE, JSON.stringify(data, null, 2)); - Promise.resolve(); - } - ); -} - -module.exports = { - initModel, - stopModel, - killSubprocess, - dispose, - updateNvidiaInfo, - getCurrentNitroProcessInfo, -}; diff --git a/extensions/inference-nitro-extension/src/node/execute.ts b/extensions/inference-nitro-extension/src/node/execute.ts new file mode 100644 index 000000000..ca266639c --- /dev/null +++ b/extensions/inference-nitro-extension/src/node/execute.ts @@ -0,0 +1,65 @@ +import { readFileSync } from "fs"; +import * as path from "path"; +import { NVIDIA_INFO_FILE } from "./nvidia"; + +export interface NitroExecutableOptions { + executablePath: string; + cudaVisibleDevices: string; +} +/** + * Find which executable file to run based on the current platform. + * @returns The name of the executable file to run. + */ +export const executableNitroFile = (): NitroExecutableOptions => { + let binaryFolder = path.join(__dirname, "..", "bin"); // Current directory by default + let cudaVisibleDevices = ""; + let binaryName = "nitro"; + /** + * The binary folder is different for each platform. + */ + if (process.platform === "win32") { + /** + * For Windows: win-cpu, win-cuda-11-7, win-cuda-12-0 + */ + let nvidiaInfo = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf-8")); + if (nvidiaInfo["run_mode"] === "cpu") { + binaryFolder = path.join(binaryFolder, "win-cpu"); + } else { + if (nvidiaInfo["cuda"].version === "12") { + binaryFolder = path.join(binaryFolder, "win-cuda-12-0"); + } else { + binaryFolder = path.join(binaryFolder, "win-cuda-11-7"); + } + cudaVisibleDevices = nvidiaInfo["gpu_highest_vram"]; + } + binaryName = "nitro.exe"; + } else if (process.platform === "darwin") { + /** + * For MacOS: mac-arm64 (Silicon), mac-x64 (InteL) + */ + if (process.arch === "arm64") { + binaryFolder = path.join(binaryFolder, "mac-arm64"); + } else { + binaryFolder = path.join(binaryFolder, "mac-x64"); + } + } else { + /** + * For Linux: linux-cpu, linux-cuda-11-7, linux-cuda-12-0 + */ + let nvidiaInfo = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf-8")); + if (nvidiaInfo["run_mode"] === "cpu") { + binaryFolder = path.join(binaryFolder, "linux-cpu"); + } else { + if (nvidiaInfo["cuda"].version === "12") { + binaryFolder = path.join(binaryFolder, "linux-cuda-12-0"); + } else { + binaryFolder = path.join(binaryFolder, "linux-cuda-11-7"); + } + cudaVisibleDevices = nvidiaInfo["gpu_highest_vram"]; + } + } + return { + executablePath: path.join(binaryFolder, binaryName), + cudaVisibleDevices, + }; +}; diff --git a/extensions/inference-nitro-extension/src/node/index.ts b/extensions/inference-nitro-extension/src/node/index.ts new file mode 100644 index 000000000..0a7a2e33e --- /dev/null +++ b/extensions/inference-nitro-extension/src/node/index.ts @@ -0,0 +1,380 @@ +import fs from "fs"; +import path from "path"; +import { ChildProcessWithoutNullStreams, spawn } from "child_process"; +import tcpPortUsed from "tcp-port-used"; +import fetchRT from "fetch-retry"; +import { log, getJanDataFolderPath } from "@janhq/core/node"; +import { getNitroProcessInfo, updateNvidiaInfo } from "./nvidia"; +import { Model, InferenceEngine, ModelSettingParams } from "@janhq/core"; +import { executableNitroFile } from "./execute"; +import { physicalCpuCount } from "./utils"; + +// Polyfill fetch with retry +const fetchRetry = fetchRT(fetch); + +/** + * The response object for model init operation. + */ +interface ModelInitOptions { + modelFullPath: string; + model: Model; +} + +/** + * The response object of Prompt Template parsing. + */ +interface PromptTemplate { + system_prompt?: string; + ai_prompt?: string; + user_prompt?: string; + error?: string; +} + +/** + * Model setting args for Nitro model load. + */ +interface ModelSettingArgs extends ModelSettingParams { + llama_model_path: string; + cpu_threads: number; +} + +// The PORT to use for the Nitro subprocess +const PORT = 3928; +// The HOST address to use for the Nitro subprocess +const LOCAL_HOST = "127.0.0.1"; +// The URL for the Nitro subprocess +const NITRO_HTTP_SERVER_URL = `http://${LOCAL_HOST}:${PORT}`; +// The URL for the Nitro subprocess to load a model +const NITRO_HTTP_LOAD_MODEL_URL = `${NITRO_HTTP_SERVER_URL}/inferences/llamacpp/loadmodel`; +// The URL for the Nitro subprocess to validate a model +const NITRO_HTTP_VALIDATE_MODEL_URL = `${NITRO_HTTP_SERVER_URL}/inferences/llamacpp/modelstatus`; +// The URL for the Nitro subprocess to kill itself +const NITRO_HTTP_KILL_URL = `${NITRO_HTTP_SERVER_URL}/processmanager/destroy`; + +// The supported model format +// TODO: Should be an array to support more models +const SUPPORTED_MODEL_FORMAT = ".gguf"; + +// The subprocess instance for Nitro +let subprocess: ChildProcessWithoutNullStreams | undefined = undefined; +// The current model file url +let currentModelFile: string = ""; +// The current model settings +let currentSettings: ModelSettingArgs | undefined = undefined; + +/** + * Stops a Nitro subprocess. + * @param wrapper - The model wrapper. + * @returns A Promise that resolves when the subprocess is terminated successfully, or rejects with an error message if the subprocess fails to terminate. + */ +function stopModel(): Promise { + return killSubprocess(); +} + +/** + * Initializes a Nitro subprocess to load a machine learning model. + * @param wrapper - The model wrapper. + * @returns A Promise that resolves when the model is loaded successfully, or rejects with an error message if the model is not found or fails to load. + * TODO: Should pass absolute of the model file instead of just the name - So we can modurize the module.ts to npm package + */ +async function runModel( + wrapper: ModelInitOptions +): Promise { + if (wrapper.model.engine !== InferenceEngine.nitro) { + // Not a nitro model + return Promise.resolve(); + } + + currentModelFile = wrapper.modelFullPath; + const janRoot = await getJanDataFolderPath(); + if (!currentModelFile.includes(janRoot)) { + currentModelFile = path.join(janRoot, currentModelFile); + } + const files: string[] = fs.readdirSync(currentModelFile); + + // Look for GGUF model file + const ggufBinFile = files.find( + (file) => + file === path.basename(currentModelFile) || + file.toLowerCase().includes(SUPPORTED_MODEL_FORMAT) + ); + + if (!ggufBinFile) return Promise.reject("No GGUF model file found"); + + currentModelFile = path.join(currentModelFile, ggufBinFile); + + if (wrapper.model.engine !== InferenceEngine.nitro) { + return Promise.reject("Not a nitro model"); + } else { + const nitroResourceProbe = await getResourcesInfo(); + // Convert settings.prompt_template to system_prompt, user_prompt, ai_prompt + if (wrapper.model.settings.prompt_template) { + const promptTemplate = wrapper.model.settings.prompt_template; + const prompt = promptTemplateConverter(promptTemplate); + if (prompt?.error) { + return Promise.reject(prompt.error); + } + wrapper.model.settings.system_prompt = prompt.system_prompt; + wrapper.model.settings.user_prompt = prompt.user_prompt; + wrapper.model.settings.ai_prompt = prompt.ai_prompt; + } + + currentSettings = { + llama_model_path: currentModelFile, + ...wrapper.model.settings, + // This is critical and requires real CPU physical core count (or performance core) + cpu_threads: Math.max(1, nitroResourceProbe.numCpuPhysicalCore), + }; + console.log(currentSettings); + return runNitroAndLoadModel(); + } +} + +/** + * 1. Spawn Nitro process + * 2. Load model into Nitro subprocess + * 3. Validate model status + * @returns + */ +async function runNitroAndLoadModel() { + // Gather system information for CPU physical cores and memory + return killSubprocess() + .then(() => tcpPortUsed.waitUntilFree(PORT, 300, 5000)) + .then(() => { + /** + * There is a problem with Windows process manager + * Should wait for awhile to make sure the port is free and subprocess is killed + * The tested threshold is 500ms + **/ + if (process.platform === "win32") { + return new Promise((resolve) => setTimeout(resolve, 500)); + } else { + return Promise.resolve(); + } + }) + .then(spawnNitroProcess) + .then(() => loadLLMModel(currentSettings)) + .then(validateModelStatus) + .catch((err) => { + // TODO: Broadcast error so app could display proper error message + log(`[NITRO]::Error: ${err}`); + return { error: err }; + }); +} + +/** + * Parse prompt template into agrs settings + * @param promptTemplate Template as string + * @returns + */ +function promptTemplateConverter(promptTemplate: string): PromptTemplate { + // Split the string using the markers + const systemMarker = "{system_message}"; + const promptMarker = "{prompt}"; + + if ( + promptTemplate.includes(systemMarker) && + promptTemplate.includes(promptMarker) + ) { + // Find the indices of the markers + const systemIndex = promptTemplate.indexOf(systemMarker); + const promptIndex = promptTemplate.indexOf(promptMarker); + + // Extract the parts of the string + const system_prompt = promptTemplate.substring(0, systemIndex); + const user_prompt = promptTemplate.substring( + systemIndex + systemMarker.length, + promptIndex + ); + const ai_prompt = promptTemplate.substring( + promptIndex + promptMarker.length + ); + + // Return the split parts + return { system_prompt, user_prompt, ai_prompt }; + } else if (promptTemplate.includes(promptMarker)) { + // Extract the parts of the string for the case where only promptMarker is present + const promptIndex = promptTemplate.indexOf(promptMarker); + const user_prompt = promptTemplate.substring(0, promptIndex); + const ai_prompt = promptTemplate.substring( + promptIndex + promptMarker.length + ); + + // Return the split parts + return { user_prompt, ai_prompt }; + } + + // Return an error if none of the conditions are met + return { error: "Cannot split prompt template" }; +} + +/** + * Loads a LLM model into the Nitro subprocess by sending a HTTP POST request. + * @returns A Promise that resolves when the model is loaded successfully, or rejects with an error message if the model is not found or fails to load. + */ +function loadLLMModel(settings: any): Promise { + log(`[NITRO]::Debug: Loading model with params ${JSON.stringify(settings)}`); + return fetchRetry(NITRO_HTTP_LOAD_MODEL_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(settings), + retries: 3, + retryDelay: 500, + }) + .then((res) => { + log( + `[NITRO]::Debug: Load model success with response ${JSON.stringify( + res + )}` + ); + return Promise.resolve(res); + }) + .catch((err) => { + log(`[NITRO]::Error: Load model failed with error ${err}`); + return Promise.reject(); + }); +} + +/** + * Validates the status of a model. + * @returns {Promise} A promise that resolves to an object. + * If the model is loaded successfully, the object is empty. + * If the model is not loaded successfully, the object contains an error message. + */ +async function validateModelStatus(): Promise { + // Send a GET request to the validation URL. + // Retry the request up to 3 times if it fails, with a delay of 500 milliseconds between retries. + return fetchRetry(NITRO_HTTP_VALIDATE_MODEL_URL, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + retries: 5, + retryDelay: 500, + }).then(async (res: Response) => { + log( + `[NITRO]::Debug: Validate model state success with response ${JSON.stringify( + res + )}` + ); + // If the response is OK, check model_loaded status. + if (res.ok) { + const body = await res.json(); + // If the model is loaded, return an empty object. + // Otherwise, return an object with an error message. + if (body.model_loaded) { + return Promise.resolve(); + } + } + return Promise.reject("Validate model status failed"); + }); +} + +/** + * Terminates the Nitro subprocess. + * @returns A Promise that resolves when the subprocess is terminated successfully, or rejects with an error message if the subprocess fails to terminate. + */ +async function killSubprocess(): Promise { + const controller = new AbortController(); + setTimeout(() => controller.abort(), 5000); + log(`[NITRO]::Debug: Request to kill Nitro`); + + return fetch(NITRO_HTTP_KILL_URL, { + method: "DELETE", + signal: controller.signal, + }) + .then(() => { + subprocess?.kill(); + subprocess = undefined; + }) + .catch(() => {}) + .then(() => tcpPortUsed.waitUntilFree(PORT, 300, 5000)) + .then(() => log(`[NITRO]::Debug: Nitro process is terminated`)); +} + +/** + * Spawns a Nitro subprocess. + * @returns A promise that resolves when the Nitro subprocess is started. + */ +function spawnNitroProcess(): Promise { + log(`[NITRO]::Debug: Spawning Nitro subprocess...`); + + return new Promise(async (resolve, reject) => { + let binaryFolder = path.join(__dirname, "..", "bin"); // Current directory by default + let executableOptions = executableNitroFile(); + + const args: string[] = ["1", LOCAL_HOST, PORT.toString()]; + // Execute the binary + log( + `[NITRO]::Debug: Spawn nitro at path: ${executableOptions.executablePath}, and args: ${args}` + ); + subprocess = spawn( + executableOptions.executablePath, + ["1", LOCAL_HOST, PORT.toString()], + { + cwd: binaryFolder, + env: { + ...process.env, + CUDA_VISIBLE_DEVICES: executableOptions.cudaVisibleDevices, + }, + } + ); + + // Handle subprocess output + subprocess.stdout.on("data", (data: any) => { + log(`[NITRO]::Debug: ${data}`); + }); + + subprocess.stderr.on("data", (data: any) => { + log(`[NITRO]::Error: ${data}`); + }); + + subprocess.on("close", (code: any) => { + log(`[NITRO]::Debug: Nitro exited with code: ${code}`); + subprocess = undefined; + reject(`child process exited with code ${code}`); + }); + + tcpPortUsed.waitUntilUsed(PORT, 300, 30000).then(() => { + log(`[NITRO]::Debug: Nitro is ready`); + resolve(); + }); + }); +} + +/** + * Get the system resources information + * TODO: Move to Core so that it can be reused + */ +function getResourcesInfo(): Promise { + return new Promise(async (resolve) => { + const cpu = await physicalCpuCount(); + log(`[NITRO]::CPU informations - ${cpu}`); + const response: ResourcesInfo = { + numCpuPhysicalCore: cpu, + memAvailable: 0, + }; + resolve(response); + }); +} + +/** + * Every module should have a dispose function + * This will be called when the extension is unloaded and should clean up any resources + * Also called when app is closed + */ +function dispose() { + // clean other registered resources here + killSubprocess(); +} + +export default { + runModel, + stopModel, + killSubprocess, + dispose, + updateNvidiaInfo, + getCurrentNitroProcessInfo: () => getNitroProcessInfo(subprocess), +}; diff --git a/extensions/inference-nitro-extension/src/node/nvidia.ts b/extensions/inference-nitro-extension/src/node/nvidia.ts new file mode 100644 index 000000000..13e43290b --- /dev/null +++ b/extensions/inference-nitro-extension/src/node/nvidia.ts @@ -0,0 +1,200 @@ +import { writeFileSync, existsSync, readFileSync } from "fs"; +import { exec } from "child_process"; +import path from "path"; +import { getJanDataFolderPath } from "@janhq/core/node"; + +/** + * Default GPU settings + **/ +const DEFALT_SETTINGS = { + notify: true, + run_mode: "cpu", + nvidia_driver: { + exist: false, + version: "", + }, + cuda: { + exist: false, + version: "", + }, + gpus: [], + gpu_highest_vram: "", +}; + +/** + * Path to the settings file + **/ +export const NVIDIA_INFO_FILE = path.join( + getJanDataFolderPath(), + "settings", + "settings.json" +); + +/** + * Current nitro process + */ +let nitroProcessInfo: NitroProcessInfo | undefined = undefined; + +/** + * Nitro process info + */ +export interface NitroProcessInfo { + isRunning: boolean; +} + +/** + * This will retrive GPU informations and persist settings.json + * Will be called when the extension is loaded to turn on GPU acceleration if supported + */ +export async function updateNvidiaInfo() { + if (process.platform !== "darwin") { + await Promise.all([ + updateNvidiaDriverInfo(), + updateCudaExistence(), + updateGpuInfo(), + ]); + } +} + +/** + * Retrieve current nitro process + */ +export const getNitroProcessInfo = (subprocess: any): NitroProcessInfo => { + nitroProcessInfo = { + isRunning: subprocess != null, + }; + return nitroProcessInfo; +}; + +/** + * Validate nvidia and cuda for linux and windows + */ +export async function updateNvidiaDriverInfo(): Promise { + exec( + "nvidia-smi --query-gpu=driver_version --format=csv,noheader", + (error, stdout) => { + let data; + try { + data = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf-8")); + } catch (error) { + data = DEFALT_SETTINGS; + } + + if (!error) { + const firstLine = stdout.split("\n")[0].trim(); + data["nvidia_driver"].exist = true; + data["nvidia_driver"].version = firstLine; + } else { + data["nvidia_driver"].exist = false; + } + + writeFileSync(NVIDIA_INFO_FILE, JSON.stringify(data, null, 2)); + Promise.resolve(); + } + ); +} + +/** + * Check if file exists in paths + */ +export function checkFileExistenceInPaths( + file: string, + paths: string[] +): boolean { + return paths.some((p) => existsSync(path.join(p, file))); +} + +/** + * Validate cuda for linux and windows + */ +export function updateCudaExistence() { + let filesCuda12: string[]; + let filesCuda11: string[]; + let paths: string[]; + let cudaVersion: string = ""; + + if (process.platform === "win32") { + filesCuda12 = ["cublas64_12.dll", "cudart64_12.dll", "cublasLt64_12.dll"]; + filesCuda11 = ["cublas64_11.dll", "cudart64_11.dll", "cublasLt64_11.dll"]; + paths = process.env.PATH ? process.env.PATH.split(path.delimiter) : []; + } else { + filesCuda12 = ["libcudart.so.12", "libcublas.so.12", "libcublasLt.so.12"]; + filesCuda11 = ["libcudart.so.11.0", "libcublas.so.11", "libcublasLt.so.11"]; + paths = process.env.LD_LIBRARY_PATH + ? process.env.LD_LIBRARY_PATH.split(path.delimiter) + : []; + paths.push("/usr/lib/x86_64-linux-gnu/"); + } + + let cudaExists = filesCuda12.every( + (file) => existsSync(file) || checkFileExistenceInPaths(file, paths) + ); + + if (!cudaExists) { + cudaExists = filesCuda11.every( + (file) => existsSync(file) || checkFileExistenceInPaths(file, paths) + ); + if (cudaExists) { + cudaVersion = "11"; + } + } else { + cudaVersion = "12"; + } + + let data; + try { + data = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf-8")); + } catch (error) { + data = DEFALT_SETTINGS; + } + + data["cuda"].exist = cudaExists; + data["cuda"].version = cudaVersion; + if (cudaExists) { + data.run_mode = "gpu"; + } + writeFileSync(NVIDIA_INFO_FILE, JSON.stringify(data, null, 2)); +} + +/** + * Get GPU information + */ +export async function updateGpuInfo(): Promise { + exec( + "nvidia-smi --query-gpu=index,memory.total --format=csv,noheader,nounits", + (error, stdout) => { + let data; + try { + data = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf-8")); + } catch (error) { + data = DEFALT_SETTINGS; + } + + if (!error) { + // Get GPU info and gpu has higher memory first + let highestVram = 0; + let highestVramId = "0"; + let gpus = stdout + .trim() + .split("\n") + .map((line) => { + let [id, vram] = line.split(", "); + vram = vram.replace(/\r/g, ""); + if (parseFloat(vram) > highestVram) { + highestVram = parseFloat(vram); + highestVramId = id; + } + return { id, vram }; + }); + + data["gpus"] = gpus; + data["gpu_highest_vram"] = highestVramId; + } else { + data["gpus"] = []; + } + + writeFileSync(NVIDIA_INFO_FILE, JSON.stringify(data, null, 2)); + Promise.resolve(); + } + ); +} diff --git a/extensions/inference-nitro-extension/src/node/utils.ts b/extensions/inference-nitro-extension/src/node/utils.ts new file mode 100644 index 000000000..c7ef2e9a6 --- /dev/null +++ b/extensions/inference-nitro-extension/src/node/utils.ts @@ -0,0 +1,56 @@ +import os from "os"; +import childProcess from "child_process"; + +function exec(command: string): Promise { + return new Promise((resolve, reject) => { + childProcess.exec(command, { encoding: "utf8" }, (error, stdout) => { + if (error) { + reject(error); + } else { + resolve(stdout); + } + }); + }); +} + +let amount: number; +const platform = os.platform(); + +export async function physicalCpuCount(): Promise { + return new Promise((resolve, reject) => { + if (platform === "linux") { + exec('lscpu -p | egrep -v "^#" | sort -u -t, -k 2,4 | wc -l') + .then((output) => { + amount = parseInt(output.trim(), 10); + resolve(amount); + }) + .catch(reject); + } else if (platform === "darwin") { + exec("sysctl -n hw.physicalcpu_max") + .then((output) => { + amount = parseInt(output.trim(), 10); + resolve(amount); + }) + .catch(reject); + } else if (platform === "win32") { + exec("WMIC CPU Get NumberOfCores") + .then((output) => { + amount = output + .split(os.EOL) + .map((line: string) => parseInt(line)) + .filter((value: number) => !isNaN(value)) + .reduce((sum: number, number: number) => sum + number, 1); + resolve(amount); + }) + .catch(reject); + } else { + const cores = os.cpus().filter((cpu: any, index: number) => { + const hasHyperthreading = cpu.model.includes("Intel"); + const isOdd = index % 2 === 1; + return !hasHyperthreading || isOdd; + }); + amount = cores.length; + resolve(amount); + } + }); +} diff --git a/extensions/inference-nitro-extension/tsconfig.json b/extensions/inference-nitro-extension/tsconfig.json index b48175a16..bada43fc7 100644 --- a/extensions/inference-nitro-extension/tsconfig.json +++ b/extensions/inference-nitro-extension/tsconfig.json @@ -1,15 +1,19 @@ { "compilerOptions": { - "target": "es2016", - "module": "ES6", "moduleResolution": "node", - - "outDir": "./dist", - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "strict": false, - "skipLibCheck": true, - "rootDir": "./src" + "target": "es5", + "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"] }, - "include": ["./src"] + "include": ["src"] } diff --git a/extensions/inference-nitro-extension/webpack.config.js b/extensions/inference-nitro-extension/webpack.config.js deleted file mode 100644 index 2927affbc..000000000 --- a/extensions/inference-nitro-extension/webpack.config.js +++ /dev/null @@ -1,43 +0,0 @@ -const path = require("path"); -const webpack = require("webpack"); -const packageJson = require("./package.json"); - -module.exports = { - experiments: { outputModule: true }, - entry: "./src/index.ts", // Adjust the entry point to match your project's main file - mode: "production", - module: { - rules: [ - { - test: /\.tsx?$/, - use: "ts-loader", - exclude: /node_modules/, - }, - ], - }, - plugins: [ - new webpack.DefinePlugin({ - MODULE: JSON.stringify(`${packageJson.name}/${packageJson.module}`), - INFERENCE_URL: JSON.stringify( - process.env.INFERENCE_URL || - "http://127.0.0.1:3928/inferences/llamacpp/chat_completion" - ), - TROUBLESHOOTING_URL: JSON.stringify("https://jan.ai/guides/troubleshooting") - }), - ], - output: { - filename: "index.js", // Adjust the output file name as needed - path: path.resolve(__dirname, "dist"), - library: { type: "module" }, // Specify ESM output format - }, - resolve: { - extensions: [".ts", ".js"], - fallback: { - path: require.resolve("path-browserify"), - }, - }, - optimization: { - minimize: false, - }, - // Add loaders and other configuration as needed for your project -}; diff --git a/extensions/inference-openai-extension/src/index.ts b/extensions/inference-openai-extension/src/index.ts index 246aa124b..54572041d 100644 --- a/extensions/inference-openai-extension/src/index.ts +++ b/extensions/inference-openai-extension/src/index.ts @@ -9,16 +9,17 @@ import { ChatCompletionRole, ContentType, - EventName, MessageRequest, MessageStatus, - ExtensionType, ThreadContent, ThreadMessage, events, fs, + BaseExtension, + MessageEvent, + ModelEvent, + InferenceEvent, } from "@janhq/core"; -import { InferenceExtension } from "@janhq/core"; import { requestInference } from "./helpers/sse"; import { ulid } from "ulid"; import { join } from "path"; @@ -28,7 +29,7 @@ import { join } from "path"; * The class provides methods for initializing and stopping a model, and for making inference requests. * It also subscribes to events emitted by the @janhq/core package and handles new message requests. */ -export default class JanInferenceOpenAIExtension implements InferenceExtension { +export default class JanInferenceOpenAIExtension extends BaseExtension { private static readonly _homeDir = "file://engines"; private static readonly _engineMetadataFileName = "openai.json"; @@ -42,14 +43,6 @@ export default class JanInferenceOpenAIExtension implements InferenceExtension { controller = new AbortController(); isCancelled = false; - /** - * Returns the type of the extension. - * @returns {ExtensionType} The type of the extension. - */ - // TODO: To fix - type(): ExtensionType { - return undefined; - } /** * Subscribes to events emitted by the @janhq/core package. */ @@ -63,18 +56,18 @@ export default class JanInferenceOpenAIExtension implements InferenceExtension { JanInferenceOpenAIExtension.writeDefaultEngineSettings(); // Events subscription - events.on(EventName.OnMessageSent, (data) => + events.on(MessageEvent.OnMessageSent, (data) => JanInferenceOpenAIExtension.handleMessageRequest(data, this) ); - events.on(EventName.OnModelInit, (model: OpenAIModel) => { + events.on(ModelEvent.OnModelInit, (model: OpenAIModel) => { JanInferenceOpenAIExtension.handleModelInit(model); }); - events.on(EventName.OnModelStop, (model: OpenAIModel) => { + events.on(ModelEvent.OnModelStop, (model: OpenAIModel) => { JanInferenceOpenAIExtension.handleModelStop(model); }); - events.on(EventName.OnInferenceStopped, () => { + events.on(InferenceEvent.OnInferenceStopped, () => { JanInferenceOpenAIExtension.handleInferenceStopped(this); }); } @@ -104,43 +97,6 @@ export default class JanInferenceOpenAIExtension implements InferenceExtension { console.error(err); } } - - /** - * Makes a single response inference request. - * @param {MessageRequest} data - The data for the inference request. - * @returns {Promise} A promise that resolves with the inference response. - */ - async inference(data: MessageRequest): Promise { - const timestamp = Date.now(); - - const message: ThreadMessage = { - thread_id: data.threadId, - created: timestamp, - updated: timestamp, - status: MessageStatus.Ready, - id: "", - role: ChatCompletionRole.Assistant, - object: "thread.message", - content: [], - }; - - return new Promise(async (resolve, reject) => { - requestInference( - data.messages ?? [], - JanInferenceOpenAIExtension._engineSettings, - JanInferenceOpenAIExtension._currentModel - ).subscribe({ - next: (_content) => {}, - complete: async () => { - resolve(message); - }, - error: async (err) => { - reject(err); - }, - }); - }); - } - private static async handleModelInit(model: OpenAIModel) { if (model.engine !== "openai") { return; @@ -148,7 +104,7 @@ export default class JanInferenceOpenAIExtension implements InferenceExtension { JanInferenceOpenAIExtension._currentModel = model; JanInferenceOpenAIExtension.writeDefaultEngineSettings(); // Todo: Check model list with API key - events.emit(EventName.OnModelReady, model); + events.emit(ModelEvent.OnModelReady, model); } } @@ -156,7 +112,7 @@ export default class JanInferenceOpenAIExtension implements InferenceExtension { if (model.engine !== "openai") { return; } - events.emit(EventName.OnModelStopped, model); + events.emit(ModelEvent.OnModelStopped, model); } private static async handleInferenceStopped( @@ -192,7 +148,7 @@ export default class JanInferenceOpenAIExtension implements InferenceExtension { updated: timestamp, object: "thread.message", }; - events.emit(EventName.OnMessageResponse, message); + events.emit(MessageEvent.OnMessageResponse, message); instance.isCancelled = false; instance.controller = new AbortController(); @@ -215,18 +171,18 @@ export default class JanInferenceOpenAIExtension implements InferenceExtension { }, }; message.content = [messageContent]; - events.emit(EventName.OnMessageUpdate, message); + events.emit(MessageEvent.OnMessageUpdate, message); }, complete: async () => { message.status = message.content.length ? MessageStatus.Ready : MessageStatus.Error; - events.emit(EventName.OnMessageUpdate, message); + events.emit(MessageEvent.OnMessageUpdate, message); }, error: async (err) => { if (instance.isCancelled || message.content.length > 0) { message.status = MessageStatus.Error; - events.emit(EventName.OnMessageUpdate, message); + events.emit(MessageEvent.OnMessageUpdate, message); return; } const messageContent: ThreadContent = { @@ -238,7 +194,7 @@ export default class JanInferenceOpenAIExtension implements InferenceExtension { }; message.content = [messageContent]; message.status = MessageStatus.Ready; - events.emit(EventName.OnMessageUpdate, message); + events.emit(MessageEvent.OnMessageUpdate, message); }, }); } diff --git a/extensions/inference-triton-trtllm-extension/src/index.ts b/extensions/inference-triton-trtllm-extension/src/index.ts index aed2f581a..11ddf7893 100644 --- a/extensions/inference-triton-trtllm-extension/src/index.ts +++ b/extensions/inference-triton-trtllm-extension/src/index.ts @@ -9,18 +9,18 @@ import { ChatCompletionRole, ContentType, - EventName, MessageRequest, MessageStatus, ModelSettingParams, - ExtensionType, ThreadContent, ThreadMessage, events, fs, Model, + BaseExtension, + MessageEvent, + ModelEvent, } from "@janhq/core"; -import { InferenceExtension } from "@janhq/core"; import { requestInference } from "./helpers/sse"; import { ulid } from "ulid"; import { join } from "path"; @@ -32,7 +32,7 @@ import { EngineSettings } from "./@types/global"; * It also subscribes to events emitted by the @janhq/core package and handles new message requests. */ export default class JanInferenceTritonTrtLLMExtension - implements InferenceExtension + extends BaseExtension { private static readonly _homeDir = "file://engines"; private static readonly _engineMetadataFileName = "triton_trtllm.json"; @@ -46,14 +46,6 @@ export default class JanInferenceTritonTrtLLMExtension controller = new AbortController(); isCancelled = false; - /** - * Returns the type of the extension. - * @returns {ExtensionType} The type of the extension. - */ - // TODO: To fix - type(): ExtensionType { - return undefined; - } /** * Subscribes to events emitted by the @janhq/core package. */ @@ -62,15 +54,15 @@ export default class JanInferenceTritonTrtLLMExtension JanInferenceTritonTrtLLMExtension.writeDefaultEngineSettings(); // Events subscription - events.on(EventName.OnMessageSent, (data) => + events.on(MessageEvent.OnMessageSent, (data) => JanInferenceTritonTrtLLMExtension.handleMessageRequest(data, this) ); - events.on(EventName.OnModelInit, (model: Model) => { + events.on(ModelEvent.OnModelInit, (model: Model) => { JanInferenceTritonTrtLLMExtension.handleModelInit(model); }); - events.on(EventName.OnModelStop, (model: Model) => { + events.on(ModelEvent.OnModelStop, (model: Model) => { JanInferenceTritonTrtLLMExtension.handleModelStop(model); }); } @@ -131,41 +123,6 @@ export default class JanInferenceTritonTrtLLMExtension this.controller?.abort(); } - /** - * Makes a single response inference request. - * @param {MessageRequest} data - The data for the inference request. - * @returns {Promise} A promise that resolves with the inference response. - */ - async inference(data: MessageRequest): Promise { - const timestamp = Date.now(); - const message: ThreadMessage = { - thread_id: data.threadId, - created: timestamp, - updated: timestamp, - status: MessageStatus.Ready, - id: "", - role: ChatCompletionRole.Assistant, - object: "thread.message", - content: [], - }; - - return new Promise(async (resolve, reject) => { - requestInference( - data.messages ?? [], - JanInferenceTritonTrtLLMExtension._engineSettings, - JanInferenceTritonTrtLLMExtension._currentModel - ).subscribe({ - next: (_content) => {}, - complete: async () => { - resolve(message); - }, - error: async (err) => { - reject(err); - }, - }); - }); - } - private static async handleModelInit(model: Model) { if (model.engine !== "triton_trtllm") { return; @@ -173,8 +130,7 @@ export default class JanInferenceTritonTrtLLMExtension JanInferenceTritonTrtLLMExtension._currentModel = model; JanInferenceTritonTrtLLMExtension.writeDefaultEngineSettings(); // Todo: Check model list with API key - events.emit(EventName.OnModelReady, model); - // events.emit(EventName.OnModelFail, model) + events.emit(ModelEvent.OnModelReady, model); } } @@ -182,7 +138,7 @@ export default class JanInferenceTritonTrtLLMExtension if (model.engine !== "triton_trtllm") { return; } - events.emit(EventName.OnModelStopped, model); + events.emit(ModelEvent.OnModelStopped, model); } /** @@ -211,7 +167,7 @@ export default class JanInferenceTritonTrtLLMExtension updated: timestamp, object: "thread.message", }; - events.emit(EventName.OnMessageResponse, message); + events.emit(MessageEvent.OnMessageResponse, message); instance.isCancelled = false; instance.controller = new AbortController(); @@ -234,18 +190,18 @@ export default class JanInferenceTritonTrtLLMExtension }, }; message.content = [messageContent]; - events.emit(EventName.OnMessageUpdate, message); + events.emit(MessageEvent.OnMessageUpdate, message); }, complete: async () => { message.status = message.content.length ? MessageStatus.Ready : MessageStatus.Error; - events.emit(EventName.OnMessageUpdate, message); + events.emit(MessageEvent.OnMessageUpdate, message); }, error: async (err) => { if (instance.isCancelled || message.content.length) { message.status = MessageStatus.Error; - events.emit(EventName.OnMessageUpdate, message); + events.emit(MessageEvent.OnMessageUpdate, message); return; } const messageContent: ThreadContent = { @@ -257,7 +213,7 @@ export default class JanInferenceTritonTrtLLMExtension }; message.content = [messageContent]; message.status = MessageStatus.Ready; - events.emit(EventName.OnMessageUpdate, message); + events.emit(MessageEvent.OnMessageUpdate, message); }, }); } diff --git a/extensions/model-extension/src/index.ts b/extensions/model-extension/src/index.ts index b26036b89..f41999bd8 100644 --- a/extensions/model-extension/src/index.ts +++ b/extensions/model-extension/src/index.ts @@ -1,20 +1,19 @@ import { - ExtensionType, fs, downloadFile, abortDownload, getResourcePath, - getUserSpace, InferenceEngine, joinPath, ModelExtension, Model, + getJanDataFolderPath, } from '@janhq/core' /** * A extension for models */ -export default class JanModelExtension implements ModelExtension { +export default class JanModelExtension extends ModelExtension { private static readonly _homeDir = 'file://models' private static readonly _modelMetadataFileName = 'model.json' private static readonly _supportedModelFormat = '.gguf' @@ -24,15 +23,6 @@ export default class JanModelExtension implements ModelExtension { private static readonly _configDirName = 'config' private static readonly _defaultModelFileName = 'default-model.json' - /** - * Implements type from JanExtension. - * @override - * @returns The type of the extension. - */ - type(): ExtensionType { - return ExtensionType.Model - } - /** * Called when the extension is loaded. * @override @@ -49,7 +39,6 @@ export default class JanModelExtension implements ModelExtension { private async copyModelsToHomeDir() { try { - // Check for migration conditions if ( localStorage.getItem(`${EXTENSION_NAME}-version`) === VERSION && @@ -63,8 +52,8 @@ export default class JanModelExtension implements ModelExtension { const resourePath = await getResourcePath() const srcPath = await joinPath([resourePath, 'models']) - const userSpace = await getUserSpace() - const destPath = await joinPath([userSpace, 'models']) + const janDataFolderPath = await getJanDataFolderPath() + const destPath = await joinPath([janDataFolderPath, 'models']) await fs.syncFile(srcPath, destPath) @@ -80,9 +69,13 @@ export default class JanModelExtension implements ModelExtension { /** * Downloads a machine learning model. * @param model - The model to download. + * @param network - Optional object to specify proxy/whether to ignore SSL certificates. * @returns A Promise that resolves when the model is downloaded. */ - async downloadModel(model: Model): Promise { + async downloadModel( + model: Model, + network?: { ignoreSSL?: boolean; proxy?: string } + ): Promise { // create corresponding directory const modelDirPath = await joinPath([JanModelExtension._homeDir, model.id]) if (!(await fs.existsSync(modelDirPath))) await fs.mkdirSync(modelDirPath) @@ -96,7 +89,7 @@ export default class JanModelExtension implements ModelExtension { ? extractedFileName : model.id const path = await joinPath([modelDirPath, fileName]) - downloadFile(model.source_url, path) + downloadFile(model.source_url, path, network) } /** @@ -304,6 +297,11 @@ export default class JanModelExtension implements ModelExtension { name: dirName, created: Date.now(), description: `${dirName} - user self import model`, + metadata: { + size: binaryFileSize, + author: 'User', + tags: [], + }, } const modelFilePath = await joinPath([ diff --git a/extensions/monitoring-extension/src/index.ts b/extensions/monitoring-extension/src/index.ts index f1cbf6dad..d3f20b437 100644 --- a/extensions/monitoring-extension/src/index.ts +++ b/extensions/monitoring-extension/src/index.ts @@ -1,4 +1,3 @@ -import { ExtensionType } from "@janhq/core"; import { MonitoringExtension } from "@janhq/core"; import { executeOnMain } from "@janhq/core"; @@ -6,15 +5,7 @@ import { executeOnMain } from "@janhq/core"; * JanMonitoringExtension is a extension that provides system monitoring functionality. * It implements the MonitoringExtension interface from the @janhq/core package. */ -export default class JanMonitoringExtension implements MonitoringExtension { - /** - * Returns the type of the extension. - * @returns The ExtensionType.SystemMonitoring value. - */ - type(): ExtensionType { - return ExtensionType.SystemMonitoring; - } - +export default class JanMonitoringExtension extends MonitoringExtension { /** * Called when the extension is loaded. */ diff --git a/extensions/monitoring-extension/src/module.ts b/extensions/monitoring-extension/src/module.ts index 310e7359c..86b553d52 100644 --- a/extensions/monitoring-extension/src/module.ts +++ b/extensions/monitoring-extension/src/module.ts @@ -1,25 +1,23 @@ -const os = require("os"); const nodeOsUtils = require("node-os-utils"); const getResourcesInfo = () => new Promise((resolve) => { - nodeOsUtils.mem.used() - .then(ramUsedInfo => { - const totalMemory = ramUsedInfo.totalMemMb * 1024 * 1024; - const usedMemory = ramUsedInfo.usedMemMb * 1024 * 1024; - const response = { - mem: { - totalMemory, - usedMemory, - }, - }; - resolve(response); - }) + nodeOsUtils.mem.used().then((ramUsedInfo) => { + const totalMemory = ramUsedInfo.totalMemMb * 1024 * 1024; + const usedMemory = ramUsedInfo.usedMemMb * 1024 * 1024; + const response = { + mem: { + totalMemory, + usedMemory, + }, + }; + resolve(response); + }); }); const getCurrentLoad = () => new Promise((resolve) => { - nodeOsUtils.cpu.usage().then(cpuPercentage =>{ + nodeOsUtils.cpu.usage().then((cpuPercentage) => { const response = { cpu: { usage: cpuPercentage, diff --git a/models/config/default-model.json b/models/config/default-model.json index 50bf067c1..2263625f4 100644 --- a/models/config/default-model.json +++ b/models/config/default-model.json @@ -9,7 +9,6 @@ "description": "User self import model", "settings": { "ctx_len": 4096, - "ngl": 0, "embedding": false, "prompt_template": "{system_message}\n### Instruction: {prompt}\n### Response:" }, diff --git a/package.json b/package.json index 4b9492e3d..4b8bc4af0 100644 --- a/package.json +++ b/package.json @@ -11,23 +11,20 @@ ], "nohoist": [ "uikit", - "uikit/*", "core", - "core/*", "electron", - "electron/**", "web", - "web/**", - "server", - "server/**" + "server" ] }, "scripts": { "lint": "yarn workspace jan lint && yarn workspace jan-web lint", + "test:unit": "yarn workspace @janhq/core test", "test": "yarn workspace jan test:e2e", "copy:assets": "cpx \"models/**\" \"electron/models/\" && cpx \"docs/openapi/**\" \"electron/docs/openapi\"", "dev:electron": "yarn copy:assets && yarn workspace jan dev", "dev:web": "yarn workspace jan-web dev", + "dev:server": "yarn workspace @janhq/server dev", "dev": "concurrently --kill-others \"yarn dev:web\" \"wait-on http://localhost:3000 && yarn dev:electron\"", "test-local": "yarn lint && yarn build:test && yarn test", "dev:uikit": "yarn workspace @janhq/uikit install && yarn workspace @janhq/uikit dev", diff --git a/server/index.ts b/server/index.ts index 49d37a447..05bfdca96 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,40 +1,86 @@ import fastify from "fastify"; import dotenv from "dotenv"; -import { log, v1Router } from "@janhq/core/node"; -import path from "path"; - -import os from "os"; +import { + getServerLogPath, + v1Router, + logServer, + getJanExtensionsPath, +} from "@janhq/core/node"; +import { join } from "path"; +// Load environment variables dotenv.config(); +// Define default settings const JAN_API_HOST = process.env.JAN_API_HOST || "127.0.0.1"; const JAN_API_PORT = Number.parseInt(process.env.JAN_API_PORT || "1337"); -const serverLogPath = path.join(os.homedir(), "jan", "logs", "server.log"); +// Initialize server settings let server: any | undefined = undefined; +let hostSetting: string = JAN_API_HOST; +let portSetting: number = JAN_API_PORT; +let corsEnabled: boolean = true; +let isVerbose: boolean = true; -export const startServer = async (schemaPath?: string, baseDir?: string) => { +/** + * Server configurations + * @param host - The host address for the server + * @param port - The port number for the server + * @param isCorsEnabled - Flag to enable or disable CORS + * @param isVerboseEnabled - Flag to enable or disable verbose logging + * @param schemaPath - Path to the OpenAPI schema file + * @param baseDir - Base directory for the OpenAPI schema file + */ +export interface ServerConfig { + host?: string; + port?: number; + isCorsEnabled?: boolean; + isVerboseEnabled?: boolean; + schemaPath?: string; + baseDir?: string; +} + +/** + * Function to start the server + * @param configs - Server configurations + */ +export const startServer = async (configs?: ServerConfig) => { + // Update server settings + isVerbose = configs?.isVerboseEnabled ?? true; + hostSetting = configs?.host ?? JAN_API_HOST; + portSetting = configs?.port ?? JAN_API_PORT; + corsEnabled = configs?.isCorsEnabled ?? true; + const serverLogPath = getServerLogPath(); + + // Start the server try { - log(`[API]::Debug: Starting JAN API server...`, "server.log") + // Log server start + if (isVerbose) logServer(`Debug: Starting JAN API server...`); + + // Initialize Fastify server with logging server = fastify({ logger: { level: "info", file: serverLogPath, }, }); - await server.register(require("@fastify/cors"), {}); + // Register CORS if enabled + if (corsEnabled) await server.register(require("@fastify/cors"), {}); + + // Register Swagger for API documentation await server.register(require("@fastify/swagger"), { mode: "static", specification: { - path: schemaPath ?? "./../docs/openapi/jan.yaml", - baseDir: baseDir ?? "./../docs/openapi", + path: configs?.schemaPath ?? "./../docs/openapi/jan.yaml", + baseDir: configs?.baseDir ?? "./../docs/openapi", }, }); + // Register Swagger UI await server.register(require("@fastify/swagger-ui"), { routePrefix: "/", - baseDir: baseDir ?? path.join(__dirname, "../..", "./docs/openapi"), + baseDir: configs?.baseDir ?? join(__dirname, "../..", "./docs/openapi"), uiConfig: { docExpansion: "full", deepLinking: false, @@ -43,12 +89,12 @@ export const startServer = async (schemaPath?: string, baseDir?: string) => { transformSpecificationClone: true, }); + // Register static file serving for extensions + // TODO: Watch extension files changes and reload await server.register( (childContext: any, _: any, done: any) => { childContext.register(require("@fastify/static"), { - root: - process.env.EXTENSION_ROOT || - path.join(require("os").homedir(), "jan", "extensions"), + root: getJanExtensionsPath(), wildcard: false, }); @@ -56,25 +102,40 @@ export const startServer = async (schemaPath?: string, baseDir?: string) => { }, { prefix: "extensions" } ); + + // Register API routes await server.register(v1Router, { prefix: "/v1" }); + + // Start listening for requests await server .listen({ - port: JAN_API_PORT, - host: JAN_API_HOST, + port: portSetting, + host: hostSetting, }) .then(() => { - log(`[API]::Debug: JAN API listening at: http://${JAN_API_HOST}:${JAN_API_PORT}`); + // Log server listening + if (isVerbose) + logServer( + `Debug: JAN API listening at: http://${hostSetting}:${portSetting}` + ); }); } catch (e) { - log(`[API]::Error: ${e}`); + // Log any errors + if (isVerbose) logServer(`Error: ${e}`); } }; +/** + * Function to stop the server + */ export const stopServer = async () => { try { - log(`[API]::Debug: Server stopped`, "server.log") + // Log server stop + if (isVerbose) logServer(`Debug: Server stopped`); + // Stop the server await server.close(); } catch (e) { - log(`[API]::Error: ${e}`); + // Log any errors + if (isVerbose) logServer(`Error: ${e}`); } }; diff --git a/server/package.json b/server/package.json index 4db9894be..9495a0d65 100644 --- a/server/package.json +++ b/server/package.json @@ -21,7 +21,7 @@ "@fastify/cors": "^8.4.2", "@fastify/static": "^6.12.0", "@fastify/swagger": "^8.13.0", - "@fastify/swagger-ui": "^2.0.1", + "@fastify/swagger-ui": "2.0.1", "@janhq/core": "link:./core", "dotenv": "^16.3.1", "fastify": "^4.24.3", diff --git a/uikit/src/button/index.tsx b/uikit/src/button/index.tsx index 7561b5c73..15919d666 100644 --- a/uikit/src/button/index.tsx +++ b/uikit/src/button/index.tsx @@ -14,6 +14,7 @@ const buttonVariants = cva('btn', { outline: 'btn-outline', secondary: 'btn-secondary', secondaryBlue: 'btn-secondary-blue', + secondaryDanger: 'btn-secondary-danger', ghost: 'btn-ghost', success: 'btn-success', }, diff --git a/uikit/src/button/styles.scss b/uikit/src/button/styles.scss index 9c634e67e..74585ed1e 100644 --- a/uikit/src/button/styles.scss +++ b/uikit/src/button/styles.scss @@ -1,20 +1,25 @@ .btn { @apply inline-flex items-center justify-center whitespace-nowrap rounded-lg font-semibold transition-colors; - @apply cursor-pointer focus:outline-none focus-visible:outline-none focus-visible:ring-0; - @apply disabled:pointer-events-none disabled:opacity-50; + @apply cursor-pointer; + @apply focus-within:outline-none focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1; + @apply disabled:pointer-events-none disabled:bg-zinc-100 disabled:text-zinc-400; &-primary { @apply bg-primary hover:bg-primary/90 text-white; } &-secondary-blue { - @apply bg-blue-200 text-blue-600 hover:bg-blue-500/80; + @apply bg-blue-200 text-blue-600 hover:bg-blue-500/50; } &-danger { @apply bg-danger text-danger-foreground hover:bg-danger/90; } + &-secondary-danger { + @apply bg-red-200 text-red-600 hover:bg-red-500/50; + } + &-outline { @apply border-input border bg-transparent; } diff --git a/uikit/src/input/styles.scss b/uikit/src/input/styles.scss index ba4d81a03..b78db270a 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:cursor-not-allowed disabled:opacity-50; - @apply focus-visible:ring-secondary focus-visible:outline-none focus-visible:ring-1; + @apply 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/select/index.tsx b/uikit/src/select/index.tsx index 9bee7a153..d1dcc0955 100644 --- a/uikit/src/select/index.tsx +++ b/uikit/src/select/index.tsx @@ -3,7 +3,6 @@ import * as React from 'react' import { CaretSortIcon, - // CheckIcon, ChevronDownIcon, ChevronUpIcon, } from '@radix-ui/react-icons' @@ -18,6 +17,8 @@ const SelectGroup = SelectPrimitive.Group const SelectValue = SelectPrimitive.Value +const SelectPortal = SelectPrimitive.Portal + const SelectTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -116,11 +117,6 @@ const SelectItem = React.forwardRef< className={twMerge('select-item', className)} {...props} > - {/* - - - - */} {children} )) @@ -130,6 +126,7 @@ export { Select, SelectGroup, SelectValue, + SelectPortal, SelectTrigger, SelectContent, SelectLabel, diff --git a/uikit/src/select/styles.scss b/uikit/src/select/styles.scss index 2c9c03333..665ca8cba 100644 --- a/uikit/src/select/styles.scss +++ b/uikit/src/select/styles.scss @@ -1,5 +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 disabled:opacity-50 [&>span]:line-clamp-1; + @apply focus-within:outline-none focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1; &-caret { @apply h-4 w-4 opacity-50; diff --git a/uikit/src/slider/styles.scss b/uikit/src/slider/styles.scss index 889c35151..718972efb 100644 --- a/uikit/src/slider/styles.scss +++ b/uikit/src/slider/styles.scss @@ -3,6 +3,9 @@ &-track { @apply relative h-1.5 w-full grow overflow-hidden rounded-full bg-gray-200 dark:bg-gray-800; + [data-disabled] { + @apply cursor-not-allowed opacity-50; + } } &-range { diff --git a/uikit/src/textarea/styles.scss b/uikit/src/textarea/styles.scss index 3bc846b8a..16b63847a 100644 --- a/uikit/src/textarea/styles.scss +++ b/uikit/src/textarea/styles.scss @@ -1,6 +1,6 @@ .textarea-input { @apply border-border placeholder:text-muted-foreground flex w-full rounded-md border bg-transparent px-3 py-2 transition-colors; @apply disabled:cursor-not-allowed disabled:opacity-50; - @apply focus-visible:ring-secondary focus-visible:outline-none focus-visible:ring-1; + @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/web/app/page.tsx b/web/app/page.tsx index cae3262a7..20b15a235 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -9,6 +9,7 @@ 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 SystemMonitorScreen from '@/screens/SystemMonitor' @@ -29,6 +30,10 @@ export default function Page() { children = break + case MainViewState.LocalServer: + children = + break + default: children = break diff --git a/web/constants/screens.ts b/web/constants/screens.ts index 19f82aaac..6a8adc185 100644 --- a/web/constants/screens.ts +++ b/web/constants/screens.ts @@ -4,4 +4,5 @@ export enum MainViewState { Settings, Thread, SystemMonitor, + LocalServer, } diff --git a/web/containers/CardSidebar/index.tsx b/web/containers/CardSidebar/index.tsx index 8222c4918..bc5047497 100644 --- a/web/containers/CardSidebar/index.tsx +++ b/web/containers/CardSidebar/index.tsx @@ -11,27 +11,28 @@ import { twMerge } from 'tailwind-merge' import { useClickOutside } from '@/hooks/useClickOutside' +import { usePath } from '@/hooks/usePath' + import { activeThreadAtom } from '@/helpers/atoms/Thread.atom' interface Props { children: ReactNode title: string - onRevealInFinderClick?: (type: string) => void - onViewJsonClick?: (type: string) => void asChild?: boolean + hideMoreVerticalAction?: boolean } export default function CardSidebar({ children, title, - onRevealInFinderClick, - onViewJsonClick, asChild, + hideMoreVerticalAction, }: Props) { const [show, setShow] = useState(true) const [more, setMore] = useState(false) const [menu, setMenu] = useState(null) const [toggle, setToggle] = useState(null) const activeThread = useAtomValue(activeThreadAtom) + const { onReviewInFinder, onViewJson } = usePath() useClickOutside(() => setMore(false), null, [menu, toggle]) @@ -58,13 +59,17 @@ export default function CardSidebar({ {title}
{!asChild && ( -
setMore(!more)} - > - -
+ <> + {!hideMoreVerticalAction && ( +
setMore(!more)} + > + +
+ )} + )} -
- - + Explore The Hub + + + + + + - {selected?.engine === InferenceEngine.openai && ( -
- - { - saveOpenAISettings({ apiKey: e.target.value }) - }} - /> -
- )} + ) } diff --git a/web/containers/Layout/BottomBar/index.tsx b/web/containers/Layout/BottomBar/index.tsx index dfd1b324d..6e334b9ef 100644 --- a/web/containers/Layout/BottomBar/index.tsx +++ b/web/containers/Layout/BottomBar/index.tsx @@ -1,5 +1,3 @@ -import { useState } from 'react' - import { Badge, Button, @@ -8,7 +6,7 @@ import { TooltipContent, TooltipTrigger, } from '@janhq/uikit' -import { useAtomValue, useSetAtom } from 'jotai' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { FaGithub, FaDiscord } from 'react-icons/fa' @@ -32,6 +30,8 @@ import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels' import useGetSystemResources from '@/hooks/useGetSystemResources' import { useMainViewState } from '@/hooks/useMainViewState' +import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' + const menuLinks = [ { name: 'Discord', @@ -53,6 +53,7 @@ const BottomBar = () => { const { setMainViewState } = useMainViewState() const { downloadStates } = useDownloadState() const setShowSelectModelModal = useSetAtom(showSelectModelModalAtom) + const [serverEnabled] = useAtom(serverEnabledAtom) return (
@@ -63,14 +64,16 @@ const BottomBar = () => { ) : null}
- setShowSelectModelModal((show) => !show)} - > - My Models - - + {!serverEnabled && ( + setShowSelectModelModal((show) => !show)} + > + My Models + + + )} {stateModel.state === 'start' && stateModel.loading && ( { if (mainViewState === state) return + if (serverEnabled && state === MainViewState.Thread) return setMainViewState(state) } @@ -35,7 +41,10 @@ export default function RibbonNav() { icon: ( ), state: MainViewState.Thread, @@ -53,6 +62,16 @@ export default function RibbonNav() { ] const secondaryMenus = [ + { + name: 'Local API Server', + icon: ( + + ), + state: MainViewState.LocalServer, + }, { name: 'System Monitor', icon: ( @@ -108,10 +127,24 @@ export default function RibbonNav() { /> )} - - {primary.name} - - + {serverEnabled && + primary.state === MainViewState.Thread ? ( + + + Threads are disabled while the server is running + + + + ) : ( + + {primary.name} + + + )} ) diff --git a/web/containers/Layout/TopBar/CommandListDownloadedModel/index.tsx b/web/containers/Layout/TopBar/CommandListDownloadedModel/index.tsx index fbb003780..3edce06eb 100644 --- a/web/containers/Layout/TopBar/CommandListDownloadedModel/index.tsx +++ b/web/containers/Layout/TopBar/CommandListDownloadedModel/index.tsx @@ -22,10 +22,13 @@ import { useActiveModel } from '@/hooks/useActiveModel' import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels' import { useMainViewState } from '@/hooks/useMainViewState' +import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' + export default function CommandListDownloadedModel() { const { setMainViewState } = useMainViewState() const { downloadedModels } = useGetDownloadedModels() const { activeModel, startModel, stopModel } = useActiveModel() + const [serverEnabled] = useAtom(serverEnabledAtom) const [showSelectModelModal, setShowSelectModelModal] = useAtom( showSelectModelModalAtom ) @@ -39,7 +42,7 @@ export default function CommandListDownloadedModel() { } const isNotDownloadedModel = downloadedModels.length === 0 - if (isNotDownloadedModel) return null + if (isNotDownloadedModel || serverEnabled) return null return ( diff --git a/web/containers/Layout/TopBar/index.tsx b/web/containers/Layout/TopBar/index.tsx index 005348ba7..ac05e4e1a 100644 --- a/web/containers/Layout/TopBar/index.tsx +++ b/web/containers/Layout/TopBar/index.tsx @@ -1,6 +1,5 @@ import { useState } from 'react' -import { getUserSpace, joinPath, openFileExplorer } from '@janhq/core' import { useAtom, useAtomValue } from 'jotai' import { PenSquareIcon, @@ -24,9 +23,11 @@ import { useCreateNewThread } from '@/hooks/useCreateNewThread' import useGetAssistants, { getAssistants } from '@/hooks/useGetAssistants' import { useMainViewState } from '@/hooks/useMainViewState' +import { usePath } from '@/hooks/usePath' + import { showRightSideBarAtom } from '@/screens/Chat/Sidebar' -import { activeThreadAtom, threadStatesAtom } from '@/helpers/atoms/Thread.atom' +import { activeThreadAtom } from '@/helpers/atoms/Thread.atom' const TopBar = () => { const activeThread = useAtomValue(activeThreadAtom) @@ -36,7 +37,7 @@ const TopBar = () => { const [showRightSideBar, setShowRightSideBar] = useAtom(showRightSideBarAtom) const [showLeftSideBar, setShowLeftSideBar] = useAtom(showLeftSideBarAtom) const showing = useAtomValue(showRightSideBarAtom) - const threadStates = useAtomValue(threadStatesAtom) + const { onReviewInFinder, onViewJson } = usePath() const [more, setMore] = useState(false) const [menu, setMenu] = useState(null) const [toggle, setToggle] = useState(null) @@ -48,6 +49,9 @@ const TopBar = () => { case MainViewState.Thread: return activeThread ? activeThread?.title : 'New Thread' + case MainViewState.LocalServer: + return 'Local API Server' + default: return MainViewState[viewStateName]?.replace(/([A-Z])/g, ' $1').trim() } @@ -66,68 +70,52 @@ const TopBar = () => { } } - const onReviewInFinderClick = async () => { - if (!activeThread) return - const activeThreadState = threadStates[activeThread.id] - if (!activeThreadState.isFinishInit) { - alert('Thread is not started yet') - return - } - - const userSpace = await getUserSpace() - let filePath = undefined - filePath = await joinPath(['threads', activeThread.id]) - - if (!filePath) return - const fullPath = await joinPath([userSpace, filePath]) - openFileExplorer(fullPath) - } - - const onViewJsonClick = async () => { - if (!activeThread) return - const activeThreadState = threadStates[activeThread.id] - if (!activeThreadState.isFinishInit) { - alert('Thread is not started yet') - return - } - - const userSpace = await getUserSpace() - let filePath = undefined - filePath = await joinPath(['threads', activeThread.id, 'thread.json']) - if (!filePath) return - const fullPath = await joinPath([userSpace, filePath]) - openFileExplorer(fullPath) - } - return (
- {mainViewState === MainViewState.Thread && ( + {mainViewState !== MainViewState.Thread && + mainViewState !== MainViewState.LocalServer ? ( +
+ + {titleScreen(mainViewState)} + +
+ ) : (
-
-
-
setShowLeftSideBar((show) => !show)} - > - -
-
- + {mainViewState == MainViewState.Thread && ( +
+
+
setShowLeftSideBar((show) => !show)} + > + +
+
+ +
-
-
+ )} +
- + {titleScreen(mainViewState)}
@@ -142,8 +130,11 @@ const TopBar = () => {
{showing && (
- - Threads Settings + + {mainViewState === MainViewState.Thread && + 'Threads Settings'} + {mainViewState === MainViewState.LocalServer && + 'Model Settings'}
{
- {more && ( + {mainViewState === MainViewState.Thread && more && (
{
{ - onReviewInFinderClick() + onReviewInFinder('Thread') setMore(false) }} > @@ -176,7 +167,7 @@ const TopBar = () => {
{ - onViewJsonClick() + onViewJson('Thread') setMore(false) }} > @@ -196,6 +187,31 @@ const TopBar = () => {
)} + + {mainViewState === MainViewState.LocalServer && more && ( +
+
{ + onReviewInFinder('Model') + setMore(false) + }} + > + +
+ + Show in Finder + +
+
+
+ )}
)}
{
)} - - {mainViewState !== MainViewState.Thread && ( -
- - {titleScreen(mainViewState)} - -
- )}
) diff --git a/web/containers/Layout/index.tsx b/web/containers/Layout/index.tsx index 0fe9b5f8b..54a7845a4 100644 --- a/web/containers/Layout/index.tsx +++ b/web/containers/Layout/index.tsx @@ -9,11 +9,14 @@ import RibbonNav from '@/containers/Layout/Ribbon' import TopBar from '@/containers/Layout/TopBar' +import { MainViewState } from '@/constants/screens' + import { useMainViewState } from '@/hooks/useMainViewState' +import { SUCCESS_SET_NEW_DESTINATION } from '@/hooks/useVaultDirectory' const BaseLayout = (props: PropsWithChildren) => { const { children } = props - const { mainViewState } = useMainViewState() + const { mainViewState, setMainViewState } = useMainViewState() const { theme, setTheme } = useTheme() @@ -21,6 +24,12 @@ const BaseLayout = (props: PropsWithChildren) => { setTheme(theme as string) }, [setTheme, theme]) + useEffect(() => { + if (localStorage.getItem(SUCCESS_SET_NEW_DESTINATION) === 'true') { + setMainViewState(MainViewState.Settings) + } + }, []) + return (
diff --git a/web/containers/Loader/ModelStart.tsx b/web/containers/Loader/ModelStart.tsx index 86a6b378e..7002c7b40 100644 --- a/web/containers/Loader/ModelStart.tsx +++ b/web/containers/Loader/ModelStart.tsx @@ -21,14 +21,15 @@ export default function ModelStart() { setTimeout(() => { setLoader(loader + 1) }, 250) - } else if (loader === 99) { - setLoader(99) + } else if (loader === 85) { + setLoader(85) } else { setLoader(loader + 1) } } else { setLoader(0) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [stateModel.loading, loader]) if (!stateModel.loading) return null @@ -40,7 +41,11 @@ export default function ModelStart() { className="absolute left-0 top-0 h-full bg-blue-200" style={{ width: `${loader}%` }} /> - Starting model {stateModel.model} + + {stateModel.state === 'start' ? 'Starting' : 'Stopping'} +  model  + {stateModel.model} +
) diff --git a/web/containers/ModelConfigInput/index.tsx b/web/containers/ModelConfigInput/index.tsx index 7e6ffdc38..e409fd424 100644 --- a/web/containers/ModelConfigInput/index.tsx +++ b/web/containers/ModelConfigInput/index.tsx @@ -18,6 +18,7 @@ import { getConfigurationsData } from '@/utils/componentSettings' import { toSettingParams } from '@/utils/modelParam' +import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' import { engineParamsUpdateAtom, getActiveThreadIdAtom, @@ -52,6 +53,8 @@ const ModelConfigInput: React.FC = ({ const { stopModel } = useActiveModel() + const serverEnabled = useAtomValue(serverEnabledAtom) + const onValueChanged = (e: React.ChangeEvent) => { if (!threadId) return if (engineParams.some((x) => x.name.includes(name))) { @@ -85,6 +88,7 @@ const ModelConfigInput: React.FC = ({ placeholder={placeholder} onChange={onValueChanged} value={value} + disabled={serverEnabled} />
) diff --git a/web/containers/ModelLabel/NotEnoughRamLabel.tsx b/web/containers/ModelLabel/NotEnoughRamLabel.tsx new file mode 100644 index 000000000..b24e42d63 --- /dev/null +++ b/web/containers/ModelLabel/NotEnoughRamLabel.tsx @@ -0,0 +1,34 @@ +import React from 'react' + +import { + Badge, + Tooltip, + TooltipArrow, + TooltipContent, + TooltipPortal, + TooltipTrigger, +} from '@janhq/uikit' +import { InfoIcon } from 'lucide-react' + +const NotEnoughRamLabel: React.FC = () => ( + + Not enough RAM + + + + + + + + {`This tag signals insufficient RAM for optimal model + performance. It's dynamic and may change with your system's + RAM availability.`} + + + + + + +) + +export default React.memo(NotEnoughRamLabel) diff --git a/web/containers/ModelLabel/RecommendedLabel.tsx b/web/containers/ModelLabel/RecommendedLabel.tsx new file mode 100644 index 000000000..f3e8b215d --- /dev/null +++ b/web/containers/ModelLabel/RecommendedLabel.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +import { Badge } from '@janhq/uikit' + +const RecommendedLabel: React.FC = () => ( + + Recommended + +) + +export default React.memo(RecommendedLabel) diff --git a/web/containers/ModelLabel/SlowOnYourDeviceLabel.tsx b/web/containers/ModelLabel/SlowOnYourDeviceLabel.tsx new file mode 100644 index 000000000..22211c296 --- /dev/null +++ b/web/containers/ModelLabel/SlowOnYourDeviceLabel.tsx @@ -0,0 +1,34 @@ +import React from 'react' + +import { + Badge, + Tooltip, + TooltipArrow, + TooltipContent, + TooltipPortal, + TooltipTrigger, +} from '@janhq/uikit' +import { InfoIcon } from 'lucide-react' + +const SlowOnYourDeviceLabel: React.FC = () => ( + + Slow on your device + + + + + + + + This tag indicates that your current RAM performance may affect + model speed. It can change based on other active apps. To improve, + consider closing unnecessary applications to free up RAM. + + + + + + +) + +export default React.memo(SlowOnYourDeviceLabel) diff --git a/web/containers/ModelLabel/index.tsx b/web/containers/ModelLabel/index.tsx new file mode 100644 index 000000000..1cd9cbb85 --- /dev/null +++ b/web/containers/ModelLabel/index.tsx @@ -0,0 +1,43 @@ +import React from 'react' + +import { useAtomValue } from 'jotai' + +import { useActiveModel } from '@/hooks/useActiveModel' + +import NotEnoughRamLabel from './NotEnoughRamLabel' + +import RecommendedLabel from './RecommendedLabel' + +import SlowOnYourDeviceLabel from './SlowOnYourDeviceLabel' + +import { totalRamAtom, usedRamAtom } from '@/helpers/atoms/SystemBar.atom' + +type Props = { + size: number +} + +const ModelLabel: React.FC = ({ size }) => { + const { activeModel } = useActiveModel() + const totalRam = useAtomValue(totalRamAtom) + const usedRam = useAtomValue(usedRamAtom) + + const getLabel = (size: number) => { + const minimumRamModel = size * 1.25 + const availableRam = totalRam - usedRam + (activeModel?.metadata.size ?? 0) + if (minimumRamModel > totalRam) { + return + } + if (minimumRamModel < availableRam) { + return + } + if (minimumRamModel < totalRam && minimumRamModel > availableRam) { + return + } + + return null + } + + return getLabel(size) +} + +export default React.memo(ModelLabel) diff --git a/web/containers/OpenAiKeyInput/index.tsx b/web/containers/OpenAiKeyInput/index.tsx new file mode 100644 index 000000000..abd79e6a8 --- /dev/null +++ b/web/containers/OpenAiKeyInput/index.tsx @@ -0,0 +1,51 @@ +import React, { useEffect, useState } from 'react' + +import { InferenceEngine, Model } from '@janhq/core' +import { Input } from '@janhq/uikit' + +import { useEngineSettings } from '@/hooks/useEngineSettings' + +type Props = { + selectedModel?: Model + serverEnabled: boolean +} + +const OpenAiKeyInput: React.FC = ({ selectedModel, serverEnabled }) => { + const [openAISettings, setOpenAISettings] = useState< + { api_key: string } | undefined + >(undefined) + const { readOpenAISettings, saveOpenAISettings } = useEngineSettings() + + useEffect(() => { + readOpenAISettings().then((settings) => { + setOpenAISettings(settings) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + if (!selectedModel || selectedModel.engine !== InferenceEngine.openai) { + return null + } + + return ( +
+ + { + saveOpenAISettings({ apiKey: e.target.value }) + }} + /> +
+ ) +} + +export default OpenAiKeyInput diff --git a/web/containers/Providers/EventHandler.tsx b/web/containers/Providers/EventHandler.tsx index 66622b1b6..1f9d6d7af 100644 --- a/web/containers/Providers/EventHandler.tsx +++ b/web/containers/Providers/EventHandler.tsx @@ -3,12 +3,13 @@ import { ReactNode, useEffect, useRef } from 'react' import { events, - EventName, ThreadMessage, - ExtensionType, + ExtensionTypeEnum, MessageStatus, Model, ConversationalExtension, + MessageEvent, + ModelEvent, } from '@janhq/core' import { useAtomValue, useSetAtom } from 'jotai' @@ -100,14 +101,14 @@ export default function EventHandler({ children }: { children: ReactNode }) { lastMessage: messageContent, } extensionManager - .get(ExtensionType.Conversational) + .get(ExtensionTypeEnum.Conversational) ?.saveThread({ ...thread, metadata, }) extensionManager - .get(ExtensionType.Conversational) + .get(ExtensionTypeEnum.Conversational) ?.addNewMessage(message) } } @@ -115,19 +116,19 @@ export default function EventHandler({ children }: { children: ReactNode }) { useEffect(() => { if (window.core?.events) { - events.on(EventName.OnMessageResponse, handleNewMessageResponse) - events.on(EventName.OnMessageUpdate, handleMessageResponseUpdate) - events.on(EventName.OnModelReady, handleModelReady) - events.on(EventName.OnModelFail, handleModelFail) - events.on(EventName.OnModelStopped, handleModelStopped) + events.on(MessageEvent.OnMessageResponse, handleNewMessageResponse) + events.on(MessageEvent.OnMessageUpdate, handleMessageResponseUpdate) + events.on(ModelEvent.OnModelReady, handleModelReady) + events.on(ModelEvent.OnModelFail, handleModelFail) + events.on(ModelEvent.OnModelStopped, handleModelStopped) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) useEffect(() => { return () => { - events.off(EventName.OnMessageResponse, handleNewMessageResponse) - events.off(EventName.OnMessageUpdate, handleMessageResponseUpdate) + events.off(MessageEvent.OnMessageResponse, handleNewMessageResponse) + events.off(MessageEvent.OnMessageUpdate, handleMessageResponseUpdate) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) diff --git a/web/containers/ShortcutModal/index.tsx b/web/containers/ShortcutModal/index.tsx new file mode 100644 index 000000000..04d6dfc0f --- /dev/null +++ b/web/containers/ShortcutModal/index.tsx @@ -0,0 +1,101 @@ +import React from 'react' + +import { + Modal, + ModalTrigger, + Button, + ModalContent, + ModalHeader, + ModalTitle, +} from '@janhq/uikit' + +const availableShortcuts = [ + { + combination: 'E', + modifierKeys: [isMac ? '⌘' : 'Ctrl'], + description: 'Show list your models', + }, + { + combination: 'K', + modifierKeys: [isMac ? '⌘' : 'Ctrl'], + description: 'Show list navigation pages', + }, + { + combination: 'B', + modifierKeys: [isMac ? '⌘' : 'Ctrl'], + description: 'Toggle collapsible left panel', + }, + { + combination: ',', + modifierKeys: [isMac ? '⌘' : 'Ctrl'], + description: 'Navigate to setting page', + }, + { + combination: 'Enter', + description: 'Send a message', + }, + { + combination: 'Shift + Enter', + description: 'Insert new line in input box', + }, + { + combination: 'Arrow Up', + description: 'Navigate to previous option (within search dialog)', + }, + { + combination: 'Arrow Down', + description: 'Navigate to next option (within search dialog)', + }, +] + +const ShortcutModal: React.FC = () => ( + + +
+ +
+
+ + + Keyboard Shortcuts + +
+
+
+
Combination
+
+
+
Description
+
+
+ {availableShortcuts.map((shortcut, index) => { + return ( +
+
+
+

{`${shortcut.modifierKeys?.[0] ?? ''} ${ + shortcut.combination + }`}

+
+
+
+

{shortcut.description}

+
+
+ ) + })} +
+
+
+) + +export default ShortcutModal diff --git a/web/containers/SliderRightPanel/index.tsx b/web/containers/SliderRightPanel/index.tsx index e7fcfa1ff..d9ed00f83 100644 --- a/web/containers/SliderRightPanel/index.tsx +++ b/web/containers/SliderRightPanel/index.tsx @@ -21,6 +21,7 @@ import useUpdateModelParameters from '@/hooks/useUpdateModelParameters' import { getConfigurationsData } from '@/utils/componentSettings' import { toSettingParams } from '@/utils/modelParam' +import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' import { engineParamsUpdateAtom, getActiveThreadIdAtom, @@ -49,6 +50,8 @@ const SliderRightPanel: React.FC = ({ const { updateModelParameter } = useUpdateModelParameters() const threadId = useAtomValue(getActiveThreadIdAtom) + const serverEnabled = useAtomValue(serverEnabledAtom) + const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom) const modelSettingParams = toSettingParams(activeModelParams) @@ -100,6 +103,7 @@ const SliderRightPanel: React.FC = ({ min={min} max={max} step={step} + disabled={serverEnabled} />

{min}

@@ -114,6 +118,7 @@ const SliderRightPanel: React.FC = ({ min={min} max={max} value={String(value)} + disabled={serverEnabled} onBlur={(e) => { if (Number(e.target.value) > Number(max)) { onValueChanged([Number(max)]) diff --git a/web/context/FeatureToggle.tsx b/web/context/FeatureToggle.tsx index 405cef904..6444359b0 100644 --- a/web/context/FeatureToggle.tsx +++ b/web/context/FeatureToggle.tsx @@ -1,13 +1,21 @@ import { createContext, ReactNode, useEffect, useState } from 'react' interface FeatureToggleContextType { - experimentalFeatureEnabed: boolean - setExperimentalFeatureEnabled: (on: boolean) => void + experimentalFeature: boolean + ignoreSSL: boolean + proxy: string + setExperimentalFeature: (on: boolean) => void + setIgnoreSSL: (on: boolean) => void + setProxy: (value: string) => void } const initialContext: FeatureToggleContextType = { - experimentalFeatureEnabed: false, - setExperimentalFeatureEnabled: () => {}, + experimentalFeature: false, + ignoreSSL: false, + proxy: '', + setExperimentalFeature: () => {}, + setIgnoreSSL: () => {}, + setProxy: () => {}, } export const FeatureToggleContext = @@ -18,25 +26,46 @@ export default function FeatureToggleWrapper({ }: { children: ReactNode }) { - const EXPERIMENTAL_FEATURE_ENABLED = 'expermientalFeatureEnabled' - const [experimentalEnabed, setExperimentalEnabled] = useState(false) + const EXPERIMENTAL_FEATURE = 'experimentalFeature' + const IGNORE_SSL = 'ignoreSSLFeature' + const HTTPS_PROXY_FEATURE = 'httpsProxyFeature' + const [experimentalFeature, directSetExperimentalFeature] = + useState(false) + const [ignoreSSL, directSetIgnoreSSL] = useState(false) + const [proxy, directSetProxy] = useState('') useEffect(() => { - setExperimentalEnabled( - localStorage.getItem(EXPERIMENTAL_FEATURE_ENABLED) === 'true' + directSetExperimentalFeature( + localStorage.getItem(EXPERIMENTAL_FEATURE) === 'true' ) + directSetIgnoreSSL(localStorage.getItem(IGNORE_SSL) === 'true') + directSetProxy(localStorage.getItem(HTTPS_PROXY_FEATURE) ?? '') }, []) const setExperimentalFeature = (on: boolean) => { - localStorage.setItem(EXPERIMENTAL_FEATURE_ENABLED, on ? 'true' : 'false') - setExperimentalEnabled(on) + localStorage.setItem(EXPERIMENTAL_FEATURE, on ? 'true' : 'false') + directSetExperimentalFeature(on) + } + + const setIgnoreSSL = (on: boolean) => { + localStorage.setItem(IGNORE_SSL, on ? 'true' : 'false') + directSetIgnoreSSL(on) + } + + const setProxy = (proxy: string) => { + localStorage.setItem(HTTPS_PROXY_FEATURE, proxy) + directSetProxy(proxy) } return ( {children} diff --git a/web/extension/ExtensionManager.ts b/web/extension/ExtensionManager.ts index 43847f344..3074177bb 100644 --- a/web/extension/ExtensionManager.ts +++ b/web/extension/ExtensionManager.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { BaseExtension, ExtensionType } from '@janhq/core' +import { BaseExtension, ExtensionTypeEnum } from '@janhq/core' import Extension from './Extension' @@ -23,7 +23,7 @@ export class ExtensionManager { * @param type - The type of the extension to retrieve. * @returns The extension, if found. */ - get(type: ExtensionType): T | undefined { + get(type: ExtensionTypeEnum): T | undefined { return this.extensions.get(type) as T | undefined } diff --git a/web/helpers/atoms/LocalServer.atom.ts b/web/helpers/atoms/LocalServer.atom.ts new file mode 100644 index 000000000..077da3ed8 --- /dev/null +++ b/web/helpers/atoms/LocalServer.atom.ts @@ -0,0 +1,3 @@ +import { atom } from 'jotai' + +export const serverEnabledAtom = atom(false) diff --git a/web/hooks/useActiveModel.ts b/web/hooks/useActiveModel.ts index 97c04caf0..336f0be21 100644 --- a/web/hooks/useActiveModel.ts +++ b/web/hooks/useActiveModel.ts @@ -1,11 +1,11 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { EventName, events, Model } from '@janhq/core' +import { events, Model, ModelEvent } from '@janhq/core' import { atom, useAtom, useAtomValue } from 'jotai' import { toaster } from '@/containers/Toast' import { useGetDownloadedModels } from './useGetDownloadedModels' import { LAST_USED_MODEL_ID } from './useRecommendedModel' + import { activeThreadAtom } from '@/helpers/atoms/Thread.atom' export const activeModelAtom = atom(undefined) @@ -63,14 +63,14 @@ export function useActiveModel() { } localStorage.setItem(LAST_USED_MODEL_ID, model.id) - events.emit(EventName.OnModelInit, model) + events.emit(ModelEvent.OnModelInit, model) } const stopModel = async () => { if (activeModel) { setActiveModel(undefined) setStateModel({ state: 'stop', loading: true, model: activeModel.id }) - events.emit(EventName.OnModelStop, activeModel) + events.emit(ModelEvent.OnModelStop, activeModel) } } diff --git a/web/hooks/useCreateNewThread.ts b/web/hooks/useCreateNewThread.ts index 81114333a..e374d0cc5 100644 --- a/web/hooks/useCreateNewThread.ts +++ b/web/hooks/useCreateNewThread.ts @@ -1,7 +1,7 @@ import { Assistant, ConversationalExtension, - ExtensionType, + ExtensionTypeEnum, Thread, ThreadAssistantInfo, ThreadState, @@ -104,8 +104,8 @@ export const useCreateNewThread = () => { } extensionManager - .get(ExtensionType.Conversational) - ?.saveThread(thread) + .get(ExtensionTypeEnum.Conversational) + ?.saveThread(thread) } return { diff --git a/web/hooks/useDeleteModel.ts b/web/hooks/useDeleteModel.ts index e8472d7ed..cd7292997 100644 --- a/web/hooks/useDeleteModel.ts +++ b/web/hooks/useDeleteModel.ts @@ -1,4 +1,4 @@ -import { ExtensionType, ModelExtension, Model } from '@janhq/core' +import { ExtensionTypeEnum, ModelExtension, Model } from '@janhq/core' import { toaster } from '@/containers/Toast' @@ -11,7 +11,7 @@ export default function useDeleteModel() { const deleteModel = async (model: Model) => { await extensionManager - .get(ExtensionType.Model) + .get(ExtensionTypeEnum.Model) ?.deleteModel(model.id) // reload models diff --git a/web/hooks/useDeleteThread.ts b/web/hooks/useDeleteThread.ts index 2aac2b207..84dd8a468 100644 --- a/web/hooks/useDeleteThread.ts +++ b/web/hooks/useDeleteThread.ts @@ -1,6 +1,6 @@ import { ChatCompletionRole, - ExtensionType, + ExtensionTypeEnum, ConversationalExtension, } from '@janhq/core' @@ -44,7 +44,7 @@ export default function useDeleteThread() { if (thread) { await extensionManager - .get(ExtensionType.Conversational) + .get(ExtensionTypeEnum.Conversational) ?.writeMessages( threadId, messages.filter((msg) => msg.role === ChatCompletionRole.System) @@ -61,7 +61,7 @@ export default function useDeleteThread() { } try { await extensionManager - .get(ExtensionType.Conversational) + .get(ExtensionTypeEnum.Conversational) ?.deleteThread(threadId) const availableThreads = threads.filter((c) => c.id !== threadId) setThreads(availableThreads) diff --git a/web/hooks/useDownloadModel.ts b/web/hooks/useDownloadModel.ts index bd587981c..49dbe1d5b 100644 --- a/web/hooks/useDownloadModel.ts +++ b/web/hooks/useDownloadModel.ts @@ -1,6 +1,8 @@ +import { useContext } from 'react' + import { Model, - ExtensionType, + ExtensionTypeEnum, ModelExtension, abortDownload, joinPath, @@ -8,6 +10,8 @@ import { import { useSetAtom } from 'jotai' +import { FeatureToggleContext } from '@/context/FeatureToggle' + import { modelBinFileName } from '@/utils/model' import { useDownloadState } from './useDownloadState' @@ -16,6 +20,7 @@ import { extensionManager } from '@/extension/ExtensionManager' import { addNewDownloadingModelAtom } from '@/helpers/atoms/Model.atom' export default function useDownloadModel() { + const { ignoreSSL, proxy } = useContext(FeatureToggleContext) const { setDownloadState } = useDownloadState() const addNewDownloadingModel = useSetAtom(addNewDownloadingModelAtom) @@ -38,8 +43,8 @@ export default function useDownloadModel() { addNewDownloadingModel(model) await extensionManager - .get(ExtensionType.Model) - ?.downloadModel(model) + .get(ExtensionTypeEnum.Model) + ?.downloadModel(model, { ignoreSSL, proxy }) } const abortModelDownload = async (model: Model) => { await abortDownload( diff --git a/web/hooks/useDownloadState.ts b/web/hooks/useDownloadState.ts index 811b6b53e..d39ab5e58 100644 --- a/web/hooks/useDownloadState.ts +++ b/web/hooks/useDownloadState.ts @@ -38,6 +38,9 @@ const setDownloadStateFailedAtom = atom( console.debug(`Cannot find download state for ${modelId}`) return } + if (error.includes('certificate')) { + error += '. To fix enable "Ignore SSL Certificates" in Advanced settings.' + } toaster({ title: 'Download Failed', description: `Model ${modelId} download failed: ${error}`, diff --git a/web/hooks/useGetAssistants.ts b/web/hooks/useGetAssistants.ts index bc8196835..2b34bfbd1 100644 --- a/web/hooks/useGetAssistants.ts +++ b/web/hooks/useGetAssistants.ts @@ -1,12 +1,12 @@ import { useEffect, useState } from 'react' -import { Assistant, ExtensionType, AssistantExtension } from '@janhq/core' +import { Assistant, ExtensionTypeEnum, AssistantExtension } from '@janhq/core' import { extensionManager } from '@/extension/ExtensionManager' export const getAssistants = async (): Promise => extensionManager - .get(ExtensionType.Assistant) + .get(ExtensionTypeEnum.Assistant) ?.getAssistants() ?? [] /** diff --git a/web/hooks/useGetConfiguredModels.ts b/web/hooks/useGetConfiguredModels.ts index d79778a00..5662d534e 100644 --- a/web/hooks/useGetConfiguredModels.ts +++ b/web/hooks/useGetConfiguredModels.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -import { ExtensionType, ModelExtension } from '@janhq/core' +import { ExtensionTypeEnum, ModelExtension } from '@janhq/core' import { Model } from '@janhq/core' import { extensionManager } from '@/extension/ExtensionManager' @@ -11,7 +11,7 @@ export function useGetConfiguredModels() { const getConfiguredModels = async (): Promise => { const models = await extensionManager - .get(ExtensionType.Model) + .get(ExtensionTypeEnum.Model) ?.getConfiguredModels() return models ?? [] } diff --git a/web/hooks/useGetDownloadedModels.ts b/web/hooks/useGetDownloadedModels.ts index adeb6eed7..f509fce63 100644 --- a/web/hooks/useGetDownloadedModels.ts +++ b/web/hooks/useGetDownloadedModels.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react' -import { ExtensionType, ModelExtension, Model } from '@janhq/core' +import { ExtensionTypeEnum, ModelExtension, Model } from '@janhq/core' import { atom, useAtom } from 'jotai' @@ -22,5 +22,5 @@ export function useGetDownloadedModels() { export const getDownloadedModels = async (): Promise => extensionManager - .get(ExtensionType.Model) + .get(ExtensionTypeEnum.Model) ?.getDownloadedModels() ?? [] diff --git a/web/hooks/useGetSystemResources.ts b/web/hooks/useGetSystemResources.ts index 9d6716480..8dffa8eb4 100644 --- a/web/hooks/useGetSystemResources.ts +++ b/web/hooks/useGetSystemResources.ts @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react' -import { ExtensionType } from '@janhq/core' -import { MonitoringExtension } from '@janhq/core' +import { ExtensionTypeEnum, MonitoringExtension } from '@janhq/core' import { useSetAtom } from 'jotai' @@ -21,12 +20,14 @@ export default function useGetSystemResources() { const getSystemResources = async () => { if ( - !extensionManager.get(ExtensionType.SystemMonitoring) + !extensionManager.get( + ExtensionTypeEnum.SystemMonitoring + ) ) { return } const monitoring = extensionManager.get( - ExtensionType.SystemMonitoring + ExtensionTypeEnum.SystemMonitoring ) const resourceInfor = await monitoring?.getResourcesInfo() const currentLoadInfor = await monitoring?.getCurrentLoad() diff --git a/web/hooks/usePath.ts b/web/hooks/usePath.ts new file mode 100644 index 000000000..db6284f93 --- /dev/null +++ b/web/hooks/usePath.ts @@ -0,0 +1,83 @@ +import { openFileExplorer, joinPath, getJanDataFolderPath } from '@janhq/core' +import { useAtomValue } from 'jotai' + +import { selectedModelAtom } from '@/containers/DropdownListSidebar' + +import { activeThreadAtom, threadStatesAtom } from '@/helpers/atoms/Thread.atom' + +export const usePath = () => { + const activeThread = useAtomValue(activeThreadAtom) + const threadStates = useAtomValue(threadStatesAtom) + const selectedModel = useAtomValue(selectedModelAtom) + + const onReviewInFinder = async (type: string) => { + if (!activeThread) return + const activeThreadState = threadStates[activeThread.id] + if (!activeThreadState.isFinishInit) { + alert('Thread is not started yet') + return + } + + const userSpace = await getJanDataFolderPath() + let filePath = undefined + const assistantId = activeThread.assistants[0]?.assistant_id + switch (type) { + case 'Engine': + case 'Thread': + filePath = await joinPath(['threads', activeThread.id]) + break + case 'Model': + if (!selectedModel) return + filePath = await joinPath(['models', selectedModel.id]) + break + case 'Assistant': + if (!assistantId) return + filePath = await joinPath(['assistants', assistantId]) + break + default: + break + } + + if (!filePath) return + const fullPath = await joinPath([userSpace, filePath]) + openFileExplorer(fullPath) + } + + const onViewJson = async (type: string) => { + if (!activeThread) return + const activeThreadState = threadStates[activeThread.id] + if (!activeThreadState.isFinishInit) { + alert('Thread is not started yet') + return + } + + const userSpace = await getJanDataFolderPath() + let filePath = undefined + const assistantId = activeThread.assistants[0]?.assistant_id + switch (type) { + case 'Engine': + case 'Thread': + filePath = await joinPath(['threads', activeThread.id, 'thread.json']) + break + case 'Model': + if (!selectedModel) return + filePath = await joinPath(['models', selectedModel.id, 'model.json']) + break + case 'Assistant': + if (!assistantId) return + filePath = await joinPath(['assistants', assistantId, 'assistant.json']) + break + default: + break + } + + if (!filePath) return + const fullPath = await joinPath([userSpace, filePath]) + openFileExplorer(fullPath) + } + + return { + onReviewInFinder, + onViewJson, + } +} diff --git a/web/hooks/useSendChatMessage.ts b/web/hooks/useSendChatMessage.ts index b4fdc9bd3..bf9740489 100644 --- a/web/hooks/useSendChatMessage.ts +++ b/web/hooks/useSendChatMessage.ts @@ -4,15 +4,15 @@ import { ChatCompletionMessage, ChatCompletionRole, ContentType, - EventName, MessageRequest, MessageStatus, - ExtensionType, + ExtensionTypeEnum, Thread, ThreadMessage, events, Model, ConversationalExtension, + MessageEvent, } from '@janhq/core' import { useAtom, useAtomValue, useSetAtom } from 'jotai' @@ -116,7 +116,7 @@ export default function useSendChatMessage() { await WaitForModelStarting(modelId) setQueuedMessage(false) } - events.emit(EventName.OnMessageSent, messageRequest) + events.emit(MessageEvent.OnMessageSent, messageRequest) } // TODO: Refactor @louis @@ -181,7 +181,7 @@ export default function useSendChatMessage() { updateThread(updatedThread) await extensionManager - .get(ExtensionType.Conversational) + .get(ExtensionTypeEnum.Conversational) ?.saveThread(updatedThread) } @@ -253,7 +253,7 @@ export default function useSendChatMessage() { addNewMessage(threadMessage) await extensionManager - .get(ExtensionType.Conversational) + .get(ExtensionTypeEnum.Conversational) ?.addNewMessage(threadMessage) const modelId = selectedModel?.id ?? activeThread.assistants[0].model.id @@ -265,7 +265,7 @@ export default function useSendChatMessage() { setQueuedMessage(false) } - events.emit(EventName.OnMessageSent, messageRequest) + events.emit(MessageEvent.OnMessageSent, messageRequest) setReloadModel(false) setEngineParamsUpdate(false) diff --git a/web/hooks/useServerLog.ts b/web/hooks/useServerLog.ts new file mode 100644 index 000000000..b263534b6 --- /dev/null +++ b/web/hooks/useServerLog.ts @@ -0,0 +1,29 @@ +import { + fs, + joinPath, + openFileExplorer, + getJanDataFolderPath, +} from '@janhq/core' + +export const useServerLog = () => { + const getServerLog = async () => { + if (!(await fs.existsSync(await joinPath(['file://logs', 'server.log'])))) + return {} + const logs = await fs.readFileSync( + await joinPath(['file://logs', 'server.log']), + 'utf-8' + ) + + return logs + } + const openServerLog = async () => { + const janDataFolderPath = await getJanDataFolderPath() + const fullPath = await joinPath([janDataFolderPath, 'logs', 'server.log']) + return openFileExplorer(fullPath) + } + + const clearServerLog = async () => { + await fs.writeFileSync(await joinPath(['file://logs', 'server.log']), '') + } + return { getServerLog, openServerLog, clearServerLog } +} diff --git a/web/hooks/useSetActiveThread.ts b/web/hooks/useSetActiveThread.ts index c029ec46c..76a744bcd 100644 --- a/web/hooks/useSetActiveThread.ts +++ b/web/hooks/useSetActiveThread.ts @@ -1,6 +1,6 @@ import { - EventName, - ExtensionType, + InferenceEvent, + ExtensionTypeEnum, Thread, events, ConversationalExtension, @@ -29,11 +29,11 @@ export default function useSetActiveThread() { return } - events.emit(EventName.OnInferenceStopped, thread.id) + events.emit(InferenceEvent.OnInferenceStopped, thread.id) // load the corresponding messages const messages = await extensionManager - .get(ExtensionType.Conversational) + .get(ExtensionTypeEnum.Conversational) ?.getAllMessages(thread.id) setThreadMessage(thread.id, messages ?? []) diff --git a/web/hooks/useThreads.ts b/web/hooks/useThreads.ts index 646969674..b79cfea92 100644 --- a/web/hooks/useThreads.ts +++ b/web/hooks/useThreads.ts @@ -1,5 +1,5 @@ import { - ExtensionType, + ExtensionTypeEnum, Thread, ThreadState, ConversationalExtension, @@ -99,7 +99,7 @@ const useThreads = () => { const getLocalThreads = async (): Promise => (await extensionManager - .get(ExtensionType.Conversational) + .get(ExtensionTypeEnum.Conversational) ?.getThreads()) ?? [] export default useThreads diff --git a/web/hooks/useUpdateModelParameters.ts b/web/hooks/useUpdateModelParameters.ts index 0edca5136..80070ef26 100644 --- a/web/hooks/useUpdateModelParameters.ts +++ b/web/hooks/useUpdateModelParameters.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { ConversationalExtension, - ExtensionType, + ExtensionTypeEnum, Thread, ThreadAssistantInfo, } from '@janhq/core' @@ -73,7 +73,7 @@ export default function useUpdateModelParameters() { } await extensionManager - .get(ExtensionType.Conversational) + .get(ExtensionTypeEnum.Conversational) ?.saveThread(updatedThread) } diff --git a/web/hooks/useVaultDirectory.ts b/web/hooks/useVaultDirectory.ts new file mode 100644 index 000000000..3aa7383c9 --- /dev/null +++ b/web/hooks/useVaultDirectory.ts @@ -0,0 +1,105 @@ +import { useEffect } from 'react' + +import { fs, AppConfiguration } from '@janhq/core' + +import { atom, useAtom } from 'jotai' + +import { useMainViewState } from './useMainViewState' + +const isSameDirectoryAtom = atom(false) +const isDirectoryConfirmAtom = atom(false) +const isErrorSetNewDestAtom = atom(false) +const currentPathAtom = atom('') +const newDestinationPathAtom = atom('') + +export const SUCCESS_SET_NEW_DESTINATION = 'successSetNewDestination' + +export function useVaultDirectory() { + const [isSameDirectory, setIsSameDirectory] = useAtom(isSameDirectoryAtom) + const { setMainViewState } = useMainViewState() + const [isDirectoryConfirm, setIsDirectoryConfirm] = useAtom( + isDirectoryConfirmAtom + ) + const [isErrorSetNewDest, setIsErrorSetNewDest] = useAtom( + isErrorSetNewDestAtom + ) + const [currentPath, setCurrentPath] = useAtom(currentPathAtom) + const [newDestinationPath, setNewDestinationPath] = useAtom( + newDestinationPathAtom + ) + + useEffect(() => { + window.core?.api + ?.getAppConfigurations() + ?.then((appConfig: AppConfiguration) => { + setCurrentPath(appConfig.data_folder) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const setNewDestination = async () => { + const destFolder = await window.core?.api?.selectDirectory() + setNewDestinationPath(destFolder) + + if (destFolder) { + console.debug(`Destination folder selected: ${destFolder}`) + try { + const appConfiguration: AppConfiguration = + await window.core?.api?.getAppConfigurations() + const currentJanDataFolder = appConfiguration.data_folder + + if (currentJanDataFolder === destFolder) { + console.debug( + `Destination folder is the same as current folder. Ignore..` + ) + setIsSameDirectory(true) + setIsDirectoryConfirm(false) + return + } else { + setIsSameDirectory(false) + setIsDirectoryConfirm(true) + } + setIsErrorSetNewDest(false) + } catch (e) { + console.error(`Error: ${e}`) + setIsErrorSetNewDest(true) + } + } + } + + const applyNewDestination = async () => { + try { + const appConfiguration: AppConfiguration = + await window.core?.api?.getAppConfigurations() + const currentJanDataFolder = appConfiguration.data_folder + + appConfiguration.data_folder = newDestinationPath + + await fs.syncFile(currentJanDataFolder, newDestinationPath) + await window.core?.api?.updateAppConfiguration(appConfiguration) + console.debug( + `File sync finished from ${currentPath} to ${newDestinationPath}` + ) + + setIsErrorSetNewDest(false) + localStorage.setItem(SUCCESS_SET_NEW_DESTINATION, 'true') + await window.core?.api?.relaunch() + } catch (e) { + console.error(`Error: ${e}`) + setIsErrorSetNewDest(true) + } + } + + return { + setNewDestination, + newDestinationPath, + applyNewDestination, + isSameDirectory, + setIsDirectoryConfirm, + isDirectoryConfirm, + setIsSameDirectory, + currentPath, + isErrorSetNewDest, + setIsErrorSetNewDest, + } +} diff --git a/web/package.json b/web/package.json index 1300cbf6b..bba3dd48b 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint", + "lint": "eslint .", "format": "prettier --write \"**/*.{js,jsx,ts,tsx}\"", "compile": "tsc --noEmit -p . --pretty" }, diff --git a/web/screens/Chat/ErrorMessage/index.tsx b/web/screens/Chat/ErrorMessage/index.tsx index f4c5fc45a..8879b15be 100644 --- a/web/screens/Chat/ErrorMessage/index.tsx +++ b/web/screens/Chat/ErrorMessage/index.tsx @@ -1,7 +1,7 @@ import { ChatCompletionRole, ConversationalExtension, - ExtensionType, + ExtensionTypeEnum, MessageStatus, ThreadMessage, } from '@janhq/core' @@ -9,6 +9,7 @@ import { Button } from '@janhq/uikit' import { useAtomValue, useSetAtom } from 'jotai' import { RefreshCcw } from 'lucide-react' +import { useActiveModel } from '@/hooks/useActiveModel' import useSendChatMessage from '@/hooks/useSendChatMessage' import { extensionManager } from '@/extension' @@ -16,6 +17,7 @@ import { deleteMessageAtom, getCurrentChatMessagesAtom, } from '@/helpers/atoms/ChatMessage.atom' +import { totalRamAtom } from '@/helpers/atoms/SystemBar.atom' import { activeThreadAtom } from '@/helpers/atoms/Thread.atom' const ErrorMessage = ({ message }: { message: ThreadMessage }) => { @@ -23,6 +25,8 @@ const ErrorMessage = ({ message }: { message: ThreadMessage }) => { const thread = useAtomValue(activeThreadAtom) const deleteMessage = useSetAtom(deleteMessageAtom) const { resendChatMessage } = useSendChatMessage() + const { activeModel } = useActiveModel() + const totalRam = useAtomValue(totalRamAtom) const regenerateMessage = async () => { const lastMessageIndex = messages.length - 1 @@ -32,7 +36,7 @@ const ErrorMessage = ({ message }: { message: ThreadMessage }) => { deleteMessage(message.id ?? '') if (thread) { await extensionManager - .get(ExtensionType.Conversational) + .get(ExtensionTypeEnum.Conversational) ?.writeMessages( thread.id, messages.filter((msg) => msg.id !== message.id) @@ -66,24 +70,33 @@ const ErrorMessage = ({ message }: { message: ThreadMessage }) => { {message.status === MessageStatus.Error && (
-

Apologies, something's amiss!

- Jan's in beta. Find troubleshooting guides{' '} - - here - {' '} - or reach out to us on{' '} - - Discord - {' '} - for assistance. + {Number(activeModel?.metadata.size) > totalRam ? ( + <> + Oops! Model size exceeds available RAM. Consider selecting a + smaller model or upgrading your RAM for smoother performance. + + ) : ( + <> +

Apologies, something's amiss!

+ Jan's in beta. Find troubleshooting guides{' '} + + here + {' '} + or reach out to us on{' '} + + Discord + {' '} + for assistance. + + )}
)} diff --git a/web/screens/Chat/MessageToolbar/index.tsx b/web/screens/Chat/MessageToolbar/index.tsx index 721b0476b..183eae814 100644 --- a/web/screens/Chat/MessageToolbar/index.tsx +++ b/web/screens/Chat/MessageToolbar/index.tsx @@ -1,6 +1,6 @@ import { MessageStatus, - ExtensionType, + ExtensionTypeEnum, ThreadMessage, ChatCompletionRole, } from '@janhq/core' @@ -31,7 +31,7 @@ const MessageToolbar = ({ message }: { message: ThreadMessage }) => { deleteMessage(message.id ?? '') if (thread) { await extensionManager - .get(ExtensionType.Conversational) + .get(ExtensionTypeEnum.Conversational) ?.writeMessages( thread.id, messages.filter((msg) => msg.id !== message.id) diff --git a/web/screens/Chat/Sidebar/index.tsx b/web/screens/Chat/Sidebar/index.tsx index 1826b1700..64e58d4d3 100644 --- a/web/screens/Chat/Sidebar/index.tsx +++ b/web/screens/Chat/Sidebar/index.tsx @@ -1,8 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import React from 'react' -import { getUserSpace, openFileExplorer, joinPath } from '@janhq/core' - import { Input, Textarea } from '@janhq/uikit' import { atom, useAtomValue } from 'jotai' @@ -12,9 +10,7 @@ import { twMerge } from 'tailwind-merge' import LogoMark from '@/containers/Brand/Logo/Mark' import CardSidebar from '@/containers/CardSidebar' -import DropdownListSidebar, { - selectedModelAtom, -} from '@/containers/DropdownListSidebar' +import DropdownListSidebar from '@/containers/DropdownListSidebar' import { useCreateNewThread } from '@/hooks/useCreateNewThread' @@ -29,7 +25,6 @@ import settingComponentBuilder from '../ModelSetting/settingComponentBuilder' import { activeThreadAtom, getActiveThreadModelParamsAtom, - threadStatesAtom, } from '@/helpers/atoms/Thread.atom' export const showRightSideBarAtom = atom(true) @@ -38,82 +33,14 @@ const Sidebar: React.FC = () => { const showing = useAtomValue(showRightSideBarAtom) const activeThread = useAtomValue(activeThreadAtom) const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom) - const selectedModel = useAtomValue(selectedModelAtom) - const { updateThreadMetadata } = useCreateNewThread() - const threadStates = useAtomValue(threadStatesAtom) + const { updateThreadMetadata } = useCreateNewThread() const modelEngineParams = toSettingParams(activeModelParams) const modelRuntimeParams = toRuntimeParams(activeModelParams) const componentDataEngineSetting = getConfigurationsData(modelEngineParams) const componentDataRuntimeSetting = getConfigurationsData(modelRuntimeParams) - const onReviewInFinderClick = async (type: string) => { - if (!activeThread) return - const activeThreadState = threadStates[activeThread.id] - if (!activeThreadState.isFinishInit) { - alert('Thread is not started yet') - return - } - - const userSpace = await getUserSpace() - let filePath = undefined - const assistantId = activeThread.assistants[0]?.assistant_id - switch (type) { - case 'Engine': - case 'Thread': - filePath = await joinPath(['threads', activeThread.id]) - break - case 'Model': - if (!selectedModel) return - filePath = await joinPath(['models', selectedModel.id]) - break - case 'Assistant': - if (!assistantId) return - filePath = await joinPath(['assistants', assistantId]) - break - default: - break - } - - if (!filePath) return - const fullPath = await joinPath([userSpace, filePath]) - openFileExplorer(fullPath) - } - - const onViewJsonClick = async (type: string) => { - if (!activeThread) return - const activeThreadState = threadStates[activeThread.id] - if (!activeThreadState.isFinishInit) { - alert('Thread is not started yet') - return - } - - const userSpace = await getUserSpace() - let filePath = undefined - const assistantId = activeThread.assistants[0]?.assistant_id - switch (type) { - case 'Engine': - case 'Thread': - filePath = await joinPath(['threads', activeThread.id, 'thread.json']) - break - case 'Model': - if (!selectedModel) return - filePath = await joinPath(['models', selectedModel.id, 'model.json']) - break - case 'Assistant': - if (!assistantId) return - filePath = await joinPath(['assistants', assistantId, 'assistant.json']) - break - default: - break - } - - if (!filePath) return - const fullPath = await joinPath([userSpace, filePath]) - openFileExplorer(fullPath) - } - return (
{
- +
@@ -216,11 +139,7 @@ const Sidebar: React.FC = () => {
*/}
- +
@@ -250,12 +169,7 @@ const Sidebar: React.FC = () => { {componentDataEngineSetting.length !== 0 && (
- +
diff --git a/web/screens/Chat/index.tsx b/web/screens/Chat/index.tsx index 2e2e6ffec..684027e49 100644 --- a/web/screens/Chat/index.tsx +++ b/web/screens/Chat/index.tsx @@ -1,6 +1,6 @@ import { ChangeEvent, Fragment, KeyboardEvent, useEffect, useRef } from 'react' -import { EventName, MessageStatus, events } from '@janhq/core' +import { InferenceEvent, MessageStatus, events } from '@janhq/core' import { Button, Textarea } from '@janhq/uikit' import { useAtom, useAtomValue } from 'jotai' @@ -104,7 +104,7 @@ const ChatScreen = () => { ) const onStopInferenceClick = async () => { - events.emit(EventName.OnInferenceStopped, {}) + events.emit(InferenceEvent.OnInferenceStopped, {}) } return ( diff --git a/web/screens/ExploreModels/ExploreModelItemHeader/index.tsx b/web/screens/ExploreModels/ExploreModelItemHeader/index.tsx index b19f30833..b56e20404 100644 --- a/web/screens/ExploreModels/ExploreModelItemHeader/index.tsx +++ b/web/screens/ExploreModels/ExploreModelItemHeader/index.tsx @@ -2,7 +2,15 @@ import { useCallback, useMemo } from 'react' import { Model } from '@janhq/core' -import { Badge, Button } from '@janhq/uikit' +import { + Badge, + Button, + Tooltip, + TooltipArrow, + TooltipContent, + TooltipPortal, + TooltipTrigger, +} from '@janhq/uikit' import { atom, useAtomValue } from 'jotai' @@ -23,6 +31,8 @@ import { useMainViewState } from '@/hooks/useMainViewState' import { toGibibytes } from '@/utils/converter' +import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' + import { totalRamAtom } from '@/helpers/atoms/SystemBar.atom' type Props = { @@ -37,6 +47,7 @@ const ExploreModelItemHeader: React.FC = ({ model, onClick, open }) => { const { modelDownloadStateAtom, downloadStates } = useDownloadState() const { requestCreateNewThread } = useCreateNewThread() const totalRam = useAtomValue(totalRamAtom) + const serverEnabled = useAtomValue(serverEnabledAtom) const downloadAtom = useMemo( () => atom((get) => get(modelDownloadStateAtom)[model.id]), @@ -68,13 +79,26 @@ const ExploreModelItemHeader: React.FC = ({ model, onClick, open }) => { if (isDownloaded) { downloadButton = ( - + + + + + {serverEnabled && ( + + + Threads are disabled while the server is running + + + + )} + ) } diff --git a/web/screens/LocalServer/Logs.tsx b/web/screens/LocalServer/Logs.tsx new file mode 100644 index 000000000..e301e38ff --- /dev/null +++ b/web/screens/LocalServer/Logs.tsx @@ -0,0 +1,172 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { useEffect, useState } from 'react' + +import React from 'react' + +import { useServerLog } from '@/hooks/useServerLog' + +const Logs = () => { + const { getServerLog } = useServerLog() + const [logs, setLogs] = useState([]) + + useEffect(() => { + getServerLog().then((log) => { + if (typeof log?.split === 'function') setLogs(log.split(/\r?\n|\r|\n/g)) + }) + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [logs]) + + return ( +
+ {logs.length > 1 ? ( +
+ + {logs.map((log, i) => { + return ( +

+ {log} +

+ ) + })} +
+
+ ) : ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Empty logs

+
+ )} +
+ ) +} + +export default Logs diff --git a/web/screens/LocalServer/index.tsx b/web/screens/LocalServer/index.tsx new file mode 100644 index 000000000..ce709d831 --- /dev/null +++ b/web/screens/LocalServer/index.tsx @@ -0,0 +1,385 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +'use client' + +import React, { useEffect, useState } from 'react' + +import ScrollToBottom from 'react-scroll-to-bottom' + +import { + Button, + Switch, + Tooltip, + TooltipArrow, + TooltipContent, + TooltipPortal, + TooltipTrigger, + Select, + SelectContent, + SelectItem, + Input, + SelectTrigger, + SelectValue, +} from '@janhq/uikit' + +import { atom, useAtom, useAtomValue } from 'jotai' + +import { Paintbrush, CodeIcon } from 'lucide-react' +import { ExternalLinkIcon, InfoIcon } from 'lucide-react' + +import { twMerge } from 'tailwind-merge' + +import CardSidebar from '@/containers/CardSidebar' +import DropdownListSidebar, { + selectedModelAtom, +} from '@/containers/DropdownListSidebar' + +import { useActiveModel } from '@/hooks/useActiveModel' +import { useServerLog } from '@/hooks/useServerLog' + +import { getConfigurationsData } from '@/utils/componentSettings' +import { toSettingParams } from '@/utils/modelParam' + +import EngineSetting from '../Chat/EngineSetting' + +import settingComponentBuilder from '../Chat/ModelSetting/settingComponentBuilder' + +import { showRightSideBarAtom } from '../Chat/Sidebar' + +import Logs from './Logs' + +import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' +import { getActiveThreadModelParamsAtom } from '@/helpers/atoms/Thread.atom' + +const corsEnabledAtom = atom(true) +const verboseEnabledAtom = atom(true) +const hostAtom = atom('127.0.0.1') +const portAtom = atom('1337') + +const LocalServerScreen = () => { + const [errorRangePort, setErrorRangePort] = useState(false) + const [serverEnabled, setServerEnabled] = useAtom(serverEnabledAtom) + const showing = useAtomValue(showRightSideBarAtom) + const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom) + + const modelEngineParams = toSettingParams(activeModelParams) + const componentDataEngineSetting = getConfigurationsData(modelEngineParams) + + const { openServerLog, clearServerLog } = useServerLog() + const { startModel, stateModel } = useActiveModel() + const [selectedModel] = useAtom(selectedModelAtom) + + const [isCorsEnabled, setIsCorsEnabled] = useAtom(corsEnabledAtom) + const [isVerboseEnabled, setIsVerboseEnabled] = useAtom(verboseEnabledAtom) + const [host, setHost] = useAtom(hostAtom) + const [port, setPort] = useAtom(portAtom) + + const FIRST_TIME_VISIT_API_SERVER = 'firstTimeVisitAPIServer' + + const [firstTimeVisitAPIServer, setFirstTimeVisitAPIServer] = + useState(false) + + const handleChangePort = (value: any) => { + if (Number(value) <= 0 || Number(value) >= 65536) { + setErrorRangePort(true) + } else { + setErrorRangePort(false) + } + setPort(value) + } + + useEffect(() => { + if ( + localStorage.getItem(FIRST_TIME_VISIT_API_SERVER) === null || + localStorage.getItem(FIRST_TIME_VISIT_API_SERVER) === 'true' + ) { + localStorage.setItem(FIRST_TIME_VISIT_API_SERVER, 'true') + setFirstTimeVisitAPIServer(true) + } + }, [firstTimeVisitAPIServer]) + + useEffect(() => { + handleChangePort(port) + }, []) + + return ( +
+ {/* Left SideBar */} +
+
+

Server Options

+

+ Start an OpenAI-compatible local HTTP server. +

+
+
+
+ + {serverEnabled && ( + + )} +
+
+ + + +
+
+

+ Server Options +

+
+ + + { + handleChangePort(e.target.value) + }} + maxLength={5} + disabled={serverEnabled} + /> +
+ {errorRangePort && ( +

{`The port range should be from 0 to 65536`}

+ )} +
+
+ +
+ setIsCorsEnabled(e)} + name="cors" + disabled={serverEnabled} + /> +
+
+
+ +
+ setIsVerboseEnabled(e)} + name="verbose" + disabled={serverEnabled} + /> +
+
+
+
+ + {serverEnabled && ( + + + Settings cannot be modified while the server is running + + + + )} + +
+
+ + {/* Middle Bar */} + +
+

Server Logs

+
+ + +
+
+ {firstTimeVisitAPIServer ? ( +
+
+
+ + + + +
+
+ Once you start the server, you cannot chat with your + assistant. +
+ +
+
+
+
+ ) : ( + + )} +
+ + {/* Right bar */} +
+
+
+ +
+ + {componentDataEngineSetting.filter( + (x) => x.name === 'prompt_template' + ).length !== 0 && ( +
+ +
+ {settingComponentBuilder(componentDataEngineSetting, true)} +
+
+
+ )} + + {componentDataEngineSetting.length !== 0 && ( +
+ +
+ +
+
+
+ )} +
+
+
+ ) +} + +export default LocalServerScreen diff --git a/web/screens/Settings/Advanced/DataFolder/ModalChangeDirectory.tsx b/web/screens/Settings/Advanced/DataFolder/ModalChangeDirectory.tsx new file mode 100644 index 000000000..f0fb7e12f --- /dev/null +++ b/web/screens/Settings/Advanced/DataFolder/ModalChangeDirectory.tsx @@ -0,0 +1,57 @@ +import React from 'react' + +import { + Modal, + ModalPortal, + ModalContent, + ModalHeader, + ModalTitle, + ModalFooter, + ModalClose, + Button, +} from '@janhq/uikit' + +import { useVaultDirectory } from '@/hooks/useVaultDirectory' + +const ModalChangeDirectory = () => { + const { + isDirectoryConfirm, + setIsDirectoryConfirm, + applyNewDestination, + newDestinationPath, + } = useVaultDirectory() + return ( + setIsDirectoryConfirm(false)} + > + + + + Relocate Jan Data Folder + +

+ Are you sure you want to relocate Jan data folder to{' '} + + {newDestinationPath} + + ? A restart will be required afterward. +

+ +
+ setIsDirectoryConfirm(false)}> + + + + + +
+
+
+
+ ) +} + +export default ModalChangeDirectory diff --git a/web/screens/Settings/Advanced/DataFolder/ModalErrorSetDestGlobal.tsx b/web/screens/Settings/Advanced/DataFolder/ModalErrorSetDestGlobal.tsx new file mode 100644 index 000000000..44a9db097 --- /dev/null +++ b/web/screens/Settings/Advanced/DataFolder/ModalErrorSetDestGlobal.tsx @@ -0,0 +1,44 @@ +import React from 'react' + +import { + Modal, + ModalPortal, + ModalContent, + ModalHeader, + ModalTitle, + ModalFooter, + ModalClose, + Button, +} from '@janhq/uikit' + +import { useVaultDirectory } from '@/hooks/useVaultDirectory' + +const ModalErrorSetDestGlobal = () => { + const { isErrorSetNewDest, setIsErrorSetNewDest } = useVaultDirectory() + return ( + setIsErrorSetNewDest(false)} + > + + + + Error Occurred + +

+ Oops! Something went wrong. Jan data folder remains the same. Please + try again. +

+ +
+ setIsErrorSetNewDest(false)}> + + +
+
+
+
+ ) +} + +export default ModalErrorSetDestGlobal diff --git a/web/screens/Settings/Advanced/DataFolder/ModalSameDirectory.tsx b/web/screens/Settings/Advanced/DataFolder/ModalSameDirectory.tsx new file mode 100644 index 000000000..bd4d32e6a --- /dev/null +++ b/web/screens/Settings/Advanced/DataFolder/ModalSameDirectory.tsx @@ -0,0 +1,49 @@ +import React from 'react' + +import { + Modal, + ModalPortal, + ModalContent, + ModalHeader, + ModalTitle, + ModalFooter, + ModalClose, + Button, +} from '@janhq/uikit' + +import { useVaultDirectory } from '@/hooks/useVaultDirectory' + +const ModalSameDirectory = () => { + const { isSameDirectory, setIsSameDirectory, setNewDestination } = + useVaultDirectory() + return ( + setIsSameDirectory(false)} + > + + + + Unable to move files + +

+ {`It seems like the folder you've chosen same with current directory`} +

+ +
+ setIsSameDirectory(false)}> + + + + + +
+
+
+
+ ) +} + +export default ModalSameDirectory diff --git a/web/screens/Settings/Advanced/DataFolder/index.tsx b/web/screens/Settings/Advanced/DataFolder/index.tsx new file mode 100644 index 000000000..936992c9d --- /dev/null +++ b/web/screens/Settings/Advanced/DataFolder/index.tsx @@ -0,0 +1,52 @@ +import { Button, Input } from '@janhq/uikit' +import { PencilIcon, FolderOpenIcon } from 'lucide-react' + +import { useVaultDirectory } from '@/hooks/useVaultDirectory' + +import ModalChangeDirectory from './ModalChangeDirectory' +import ModalErrorSetDestGlobal from './ModalErrorSetDestGlobal' +import ModalSameDirectory from './ModalSameDirectory' + +const DataFolder = () => { + const { currentPath, setNewDestination } = useVaultDirectory() + + return ( + <> +
+
+
+
+ Jan Data Folder +
+
+

+ Where messages, model configurations, and other user data are + placed. +

+
+
+
+ + +
+ +
+
+ + + + + ) +} + +export default DataFolder diff --git a/web/screens/Settings/Advanced/index.tsx b/web/screens/Settings/Advanced/index.tsx index 9a25b0f35..227bae47d 100644 --- a/web/screens/Settings/Advanced/index.tsx +++ b/web/screens/Settings/Advanced/index.tsx @@ -1,37 +1,53 @@ /* eslint-disable react-hooks/exhaustive-deps */ 'use client' -import { useContext, useEffect, useState } from 'react' +import { + useContext, + useEffect, + useState, + useCallback, + ChangeEvent, +} from 'react' import { fs } from '@janhq/core' -import { - Switch, - Button, - Modal, - ModalContent, - ModalHeader, - ModalTitle, - ModalTrigger, -} from '@janhq/uikit' +import { Switch, Button, Input } from '@janhq/uikit' -import { atom, useAtom } from 'jotai' +import ShortcutModal from '@/containers/ShortcutModal' -import ShortCut from '@/containers/Shortcut' +import { toaster } from '@/containers/Toast' import { FeatureToggleContext } from '@/context/FeatureToggle' import { useSettings } from '@/hooks/useSettings' -import { toaster } from '@/containers/Toast' -const serverEnabledAtom = atom(false) +import DataFolder from './DataFolder' const Advanced = () => { - const { experimentalFeatureEnabed, setExperimentalFeatureEnabled } = - useContext(FeatureToggleContext) + const { + experimentalFeature, + setExperimentalFeature, + ignoreSSL, + setIgnoreSSL, + proxy, + setProxy, + } = useContext(FeatureToggleContext) + const [partialProxy, setPartialProxy] = useState(proxy) const [gpuEnabled, setGpuEnabled] = useState(false) - const [serverEnabled, setServerEnabled] = useAtom(serverEnabledAtom) + const { readSettings, saveSettings, validateSettings, setShowNotification } = useSettings() + const onProxyChange = useCallback( + (event: ChangeEvent) => { + const value = event.target.value || '' + setPartialProxy(value) + if (value.trim().startsWith('http')) { + setProxy(value.trim()) + } else { + setProxy('') + } + }, + [setPartialProxy, setProxy] + ) useEffect(() => { readSettings().then((settings) => { @@ -51,14 +67,53 @@ const Advanced = () => { return (
+ {/* Keyboard shortcut */} +
+
+
+
+ Keyboard Shortcuts +
+
+

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

+
+ +
+ + {/* Experimental */} +
+
+
+
+ Experimental Mode +
+
+

+ Enable experimental features that may be unstable tested. +

+
+ { + if (e === true) { + setExperimentalFeature(true) + } else { + setExperimentalFeature(false) + } + }} + /> +
+ {/* CPU / GPU switching */} {!isMac && (
-
+
NVidia GPU
-

+

Enable GPU acceleration for NVidia GPUs.

@@ -80,230 +135,65 @@ const Advanced = () => { />
)} - {/* Experimental */} + + {/* Directory */} + {experimentalFeature && } + + {/* Proxy */}
-
+
+
+
HTTPS Proxy
+
+

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

+ :@:'} + value={partialProxy} + onChange={onProxyChange} + /> +
+
+ + {/* Ignore SSL certificates */} +
+
- Experimental Mode + Ignore SSL certificates
-

- Enable experimental features that may be unstable tested. +

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

{ if (e === true) { - setExperimentalFeatureEnabled(true) + setIgnoreSSL(true) } else { - setExperimentalFeatureEnabled(false) + setIgnoreSSL(false) } }} />
- {/* Server */} + + {/* Claer log */}
-
-
-
- Enable API Server -
-
-

- Enable API server for Jan app. -

-
- { - if (e === true) { - window.core?.api?.startServer() - } else { - window.core?.api?.stopServer() - } - setServerEnabled(e) - }} - /> -
- {window.electronAPI && ( -
-
-
-
- Open App Directory -
-
-

- Open the directory where your app data, like conversation history - and model configurations, is located. -

-
- -
- )} -
-
+
Clear logs
-

- Clear all logs from Jan app. -

+

Clear all logs from Jan app.

-
-
-
-
-
- Keyboard Shortcuts -
-
-

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

-
- - - - - - - Keyboard Shortcuts - -
-
-
-
-
Combination
-
-
-
-
-
Description
-
-
-
-
-
-
- -
-
-
-
-

Show list your models

-
-
-
-
-
-
- -
-
-
-
-

Show list navigation pages

-
-
-
-
-
-
- -
-
-
-
-

Toggle collapsible left panel

-
-
-
-
-
-
- -
-
-
-
-

Navigate to setting page

-
-
-
-
-
-
-
-

Enter

-
-
-
-
-
-

Send a message

-
-
-
-
-
-
-
-

Shift + Enter

-
-
-
-
-
-

Insert new line in input box

-
-
-
-
-
-
-
-

Arrow Up

-
-
-
-
-
-

Navigate to previous option (within search dialog)

-
-
-
-
-
-
-
-

Arrow Down

-
-
-
-
-
-

Navigate to next option (within search dialog)

-
-
-
-
-
-
-
) } diff --git a/web/screens/Settings/Appearance/index.tsx b/web/screens/Settings/Appearance/index.tsx index 1a10ae84d..ecf37b91c 100644 --- a/web/screens/Settings/Appearance/index.tsx +++ b/web/screens/Settings/Appearance/index.tsx @@ -9,7 +9,9 @@ export default function AppearanceOptions() {
Base color scheme
-

Choose between light and dark modes.

+

+ Choose between light and dark modes. +

diff --git a/web/screens/Settings/CoreExtensions/PreferenceExtensions/index.tsx b/web/screens/Settings/CoreExtensions/PreferenceExtensions/index.tsx deleted file mode 100644 index e6df90633..000000000 --- a/web/screens/Settings/CoreExtensions/PreferenceExtensions/index.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -type Props = { - extensionName: string - preferenceValues: any - preferenceItems: any -} - -import { useForm } from 'react-hook-form' - -import { zodResolver } from '@hookform/resolvers/zod' - -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - Input, - FormLabel, - FormMessage, - Button, -} from '@janhq/uikit' - -import * as z from 'zod' - -import { toaster } from '@/containers/Toast' - -import { formatExtensionsName } from '@/utils/converter' - -const PreferenceExtensions = (props: Props) => { - const { extensionName, preferenceValues, preferenceItems } = props - - const FormSchema = z.record( - z - .string({ required_error: 'Field is Required' }) - .min(1, { message: 'Field is Required' }) - ) - - const form = useForm>({ - resolver: zodResolver(FormSchema), - defaultValues: preferenceValues.reduce( - (obj: any, item: { key: any; value: any }) => - Object.assign(obj, { [item.key]: item.value }), - {} - ), - }) - - const onSubmit = async (values: z.infer) => { - for (const [key, value] of Object.entries(values)) { - // await preferences.set(extensionName, key, value) - // await execute(ExtensionService.OnPreferencesUpdate, {}) - } - toaster({ - title: formatExtensionsName(extensionName), - description: 'Successfully updated preferences', - }) - } - - return ( -
-
- {formatExtensionsName(extensionName)} -
-
- - {preferenceItems - .filter((x: any) => x.extensionName === extensionName) - ?.map((e: any) => ( - ( - - {e.preferenceName} - - {e.preferenceDescription} - - - - - - - )} - /> - ))} -
- -
- - -
- ) -} - -export default PreferenceExtensions diff --git a/web/screens/Settings/CoreExtensions/ExtensionsCatalog/index.tsx b/web/screens/Settings/CoreExtensions/index.tsx similarity index 56% rename from web/screens/Settings/CoreExtensions/ExtensionsCatalog/index.tsx rename to web/screens/Settings/CoreExtensions/index.tsx index 650ed318c..6ca8d82f7 100644 --- a/web/screens/Settings/CoreExtensions/ExtensionsCatalog/index.tsx +++ b/web/screens/Settings/CoreExtensions/index.tsx @@ -1,38 +1,17 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -import React, { useState, useEffect, useRef, useContext } from 'react' +import React, { useState, useEffect, useRef } from 'react' import { Button } from '@janhq/uikit' -import { FeatureToggleContext } from '@/context/FeatureToggle' - import { formatExtensionsName } from '@/utils/converter' import { extensionManager } from '@/extension' +import Extension from '@/extension/Extension' const ExtensionCatalog = () => { - const [activeExtensions, setActiveExtensions] = useState([]) - const [extensionCatalog, setExtensionCatalog] = useState([]) + const [activeExtensions, setActiveExtensions] = useState([]) const fileInputRef = useRef(null) - const { experimentalFeatureEnabed } = useContext(FeatureToggleContext) - /** - * Loads the extension catalog module from a CDN and sets it as the extension catalog state. - */ - useEffect(() => { - if (!window.electronAPI) { - return - } - - // Get extension manifest - import(/* webpackIgnore: true */ PLUGIN_CATALOG + `?t=${Date.now()}`).then( - (data) => { - if (Array.isArray(data.default) && experimentalFeatureEnabed) - setExtensionCatalog(data.default) - } - ) - }, [experimentalFeatureEnabed]) - /** * Fetches the active extensions and their preferences from the `extensions` and `preferences` modules. * If the `experimentComponent` extension point is available, it executes the extension point and @@ -90,57 +69,28 @@ const ExtensionCatalog = () => { return (
- {extensionCatalog - .concat( - activeExtensions.filter( - (e) => !(extensionCatalog ?? []).some((p) => p.name === e.name) - ) ?? [] - ) - .map((item, i) => { - const isActiveExtension = activeExtensions.some( - (x) => x.name === item.name - ) - const installedExtension = activeExtensions.filter( - (p) => p.name === item.name - )[0] - const updateVersionExtensions = Number( - installedExtension?.version.replaceAll('.', '') - ) - - const hasUpdateVersionExtensions = - item.version.replaceAll('.', '') > updateVersionExtensions - - return ( -
-
-
-
- {formatExtensionsName(item.name)} -
-

- v{item.version} -

-
-

- {item.description} + {activeExtensions.map((item, i) => { + return ( +

+
+
+
+ {formatExtensionsName(item.name ?? item.description ?? '')} +
+

+ v{item.version}

- {isActiveExtension && ( -
-

- Installed{' '} - {hasUpdateVersionExtensions - ? `v${installedExtension.version}` - : 'the latest version'} -

-
- )}
+

+ {item.description} +

- ) - })} +
+ ) + })} {/* Manual Installation */}
@@ -161,7 +111,7 @@ const ExtensionCatalog = () => { onChange={handleFileChange} /> + + + + + {serverEnabled && ( + + + + The API server is running, stop the model will + also stop the server + + + + + )} + diff --git a/web/services/extensionService.ts b/web/services/extensionService.ts index b18fb3cc1..6ae4f78f0 100644 --- a/web/services/extensionService.ts +++ b/web/services/extensionService.ts @@ -1,15 +1,15 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ 'use client' -import { ExtensionType } from '@janhq/core' +import { ExtensionTypeEnum } from '@janhq/core' import { extensionManager } from '@/extension/ExtensionManager' export const isCoreExtensionInstalled = () => { - if (!extensionManager.get(ExtensionType.Conversational)) { + if (!extensionManager.get(ExtensionTypeEnum.Conversational)) { return false } - if (!extensionManager.get(ExtensionType.Inference)) return false - if (!extensionManager.get(ExtensionType.Model)) { + if (!extensionManager.get(ExtensionTypeEnum.Inference)) return false + if (!extensionManager.get(ExtensionTypeEnum.Model)) { return false } return true @@ -21,9 +21,9 @@ export const setupBaseExtensions = async () => { const baseExtensions = await window.core?.api.baseExtensions() if ( - !extensionManager.get(ExtensionType.Conversational) || - !extensionManager.get(ExtensionType.Inference) || - !extensionManager.get(ExtensionType.Model) + !extensionManager.get(ExtensionTypeEnum.Conversational) || + !extensionManager.get(ExtensionTypeEnum.Inference) || + !extensionManager.get(ExtensionTypeEnum.Model) ) { const installed = await extensionManager.install(baseExtensions) if (installed) {