diff --git a/.github/workflows/jan-electron-build.yml b/.github/workflows/jan-electron-build.yml index df2b24504..f1c0f5c1e 100644 --- a/.github/workflows/jan-electron-build.yml +++ b/.github/workflows/jan-electron-build.yml @@ -122,7 +122,7 @@ jobs: yarn build:core yarn install $env:NITRO_VERSION = Get-Content .\plugins\inference-plugin\nitro\version.txt; echo $env:NITRO_VERSION - yarn build:plugins-windows + yarn build:plugins-win32 - name: Build and publish app run: | diff --git a/.github/workflows/jan-electron-linter-and-test.yml b/.github/workflows/jan-electron-linter-and-test.yml index 2c781f602..1938a40f5 100644 --- a/.github/workflows/jan-electron-linter-and-test.yml +++ b/.github/workflows/jan-electron-linter-and-test.yml @@ -95,7 +95,7 @@ jobs: yarn build:core yarn install $env:NITRO_VERSION = Get-Content .\plugins\inference-plugin\nitro\version.txt; echo $env:NITRO_VERSION - yarn build:plugins-windows + yarn build:plugins-win32 yarn build:test-win32 $env:CI="e2e" yarn test diff --git a/.github/workflows/jan-plugin-core.yml b/.github/workflows/jan-plugin-core.yml deleted file mode 100644 index 39cc2f349..000000000 --- a/.github/workflows/jan-plugin-core.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Plugin Core -on: - push: - branches: - - main - paths: - - "core/**" - - "!core/package.json" - pull_request: - branches: - - main - paths: - - "core/**" - - ".github/workflows/jan-plugin-core.yml" - - "!core/package.json" -jobs: - build-and-publish-plugins: - runs-on: ubuntu-latest - environment: production - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: "0" - token: ${{ secrets.PAT_SERVICE_ACCOUNT }} - - - name: Install jq - uses: dcarbone/install-jq-action@v2.0.1 - - - name: "Auto Increase package Version" - run: | - # Extract current version - current_version=$(jq -r '.version' core/package.json) - - # Break the version into its components - major_version=$(echo $current_version | cut -d "." -f 1) - minor_version=$(echo $current_version | cut -d "." -f 2) - patch_version=$(echo $current_version | cut -d "." -f 3) - - # Increment the patch version by one - new_patch_version=$((patch_version+1)) - - # Construct the new version - new_version="$major_version.$minor_version.$new_patch_version" - - # Replace the old version with the new version in package.json - jq --arg version "$new_version" '.version = $version' core/package.json > /tmp/package.json && mv /tmp/package.json core/package.json - - # Print the new version - echo "Updated package.json version to: $new_version" - - # Setup .npmrc file to publish to npm - - uses: actions/setup-node@v3 - with: - node-version: "20.x" - registry-url: "https://registry.npmjs.org" - - run: npm install && npm run build - working-directory: ./core - - run: npm publish --access public - if: github.event_name == 'push' && github.event.pull_request.head.repo.full_name != github.repository - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - working-directory: ./core - - - name: "Commit new version to main and create tag" - if: github.event_name == 'push' && github.event.pull_request.head.repo.full_name != github.repository - run: | - version=$(jq -r '.version' core/package.json) - git config --global user.email "service@jan.ai" - git config --global user.name "Service Account" - git add core/package.json - git commit -m "${GITHUB_REPOSITORY}: Update tag build $version" - git -c http.extraheader="AUTHORIZATION: bearer ${{ secrets.PAT_SERVICE_ACCOUNT }}" push origin HEAD:main - git tag -a core-$version -m "${GITHUB_REPOSITORY}: Update tag build $version for core" - git -c http.extraheader="AUTHORIZATION: bearer ${{ secrets.PAT_SERVICE_ACCOUNT }}" push origin core-$version diff --git a/.github/workflows/jan-plugins.yml b/.github/workflows/jan-plugins.yml deleted file mode 100644 index fee3b977d..000000000 --- a/.github/workflows/jan-plugins.yml +++ /dev/null @@ -1,163 +0,0 @@ -name: Jan Default Plugins -on: - push: - branches: - - main - paths: - - "plugins/**" - - "!plugins/*/package.json" - pull_request: - branches: - - main - paths: - - "plugins/**" - - ".github/workflows/jan-plugins.yml" - - "!plugins/*/package.json" -jobs: - build: - runs-on: macos-latest - environment: production - outputs: - branch_name: ${{ steps.commit_and_tag.outputs.branch_name }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: "0" - token: ${{ secrets.PAT_SERVICE_ACCOUNT }} - - - name: Install jq - uses: dcarbone/install-jq-action@v2.0.1 - - - name: Get Cer for code signing - run: base64 -d <<< "$CODE_SIGN_P12_BASE64" > /tmp/codesign.p12 - shell: bash - env: - CODE_SIGN_P12_BASE64: ${{ secrets.CODE_SIGN_P12_BASE64 }} - - - uses: apple-actions/import-codesign-certs@v2 - continue-on-error: true - with: - p12-file-base64: ${{ secrets.CODE_SIGN_P12_BASE64 }} - p12-password: ${{ secrets.CODE_SIGN_P12_PASSWORD }} - - - name: Check Path Change - run: | - git config --global user.email "service@jan.ai" - git config --global user.name "Service Account" - echo "Changes in these directories trigger the build:" - changed_dirs=$(git -c http.extraheader="AUTHORIZATION: bearer ${{ secrets.GITHUB_TOKEN }}" diff --name-only HEAD HEAD~1 | grep '^plugins/' | awk -F/ '{print $2}' | uniq) - echo $changed_dirs > /tmp/change_dir.txt - - - name: "Auto Increase package Version" - run: | - cd plugins - for dir in $(cat /tmp/change_dir.txt) - do - echo "$dir" - if [ ! -d "$dir" ]; then - echo "Directory $dir does not exist, plugin might be removed, skipping..." - continue - fi - - # Extract current version - current_version=$(jq -r '.version' $dir/package.json) - - # Break the version into its components - major_version=$(echo $current_version | cut -d "." -f 1) - minor_version=$(echo $current_version | cut -d "." -f 2) - patch_version=$(echo $current_version | cut -d "." -f 3) - - # Increment the patch version by one - new_patch_version=$((patch_version+1)) - - # Construct the new version - new_version="$major_version.$minor_version.$new_patch_version" - - # Replace the old version with the new version in package.json - jq --arg version "$new_version" '.version = $version' $dir/package.json > /tmp/package.json && mv /tmp/package.json $dir/package.json - - # Print the new version - echo "Updated $dir package.json version to: $new_version" - done - - # Setup .npmrc file to publish to npm - - uses: actions/setup-node@v3 - with: - node-version: "20.x" - registry-url: "https://registry.npmjs.org" - - name: Build core module - run: | - cd core - npm install - npm run build - - - name: Publish npm packages - run: | - cd plugins - for dir in $(cat /tmp/change_dir.txt) - do - echo $dir - if [ ! -d "$dir" ]; then - echo "Directory $dir does not exist, plugin might be removed, skipping..." - continue - fi - cd $dir - npm install - if [[ $dir == 'data-plugin' ]]; then - npm run build:deps - fi - npm run postinstall && ../../.github/scripts/auto-sign.sh - if [[ $GITHUB_EVENT_NAME == 'push' && $GITHUB_EVENT_PULL_REQUEST_HEAD_REPO_FULL_NAME != $GITHUB_REPOSITORY ]]; then - npm publish --access public - fi - cd .. - done - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - DEVELOPER_ID: ${{ secrets.DEVELOPER_ID }} - APP_PATH: "." - - - name: "Commit new version to main and create tag" - id: commit_and_tag - if: github.event_name == 'push' && github.event.pull_request.head.repo.full_name != github.repository - run: | - rm -rf /tmp/plugin-catalog - git clone https://${{ secrets.SERVICE_ACCOUNT_USERNAME }}:${{ secrets.PAT_SERVICE_ACCOUNT }}@github.com/janhq/plugin-catalog.git /tmp/plugin-catalog - for dir in $(cat /tmp/change_dir.txt) - do - echo "$dir" - if [ ! -d "$dir" ]; then - echo "Directory $dir does not exist, plugin might be removed, skipping..." - continue - fi - version=$(jq -r '.version' plugins/$dir/package.json) - git config --global user.email "service@jan.ai" - git config --global user.name "Service Account" - git add plugins/$dir/package.json - git commit -m "${GITHUB_REPOSITORY}: Update tag build $version for $dir" - git -c http.extraheader="AUTHORIZATION: bearer ${{ secrets.PAT_SERVICE_ACCOUNT }}" push origin HEAD:main - git tag -a $dir-$version -m "${GITHUB_REPOSITORY}: Update tag build $version for $dir" - git -c http.extraheader="AUTHORIZATION: bearer ${{ secrets.PAT_SERVICE_ACCOUNT }}" push origin $dir-$version - plugin_name=$(jq -r '.name | sub("@janhq/"; "")' plugins/$dir/package.json) - cp plugins/$dir/package.json /tmp/plugin-catalog/${plugin_name}.json - done - cd /tmp/plugin-catalog - BRANCH_NAME="update-package-$(date +'%Y%m%d%H%M%S')" - git checkout -b $BRANCH_NAME - git add . - git commit -m "Update plugin catalog" - git push origin $BRANCH_NAME - cd /tmp && rm -rf /tmp/plugin-catalog - echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV - echo "::set-output name=branch_name::$BRANCH_NAME" - - pull_request: - runs-on: ubuntu-latest - environment: production - if: github.event_name == 'push' && github.event.pull_request.head.repo.full_name != github.repository - needs: build - steps: - - run: | - gh pr create --title "Update plugin catalog" --body "Update plugin catalog" --base main --head ${{ needs.build.outputs.branch_name }} --repo janhq/plugin-catalog --reviewer louis-jan,hiento09 - env: - GITHUB_TOKEN: ${{ secrets.PAT_SERVICE_ACCOUNT }} diff --git a/README.md b/README.md index e5405a4a6..5464ff1c1 100644 --- a/README.md +++ b/README.md @@ -55,13 +55,34 @@ As Jan is development mode, you might get stuck on a broken build. To reset your installation: -1. Delete Jan Application from /Applications +1. Delete Jan from your `/Applications` folder -1. Clear cache: - `rm -rf /Users/$(whoami)/Library/Application\ Support/jan-electron` - OR - `rm -rf /Users/$(whoami)/Library/Application\ Support/jan` +1. Delete Application data: + ```sh + # Newer versions + rm -rf /Users/$(whoami)/Library/Application\ Support/jan + # Versions 0.2.0 and older + rm -rf /Users/$(whoami)/Library/Application\ Support/jan-electron + ``` + +1. Clear Application cache: + ```sh + rm -rf /Users/$(whoami)/Library/Caches/jan* + ``` + +1. Use the following commands to remove any dangling backend processes: + + ```sh + ps aux | grep nitro + ``` + + Look for processes like "nitro" and "nitro_arm_64," and kill them one by one with: + + ```sh + kill -9 + ``` + ## Contributing Contributions are welcome! Please read the [CONTRIBUTING.md](CONTRIBUTING.md) file diff --git a/core/src/index.ts b/core/src/index.ts index cba3efe92..39a69d702 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -8,7 +8,7 @@ export { core, deleteFile, invokePluginFunc } from "./core"; * Core module exports. * @module */ -export { downloadFile, executeOnMain } from "./core"; +export { downloadFile, executeOnMain, appDataPath } from "./core"; /** * Events module exports. diff --git a/docs/docs/getting-started/troubleshooting.md b/docs/docs/getting-started/troubleshooting.md index 0859dc5d5..815157423 100644 --- a/docs/docs/getting-started/troubleshooting.md +++ b/docs/docs/getting-started/troubleshooting.md @@ -7,21 +7,28 @@ sidebar_position: 5 Please note that 👋Jan is in "development mode," and you might encounter issues. If you need to reset your installation, follow these steps: ## Issue 1: Broken Build -1. Delete the Jan Application from your computer. -2. Clear the cache by running one of the following commands: +As Jan is development mode, you might get stuck on a broken build. - ```sh - rm -rf /Users/$(whoami)/Library/Application\ Support/jan-electron - ``` +To reset your installation: - or +1. Delete Jan from your `/Applications` folder - ```sh - rm -rf /Users/$(whoami)/Library/Application\ Support/jan - ``` +1. Delete Application data: + ```sh + # Newer versions + rm -rf /Users/$(whoami)/Library/Application\ Support/jan -3. If the above steps fail, use the following commands to find and kill any problematic processes: + # Versions 0.2.0 and older + rm -rf /Users/$(whoami)/Library/Application\ Support/jan-electron + ``` + +1. Clear Application cache: + ```sh + rm -rf /Users/$(whoami)/Library/Caches/jan* + ``` + +1. Use the following commands to remove any dangling backend processes: ```sh ps aux | grep nitro @@ -31,4 +38,5 @@ Please note that 👋Jan is in "development mode," and you might encounter issue ```sh kill -9 - ``` \ No newline at end of file + ``` + diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index f669a70a1..ce390b198 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -51,8 +51,8 @@ const config = { [ "posthog-docusaurus", { - apiKey: process.env.POSTHOG_PROJECT_API_KEY, - appUrl: process.env.POSTHOG_APP_URL, // optional + apiKey: process.env.POSTHOG_PROJECT_API_KEY || "XXX", + appUrl: process.env.POSTHOG_APP_URL || "XXX", // optional enableInDevelopment: false, // optional }, ], @@ -93,7 +93,7 @@ const config = { }, // GTM is always inactive in development and only active in production to avoid polluting the analytics statistics. googleTagManager: { - containerId: process.env.GTM_ID, + containerId: process.env.GTM_ID || "XXX", }, // Will be passed to @docusaurus/plugin-content-pages (false to disable) // pages: {}, @@ -162,7 +162,7 @@ const config = { ], }, prism: { - theme: lightCodeTheme, + theme: darkCodeTheme, darkTheme: darkCodeTheme, additionalLanguages: ["python"], }, diff --git a/docs/src/components/Elements/dropdown.js b/docs/src/components/Elements/dropdown.js index 443547132..31c2bf05a 100644 --- a/docs/src/components/Elements/dropdown.js +++ b/docs/src/components/Elements/dropdown.js @@ -53,20 +53,27 @@ export default function Dropdown() { return match ? match[1] : null; }; - const changeDefaultSystem = (systems) => { + const changeDefaultSystem = async (systems) => { const userAgent = navigator.userAgent; + + const arc = await navigator?.userAgentData?.getHighEntropyValues([ + "architecture", + ]); + if (userAgent.includes("Windows")) { // windows user setDefaultSystem(systems[2]); } else if (userAgent.includes("Linux")) { // linux user setDefaultSystem(systems[3]); - } else if (userAgent.includes("Mac OS") && userAgent.includes("Intel")) { - // mac intel user - setDefaultSystem(systems[1]); - } else { - // mac user and also default + } else if ( + userAgent.includes("Mac OS") && + arc && + arc.architecture === "arm" + ) { setDefaultSystem(systems[0]); + } else { + setDefaultSystem(systems[1]); } }; useEffect(() => { diff --git a/docs/src/css/custom_toc.css b/docs/src/css/custom_toc.css index caa5d1982..68abfb866 100644 --- a/docs/src/css/custom_toc.css +++ b/docs/src/css/custom_toc.css @@ -1,5 +1,5 @@ .custom-toc-title { - font-weight: bold; - margin-bottom: 16px; - margin-top: -20px; - } \ No newline at end of file + font-weight: bold; + margin-bottom: 16px; + margin-top: -20px; +} diff --git a/docs/src/styles/base.scss b/docs/src/styles/base.scss index fb6388922..27f85cb28 100644 --- a/docs/src/styles/base.scss +++ b/docs/src/styles/base.scss @@ -27,10 +27,10 @@ --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); } body { - @apply text-sm; + @apply text-base; @apply antialiased; @apply bg-white dark:bg-black; - @apply text-gray-700 dark:text-gray-400; + @apply text-gray-800 dark:text-gray-300; } img { pointer-events: none; diff --git a/docs/src/styles/tweaks.scss b/docs/src/styles/tweaks.scss index 14ec842a9..e4d44f6b9 100644 --- a/docs/src/styles/tweaks.scss +++ b/docs/src/styles/tweaks.scss @@ -92,9 +92,23 @@ } ul, ol { - padding-left: 16px; + padding-left: 32px; li { @apply leading-loose; } } } + +.markdown h1:first-child, +.markdown > p { + margin-bottom: 16px; +} + +.theme-code-block { + font-size: 14px; + background-color: black; +} + +.table-of-contents { + font-size: 14px; +} diff --git a/electron/handlers/app.ts b/electron/handlers/app.ts new file mode 100644 index 000000000..022e4d61a --- /dev/null +++ b/electron/handlers/app.ts @@ -0,0 +1,65 @@ +import { app, ipcMain, shell } from "electron"; +import { ModuleManager } from "../managers/module"; +import { join } from "path"; +import { PluginManager } from "../managers/plugin"; +import { WindowManager } from "../managers/window"; + +export function handleAppIPCs() { + /** + * Retrieves the path to the app data directory using the `coreAPI` object. + * If the `coreAPI` object is not available, the function returns `undefined`. + * @returns A Promise that resolves with the path to the app data directory, or `undefined` if the `coreAPI` object is not available. + */ + ipcMain.handle("appDataPath", async (_event) => { + return app.getPath("userData"); + }); + + /** + * Returns the version of the app. + * @param _event - The IPC event object. + * @returns The version of the app. + */ + ipcMain.handle("appVersion", async (_event) => { + return app.getVersion(); + }); + + /** + * Handles the "openAppDirectory" IPC message by opening the app's user data directory. + * The `shell.openPath` method is used to open the directory in the user's default file explorer. + * @param _event - The IPC event object. + */ + ipcMain.handle("openAppDirectory", async (_event) => { + shell.openPath(app.getPath("userData")); + }); + + /** + * Opens a URL in the user's default browser. + * @param _event - The IPC event object. + * @param url - The URL to open. + */ + ipcMain.handle("openExternalUrl", async (_event, url) => { + shell.openExternal(url); + }); + + /** + * Relaunches the app in production - reload window in development. + * @param _event - The IPC event object. + * @param url - The URL to reload. + */ + ipcMain.handle("relaunch", async (_event, url) => { + ModuleManager.instance.clearImportedModules(); + + if (app.isPackaged) { + app.relaunch(); + app.exit(); + } else { + for (const modulePath in ModuleManager.instance.requiredModules) { + delete require.cache[ + require.resolve(join(app.getPath("userData"), "plugins", modulePath)) + ]; + } + PluginManager.instance.setupPlugins(); + WindowManager.instance.currentWindow?.reload(); + } + }); +} diff --git a/electron/handlers/download.ts b/electron/handlers/download.ts new file mode 100644 index 000000000..ab672f25c --- /dev/null +++ b/electron/handlers/download.ts @@ -0,0 +1,106 @@ +import { app, ipcMain } from "electron"; +import { DownloadManager } from "../managers/download"; +import { resolve, join } from "path"; +import { WindowManager } from "../managers/window"; +import request from "request"; +import { createWriteStream, unlink } from "fs"; +const progress = require("request-progress"); + +export function handleDownloaderIPCs() { + /** + * Handles the "pauseDownload" IPC message by pausing the download associated with the provided fileName. + * @param _event - The IPC event object. + * @param fileName - The name of the file being downloaded. + */ + ipcMain.handle("pauseDownload", async (_event, fileName) => { + DownloadManager.instance.networkRequests[fileName]?.pause(); + }); + + /** + * Handles the "resumeDownload" IPC message by resuming the download associated with the provided fileName. + * @param _event - The IPC event object. + * @param fileName - The name of the file being downloaded. + */ + ipcMain.handle("resumeDownload", async (_event, fileName) => { + DownloadManager.instance.networkRequests[fileName]?.resume(); + }); + + /** + * Handles the "abortDownload" IPC message by aborting the download associated with the provided fileName. + * The network request associated with the fileName is then removed from the networkRequests object. + * @param _event - The IPC event object. + * @param fileName - The name of the file being downloaded. + */ + ipcMain.handle("abortDownload", async (_event, fileName) => { + const rq = DownloadManager.instance.networkRequests[fileName]; + DownloadManager.instance.networkRequests[fileName] = undefined; + const userDataPath = app.getPath("userData"); + const fullPath = join(userDataPath, fileName); + rq?.abort(); + let result = "NULL"; + unlink(fullPath, function (err) { + if (err && err.code == "ENOENT") { + result = `File not exist: ${err}`; + } else if (err) { + result = `File delete error: ${err}`; + } else { + result = "File deleted successfully"; + } + console.log(`Delete file ${fileName} from ${fullPath} result: ${result}`); + }); + }); + + /** + * Downloads a file from a given URL. + * @param _event - The IPC event object. + * @param url - The URL to download the file from. + * @param fileName - The name to give the downloaded file. + */ + ipcMain.handle("downloadFile", async (_event, url, fileName) => { + const userDataPath = app.getPath("userData"); + const destination = resolve(userDataPath, fileName); + const rq = request(url); + + progress(rq, {}) + .on("progress", function (state: any) { + WindowManager?.instance.currentWindow?.webContents.send( + "FILE_DOWNLOAD_UPDATE", + { + ...state, + fileName, + } + ); + }) + .on("error", function (err: Error) { + WindowManager?.instance.currentWindow?.webContents.send( + "FILE_DOWNLOAD_ERROR", + { + fileName, + err, + } + ); + }) + .on("end", function () { + if (DownloadManager.instance.networkRequests[fileName]) { + WindowManager?.instance.currentWindow?.webContents.send( + "FILE_DOWNLOAD_COMPLETE", + { + fileName, + } + ); + DownloadManager.instance.setRequest(fileName, undefined); + } else { + WindowManager?.instance.currentWindow?.webContents.send( + "FILE_DOWNLOAD_ERROR", + { + fileName, + err: "Download cancelled", + } + ); + } + }) + .pipe(createWriteStream(destination)); + + DownloadManager.instance.setRequest(fileName, rq); + }); +} diff --git a/electron/handlers/fs.ts b/electron/handlers/fs.ts index 96714259d..af77e3002 100644 --- a/electron/handlers/fs.ts +++ b/electron/handlers/fs.ts @@ -5,7 +5,7 @@ import { join } from "path"; /** * Handles file system operations. */ -export function handleFs() { +export function handleFsIPCs() { /** * Reads a file from the user data directory. * @param event - The event object. @@ -115,4 +115,29 @@ export function handleFs() { }); } ); + + /** + * Deletes a file from the user data folder. + * @param _event - The IPC event object. + * @param filePath - The path to the file to delete. + * @returns A string indicating the result of the operation. + */ + ipcMain.handle("deleteFile", async (_event, filePath) => { + const userDataPath = app.getPath("userData"); + const fullPath = join(userDataPath, filePath); + + let result = "NULL"; + fs.unlink(fullPath, function (err) { + if (err && err.code == "ENOENT") { + result = `File not exist: ${err}`; + } else if (err) { + result = `File delete error: ${err}`; + } else { + result = "File deleted successfully"; + } + console.log(`Delete file ${filePath} from ${fullPath} result: ${result}`); + }); + + return result; + }); } diff --git a/electron/handlers/plugin.ts b/electron/handlers/plugin.ts new file mode 100644 index 000000000..26eb3c583 --- /dev/null +++ b/electron/handlers/plugin.ts @@ -0,0 +1,118 @@ +import { app, ipcMain } from "electron"; +import { readdirSync, rmdir, writeFileSync } from "fs"; +import { ModuleManager } from "../managers/module"; +import { join, extname } from "path"; +import { PluginManager } from "../managers/plugin"; +import { WindowManager } from "../managers/window"; +import { manifest, tarball } from "pacote"; + +export function handlePluginIPCs() { + /** + * Invokes a function from a plugin module in main node process. + * @param _event - The IPC event object. + * @param modulePath - The path to the plugin module. + * @param method - The name of the function to invoke. + * @param args - The arguments to pass to the function. + * @returns The result of the invoked function. + */ + ipcMain.handle( + "invokePluginFunc", + async (_event, modulePath, method, ...args) => { + const module = require( + /* webpackIgnore: true */ join( + app.getPath("userData"), + "plugins", + modulePath + ) + ); + ModuleManager.instance.setModule(modulePath, module); + + if (typeof module[method] === "function") { + return module[method](...args); + } else { + console.log(module[method]); + console.error(`Function "${method}" does not exist in the module.`); + } + } + ); + + /** + * Returns the paths of the base plugins. + * @param _event - The IPC event object. + * @returns An array of paths to the base plugins. + */ + ipcMain.handle("basePlugins", async (_event) => { + const basePluginPath = join( + __dirname, + "../", + app.isPackaged + ? "../../app.asar.unpacked/core/pre-install" + : "../core/pre-install" + ); + return readdirSync(basePluginPath) + .filter((file) => extname(file) === ".tgz") + .map((file) => join(basePluginPath, file)); + }); + + /** + * Returns the path to the user's plugin directory. + * @param _event - The IPC event object. + * @returns The path to the user's plugin directory. + */ + ipcMain.handle("pluginPath", async (_event) => { + return join(app.getPath("userData"), "plugins"); + }); + + /** + * Deletes the `plugins` directory in the user data path and disposes of required modules. + * If the app is packaged, the function relaunches the app and exits. + * Otherwise, the function deletes the cached modules and sets up the plugins and reloads the main window. + * @param _event - The IPC event object. + * @param url - The URL to reload. + */ + ipcMain.handle("reloadPlugins", async (_event, url) => { + const userDataPath = app.getPath("userData"); + const fullPath = join(userDataPath, "plugins"); + + rmdir(fullPath, { recursive: true }, function (err) { + if (err) console.log(err); + ModuleManager.instance.clearImportedModules(); + + // just relaunch if packaged, should launch manually in development mode + if (app.isPackaged) { + app.relaunch(); + app.exit(); + } else { + for (const modulePath in ModuleManager.instance.requiredModules) { + delete require.cache[ + require.resolve( + join(app.getPath("userData"), "plugins", modulePath) + ) + ]; + } + PluginManager.instance.setupPlugins(); + WindowManager.instance.currentWindow?.reload(); + } + }); + }); + + /** + * Installs a remote plugin by downloading its tarball and writing it to a tgz file. + * @param _event - The IPC event object. + * @param pluginName - The name of the remote plugin to install. + * @returns A Promise that resolves to the path of the installed plugin file. + */ + ipcMain.handle("installRemotePlugin", async (_event, pluginName) => { + const destination = join( + app.getPath("userData"), + pluginName.replace(/^@.*\//, "") + ".tgz" + ); + return manifest(pluginName) + .then(async (manifest: any) => { + await tarball(manifest._resolved).then((data: Buffer) => { + writeFileSync(destination, data); + }); + }) + .then(() => destination); + }); +} diff --git a/electron/handlers/theme.ts b/electron/handlers/theme.ts new file mode 100644 index 000000000..0038002a8 --- /dev/null +++ b/electron/handlers/theme.ts @@ -0,0 +1,27 @@ +import { ipcMain, nativeTheme } from "electron"; + +export function handleThemesIPCs() { + /** + * Handles the "setNativeThemeLight" IPC message by setting the native theme source to "light". + * This will change the appearance of the app to the light theme. + */ + ipcMain.handle("setNativeThemeLight", () => { + nativeTheme.themeSource = "light"; + }); + + /** + * Handles the "setNativeThemeDark" IPC message by setting the native theme source to "dark". + * This will change the appearance of the app to the dark theme. + */ + ipcMain.handle("setNativeThemeDark", () => { + nativeTheme.themeSource = "dark"; + }); + + /** + * Handles the "setNativeThemeSystem" IPC message by setting the native theme source to "system". + * This will change the appearance of the app to match the system's current theme. + */ + ipcMain.handle("setNativeThemeSystem", () => { + nativeTheme.themeSource = "system"; + }); +} diff --git a/electron/handlers/update.ts b/electron/handlers/update.ts new file mode 100644 index 000000000..096d09bac --- /dev/null +++ b/electron/handlers/update.ts @@ -0,0 +1,58 @@ +import { app, dialog } from "electron"; +import { WindowManager } from "../managers/window"; +import { autoUpdater } from "electron-updater"; + +export function handleAppUpdates() { + /* Should not check for update during development */ + if (!app.isPackaged) { + return; + } + /* New Update Available */ + autoUpdater.on("update-available", async (_info: any) => { + const action = await dialog.showMessageBox({ + message: `Update available. Do you want to download the latest update?`, + buttons: ["Download", "Later"], + }); + if (action.response === 0) await autoUpdater.downloadUpdate(); + }); + + /* App Update Completion Message */ + autoUpdater.on("update-downloaded", async (_info: any) => { + WindowManager.instance.currentWindow?.webContents.send( + "APP_UPDATE_COMPLETE", + {} + ); + const action = await dialog.showMessageBox({ + message: `Update downloaded. Please restart the application to apply the updates.`, + buttons: ["Restart", "Later"], + }); + if (action.response === 0) { + autoUpdater.quitAndInstall(); + } + }); + + /* App Update Error */ + autoUpdater.on("error", (info: any) => { + dialog.showMessageBox({ message: info.message }); + WindowManager.instance.currentWindow?.webContents.send( + "APP_UPDATE_ERROR", + {} + ); + }); + + /* App Update Progress */ + autoUpdater.on("download-progress", (progress: any) => { + console.log("app update progress: ", progress.percent); + WindowManager.instance.currentWindow?.webContents.send( + "APP_UPDATE_PROGRESS", + { + percent: progress.percent, + } + ); + }); + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = true; + if (process.env.CI !== "e2e") { + autoUpdater.checkForUpdates(); + } +} diff --git a/electron/main.ts b/electron/main.ts index cecc90f42..741a75867 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,33 +1,28 @@ -import { - app, - BrowserWindow, - ipcMain, - dialog, - shell, - nativeTheme, -} from "electron"; -import { readdirSync, writeFileSync } from "fs"; -import { resolve, join, extname } from "path"; -import { rmdir, unlink, createWriteStream } from "fs"; -import { init } from "./core/plugin/index"; +import { app, BrowserWindow } from "electron"; +import { join } from "path"; import { setupMenu } from "./utils/menu"; -import { dispose } from "./utils/disposable"; -import { handleFs } from "./handlers/fs"; +import { handleFsIPCs } from "./handlers/fs"; -const pacote = require("pacote"); -const request = require("request"); -const progress = require("request-progress"); -const { autoUpdater } = require("electron-updater"); -const Store = require("electron-store"); +/** + * Managers + **/ +import { WindowManager } from "./managers/window"; +import { ModuleManager } from "./managers/module"; +import { PluginManager } from "./managers/plugin"; -let requiredModules: Record = {}; -const networkRequests: Record = {}; -let mainWindow: BrowserWindow | undefined = undefined; +/** + * IPC Handlers + **/ +import { handleDownloaderIPCs } from "./handlers/download"; +import { handleThemesIPCs } from "./handlers/theme"; +import { handlePluginIPCs } from "./handlers/plugin"; +import { handleAppIPCs } from "./handlers/app"; +import { handleAppUpdates } from "./handlers/update"; app .whenReady() - .then(migratePlugins) - .then(setupPlugins) + .then(PluginManager.instance.migratePlugins) + .then(PluginManager.instance.setupPlugins) .then(setupMenu) .then(handleIPCs) .then(handleAppUpdates) @@ -41,27 +36,18 @@ app }); app.on("window-all-closed", () => { - clearImportedModules(); + ModuleManager.instance.clearImportedModules(); app.quit(); }); app.on("quit", () => { - clearImportedModules(); + ModuleManager.instance.clearImportedModules(); app.quit(); }); function createMainWindow() { - mainWindow = new BrowserWindow({ - width: 1200, - minWidth: 800, - height: 800, - show: false, - trafficLightPosition: { - x: 10, - y: 15, - }, - titleBarStyle: "hidden", - vibrancy: "sidebar", + /* Create main window */ + const mainWindow = WindowManager.instance.createWindow({ webPreferences: { nodeIntegration: true, preload: join(__dirname, "preload.js"), @@ -73,6 +59,7 @@ function createMainWindow() { ? `file://${join(__dirname, "../renderer/index.html")}` : "http://localhost:3000"; + /* Load frontend app to the window */ mainWindow.loadURL(startURL); mainWindow.once("ready-to-show", () => mainWindow?.show()); @@ -80,390 +67,17 @@ function createMainWindow() { if (process.platform !== "darwin") app.quit(); }); + /* Enable dev tools for development */ if (!app.isPackaged) mainWindow.webContents.openDevTools(); } -function handleAppUpdates() { - /*New Update Available*/ - autoUpdater.on("update-available", async (_info: any) => { - const action = await dialog.showMessageBox({ - message: `Update available. Do you want to download the latest update?`, - buttons: ["Download", "Later"], - }); - if (action.response === 0) await autoUpdater.downloadUpdate(); - }); - - /*App Update Completion Message*/ - autoUpdater.on("update-downloaded", async (_info: any) => { - mainWindow?.webContents.send("APP_UPDATE_COMPLETE", {}); - const action = await dialog.showMessageBox({ - message: `Update downloaded. Please restart the application to apply the updates.`, - buttons: ["Restart", "Later"], - }); - if (action.response === 0) { - autoUpdater.quitAndInstall(); - } - }); - - /*App Update Error */ - autoUpdater.on("error", (info: any) => { - dialog.showMessageBox({ message: info.message }); - mainWindow?.webContents.send("APP_UPDATE_ERROR", {}); - }); - - /*App Update Progress */ - autoUpdater.on("download-progress", (progress: any) => { - console.log("app update progress: ", progress.percent); - mainWindow?.webContents.send("APP_UPDATE_PROGRESS", { - percent: progress.percent, - }); - }); - autoUpdater.autoDownload = false; - autoUpdater.autoInstallOnAppQuit = true; - if (process.env.CI !== "e2e") { - autoUpdater.checkForUpdates(); - } -} - /** * Handles various IPC messages from the renderer process. */ function handleIPCs() { - handleFs(); - /** - * Handles the "setNativeThemeLight" IPC message by setting the native theme source to "light". - * This will change the appearance of the app to the light theme. - */ - ipcMain.handle("setNativeThemeLight", () => { - nativeTheme.themeSource = "light"; - }); - - /** - * Handles the "setNativeThemeDark" IPC message by setting the native theme source to "dark". - * This will change the appearance of the app to the dark theme. - */ - ipcMain.handle("setNativeThemeDark", () => { - nativeTheme.themeSource = "dark"; - }); - - /** - * Handles the "setNativeThemeSystem" IPC message by setting the native theme source to "system". - * This will change the appearance of the app to match the system's current theme. - */ - ipcMain.handle("setNativeThemeSystem", () => { - nativeTheme.themeSource = "system"; - }); - - /** - * Invokes a function from a plugin module in main node process. - * @param _event - The IPC event object. - * @param modulePath - The path to the plugin module. - * @param method - The name of the function to invoke. - * @param args - The arguments to pass to the function. - * @returns The result of the invoked function. - */ - ipcMain.handle( - "invokePluginFunc", - async (_event, modulePath, method, ...args) => { - const module = require( - /* webpackIgnore: true */ join( - app.getPath("userData"), - "plugins", - modulePath - ) - ); - requiredModules[modulePath] = module; - - if (typeof module[method] === "function") { - return module[method](...args); - } else { - console.log(module[method]); - console.error(`Function "${method}" does not exist in the module.`); - } - } - ); - - /** - * Returns the paths of the base plugins. - * @param _event - The IPC event object. - * @returns An array of paths to the base plugins. - */ - ipcMain.handle("basePlugins", async (_event) => { - const basePluginPath = join( - __dirname, - "../", - app.isPackaged - ? "../app.asar.unpacked/core/pre-install" - : "/core/pre-install" - ); - return readdirSync(basePluginPath) - .filter((file) => extname(file) === ".tgz") - .map((file) => join(basePluginPath, file)); - }); - - /** - * Returns the path to the user's plugin directory. - * @param _event - The IPC event object. - * @returns The path to the user's plugin directory. - */ - ipcMain.handle("pluginPath", async (_event) => { - return join(app.getPath("userData"), "plugins"); - }); - - /** - * Retrieves the path to the app data directory using the `coreAPI` object. - * If the `coreAPI` object is not available, the function returns `undefined`. - * @returns A Promise that resolves with the path to the app data directory, or `undefined` if the `coreAPI` object is not available. - */ - ipcMain.handle("appDataPath", async (_event) => { - return app.getPath("userData"); - }); - - /** - * Returns the version of the app. - * @param _event - The IPC event object. - * @returns The version of the app. - */ - ipcMain.handle("appVersion", async (_event) => { - return app.getVersion(); - }); - - /** - * Handles the "openAppDirectory" IPC message by opening the app's user data directory. - * The `shell.openPath` method is used to open the directory in the user's default file explorer. - * @param _event - The IPC event object. - */ - ipcMain.handle("openAppDirectory", async (_event) => { - shell.openPath(app.getPath("userData")); - }); - - /** - * Opens a URL in the user's default browser. - * @param _event - The IPC event object. - * @param url - The URL to open. - */ - ipcMain.handle("openExternalUrl", async (_event, url) => { - shell.openExternal(url); - }); - - /** - * Relaunches the app in production - reload window in development. - * @param _event - The IPC event object. - * @param url - The URL to reload. - */ - ipcMain.handle("relaunch", async (_event, url) => { - clearImportedModules(); - - if (app.isPackaged) { - app.relaunch(); - app.exit(); - } else { - for (const modulePath in requiredModules) { - delete require.cache[ - require.resolve(join(app.getPath("userData"), "plugins", modulePath)) - ]; - } - setupPlugins(); - mainWindow?.reload(); - } - }); - - /** - * Deletes the `plugins` directory in the user data path and disposes of required modules. - * If the app is packaged, the function relaunches the app and exits. - * Otherwise, the function deletes the cached modules and sets up the plugins and reloads the main window. - * @param _event - The IPC event object. - * @param url - The URL to reload. - */ - ipcMain.handle("reloadPlugins", async (_event, url) => { - const userDataPath = app.getPath("userData"); - const fullPath = join(userDataPath, "plugins"); - - rmdir(fullPath, { recursive: true }, function (err) { - if (err) console.log(err); - clearImportedModules(); - - // just relaunch if packaged, should launch manually in development mode - if (app.isPackaged) { - app.relaunch(); - app.exit(); - } else { - for (const modulePath in requiredModules) { - delete require.cache[ - require.resolve( - join(app.getPath("userData"), "plugins", modulePath) - ) - ]; - } - setupPlugins(); - mainWindow?.reload(); - } - }); - }); - - /** - * Deletes a file from the user data folder. - * @param _event - The IPC event object. - * @param filePath - The path to the file to delete. - * @returns A string indicating the result of the operation. - */ - ipcMain.handle("deleteFile", async (_event, filePath) => { - const userDataPath = app.getPath("userData"); - const fullPath = join(userDataPath, filePath); - - let result = "NULL"; - unlink(fullPath, function (err) { - if (err && err.code == "ENOENT") { - result = `File not exist: ${err}`; - } else if (err) { - result = `File delete error: ${err}`; - } else { - result = "File deleted successfully"; - } - console.log(`Delete file ${filePath} from ${fullPath} result: ${result}`); - }); - - return result; - }); - - /** - * Downloads a file from a given URL. - * @param _event - The IPC event object. - * @param url - The URL to download the file from. - * @param fileName - The name to give the downloaded file. - */ - ipcMain.handle("downloadFile", async (_event, url, fileName) => { - const userDataPath = app.getPath("userData"); - const destination = resolve(userDataPath, fileName); - const rq = request(url); - - progress(rq, {}) - .on("progress", function (state: any) { - mainWindow?.webContents.send("FILE_DOWNLOAD_UPDATE", { - ...state, - fileName, - }); - }) - .on("error", function (err: Error) { - mainWindow?.webContents.send("FILE_DOWNLOAD_ERROR", { - fileName, - err, - }); - networkRequests[fileName] = undefined; - }) - .on("end", function () { - if (networkRequests[fileName]) { - mainWindow?.webContents.send("FILE_DOWNLOAD_COMPLETE", { - fileName, - }); - networkRequests[fileName] = undefined; - } else { - mainWindow?.webContents.send("FILE_DOWNLOAD_ERROR", { - fileName, - err: "Download cancelled", - }); - } - }) - .pipe(createWriteStream(destination)); - - networkRequests[fileName] = rq; - }); - - /** - * Handles the "pauseDownload" IPC message by pausing the download associated with the provided fileName. - * @param _event - The IPC event object. - * @param fileName - The name of the file being downloaded. - */ - ipcMain.handle("pauseDownload", async (_event, fileName) => { - networkRequests[fileName]?.pause(); - }); - - /** - * Handles the "resumeDownload" IPC message by resuming the download associated with the provided fileName. - * @param _event - The IPC event object. - * @param fileName - The name of the file being downloaded. - */ - ipcMain.handle("resumeDownload", async (_event, fileName) => { - networkRequests[fileName]?.resume(); - }); - - /** - * Handles the "abortDownload" IPC message by aborting the download associated with the provided fileName. - * The network request associated with the fileName is then removed from the networkRequests object. - * @param _event - The IPC event object. - * @param fileName - The name of the file being downloaded. - */ - ipcMain.handle("abortDownload", async (_event, fileName) => { - const rq = networkRequests[fileName]; - networkRequests[fileName] = undefined; - rq?.abort(); - }); - - /** - * Installs a remote plugin by downloading its tarball and writing it to a tgz file. - * @param _event - The IPC event object. - * @param pluginName - The name of the remote plugin to install. - * @returns A Promise that resolves to the path of the installed plugin file. - */ - ipcMain.handle("installRemotePlugin", async (_event, pluginName) => { - const destination = join( - app.getPath("userData"), - pluginName.replace(/^@.*\//, "") + ".tgz" - ); - return pacote - .manifest(pluginName) - .then(async (manifest: any) => { - await pacote.tarball(manifest._resolved).then((data: Buffer) => { - writeFileSync(destination, data); - }); - }) - .then(() => destination); - }); -} - -/** - * Migrates the plugins by deleting the `plugins` directory in the user data path. - * If the `migrated_version` key in the `Store` object does not match the current app version, - * the function deletes the `plugins` directory and sets the `migrated_version` key to the current app version. - * @returns A Promise that resolves when the migration is complete. - */ -function migratePlugins() { - return new Promise((resolve) => { - const store = new Store(); - if (store.get("migrated_version") !== app.getVersion()) { - console.log("start migration:", store.get("migrated_version")); - const userDataPath = app.getPath("userData"); - const fullPath = join(userDataPath, "plugins"); - - rmdir(fullPath, { recursive: true }, function (err) { - if (err) console.log(err); - store.set("migrated_version", app.getVersion()); - console.log("migrate plugins done"); - resolve(undefined); - }); - } else { - resolve(undefined); - } - }); -} - -/** - * Sets up the plugins by initializing the `plugins` module with the `confirmInstall` and `pluginsPath` options. - * The `confirmInstall` function always returns `true` to allow plugin installation. - * The `pluginsPath` option specifies the path to install plugins to. - */ -function setupPlugins() { - init({ - // Function to check from the main process that user wants to install a plugin - confirmInstall: async (_plugins: string[]) => { - return true; - }, - // Path to install plugin to - pluginsPath: join(app.getPath("userData"), "plugins"), - }); -} - -function clearImportedModules() { - dispose(requiredModules); - requiredModules = {}; + handleFsIPCs(); + handleDownloaderIPCs(); + handleThemesIPCs(); + handlePluginIPCs(); + handleAppIPCs(); } diff --git a/electron/managers/download.ts b/electron/managers/download.ts new file mode 100644 index 000000000..08c089b74 --- /dev/null +++ b/electron/managers/download.ts @@ -0,0 +1,24 @@ +import { Request } from "request"; + +/** + * Manages file downloads and network requests. + */ +export class DownloadManager { + public networkRequests: Record = {}; + + public static instance: DownloadManager = new DownloadManager(); + + constructor() { + if (DownloadManager.instance) { + return DownloadManager.instance; + } + } + /** + * Sets a network request for a specific file. + * @param {string} fileName - The name of the file. + * @param {Request | undefined} request - The network request to set, or undefined to clear the request. + */ + setRequest(fileName: string, request: Request | undefined) { + this.networkRequests[fileName] = request; + } +} diff --git a/electron/managers/module.ts b/electron/managers/module.ts new file mode 100644 index 000000000..43dda0fb6 --- /dev/null +++ b/electron/managers/module.ts @@ -0,0 +1,33 @@ +import { dispose } from "../utils/disposable"; + +/** + * Manages imported modules. + */ +export class ModuleManager { + public requiredModules: Record = {}; + + public static instance: ModuleManager = new ModuleManager(); + + constructor() { + if (ModuleManager.instance) { + return ModuleManager.instance; + } + } + + /** + * Sets a module. + * @param {string} moduleName - The name of the module. + * @param {any | undefined} nodule - The module to set, or undefined to clear the module. + */ + setModule(moduleName: string, nodule: any | undefined) { + this.requiredModules[moduleName] = nodule; + } + + /** + * Clears all imported modules. + */ + clearImportedModules() { + dispose(this.requiredModules); + this.requiredModules = {}; + } +} diff --git a/electron/managers/plugin.ts b/electron/managers/plugin.ts new file mode 100644 index 000000000..889425ec7 --- /dev/null +++ b/electron/managers/plugin.ts @@ -0,0 +1,60 @@ +import { app } from "electron"; +import { init } from "../core/plugin/index"; +import { join } from "path"; +import { rmdir } from "fs"; +import Store from "electron-store"; + +/** + * Manages plugin installation and migration. + */ +export class PluginManager { + public static instance: PluginManager = new PluginManager(); + + constructor() { + if (PluginManager.instance) { + return PluginManager.instance; + } + } + + /** + * Sets up the plugins by initializing the `plugins` module with the `confirmInstall` and `pluginsPath` options. + * The `confirmInstall` function always returns `true` to allow plugin installation. + * The `pluginsPath` option specifies the path to install plugins to. + */ + setupPlugins() { + init({ + // Function to check from the main process that user wants to install a plugin + confirmInstall: async (_plugins: string[]) => { + return true; + }, + // Path to install plugin to + pluginsPath: join(app.getPath("userData"), "plugins"), + }); + } + + /** + * Migrates the plugins by deleting the `plugins` directory in the user data path. + * If the `migrated_version` key in the `Store` object does not match the current app version, + * the function deletes the `plugins` directory and sets the `migrated_version` key to the current app version. + * @returns A Promise that resolves when the migration is complete. + */ + migratePlugins() { + return new Promise((resolve) => { + const store = new Store(); + if (store.get("migrated_version") !== app.getVersion()) { + console.log("start migration:", store.get("migrated_version")); + const userDataPath = app.getPath("userData"); + const fullPath = join(userDataPath, "plugins"); + + rmdir(fullPath, { recursive: true }, function (err) { + if (err) console.log(err); + store.set("migrated_version", app.getVersion()); + console.log("migrate plugins done"); + resolve(undefined); + }); + } else { + resolve(undefined); + } + }); + } +} diff --git a/electron/managers/window.ts b/electron/managers/window.ts new file mode 100644 index 000000000..c930dd5ec --- /dev/null +++ b/electron/managers/window.ts @@ -0,0 +1,37 @@ +import { BrowserWindow } from "electron"; + +/** + * Manages the current window instance. + */ +export class WindowManager { + public static instance: WindowManager = new WindowManager(); + public currentWindow?: BrowserWindow; + + constructor() { + if (WindowManager.instance) { + return WindowManager.instance; + } + } + + /** + * Creates a new window instance. + * @param {Electron.BrowserWindowConstructorOptions} options - The options to create the window with. + * @returns The created window instance. + */ + createWindow(options?: Electron.BrowserWindowConstructorOptions | undefined) { + this.currentWindow = new BrowserWindow({ + width: 1200, + minWidth: 800, + height: 800, + show: false, + trafficLightPosition: { + x: 10, + y: 15, + }, + titleBarStyle: "hidden", + vibrancy: "sidebar", + ...options, + }); + return this.currentWindow; + } +} diff --git a/electron/package.json b/electron/package.json index da9b34e38..107264805 100644 --- a/electron/package.json +++ b/electron/package.json @@ -67,6 +67,7 @@ }, "dependencies": { "@npmcli/arborist": "^7.1.0", + "@types/request": "^2.48.12", "@uiball/loaders": "^1.3.0", "electron-store": "^8.1.0", "electron-updater": "^6.1.4", diff --git a/electron/tsconfig.json b/electron/tsconfig.json index 8276542b2..3cc218f93 100644 --- a/electron/tsconfig.json +++ b/electron/tsconfig.json @@ -8,6 +8,7 @@ "outDir": "./build", "rootDir": "./", "noEmitOnError": true, + "esModuleInterop": true, "baseUrl": ".", "allowJs": true, "skipLibCheck": true, diff --git a/package.json b/package.json index a8a7397b4..32506ea80 100644 --- a/package.json +++ b/package.json @@ -35,27 +35,22 @@ "build:web": "yarn workspace jan-web build && cpx \"web/out/**\" \"electron/renderer/\"", "build:electron": "yarn workspace jan build", "build:electron:test": "yarn workspace jan build:test", - "build:pull-plugins": "rimraf ./electron/core/pre-install/*.tgz && cd ./electron/core/pre-install && npm pack @janhq/inference-plugin @janhq/monitoring-plugin", - "build:plugins": "rimraf ./electron/core/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./plugins/conversational-json && npm install && npm run postinstall && npm run build:publish\" \"cd ./plugins/inference-plugin && npm install --ignore-scripts && npm run postinstall:dev && npm run build:publish\" \"cd ./plugins/model-plugin && npm install && npm run postinstall && npm run build:publish\" \"cd ./plugins/monitoring-plugin && npm install && npm run postinstall && npm run build:publish\"", - "build:plugins-windows": "rimraf ./electron/core/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./plugins/conversational-json && npm install && npm run postinstall && npm run build:publish\" \"cd ./plugins/inference-plugin && npm install --ignore-scripts && npm run postinstall:windows && npm run build:publish\" \"cd ./plugins/model-plugin && npm install && npm run postinstall && npm run build:publish\" \"cd ./plugins/monitoring-plugin && npm install && npm run postinstall && npm run build:publish\"", - "build:plugins-linux": "rimraf ./electron/core/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./plugins/conversational-json && npm install && npm run postinstall && npm run build:publish\" \"cd ./plugins/inference-plugin && npm install && npm run postinstall && npm run build:publish\" \"cd ./plugins/model-plugin && npm install && npm run postinstall && npm run build:publish\" \"cd ./plugins/monitoring-plugin && npm install && npm run postinstall && npm run build:publish\"", - "build:plugins-darwin": "rimraf ./electron/core/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./plugins/conversational-json && npm install && npm run postinstall && npm run build:publish\" \"cd ./plugins/inference-plugin && npm install && npm run postinstall && ../../.github/scripts/auto-sign.sh && npm run build:publish\" \"cd ./plugins/model-plugin && npm install && npm run postinstall && npm run build:publish\" \"cd ./plugins/monitoring-plugin && npm install && npm run postinstall && npm run build:publish\"", - "build:plugins-web": "rimraf ./electron/core/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./plugins/conversational-json && npm install && npm run build:deps && npm run postinstall\" \"cd ./plugins/inference-plugin && npm install && npm run postinstall\" \"cd ./plugins/model-plugin && npm install && npm run postinstall\" \"cd ./plugins/monitoring-plugin && npm install && npm run postinstall\" && concurrently --kill-others-on-fail \"cd ./plugins/conversational-json && npm run build:publish\" \"cd ./plugins/inference-plugin && npm run build:publish\" \"cd ./plugins/model-plugin && npm run build:publish\" \"cd ./plugins/monitoring-plugin && npm run build:publish\"", - "build": "yarn build:web && yarn build:electron", + "build:plugins": "rimraf ./electron/core/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./plugins/conversational-json && npm install && npm run build:publish\" \"cd ./plugins/inference-plugin && npm install && npm run build:publish\" \"cd ./plugins/model-plugin && npm install && npm run build:publish\" \"cd ./plugins/monitoring-plugin && npm install && npm run build:publish\"", + "build:plugins-win32": "rimraf ./electron/core/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./plugins/conversational-json && npm install && npm run build:publish\" \"cd ./plugins/inference-plugin && npm install && npm run build:publish-win32\" \"cd ./plugins/model-plugin && npm install && npm run build:publish\" \"cd ./plugins/monitoring-plugin && npm install && npm run build:publish\"", + "build:plugins-linux": "rimraf ./electron/core/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./plugins/conversational-json && npm install && npm run build:publish\" \"cd ./plugins/inference-plugin && npm install && npm run build:publish-linux\" \"cd ./plugins/model-plugin && npm install && npm run build:publish\" \"cd ./plugins/monitoring-plugin && npm install && npm run build:publish\"", + "build:plugins-darwin": "rimraf ./electron/core/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./plugins/conversational-json && npm install && npm run build:publish\" \"cd ./plugins/inference-plugin && npm install && npm run build:publish-darwin\" \"cd ./plugins/model-plugin && npm install && npm run build:publish\" \"cd ./plugins/monitoring-plugin && npm install && npm run build:publish\"", "build:test": "yarn build:web && yarn build:electron:test", "build:test-darwin": "yarn build:web && yarn workspace jan build:test-darwin", "build:test-win32": "yarn build:web && yarn workspace jan build:test-win32", "build:test-linux": "yarn build:web && yarn workspace jan build:test-linux", + "build": "yarn build:web && yarn build:electron", "build:darwin": "yarn build:web && yarn workspace jan build:darwin", "build:win32": "yarn build:web && yarn workspace jan build:win32", "build:linux": "yarn build:web && yarn workspace jan build:linux", "build:publish": "yarn build:web && yarn workspace jan build:publish", "build:publish-darwin": "yarn build:web && yarn workspace jan build:publish-darwin", "build:publish-win32": "yarn build:web && yarn workspace jan build:publish-win32", - "build:publish-linux": "yarn build:web && yarn workspace jan build:publish-linux", - "build:web-plugins": "yarn build:web && yarn build:plugins-web && mkdir -p \"./web/out/plugins/conversational-json\" && cp \"./plugins/conversational-json/dist/index.js\" \"./web/out/plugins/conversational-json\" && mkdir -p \"./web/out/plugins/inference-plugin\" && cp \"./plugins/inference-plugin/dist/index.js\" \"./web/out/plugins/inference-plugin\" && mkdir -p \"./web/out/plugins/model-plugin\" && cp \"./plugins/model-plugin/dist/index.js\" \"./web/out/plugins/model-plugin\" && mkdir -p \"./web/out/plugins/monitoring-plugin\" && cp \"./plugins/monitoring-plugin/dist/index.js\" \"./web/out/plugins/monitoring-plugin\"", - "server:prod": "yarn workspace server build && yarn build:web-plugins && cpx \"web/out/**\" \"server/build/renderer/\" && mkdir -p ./server/build/@janhq && cp -r ./plugins/* ./server/build/@janhq", - "start:server": "yarn server:prod && node server/build/main.js" + "build:publish-linux": "yarn build:web && yarn workspace jan build:publish-linux" }, "devDependencies": { "concurrently": "^8.2.1", diff --git a/plugins/conversational-json/package.json b/plugins/conversational-json/package.json index db2978aab..198756f82 100644 --- a/plugins/conversational-json/package.json +++ b/plugins/conversational-json/package.json @@ -10,9 +10,7 @@ ], "scripts": { "build": "tsc -b . && webpack --config webpack.config.js", - "postinstall": "rimraf *.tgz --glob && npm run build", - "build:publish": "npm pack && cpx *.tgz ../../electron/core/pre-install", - "build:debug": "rimraf *.tgz --glob && npm run build && npm pack" + "build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../electron/core/pre-install" }, "exports": { ".": "./dist/index.js", diff --git a/plugins/conversational-plugin/package.json b/plugins/conversational-plugin/package.json index 3566ef04b..e7a29f9e7 100644 --- a/plugins/conversational-plugin/package.json +++ b/plugins/conversational-plugin/package.json @@ -11,8 +11,7 @@ ], "scripts": { "build": "tsc -b . && webpack --config webpack.config.js", - "postinstall": "rimraf *.tgz --glob && npm run build", - "build:publish": "npm pack && cpx *.tgz ../../electron/core/pre-install" + "build:publish": "rimraf *.tgz --glob npm run build && && npm pack && cpx *.tgz ../../electron/core/pre-install" }, "exports": { ".": "./dist/index.js", diff --git a/plugins/inference-plugin/nitro/version.txt b/plugins/inference-plugin/nitro/version.txt index a19223320..84aa3a7dd 100644 --- a/plugins/inference-plugin/nitro/version.txt +++ b/plugins/inference-plugin/nitro/version.txt @@ -1 +1 @@ -0.1.6 \ No newline at end of file +0.1.8 \ No newline at end of file diff --git a/plugins/inference-plugin/package.json b/plugins/inference-plugin/package.json index 78f803795..5c52d13d0 100644 --- a/plugins/inference-plugin/package.json +++ b/plugins/inference-plugin/package.json @@ -15,14 +15,16 @@ "build": "tsc -b . && webpack --config webpack.config.js", "downloadnitro:linux-cpu": "NITRO_VERSION=$(cat ./nitro/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-linux-amd64.zip -e --strip 1 -o ./nitro/linux-cpu && chmod +x ./nitro/linux-cpu/nitro && chmod +x ./nitro/linux-start.sh ", "downloadnitro:linux-cuda": "NITRO_VERSION=$(cat ./nitro/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-linux-amd64-cuda.zip -e --strip 1 -o ./nitro/linux-cuda && chmod +x ./nitro/linux-cuda/nitro && chmod +x ./nitro/linux-start.sh", - "downloadnitro:mac-arm64": "NITRO_VERSION=$(cat ./nitro/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-mac-arm64.zip -e --strip 1 -o ./nitro/mac-arm64 && chmod +x ./nitro/mac-arm64/nitro", - "downloadnitro:mac-x64": "NITRO_VERSION=$(cat ./nitro/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-mac-amd64.zip -e --strip 1 -o ./nitro/mac-x64 && chmod +x ./nitro/mac-x64/nitro", - "downloadnitro:win-cpu": "download https://github.com/janhq/nitro/releases/download/v%NITRO_VERSION%/nitro-%NITRO_VERSION%-win-amd64.zip -e --strip 1 -o ./nitro/win-cpu", - "downloadnitro:win-cuda": "download https://github.com/janhq/nitro/releases/download/v%NITRO_VERSION%/nitro-%NITRO_VERSION%-win-amd64-cuda.zip -e --strip 1 -o ./nitro/win-cuda", - "postinstall": "rimraf *.tgz --glob && npm run build && npm run downloadnitro:linux-cpu && npm run downloadnitro:linux-cuda && npm run downloadnitro:mac-arm64 && npm run downloadnitro:mac-x64 && rimraf dist/nitro/* && cpx \"nitro/**\" \"dist/nitro\"", - "postinstall:dev": "rimraf *.tgz --glob && npm run build && npm run downloadnitro:mac-arm64 && npm run downloadnitro:mac-x64 && rimraf dist/nitro/* && cpx \"nitro/**\" \"dist/nitro\"", - "postinstall:windows": "rimraf *.tgz --glob && npm run build && npm run downloadnitro:win-cpu && npm run downloadnitro:win-cuda && rimraf dist/nitro/* && cpx \"nitro/**\" \"dist/nitro\"", - "build:publish": "npm pack && cpx *.tgz ../../electron/core/pre-install" + "downloadnitro:darwin-arm64": "NITRO_VERSION=$(cat ./nitro/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-mac-arm64.zip -e --strip 1 -o ./nitro/mac-arm64 && chmod +x ./nitro/mac-arm64/nitro", + "downloadnitro:darwin-x64": "NITRO_VERSION=$(cat ./nitro/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-mac-amd64.zip -e --strip 1 -o ./nitro/mac-x64 && chmod +x ./nitro/mac-x64/nitro", + "downloadnitro:win32-cpu": "download https://github.com/janhq/nitro/releases/download/v%NITRO_VERSION%/nitro-%NITRO_VERSION%-win-amd64.zip -e --strip 1 -o ./nitro/win-cpu", + "downloadnitro:win32-cuda": "download https://github.com/janhq/nitro/releases/download/v%NITRO_VERSION%/nitro-%NITRO_VERSION%-win-amd64-cuda.zip -e --strip 1 -o ./nitro/win-cuda", + "downloadnitro:all": "npm run downloadnitro:darwin-arm64 && npm run downloadnitro:darwin-x64 && downloadnitro:win32-cpu && npm run downloadnitro:win32-cuda && npm run downloadnitro:linux-cpu && npm run downloadnitro:linux-cuda", + "build:publish": "rimraf *.tgz --glob && npm run build && npm run downloadnitro:darwin-arm64 && npm run downloadnitro:darwin-x64 && cpx \"nitro/**\" \"dist/nitro\" && npm pack && cpx *.tgz ../../electron/core/pre-install", + "build:publish-darwin": "rimraf *.tgz --glob && npm run build && npm run downloadnitro:darwin-arm64 && npm run downloadnitro:darwin-x64 && ../../.github/scripts/auto-sign.sh && cpx \"nitro/**\" \"dist/nitro\" && npm pack && cpx *.tgz ../../electron/core/pre-install", + "build:publish-win32": "rimraf *.tgz --glob && npm run build && npm run downloadnitro:win32-cpu && npm run downloadnitro:win32-cuda && cpx \"nitro/**\" \"dist/nitro\" && npm pack && cpx *.tgz ../../electron/core/pre-install", + "build:publish-linux": "rimraf *.tgz --glob && npm run build && npm run downloadnitro:linux-cpu && npm run downloadnitro:linux-cuda && cpx \"nitro/**\" \"dist/nitro\" && npm pack && cpx *.tgz ../../electron/core/pre-install", + "build:publish-all": "rimraf *.tgz --glob && npm run build && npm run downloadnitro:all && cpx \"nitro/**\" \"dist/nitro\" && npm pack && cpx *.tgz ../../electron/core/pre-install" }, "exports": { ".": "./dist/index.js", @@ -37,10 +39,13 @@ "dependencies": { "@janhq/core": "file:../../core", "download-cli": "^1.1.1", + "fetch-retry": "^5.0.6", "kill-port": "^2.0.1", + "path-browserify": "^1.0.1", "rxjs": "^7.8.1", "tcp-port-used": "^1.0.2", - "ts-loader": "^9.5.0" + "ts-loader": "^9.5.0", + "ulid": "^2.3.0" }, "engines": { "node": ">=18.0.0" @@ -52,6 +57,7 @@ ], "bundleDependencies": [ "tcp-port-used", - "kill-port" + "kill-port", + "fetch-retry" ] } diff --git a/plugins/inference-plugin/src/helpers/message.ts b/plugins/inference-plugin/src/helpers/message.ts deleted file mode 100644 index 0913f9403..000000000 --- a/plugins/inference-plugin/src/helpers/message.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const generateMessageId = () => { - return `m-${Date.now()}` -} diff --git a/plugins/inference-plugin/src/index.ts b/plugins/inference-plugin/src/index.ts index f82ceec88..ebd44657f 100644 --- a/plugins/inference-plugin/src/index.ts +++ b/plugins/inference-plugin/src/index.ts @@ -16,7 +16,9 @@ import { } from "@janhq/core"; import { InferencePlugin } from "@janhq/core/lib/plugins"; import { requestInference } from "./helpers/sse"; -import { generateMessageId } from "./helpers/message"; +import { ulid } from "ulid"; +import { join } from "path"; +import { appDataPath } from "@janhq/core"; /** * A class that implements the InferencePlugin interface from the @janhq/core package. @@ -48,18 +50,19 @@ export default class JanInferencePlugin implements InferencePlugin { /** * Initializes the model with the specified file name. - * @param {string} modelFileName - The name of the model file. + * @param {string} modelFileName - The file name of the model file. * @returns {Promise} A promise that resolves when the model is initialized. */ - initModel(modelFileName: string): Promise { - return executeOnMain(MODULE, "initModel", modelFileName); + async initModel(modelFileName: string): Promise { + const appPath = await appDataPath(); + return executeOnMain(MODULE, "initModel", join(appPath, modelFileName)); } /** * Stops the model. * @returns {Promise} A promise that resolves when the model is stopped. */ - stopModel(): Promise { + async stopModel(): Promise { return executeOnMain(MODULE, "killSubprocess"); } @@ -112,13 +115,13 @@ export default class JanInferencePlugin implements InferencePlugin { content: data.message, }, ]; - const recentMessages = await (data.history ?? prompts); + const recentMessages = data.history ?? prompts; const message = { ...data, message: "", user: "assistant", createdAt: new Date().toISOString(), - _id: generateMessageId(), + _id: ulid(), }; events.emit(EventName.OnNewMessageResponse, message); diff --git a/plugins/inference-plugin/src/module.ts b/plugins/inference-plugin/src/module.ts index ed3be094d..ba6afdf90 100644 --- a/plugins/inference-plugin/src/module.ts +++ b/plugins/inference-plugin/src/module.ts @@ -1,119 +1,251 @@ -const path = require("path"); -const { app } = require("electron"); -const { spawn } = require("child_process"); const fs = require("fs"); -const tcpPortUsed = require("tcp-port-used"); const kill = require("kill-port"); +const path = require("path"); +const { spawn } = require("child_process"); +const tcpPortUsed = require("tcp-port-used"); +const fetchRetry = require("fetch-retry")(global.fetch); +// 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_UNLOAD_MODEL_URL = `${NITRO_HTTP_SERVER_URL}/inferences/llamacpp/unloadModel`; +const NITRO_HTTP_VALIDATE_MODEL_URL = `${NITRO_HTTP_SERVER_URL}/inferences/llamacpp/modelstatus`; + +// The subprocess instance for Nitro let subprocess = null; +let currentModelFile = null; + +/** + * The response from the initModel function. + * @property error - An error message if the model fails to load. + */ +interface InitModelResponse { + error?: any; +} + +/** + * Initializes a Nitro subprocess to load a machine learning model. + * @param modelFile - The name of the machine learning model file. + * @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? + */ +function initModel(modelFile: string): Promise { + // 1. Check if the model file exists + currentModelFile = modelFile; -const initModel = (fileName) => { return ( - new Promise(async (resolve, reject) => { - if (!fileName) { - reject("Model not found, please download again."); - } - resolve(fileName); - }) - // Spawn Nitro subprocess to load model - .then(() => { - return tcpPortUsed.check(PORT, "127.0.0.1").then((inUse) => { - if (!inUse) { - let binaryFolder = path.join(__dirname, "nitro"); // Current directory by default - let binaryName; - - if (process.platform === "win32") { - // Todo: Need to check for CUDA support to switch between CUDA and non-CUDA binaries - binaryName = "win-start.bat"; - } else if (process.platform === "darwin") { - // Mac OS platform - if (process.arch === "arm64") { - binaryFolder = path.join(binaryFolder, "mac-arm64") - } else { - binaryFolder = path.join(binaryFolder, "mac-x64") - } - binaryName = "nitro" - } else { - // Linux - // Todo: Need to check for CUDA support to switch between CUDA and non-CUDA binaries - binaryName = "linux-start.sh"; // For other platforms - } - - const binaryPath = path.join(binaryFolder, binaryName); - - // Execute the binary - subprocess = spawn(binaryPath,["0.0.0.0", PORT], { cwd: binaryFolder }); - - // Handle subprocess output - subprocess.stdout.on("data", (data) => { - console.log(`stdout: ${data}`); - }); - - subprocess.stderr.on("data", (data) => { - console.error(`stderr: ${data}`); - }); - - subprocess.on("close", (code) => { - console.log(`child process exited with code ${code}`); - subprocess = null; - }); - } - }); - }) + // 1. Check if the port is used, if used, attempt to unload model / kill nitro process + validateModelVersion() + .then(checkAndUnloadNitro) + // 2. Spawn the Nitro subprocess + .then(spawnNitroProcess) + // 3. Wait until the port is used (Nitro http server is up) .then(() => tcpPortUsed.waitUntilUsed(PORT, 300, 30000)) - .then(() => { - const llama_model_path = path.join(appPath(), fileName); - - const config = { - llama_model_path, - ctx_len: 2048, - ngl: 100, - embedding: true, // Always enable embedding mode on - }; - - // Load model config - return fetch(`http://127.0.0.1:${PORT}/inferences/llamacpp/loadmodel`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(config), - }); - }) - .then((res) => { - if (res.ok) { - return {}; - } - throw new Error("Nitro: Model failed to load."); - }) + // 4. Load the model into the Nitro subprocess (HTTP POST request) + .then(loadLLMModel) + // 5. Check if the model is loaded successfully + .then(validateModelStatus) .catch((err) => { return { error: err }; }) ); -}; - -function dispose() { - killSubprocess(); - // clean other registered resources here } -function killSubprocess() { +/** + * 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(): Promise { + const config = { + llama_model_path: currentModelFile, + ctx_len: 2048, + ngl: 100, + embedding: false, // Always enable embedding mode on + }; + + // Load model config + return fetchRetry(NITRO_HTTP_LOAD_MODEL_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(config), + retries: 3, + retryDelay: 500, + }).catch((err) => { + console.error(err); + // Fetch error, Nitro server might not started properly + throw new Error("Model loading failed."); + }); +} + +/** + * 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 is not loaded successfully" }; + }) + .catch((err) => { + return { error: `Model is not loaded successfully. ${err.message}` }; + }); +} + +/** + * 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. + */ +function killSubprocess(): Promise { if (subprocess) { subprocess.kill(); subprocess = null; console.log("Subprocess terminated."); } else { - kill(PORT, "tcp").then(console.log).catch(console.log); - console.error("No subprocess is currently running."); + return kill(PORT, "tcp").then(console.log).catch(console.log); } } -function appPath() { - if (app) { - return app.getPath("userData"); +/** + * Check port is used or not, if used, attempt to unload model + * If unload failed, kill the port + */ +function checkAndUnloadNitro() { + return tcpPortUsed.check(PORT, LOCAL_HOST).then((inUse) => { + // If inUse - try unload or kill process, otherwise do nothing + if (inUse) { + // Attempt to unload model + return fetch(NITRO_HTTP_UNLOAD_MODEL_URL, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }).catch((err) => { + console.log(err); + // Fallback to kill the port + return killSubprocess(); + }); + } + }); +} + +/** + * Look for the Nitro binary and execute it + * Using child-process to spawn the process + * Should run exactly platform specified Nitro binary version + */ +function spawnNitroProcess() { + let binaryFolder = path.join(__dirname, "nitro"); // Current directory by default + let binaryName; + + if (process.platform === "win32") { + // Todo: Need to check for CUDA support to switch between CUDA and non-CUDA binaries + binaryName = "win-start.bat"; + } else if (process.platform === "darwin") { + // Mac OS platform + if (process.arch === "arm64") { + binaryFolder = path.join(binaryFolder, "mac-arm64"); + } else { + binaryFolder = path.join(binaryFolder, "mac-x64"); + } + binaryName = "nitro"; + } else { + // Linux + // Todo: Need to check for CUDA support to switch between CUDA and non-CUDA binaries + binaryName = "linux-start.sh"; // For other platforms } - return process.env.APPDATA || (process.platform == 'darwin' ? process.env.HOME + '/Library/Preferences' : process.env.HOME + "/.local/share"); + + const binaryPath = path.join(binaryFolder, binaryName); + + // Execute the binary + subprocess = spawn(binaryPath, [1, "0.0.0.0", PORT], { + cwd: binaryFolder, + }); + + // Handle subprocess output + subprocess.stdout.on("data", (data) => { + console.log(`stdout: ${data}`); + }); + + subprocess.stderr.on("data", (data) => { + console.error(`stderr: ${data}`); + }); + + subprocess.on("close", (code) => { + console.log(`child process exited with code ${code}`); + subprocess = null; + }); +} + +/** + * Validate the model version, if it is GGUFv1, reject the promise + * @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 validateModelVersion(): Promise { + // Read the file + return new Promise((resolve, reject) => { + fs.open(currentModelFile, "r", (err, fd) => { + if (err) { + console.error(err.message); + return; + } + + // Buffer to store the byte + const buffer = Buffer.alloc(1); + + // Model version will be the 5th byte of the file + fs.read(fd, buffer, 0, 1, 4, (err, bytesRead, buffer) => { + if (err) { + console.error(err.message); + } else { + // Interpret the byte as ASCII + if (buffer[0] === 0x01) { + // This is GGUFv1, which is deprecated + reject("GGUFv1 model is deprecated, please try another model."); + } + } + + // Close the file descriptor + fs.close(fd, (err) => { + if (err) console.error(err.message); + }); + resolve(); + }); + }); + }); +} + +/** + * Cleans up any registered resources. + * Its module specific function, should be called when application is closed + */ +function dispose() { + // clean other registered resources here + killSubprocess(); } module.exports = { diff --git a/plugins/inference-plugin/webpack.config.js b/plugins/inference-plugin/webpack.config.js index f6f32a263..45be62271 100644 --- a/plugins/inference-plugin/webpack.config.js +++ b/plugins/inference-plugin/webpack.config.js @@ -18,7 +18,10 @@ module.exports = { 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"), + INFERENCE_URL: JSON.stringify( + process.env.INFERENCE_URL || + "http://127.0.0.1:3928/inferences/llamacpp/chat_completion" + ), }), ], output: { @@ -28,6 +31,9 @@ module.exports = { }, resolve: { extensions: [".ts", ".js"], + fallback: { + path: require.resolve("path-browserify"), + }, }, optimization: { minimize: false, diff --git a/plugins/model-plugin/package.json b/plugins/model-plugin/package.json index 1ee8e5496..171f0a4e9 100644 --- a/plugins/model-plugin/package.json +++ b/plugins/model-plugin/package.json @@ -14,8 +14,7 @@ ], "scripts": { "build": "tsc -b . && webpack --config webpack.config.js", - "postinstall": "rimraf *.tgz --glob && npm run build", - "build:publish": "npm pack && cpx *.tgz ../../electron/core/pre-install" + "build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../electron/core/pre-install" }, "devDependencies": { "cpx": "^1.5.0", diff --git a/plugins/monitoring-plugin/package.json b/plugins/monitoring-plugin/package.json index a60c910c6..4054b8b4d 100644 --- a/plugins/monitoring-plugin/package.json +++ b/plugins/monitoring-plugin/package.json @@ -14,8 +14,7 @@ ], "scripts": { "build": "tsc -b . && webpack --config webpack.config.js", - "postinstall": "rimraf *.tgz --glob && npm run build", - "build:publish": "npm pack && cpx *.tgz ../../electron/core/pre-install" + "build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../electron/core/pre-install" }, "devDependencies": { "rimraf": "^3.0.2", diff --git a/server/main.ts b/server/main.ts deleted file mode 100644 index b98b4097c..000000000 --- a/server/main.ts +++ /dev/null @@ -1,179 +0,0 @@ -import express, { Express, Request, Response, NextFunction } from 'express' -import cors from "cors"; -import { resolve } from "path"; -const fs = require("fs"); -const progress = require("request-progress"); -const path = require("path"); -const request = require("request"); - -// Create app dir -const userDataPath = appPath(); -if (!fs.existsSync(userDataPath)) fs.mkdirSync(userDataPath); - -interface ProgressState { - percent?: number; - speed?: number; - size?: { - total: number; - transferred: number; - }; - time?: { - elapsed: number; - remaining: number; - }; - success?: boolean | undefined; - fileName: string; -} - -const options: cors.CorsOptions = { origin: "*" }; -const requiredModules: Record = {}; -const port = process.env.PORT || 4000; -const dataDir = __dirname; -type DownloadProgress = Record; -const downloadProgress: DownloadProgress = {}; -const app: Express = express() -app.use(express.static(dataDir + '/renderer')) -app.use(cors(options)) -app.use(express.json()); - -/** - * Execute a plugin module function via API call - * - * @param modulePath path to module name to import - * @param method function name to execute. The methods "deleteFile" and "downloadFile" will call the server function {@link deleteFile}, {@link downloadFile} instead of the plugin function. - * @param args arguments to pass to the function - * @returns Promise - * - */ -app.post('/api/v1/invokeFunction', (req: Request, res: Response, next: NextFunction): void => { - const method = req.body["method"]; - const args = req.body["args"]; - switch (method) { - case "deleteFile": - deleteFile(args).then(() => res.json(Object())).catch((err: any) => next(err)); - break; - case "downloadFile": - downloadFile(args.downloadUrl, args.fileName).then(() => res.json(Object())).catch((err: any) => next(err)); - break; - default: - const result = invokeFunction(req.body["modulePath"], method, args) - if (typeof result === "undefined") { - res.json(Object()) - } else { - result?.then((result: any) => { - res.json(result) - }).catch((err: any) => next(err)); - } - } -}); - -app.post('/api/v1/downloadProgress', (req: Request, res: Response): void => { - const fileName = req.body["fileName"]; - if (fileName && downloadProgress[fileName]) { - res.json(downloadProgress[fileName]) - return; - } else { - const obj = downloadingFile(); - if (obj) { - res.json(obj) - return; - } - } - res.json(Object()); -}); - -app.use((err: Error, req: Request, res: Response, next: NextFunction): void => { - console.error("ErrorHandler", req.url, req.body, err); - res.status(500); - res.json({ error: err?.message ?? "Internal Server Error" }) -}); - -app.listen(port, () => console.log(`Application is running on port ${port}`)); - - -async function invokeFunction(modulePath: string, method: string, args: any): Promise { - console.log(modulePath, method, args); - const module = require(/* webpackIgnore: true */ path.join( - dataDir, - "", - modulePath - )); - requiredModules[modulePath] = module; - if (typeof module[method] === "function") { - return module[method](...args); - } else { - return Promise.resolve(); - } -} - -function downloadModel(downloadUrl: string, fileName: string): void { - const userDataPath = appPath(); - const destination = resolve(userDataPath, fileName); - console.log("Download file", fileName, "to", destination); - progress(request(downloadUrl), {}) - .on("progress", function (state: any) { - downloadProgress[fileName] = { - ...state, - fileName, - success: undefined - }; - console.log("downloading file", fileName, (state.percent * 100).toFixed(2) + '%'); - }) - .on("error", function (err: Error) { - downloadProgress[fileName] = { - ...downloadProgress[fileName], - success: false, - fileName: fileName, - }; - }) - .on("end", function () { - downloadProgress[fileName] = { - success: true, - fileName: fileName, - }; - }) - .pipe(fs.createWriteStream(destination)); -} - -function deleteFile(filePath: string): Promise { - const userDataPath = appPath(); - const fullPath = resolve(userDataPath, filePath); - return new Promise((resolve, reject) => { - fs.unlink(fullPath, function (err: any) { - if (err && err.code === "ENOENT") { - reject(Error(`File does not exist: ${err}`)); - } else if (err) { - reject(Error(`File delete error: ${err}`)); - } else { - console.log(`Delete file ${filePath} from ${fullPath}`) - resolve(); - } - }); - }) -} - -function downloadingFile(): ProgressState | undefined { - const obj = Object.values(downloadProgress).find(obj => obj && typeof obj.success === "undefined") - return obj -} - - -async function downloadFile(downloadUrl: string, fileName: string): Promise { - return new Promise((resolve, reject) => { - const obj = downloadingFile(); - if (obj) { - reject(Error(obj.fileName + " is being downloaded!")) - return; - }; - (async () => { - downloadModel(downloadUrl, fileName); - })().catch(e => { - console.error("downloadModel", fileName, e); - }); - resolve(); - }); -} - -function appPath(): string { - return process.env.APPDATA || (process.platform == 'darwin' ? process.env.HOME + '/Library/Preferences' : process.env.HOME + "/.local/share") -} \ No newline at end of file diff --git a/server/nodemon.json b/server/nodemon.json deleted file mode 100644 index fa415fa52..000000000 --- a/server/nodemon.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "watch": [ - "main.ts" - ] -} \ No newline at end of file diff --git a/server/package.json b/server/package.json deleted file mode 100644 index 895cae2b9..000000000 --- a/server/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "server", - "version": "1.0.0", - "main": "index.js", - "license": "MIT", - "dependencies": { - "cors": "^2.8.5", - "electron": "^26.2.1", - "express": "^4.18.2", - "request": "^2.88.2", - "request-progress": "^3.0.0" - }, - "devDependencies": { - "@types/cors": "^2.8.14", - "@types/express": "^4.17.18", - "@types/node": "^20.8.2", - "nodemon": "^3.0.1", - "ts-node": "^10.9.1", - "typescript": "^5.2.2" - }, - "scripts": { - "build": "tsc --project ./", - "dev": "nodemon main.ts", - "prod": "node build/main.js" - } -} diff --git a/server/tsconfig.json b/server/tsconfig.json deleted file mode 100644 index a79afcdfe..000000000 --- a/server/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "compilerOptions": { - "target": "es5", - "module": "commonjs", - "noImplicitAny": true, - "sourceMap": true, - "strict": true, - "outDir": "./build", - "rootDir": "./", - "noEmitOnError": true, - "baseUrl": ".", - "allowJs": true, - "paths": { "*": ["node_modules/*"] }, - "typeRoots": ["node_modules/@types"], - "esModuleInterop": true - }, - "include": ["./**/*.ts"], - "exclude": ["core", "build", "dist", "tests"] - } \ No newline at end of file diff --git a/web/app/layout.tsx b/web/app/layout.tsx index fd100975f..38dee2056 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -2,18 +2,10 @@ import { PropsWithChildren } from 'react' import { Metadata } from 'next' -// import { Inter } from 'next/font/google' - import Providers from '@/containers/Providers' import '@/styles/main.scss' -// const inter = Inter({ -// subsets: ['latin'], -// display: 'swap', -// variable: '--font-inter', -// }) - export const metadata: Metadata = { title: 'Jan', description: diff --git a/web/containers/Providers/EventHandler.tsx b/web/containers/Providers/EventHandler.tsx index f3a603cbf..f1b943427 100644 --- a/web/containers/Providers/EventHandler.tsx +++ b/web/containers/Providers/EventHandler.tsx @@ -24,6 +24,7 @@ import { import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom' import { MessageStatus, toChatMessage } from '@/models/ChatMessage' import { pluginManager } from '@/plugin' +import { ChatMessage, Conversation } from '@/types/chatMessage' let currentConversation: Conversation | undefined = undefined diff --git a/web/helpers/atoms/ChatMessage.atom.ts b/web/helpers/atoms/ChatMessage.atom.ts index 079568a9a..c45808288 100644 --- a/web/helpers/atoms/ChatMessage.atom.ts +++ b/web/helpers/atoms/ChatMessage.atom.ts @@ -2,7 +2,7 @@ import { atom } from 'jotai' import { getActiveConvoIdAtom } from './Conversation.atom' -import { MessageStatus } from '@/models/ChatMessage' +import { ChatMessage, MessageStatus } from '@/models/ChatMessage' /** * Stores all chat messages for all conversations diff --git a/web/helpers/atoms/Conversation.atom.ts b/web/helpers/atoms/Conversation.atom.ts index f15b60581..3a661d385 100644 --- a/web/helpers/atoms/Conversation.atom.ts +++ b/web/helpers/atoms/Conversation.atom.ts @@ -1,3 +1,4 @@ +import { Conversation, ConversationState } from '@/types/chatMessage' import { atom } from 'jotai' // import { MainViewState, setMainViewStateAtom } from './MainView.atom' diff --git a/web/hooks/useActiveModel.ts b/web/hooks/useActiveModel.ts index 5d317bff5..a9d517fbf 100644 --- a/web/hooks/useActiveModel.ts +++ b/web/hooks/useActiveModel.ts @@ -45,14 +45,14 @@ export function useActiveModel() { const res = await initModel(`models/${modelId}`) if (res?.error) { - const errorMessage = `Failed to init model: ${res.error}` - console.error(errorMessage) + const errorMessage = `${res.error}` alert(errorMessage) setStateModel(() => ({ state: 'start', loading: false, model: modelId, })) + setActiveModel(undefined) } else { console.debug( `Init model ${modelId} successfully!, take ${ diff --git a/web/hooks/useCreateConversation.ts b/web/hooks/useCreateConversation.ts index 0bb03582c..755876c0a 100644 --- a/web/hooks/useCreateConversation.ts +++ b/web/hooks/useCreateConversation.ts @@ -12,6 +12,7 @@ import { addNewConversationStateAtom, } from '@/helpers/atoms/Conversation.atom' import { pluginManager } from '@/plugin' +import { Conversation } from '@/types/chatMessage' export const useCreateConversation = () => { const [userConversations, setUserConversations] = useAtom( diff --git a/web/hooks/useGetInputState.ts b/web/hooks/useGetInputState.ts index e2b654e8c..26a1c83d7 100644 --- a/web/hooks/useGetInputState.ts +++ b/web/hooks/useGetInputState.ts @@ -7,6 +7,7 @@ import { useActiveModel } from './useActiveModel' import { useGetDownloadedModels } from './useGetDownloadedModels' import { currentConversationAtom } from '@/helpers/atoms/Conversation.atom' +import { Conversation } from '@/types/chatMessage' export default function useGetInputState() { const [inputState, setInputState] = useState('loading') diff --git a/web/hooks/useGetUserConversations.ts b/web/hooks/useGetUserConversations.ts index 579b9ca5e..35e659ee2 100644 --- a/web/hooks/useGetUserConversations.ts +++ b/web/hooks/useGetUserConversations.ts @@ -10,6 +10,7 @@ import { } from '@/helpers/atoms/Conversation.atom' import { toChatMessage } from '@/models/ChatMessage' import { pluginManager } from '@/plugin/PluginManager' +import { ChatMessage, ConversationState } from '@/types/chatMessage' const useGetUserConversations = () => { const setConversationStates = useSetAtom(conversationStatesAtom) diff --git a/web/hooks/useSendChatMessage.ts b/web/hooks/useSendChatMessage.ts index a06c67b4c..256c701ef 100644 --- a/web/hooks/useSendChatMessage.ts +++ b/web/hooks/useSendChatMessage.ts @@ -12,9 +12,7 @@ import { Message } from '@janhq/core/lib/types' import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { currentPromptAtom } from '@/containers/Providers/Jotai' - -import { generateMessageId } from '@/utils/message' - +import { ulid } from 'ulid' import { addNewMessageAtom, getCurrentChatMessagesAtom, @@ -24,9 +22,10 @@ import { updateConversationAtom, updateConversationWaitingForResponseAtom, } from '@/helpers/atoms/Conversation.atom' -import { toChatMessage } from '@/models/ChatMessage' +import { MessageSenderType, toChatMessage } from '@/models/ChatMessage' import { pluginManager } from '@/plugin/PluginManager' +import { ChatMessage, Conversation } from '@/types/chatMessage' export default function useSendChatMessage() { const currentConvo = useAtomValue(currentConversationAtom) @@ -73,16 +72,13 @@ export default function useSendChatMessage() { ...updatedConv, name: updatedConv.name ?? '', message: updatedConv.lastMessage ?? '', - messages: currentMessages.map((e: ChatMessage) => { - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - _id: e.id, - message: e.text, - user: e.senderUid, - updatedAt: new Date(e.createdAt).toISOString(), - createdAt: new Date(e.createdAt).toISOString(), - } - }), + messages: currentMessages.map((e: ChatMessage) => ({ + _id: e.id, + message: e.text, + user: e.senderUid, + updatedAt: new Date(e.createdAt).toISOString(), + createdAt: new Date(e.createdAt).toISOString(), + })), }) } }, 1000) @@ -98,25 +94,23 @@ export default function useSendChatMessage() { const prompt = currentPrompt.trim() const messageHistory: MessageHistory[] = currentMessages - .map((msg) => { - return { - role: msg.senderUid === 'user' ? 'user' : 'assistant', - content: msg.text ?? '', - } - }) + .map((msg) => ({ + role: msg.senderUid, + content: msg.text ?? '', + })) .reverse() .concat([ { - role: 'user', + role: MessageSenderType.User, content: prompt, } as MessageHistory, ]) const newMessage: NewMessageRequest = { // eslint-disable-next-line @typescript-eslint/naming-convention - _id: generateMessageId(), + _id: ulid(), conversationId: convoId, message: prompt, - user: 'user', + user: MessageSenderType.User, createdAt: new Date().toISOString(), history: messageHistory, } @@ -124,6 +118,11 @@ export default function useSendChatMessage() { const newChatMessage = toChatMessage(newMessage) addNewMessage(newChatMessage) + // delay randomly from 50 - 100ms + // to prevent duplicate message id + const delay = Math.floor(Math.random() * 50) + 50 + await new Promise((resolve) => setTimeout(resolve, delay)) + events.emit(EventName.OnNewMessageRequest, newMessage) if (!currentConvo?.summary && currentConvo) { const updatedConv: Conversation = { diff --git a/web/package.json b/web/package.json index 6d0347646..59d142dbf 100644 --- a/web/package.json +++ b/web/package.json @@ -38,6 +38,7 @@ "tailwind-merge": "^2.0.0", "tailwindcss": "3.3.5", "typescript": "5.2.2", + "ulid": "^2.3.0", "uuid": "^9.0.1", "zod": "^3.22.4" }, diff --git a/web/screens/Chat/ChatItem/index.tsx b/web/screens/Chat/ChatItem/index.tsx index 6e38230a3..6b4f0b3f8 100644 --- a/web/screens/Chat/ChatItem/index.tsx +++ b/web/screens/Chat/ChatItem/index.tsx @@ -1,6 +1,7 @@ import React, { forwardRef } from 'react' import SimpleTextMessage from '../SimpleTextMessage' +import { ChatMessage } from '@/types/chatMessage' type Props = { message: ChatMessage @@ -8,20 +9,18 @@ type Props = { type Ref = HTMLDivElement -const ChatItem = forwardRef(({ message }, ref) => { - return ( -
- -
- ) -}) +const ChatItem = forwardRef(({ message }, ref) => ( +
+ +
+)) export default ChatItem diff --git a/web/screens/Chat/index.tsx b/web/screens/Chat/index.tsx index 8723545fc..285a2764e 100644 --- a/web/screens/Chat/index.tsx +++ b/web/screens/Chat/index.tsx @@ -56,7 +56,7 @@ const ChatScreen = () => { const conversations = useAtomValue(userConversationsAtom) const isEnableChat = (currentConvo && activeModel) || conversations.length > 0 const [isModelAvailable, setIsModelAvailable] = useState( - downloadedModels.some((x) => x.name !== currentConvo?.name) + downloadedModels.some((x) => x._id === currentConvo?.modelId) ) const textareaRef = useRef(null) @@ -72,9 +72,8 @@ const ChatScreen = () => { useEffect(() => { setIsModelAvailable( - downloadedModels.some((x) => x.name !== currentConvo?.name) + downloadedModels.some((x) => x._id === currentConvo?.modelId) ) - // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentConvo, downloadedModels]) const handleSendMessage = async () => { @@ -131,10 +130,10 @@ const ChatScreen = () => {
- {isModelAvailable && ( + {!isModelAvailable && (