diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..600e365ec8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +**/node_modules \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..949a92673f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +FROM node:20-bullseye AS base + +# 1. Install dependencies only when needed +FROM base AS deps +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ +RUN yarn install + +# # 2. Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +# This will do the trick, use the corresponding env file for each environment. +RUN yarn workspace server install +RUN yarn server:prod + +# 3. Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production + +# RUN addgroup -g 1001 -S nodejs; +COPY --from=builder /app/server/build ./ + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder /app/server/node_modules ./node_modules +COPY --from=builder /app/server/package.json ./package.json + +EXPOSE 4000 3928 + +ENV PORT 4000 +ENV APPDATA /app/data + +CMD ["node", "main.js"] \ No newline at end of file diff --git a/electron/core/plugin-manager/execution/facade.js b/electron/core/plugin-manager/execution/facade.js index c4f62f9951..df09bd5aa7 100644 --- a/electron/core/plugin-manager/execution/facade.js +++ b/electron/core/plugin-manager/execution/facade.js @@ -7,6 +7,7 @@ import Plugin from "./Plugin"; import { register } from "./activation-manager"; +import plugins from "../../../../web/public/plugins/plugin.json" /** * @typedef {Object.} installOptions The {@link https://www.npmjs.com/package/pacote|pacote options} @@ -65,7 +66,7 @@ export async function getActive() { return; } // eslint-disable-next-line no-undef - const plgList = await window.pluggableElectronIpc.getActive(); + const plgList = await window.pluggableElectronIpc?.getActive() ?? plugins; return plgList.map( (plugin) => new Plugin( @@ -90,7 +91,7 @@ export async function registerActive() { return; } // eslint-disable-next-line no-undef - const plgList = await window.pluggableElectronIpc.getActive(); + const plgList = await getActive() plgList.forEach((plugin) => register( new Plugin( diff --git a/package.json b/package.json index 53c046c72f..e3205fed85 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,16 @@ "workspaces": { "packages": [ "electron", - "web" + "web", + "server" ], "nohoist": [ "electron", "electron/**", "web", - "web/**" + "web/**", + "server", + "server/**" ] }, "scripts": { @@ -32,7 +35,10 @@ "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:publish-linux": "yarn build:web && yarn workspace jan build:publish-linux", + "build:web-plugins": "yarn build:web && yarn build:plugins && mkdir -p \"./web/out/plugins/data-plugin\" && cp \"./plugins/data-plugin/dist/esm/index.js\" \"./web/out/plugins/data-plugin\" && 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-management-plugin\" && cp \"./plugins/model-management-plugin/dist/index.js\" \"./web/out/plugins/model-management-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" }, "devDependencies": { "concurrently": "^8.2.1", diff --git a/plugins/data-plugin/module.ts b/plugins/data-plugin/module.ts index b5497e978e..21878d0f77 100644 --- a/plugins/data-plugin/module.ts +++ b/plugins/data-plugin/module.ts @@ -16,7 +16,7 @@ const dbs: Record = {}; */ function createCollection(name: string, schema?: { [key: string]: any }): Promise { return new Promise((resolve) => { - const dbPath = path.join(app.getPath("userData"), "databases"); + const dbPath = path.join(appPath(), "databases"); if (!fs.existsSync(dbPath)) fs.mkdirSync(dbPath); const db = new PouchDB(`${path.join(dbPath, name)}`); dbs[name] = db; @@ -226,6 +226,13 @@ function findMany( .then((data) => data.docs); // Return documents } +function appPath() { + if (app) { + return app.getPath("userData"); + } + return process.env.APPDATA || (process.platform == 'darwin' ? process.env.HOME + '/Library/Preferences' : process.env.HOME + "/.local/share"); +} + module.exports = { createCollection, deleteCollection, diff --git a/plugins/inference-plugin/module.ts b/plugins/inference-plugin/module.ts index b851f0c4e0..552936eafb 100644 --- a/plugins/inference-plugin/module.ts +++ b/plugins/inference-plugin/module.ts @@ -23,25 +23,22 @@ const initModel = (fileName) => { 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 = "nitro_start_windows.bat"; - } else if (process.platform === "darwin") { - // Mac OS platform - binaryName = - process.arch === "arm64" - ? "nitro_mac_arm64" - : "nitro_mac_intel"; - } else { - // Linux - // Todo: Need to check for CUDA support to switch between CUDA and non-CUDA binaries - binaryName = "nitro_start_linux.sh"; // For other platforms - } + if (process.platform === "win32") { + // Todo: Need to check for CUDA support to switch between CUDA and non-CUDA binaries + binaryName = "nitro_start_windows.bat"; + } else if (process.platform === "darwin") { + // Mac OS platform + binaryName = process.arch === "arm64" ? "nitro_mac_arm64" : "nitro_mac_intel"; + } else { + // Linux + // Todo: Need to check for CUDA support to switch between CUDA and non-CUDA binaries + binaryName = "nitro_start_linux.sh"; // For other platforms + } const binaryPath = path.join(binaryFolder, binaryName); - // Execute the binary - subprocess = spawn(binaryPath, { cwd: binaryFolder }); + // Execute the binary + subprocess = spawn(binaryPath,["0.0.0.0", PORT], { cwd: binaryFolder }); // Handle subprocess output subprocess.stdout.on("data", (data) => { @@ -61,7 +58,7 @@ const initModel = (fileName) => { }) .then(() => tcpPortUsed.waitUntilUsed(PORT, 300, 30000)) .then(() => { - const llama_model_path = path.join(app.getPath("userData"), fileName); + const llama_model_path = path.join(appPath(), fileName); const config = { llama_model_path, @@ -107,6 +104,13 @@ function killSubprocess() { } } +function appPath() { + if (app) { + return app.getPath("userData"); + } + return process.env.APPDATA || (process.platform == 'darwin' ? process.env.HOME + '/Library/Preferences' : process.env.HOME + "/.local/share"); +} + module.exports = { initModel, killSubprocess, diff --git a/plugins/inference-plugin/nitro/nitro_start_linux.sh b/plugins/inference-plugin/nitro/nitro_start_linux.sh index 19ac36f21a..bba9938007 100755 --- a/plugins/inference-plugin/nitro/nitro_start_linux.sh +++ b/plugins/inference-plugin/nitro/nitro_start_linux.sh @@ -3,4 +3,4 @@ #!/bin/bash # Attempt to run the nitro_linux_amd64_cuda file and if it fails, run nitro_linux_amd64 -./nitro_linux_amd64_cuda || (echo "nitro_linux_amd64_cuda encountered an error, attempting to run nitro_linux_amd64..." && ./nitro_linux_amd64) +./nitro_linux_amd64_cuda "$@" || (echo "nitro_linux_amd64_cuda encountered an error, attempting to run nitro_linux_amd64..." && ./nitro_linux_amd64 "$@") diff --git a/plugins/model-management-plugin/index.ts b/plugins/model-management-plugin/index.ts index f20814fd37..43348b0866 100644 --- a/plugins/model-management-plugin/index.ts +++ b/plugins/model-management-plugin/index.ts @@ -5,11 +5,56 @@ import { downloadFile, deleteFile, store, + EventName, + events } from "@janhq/core"; import { parseToModel } from "./helper"; -const downloadModel = (product) => +const downloadModel = (product) => { downloadFile(product.downloadUrl, product.fileName); + checkDownloadProgress(product.fileName); +} + +async function checkDownloadProgress(fileName: string) { + if (typeof window !== "undefined" && typeof (window as any).electronAPI === "undefined") { + const intervalId = setInterval(() => { + fetchDownloadProgress(fileName, intervalId); + }, 3000); + } +} + +async function fetchDownloadProgress(fileName: string, intervalId: NodeJS.Timeout): Promise { + const response = await fetch("/api/v1/downloadProgress", { + method: 'POST', + body: JSON.stringify({ fileName: fileName }), + headers: { 'Content-Type': 'application/json', 'Authorization': '' } + }); + + if (!response.ok) { + events.emit(EventName.OnDownloadError, null); + clearInterval(intervalId); + return; + } + const json = await response.json(); + if (isEmptyObject(json)) { + if (!fileName && intervalId) { + clearInterval(intervalId); + } + return Promise.resolve(""); + } + if (json.success === true) { + events.emit(EventName.OnDownloadSuccess, json); + clearInterval(intervalId); + return Promise.resolve(""); + } else { + events.emit(EventName.OnDownloadUpdate, json); + return Promise.resolve(json.fileName); + } +} + +function isEmptyObject(ojb: any): boolean { + return Object.keys(ojb).length === 0; +} const deleteModel = (path) => deleteFile(path); @@ -87,6 +132,7 @@ function getModelById(modelId: string): Promise { function onStart() { store.createCollection("models", {}); + fetchDownloadProgress(null, null).then((fileName: string) => fileName && checkDownloadProgress(fileName)); } // Register all the above functions and objects with the relevant extension points diff --git a/server/main.ts b/server/main.ts new file mode 100644 index 0000000000..b98b4097ce --- /dev/null +++ b/server/main.ts @@ -0,0 +1,179 @@ +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 new file mode 100644 index 0000000000..fa415fa52b --- /dev/null +++ b/server/nodemon.json @@ -0,0 +1,5 @@ +{ + "watch": [ + "main.ts" + ] +} \ No newline at end of file diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000000..895cae2b99 --- /dev/null +++ b/server/package.json @@ -0,0 +1,26 @@ +{ + "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 new file mode 100644 index 0000000000..a79afcdfe6 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "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/_components/SidebarFooter/index.tsx b/web/app/_components/SidebarFooter/index.tsx index 42d7c3f3bd..1b2430a5f4 100644 --- a/web/app/_components/SidebarFooter/index.tsx +++ b/web/app/_components/SidebarFooter/index.tsx @@ -6,14 +6,14 @@ const SidebarFooter: React.FC = () => ( - window.electronAPI?.openExternalUrl('https://discord.gg/AsJ8krTT3N') + window.coreAPI?.openExternalUrl('https://discord.gg/AsJ8krTT3N') } className="flex-1" /> - window.electronAPI?.openExternalUrl('https://twitter.com/janhq_') + window.coreAPI?.openExternalUrl('https://twitter.com/janhq_') } className="flex-1" /> diff --git a/web/app/page.tsx b/web/app/page.tsx index d7bfca79b6..2d23fc8e56 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -18,6 +18,9 @@ import React from 'react' import BaseLayout from '@containers/Layout' +import { ToastContainer } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; + const Page: React.FC = () => { const viewState = useAtomValue(getMainViewStateAtom) @@ -53,7 +56,10 @@ const Page: React.FC = () => { break } - return {children} + return + {children} +
+
} export default Page diff --git a/web/containers/Providers/index.tsx b/web/containers/Providers/index.tsx index 623ef9a5e5..e8c185d79d 100644 --- a/web/containers/Providers/index.tsx +++ b/web/containers/Providers/index.tsx @@ -57,7 +57,7 @@ const Providers = (props: PropsWithChildren) => { useEffect(() => { if (setupCore) { // Electron - if (window && window.electronAPI) { + if (window && window.coreAPI) { setupPE() } else { // Host diff --git a/web/containers/Sidebar/Left.tsx b/web/containers/Sidebar/Left.tsx index e837058f31..52500222e7 100644 --- a/web/containers/Sidebar/Left.tsx +++ b/web/containers/Sidebar/Left.tsx @@ -170,7 +170,7 @@ export const SidebarLeft = () => {