Merge branch 'main' into internal-48

This commit is contained in:
Daniel 2023-11-15 17:30:34 +08:00
commit 2555dae49f
55 changed files with 993 additions and 1114 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <PID>
```
## Contributing
Contributions are welcome! Please read the [CONTRIBUTING.md](CONTRIBUTING.md) file

View File

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

View File

@ -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 <PID>
```
```

View File

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

View File

@ -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(() => {

View File

@ -1,5 +1,5 @@
.custom-toc-title {
font-weight: bold;
margin-bottom: 16px;
margin-top: -20px;
}
font-weight: bold;
margin-bottom: 16px;
margin-top: -20px;
}

View File

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

View File

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

65
electron/handlers/app.ts Normal file
View File

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

View File

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

View File

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

118
electron/handlers/plugin.ts Normal file
View File

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

View File

@ -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";
});
}

View File

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

View File

@ -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<string, any> = {};
const networkRequests: Record<string, any> = {};
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();
}

View File

@ -0,0 +1,24 @@
import { Request } from "request";
/**
* Manages file downloads and network requests.
*/
export class DownloadManager {
public networkRequests: Record<string, any> = {};
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;
}
}

View File

@ -0,0 +1,33 @@
import { dispose } from "../utils/disposable";
/**
* Manages imported modules.
*/
export class ModuleManager {
public requiredModules: Record<string, any> = {};
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 = {};
}
}

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@
"outDir": "./build",
"rootDir": "./",
"noEmitOnError": true,
"esModuleInterop": true,
"baseUrl": ".",
"allowJs": true,
"skipLibCheck": true,

View File

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

View File

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

View File

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

View File

@ -1 +1 @@
0.1.6
0.1.8

View File

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

View File

@ -1,3 +0,0 @@
export const generateMessageId = () => {
return `m-${Date.now()}`
}

View File

@ -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<void>} A promise that resolves when the model is initialized.
*/
initModel(modelFileName: string): Promise<void> {
return executeOnMain(MODULE, "initModel", modelFileName);
async initModel(modelFileName: string): Promise<void> {
const appPath = await appDataPath();
return executeOnMain(MODULE, "initModel", join(appPath, modelFileName));
}
/**
* Stops the model.
* @returns {Promise<void>} A promise that resolves when the model is stopped.
*/
stopModel(): Promise<void> {
async stopModel(): Promise<void> {
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);

View File

@ -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<InitModelResponse> {
// 1. Check if the model file exists
currentModelFile = modelFile;
const initModel = (fileName) => {
return (
new Promise<void>(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<Response> {
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<InitModelResponse>} 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<InitModelResponse> {
// 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<void> {
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<void> {
// 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 = {

View File

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

View File

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

View File

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

View File

@ -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<string, any> = {};
const port = process.env.PORT || 4000;
const dataDir = __dirname;
type DownloadProgress = Record<string, ProgressState>;
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<any>
*
*/
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<any> {
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<void> {
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<void> {
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")
}

View File

@ -1,5 +0,0 @@
{
"watch": [
"main.ts"
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import { Conversation, ConversationState } from '@/types/chatMessage'
import { atom } from 'jotai'
// import { MainViewState, setMainViewStateAtom } from './MainView.atom'

View File

@ -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 ${

View File

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

View File

@ -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<InputType>('loading')

View File

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

View File

@ -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<Message>((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<Message>((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 = {

View File

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

View File

@ -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<Ref, Props>(({ message }, ref) => {
return (
<div ref={ref} className="py-4 even:bg-secondary dark:even:bg-secondary/20">
<SimpleTextMessage
status={message.status}
key={message.id}
avatarUrl={message.senderAvatarUrl}
senderName={message.senderName}
createdAt={message.createdAt}
senderType={message.messageSenderType}
text={message.text}
/>
</div>
)
})
const ChatItem = forwardRef<Ref, Props>(({ message }, ref) => (
<div ref={ref} className="py-4 even:bg-secondary dark:even:bg-secondary/20">
<SimpleTextMessage
status={message.status}
key={message.id}
avatarUrl={message.senderAvatarUrl}
senderName={message.senderName}
createdAt={message.createdAt}
senderType={message.messageSenderType}
text={message.text}
/>
</div>
))
export default ChatItem

View File

@ -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<HTMLTextAreaElement>(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 = () => {
<div
className={twMerge(
'flex items-center space-x-3',
isModelAvailable && '-mt-1'
!isModelAvailable && '-mt-1'
)}
>
{isModelAvailable && (
{!isModelAvailable && (
<Button
themes="secondary"
size="sm"

View File

@ -64,11 +64,12 @@
border-radius: 0.4rem;
margin-top: 1rem;
margin-bottom: 1rem;
white-space: normal;
}
pre > code {
display: block;
text-indent: 0;
white-space: inherit;
white-space: pre;
}
.hljs-emphasis {

View File

@ -6,7 +6,7 @@ enum MessageType {
Error = 'Error',
}
enum MessageSenderType {
export enum MessageSenderType {
Ai = 'assistant',
User = 'user',
}

View File

@ -25,7 +25,3 @@ export function mergeAndRemoveDuplicates(
return result.reverse()
}
export const generateMessageId = () => {
return `m-${Date.now()}`
}