From ed5413a1eea38710f1608b2cd172c3ce28d31dca Mon Sep 17 00:00:00 2001 From: Linh Tran Date: Tue, 28 Nov 2023 00:18:36 +0700 Subject: [PATCH 01/85] Base server code (mirrored from electron + removed unnecessary handlers --- server/core/plugin/facade.ts | 30 +++++ server/core/plugin/globals.ts | 36 ++++++ server/core/plugin/index.ts | 149 +++++++++++++++++++++ server/core/plugin/plugin.ts | 213 +++++++++++++++++++++++++++++++ server/core/plugin/router.ts | 97 ++++++++++++++ server/core/plugin/store.ts | 131 +++++++++++++++++++ server/core/pre-install/.gitkeep | 0 server/handlers/download.ts | 108 ++++++++++++++++ server/handlers/fs.ts | 156 ++++++++++++++++++++++ server/handlers/plugin.ts | 118 +++++++++++++++++ server/icons/icon.png | Bin 0 -> 38651 bytes server/main.ts | 32 +++++ server/managers/download.ts | 24 ++++ server/managers/module.ts | 33 +++++ server/managers/plugin.ts | 60 +++++++++ server/managers/window.ts | 37 ++++++ server/package.json | 97 ++++++++++++++ server/tsconfig.json | 20 +++ server/utils/disposable.ts | 8 ++ server/utils/menu.ts | 111 ++++++++++++++++ server/utils/versionDiff.ts | 21 +++ 21 files changed, 1481 insertions(+) create mode 100644 server/core/plugin/facade.ts create mode 100644 server/core/plugin/globals.ts create mode 100644 server/core/plugin/index.ts create mode 100644 server/core/plugin/plugin.ts create mode 100644 server/core/plugin/router.ts create mode 100644 server/core/plugin/store.ts create mode 100644 server/core/pre-install/.gitkeep create mode 100644 server/handlers/download.ts create mode 100644 server/handlers/fs.ts create mode 100644 server/handlers/plugin.ts create mode 100644 server/icons/icon.png create mode 100644 server/main.ts create mode 100644 server/managers/download.ts create mode 100644 server/managers/module.ts create mode 100644 server/managers/plugin.ts create mode 100644 server/managers/window.ts create mode 100644 server/package.json create mode 100644 server/tsconfig.json create mode 100644 server/utils/disposable.ts create mode 100644 server/utils/menu.ts create mode 100644 server/utils/versionDiff.ts diff --git a/server/core/plugin/facade.ts b/server/core/plugin/facade.ts new file mode 100644 index 000000000..bd1089109 --- /dev/null +++ b/server/core/plugin/facade.ts @@ -0,0 +1,30 @@ +const { ipcRenderer, contextBridge } = require("electron"); + +export function useFacade() { + const interfaces = { + install(plugins: any[]) { + return ipcRenderer.invoke("pluggable:install", plugins); + }, + uninstall(plugins: any[], reload: boolean) { + return ipcRenderer.invoke("pluggable:uninstall", plugins, reload); + }, + getActive() { + return ipcRenderer.invoke("pluggable:getActivePlugins"); + }, + update(plugins: any[], reload: boolean) { + return ipcRenderer.invoke("pluggable:update", plugins, reload); + }, + updatesAvailable(plugin: any) { + return ipcRenderer.invoke("pluggable:updatesAvailable", plugin); + }, + toggleActive(plugin: any, active: boolean) { + return ipcRenderer.invoke("pluggable:togglePluginActive", plugin, active); + }, + }; + + if (contextBridge) { + contextBridge.exposeInMainWorld("pluggableElectronIpc", interfaces); + } + + return interfaces; +} diff --git a/server/core/plugin/globals.ts b/server/core/plugin/globals.ts new file mode 100644 index 000000000..69df7925c --- /dev/null +++ b/server/core/plugin/globals.ts @@ -0,0 +1,36 @@ +import { existsSync, mkdirSync, writeFileSync } from "fs"; +import { join, resolve } from "path"; + +export let pluginsPath: string | undefined = undefined; + +/** + * @private + * Set path to plugins directory and create the directory if it does not exist. + * @param {string} plgPath path to plugins directory + */ +export function setPluginsPath(plgPath: string) { + // Create folder if it does not exist + let plgDir; + try { + plgDir = resolve(plgPath); + if (plgDir.length < 2) throw new Error(); + + if (!existsSync(plgDir)) mkdirSync(plgDir); + + const pluginsJson = join(plgDir, "plugins.json"); + if (!existsSync(pluginsJson)) writeFileSync(pluginsJson, "{}", "utf8"); + + pluginsPath = plgDir; + } catch (error) { + throw new Error("Invalid path provided to the plugins folder"); + } +} + +/** + * @private + * Get the path to the plugins.json file. + * @returns location of plugins.json + */ +export function getPluginsFile() { + return join(pluginsPath ?? "", "plugins.json"); +} \ No newline at end of file diff --git a/server/core/plugin/index.ts b/server/core/plugin/index.ts new file mode 100644 index 000000000..e8c64747b --- /dev/null +++ b/server/core/plugin/index.ts @@ -0,0 +1,149 @@ +import { readFileSync } from "fs"; +import { protocol } from "electron"; +import { normalize } from "path"; + +import Plugin from "./plugin"; +import { + getAllPlugins, + removePlugin, + persistPlugins, + installPlugins, + getPlugin, + getActivePlugins, + addPlugin, +} from "./store"; +import { + pluginsPath as storedPluginsPath, + setPluginsPath, + getPluginsFile, +} from "./globals"; +import router from "./router"; + +/** + * Sets up the required communication between the main and renderer processes. + * Additionally sets the plugins up using {@link usePlugins} if a pluginsPath is provided. + * @param {Object} options configuration for setting up the renderer facade. + * @param {confirmInstall} [options.confirmInstall] Function to validate that a plugin should be installed. + * @param {Boolean} [options.useFacade=true] Whether to make a facade to the plugins available in the renderer. + * @param {string} [options.pluginsPath] Optional path to the plugins folder. + * @returns {pluginManager|Object} A set of functions used to manage the plugin lifecycle if usePlugins is provided. + * @function + */ +export function init(options: any) { + if ( + !Object.prototype.hasOwnProperty.call(options, "useFacade") || + options.useFacade + ) { + // Enable IPC to be used by the facade + router(); + } + + // Create plugins protocol to serve plugins to renderer + registerPluginProtocol(); + + // perform full setup if pluginsPath is provided + if (options.pluginsPath) { + return usePlugins(options.pluginsPath); + } + + return {}; +} + +/** + * Create plugins protocol to provide plugins to renderer + * @private + * @returns {boolean} Whether the protocol registration was successful + */ +function registerPluginProtocol() { + return protocol.registerFileProtocol("plugin", (request, callback) => { + const entry = request.url.substr(8); + const url = normalize(storedPluginsPath + entry); + callback({ path: url }); + }); +} + +/** + * Set Pluggable Electron up to run from the pluginPath folder if it is provided and + * load plugins persisted in that folder. + * @param {string} pluginsPath Path to the plugins folder. Required if not yet set up. + * @returns {pluginManager} A set of functions used to manage the plugin lifecycle. + */ +export function usePlugins(pluginsPath: string) { + if (!pluginsPath) + throw Error( + "A path to the plugins folder is required to use Pluggable Electron" + ); + // Store the path to the plugins folder + setPluginsPath(pluginsPath); + + // Remove any registered plugins + for (const plugin of getAllPlugins()) { + if (plugin.name) removePlugin(plugin.name, false); + } + + // Read plugin list from plugins folder + const plugins = JSON.parse(readFileSync(getPluginsFile(), "utf-8")); + try { + // Create and store a Plugin instance for each plugin in list + for (const p in plugins) { + loadPlugin(plugins[p]); + } + persistPlugins(); + } catch (error) { + // Throw meaningful error if plugin loading fails + throw new Error( + "Could not successfully rebuild list of installed plugins.\n" + + error + + "\nPlease check the plugins.json file in the plugins folder." + ); + } + + // Return the plugin lifecycle functions + return getStore(); +} + +/** + * Check the given plugin object. If it is marked for uninstalling, the plugin files are removed. + * Otherwise a Plugin instance for the provided object is created and added to the store. + * @private + * @param {Object} plg Plugin info + */ +function loadPlugin(plg: any) { + // Create new plugin, populate it with plg details and save it to the store + const plugin = new Plugin(); + + for (const key in plg) { + if (Object.prototype.hasOwnProperty.call(plg, key)) { + // Use Object.defineProperty to set the properties as writable + Object.defineProperty(plugin, key, { + value: plg[key], + writable: true, + enumerable: true, + configurable: true, + }); + } + } + + addPlugin(plugin, false); + plugin.subscribe("pe-persist", persistPlugins); +} + +/** + * Returns the publicly available store functions. + * @returns {pluginManager} A set of functions used to manage the plugin lifecycle. + */ +export function getStore() { + if (!storedPluginsPath) { + throw new Error( + "The plugin path has not yet been set up. Please run usePlugins before accessing the store" + ); + } + + return { + installPlugins, + getPlugin, + getAllPlugins, + getActivePlugins, + removePlugin, + }; +} diff --git a/server/core/plugin/plugin.ts b/server/core/plugin/plugin.ts new file mode 100644 index 000000000..f0fc073d7 --- /dev/null +++ b/server/core/plugin/plugin.ts @@ -0,0 +1,213 @@ +import { rmdir } from "fs/promises"; +import { resolve, join } from "path"; +import { manifest, extract } from "pacote"; +import * as Arborist from "@npmcli/arborist"; + +import { pluginsPath } from "./globals"; + +/** + * An NPM package that can be used as a Pluggable Electron plugin. + * Used to hold all the information and functions necessary to handle the plugin lifecycle. + */ +class Plugin { + /** + * @property {string} origin Original specification provided to fetch the package. + * @property {Object} installOptions Options provided to pacote when fetching the manifest. + * @property {name} name The name of the plugin as defined in the manifest. + * @property {string} url Electron URL where the package can be accessed. + * @property {string} version Version of the package as defined in the manifest. + * @property {Array} activationPoints List of {@link ./Execution-API#activationPoints|activation points}. + * @property {string} main The entry point as defined in the main entry of the manifest. + * @property {string} description The description of plugin as defined in the manifest. + * @property {string} icon The icon of plugin as defined in the manifest. + */ + origin?: string; + installOptions: any; + name?: string; + url?: string; + version?: string; + activationPoints?: Array; + main?: string; + description?: string; + icon?: string; + + /** @private */ + _active = false; + + /** + * @private + * @property {Object.} #listeners A list of callbacks to be executed when the Plugin is updated. + */ + listeners: Record void> = {}; + + /** + * Set installOptions with defaults for options that have not been provided. + * @param {string} [origin] Original specification provided to fetch the package. + * @param {Object} [options] Options provided to pacote when fetching the manifest. + */ + constructor(origin?: string, options = {}) { + const defaultOpts = { + version: false, + fullMetadata: false, + Arborist, + }; + + this.origin = origin; + this.installOptions = { ...defaultOpts, ...options }; + } + + /** + * Package name with version number. + * @type {string} + */ + get specifier() { + return ( + this.origin + + (this.installOptions.version ? "@" + this.installOptions.version : "") + ); + } + + /** + * Whether the plugin should be registered with its activation points. + * @type {boolean} + */ + get active() { + return this._active; + } + + /** + * Set Package details based on it's manifest + * @returns {Promise.} Resolves to true when the action completed + */ + async getManifest() { + // Get the package's manifest (package.json object) + try { + const mnf = await manifest(this.specifier, this.installOptions); + + // set the Package properties based on the it's manifest + this.name = mnf.name; + this.version = mnf.version; + this.activationPoints = mnf.activationPoints + ? (mnf.activationPoints as string[]) + : undefined; + this.main = mnf.main; + this.description = mnf.description; + this.icon = mnf.icon as any; + } catch (error) { + throw new Error( + `Package ${this.origin} does not contain a valid manifest: ${error}` + ); + } + + return true; + } + + /** + * Extract plugin to plugins folder. + * @returns {Promise.} This plugin + * @private + */ + async _install() { + try { + // import the manifest details + await this.getManifest(); + + // Install the package in a child folder of the given folder + await extract( + this.specifier, + join(pluginsPath ?? "", this.name ?? ""), + this.installOptions + ); + + if (!Array.isArray(this.activationPoints)) + throw new Error("The plugin does not contain any activation points"); + + // Set the url using the custom plugins protocol + this.url = `plugin://${this.name}/${this.main}`; + + this.emitUpdate(); + } catch (err) { + // Ensure the plugin is not stored and the folder is removed if the installation fails + this.setActive(false); + throw err; + } + + return [this]; + } + + /** + * Subscribe to updates of this plugin + * @param {string} name name of the callback to register + * @param {callback} cb The function to execute on update + */ + subscribe(name: string, cb: () => void) { + this.listeners[name] = cb; + } + + /** + * Remove subscription + * @param {string} name name of the callback to remove + */ + unsubscribe(name: string) { + delete this.listeners[name]; + } + + /** + * Execute listeners + */ + emitUpdate() { + for (const cb in this.listeners) { + this.listeners[cb].call(null, this); + } + } + + /** + * Check for updates and install if available. + * @param {string} version The version to update to. + * @returns {boolean} Whether an update was performed. + */ + async update(version = false) { + if (await this.isUpdateAvailable()) { + this.installOptions.version = version; + await this._install(); + return true; + } + + return false; + } + + /** + * Check if a new version of the plugin is available at the origin. + * @returns the latest available version if a new version is available or false if not. + */ + async isUpdateAvailable() { + if (this.origin) { + const mnf = await manifest(this.origin); + return mnf.version !== this.version ? mnf.version : false; + } + } + + /** + * Remove plugin and refresh renderers. + * @returns {Promise} + */ + async uninstall() { + const plgPath = resolve(pluginsPath ?? "", this.name ?? ""); + await rmdir(plgPath, { recursive: true }); + + this.emitUpdate(); + } + + /** + * Set a plugin's active state. This determines if a plugin should be loaded on initialisation. + * @param {boolean} active State to set _active to + * @returns {Plugin} This plugin + */ + setActive(active: boolean) { + this._active = active; + this.emitUpdate(); + return this; + } +} + +export default Plugin; diff --git a/server/core/plugin/router.ts b/server/core/plugin/router.ts new file mode 100644 index 000000000..09c79485b --- /dev/null +++ b/server/core/plugin/router.ts @@ -0,0 +1,97 @@ +import { ipcMain, webContents } from "electron"; + +import { + getPlugin, + getActivePlugins, + installPlugins, + removePlugin, + getAllPlugins, +} from "./store"; +import { pluginsPath } from "./globals"; +import Plugin from "./plugin"; + +// Throw an error if pluginsPath has not yet been provided by usePlugins. +const checkPluginsPath = () => { + if (!pluginsPath) + throw Error("Path to plugins folder has not yet been set up."); +}; +let active = false; +/** + * Provide the renderer process access to the plugins. + **/ +export default function () { + if (active) return; + // Register IPC route to install a plugin + ipcMain.handle("pluggable:install", async (e, plugins) => { + checkPluginsPath(); + + // Install and activate all provided plugins + const installed = await installPlugins(plugins); + return JSON.parse(JSON.stringify(installed)); + }); + + // Register IPC route to uninstall a plugin + ipcMain.handle("pluggable:uninstall", async (e, plugins, reload) => { + checkPluginsPath(); + + // Uninstall all provided plugins + for (const plg of plugins) { + const plugin = getPlugin(plg); + await plugin.uninstall(); + if (plugin.name) removePlugin(plugin.name); + } + + // Reload all renderer pages if needed + reload && webContents.getAllWebContents().forEach((wc) => wc.reload()); + return true; + }); + + // Register IPC route to update a plugin + ipcMain.handle("pluggable:update", async (e, plugins, reload) => { + checkPluginsPath(); + + // Update all provided plugins + const updated: Plugin[] = []; + for (const plg of plugins) { + const plugin = getPlugin(plg); + const res = await plugin.update(); + if (res) updated.push(plugin); + } + + // Reload all renderer pages if needed + if (updated.length && reload) + webContents.getAllWebContents().forEach((wc) => wc.reload()); + + return JSON.parse(JSON.stringify(updated)); + }); + + // Register IPC route to check if updates are available for a plugin + ipcMain.handle("pluggable:updatesAvailable", (e, names) => { + checkPluginsPath(); + + const plugins = names + ? names.map((name: string) => getPlugin(name)) + : getAllPlugins(); + + const updates: Record = {}; + for (const plugin of plugins) { + updates[plugin.name] = plugin.isUpdateAvailable(); + } + return updates; + }); + + // Register IPC route to get the list of active plugins + ipcMain.handle("pluggable:getActivePlugins", () => { + checkPluginsPath(); + return JSON.parse(JSON.stringify(getActivePlugins())); + }); + + // Register IPC route to toggle the active state of a plugin + ipcMain.handle("pluggable:togglePluginActive", (e, plg, active) => { + checkPluginsPath(); + const plugin = getPlugin(plg); + return JSON.parse(JSON.stringify(plugin.setActive(active))); + }); + + active = true; +} diff --git a/server/core/plugin/store.ts b/server/core/plugin/store.ts new file mode 100644 index 000000000..cfd25e5ca --- /dev/null +++ b/server/core/plugin/store.ts @@ -0,0 +1,131 @@ +/** + * Provides access to the plugins stored by Pluggable Electron + * @typedef {Object} pluginManager + * @prop {getPlugin} getPlugin + * @prop {getAllPlugins} getAllPlugins + * @prop {getActivePlugins} getActivePlugins + * @prop {installPlugins} installPlugins + * @prop {removePlugin} removePlugin + */ + +import { writeFileSync } from "fs"; +import Plugin from "./plugin"; +import { getPluginsFile } from "./globals"; + +/** + * @module store + * @private + */ + +/** + * Register of installed plugins + * @type {Object.} plugin - List of installed plugins + */ +const plugins: Record = {}; + +/** + * Get a plugin from the stored plugins. + * @param {string} name Name of the plugin to retrieve + * @returns {Plugin} Retrieved plugin + * @alias pluginManager.getPlugin + */ +export function getPlugin(name: string) { + if (!Object.prototype.hasOwnProperty.call(plugins, name)) { + throw new Error(`Plugin ${name} does not exist`); + } + + return plugins[name]; +} + +/** + * Get list of all plugin objects. + * @returns {Array.} All plugin objects + * @alias pluginManager.getAllPlugins + */ +export function getAllPlugins() { + return Object.values(plugins); +} + +/** + * Get list of active plugin objects. + * @returns {Array.} Active plugin objects + * @alias pluginManager.getActivePlugins + */ +export function getActivePlugins() { + return Object.values(plugins).filter((plugin) => plugin.active); +} + +/** + * Remove plugin from store and maybe save stored plugins to file + * @param {string} name Name of the plugin to remove + * @param {boolean} persist Whether to save the changes to plugins to file + * @returns {boolean} Whether the delete was successful + * @alias pluginManager.removePlugin + */ +export function removePlugin(name: string, persist = true) { + const del = delete plugins[name]; + if (persist) persistPlugins(); + return del; +} + +/** + * Add plugin to store and maybe save stored plugins to file + * @param {Plugin} plugin Plugin to add to store + * @param {boolean} persist Whether to save the changes to plugins to file + * @returns {void} + */ +export function addPlugin(plugin: Plugin, persist = true) { + if (plugin.name) plugins[plugin.name] = plugin; + if (persist) { + persistPlugins(); + plugin.subscribe("pe-persist", persistPlugins); + } +} + +/** + * Save stored plugins to file + * @returns {void} + */ +export function persistPlugins() { + const persistData: Record = {}; + for (const name in plugins) { + persistData[name] = plugins[name]; + } + writeFileSync(getPluginsFile(), JSON.stringify(persistData), "utf8"); +} + +/** + * Create and install a new plugin for the given specifier. + * @param {Array.} plugins A list of NPM specifiers, or installation configuration objects. + * @param {boolean} [store=true] Whether to store the installed plugins in the store + * @returns {Promise.>} New plugin + * @alias pluginManager.installPlugins + */ +export async function installPlugins(plugins: any, store = true) { + const installed: Plugin[] = []; + for (const plg of plugins) { + // Set install options and activation based on input type + const isObject = typeof plg === "object"; + const spec = isObject ? [plg.specifier, plg] : [plg]; + const activate = isObject ? plg.activate !== false : true; + + // Install and possibly activate plugin + const plugin = new Plugin(...spec); + await plugin._install(); + if (activate) plugin.setActive(true); + + // Add plugin to store if needed + if (store) addPlugin(plugin); + installed.push(plugin); + } + + // Return list of all installed plugins + return installed; +} + +/** + * @typedef {Object.} installOptions The {@link https://www.npmjs.com/package/pacote|pacote} + * options used to install the plugin with some extra options. + * @param {string} specifier the NPM specifier that identifies the package. + * @param {boolean} [activate] Whether this plugin should be activated after installation. Defaults to true. + */ diff --git a/server/core/pre-install/.gitkeep b/server/core/pre-install/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/server/handlers/download.ts b/server/handlers/download.ts new file mode 100644 index 000000000..3a1fc36d1 --- /dev/null +++ b/server/handlers/download.ts @@ -0,0 +1,108 @@ +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.debug( + `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 = join(app.getPath('home'), 'jan') + const destination = resolve(userDataPath, fileName) + const rq = request(url) + + progress(rq, {}) + .on('progress', function (state: any) { + WindowManager?.instance.currentWindow?.webContents.send( + 'FILE_DOWNLOAD_UPDATE', + { + ...state, + fileName, + } + ) + }) + .on('error', function (err: Error) { + WindowManager?.instance.currentWindow?.webContents.send( + 'FILE_DOWNLOAD_ERROR', + { + fileName, + err, + } + ) + }) + .on('end', function () { + if (DownloadManager.instance.networkRequests[fileName]) { + WindowManager?.instance.currentWindow?.webContents.send( + 'FILE_DOWNLOAD_COMPLETE', + { + fileName, + } + ) + DownloadManager.instance.setRequest(fileName, undefined) + } else { + WindowManager?.instance.currentWindow?.webContents.send( + 'FILE_DOWNLOAD_ERROR', + { + fileName, + err: 'Download cancelled', + } + ) + } + }) + .pipe(createWriteStream(destination)) + + DownloadManager.instance.setRequest(fileName, rq) + }) +} diff --git a/server/handlers/fs.ts b/server/handlers/fs.ts new file mode 100644 index 000000000..c1e8a85e4 --- /dev/null +++ b/server/handlers/fs.ts @@ -0,0 +1,156 @@ +import { app, ipcMain } from 'electron' +import * as fs from 'fs' +import { join } from 'path' + +/** + * Handles file system operations. + */ +export function handleFsIPCs() { + const userSpacePath = join(app.getPath('home'), 'jan') + + /** + * Gets the path to the user data directory. + * @param event - The event object. + * @returns A promise that resolves with the path to the user data directory. + */ + ipcMain.handle( + 'getUserSpace', + (): Promise => Promise.resolve(userSpacePath) + ) + + /** + * Checks whether the path is a directory. + * @param event - The event object. + * @param path - The path to check. + * @returns A promise that resolves with a boolean indicating whether the path is a directory. + */ + ipcMain.handle('isDirectory', (_event, path: string): Promise => { + const fullPath = join(userSpacePath, path) + return Promise.resolve( + fs.existsSync(fullPath) && fs.lstatSync(fullPath).isDirectory() + ) + }) + + /** + * Reads a file from the user data directory. + * @param event - The event object. + * @param path - The path of the file to read. + * @returns A promise that resolves with the contents of the file. + */ + ipcMain.handle('readFile', async (event, path: string): Promise => { + return new Promise((resolve, reject) => { + fs.readFile(join(userSpacePath, path), 'utf8', (err, data) => { + if (err) { + reject(err) + } else { + resolve(data) + } + }) + }) + }) + + /** + * Writes data to a file in the user data directory. + * @param event - The event object. + * @param path - The path of the file to write to. + * @param data - The data to write to the file. + * @returns A promise that resolves when the file has been written. + */ + ipcMain.handle( + 'writeFile', + async (event, path: string, data: string): Promise => { + return new Promise((resolve, reject) => { + fs.writeFile(join(userSpacePath, path), data, 'utf8', (err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + } + ) + + /** + * Creates a directory in the user data directory. + * @param event - The event object. + * @param path - The path of the directory to create. + * @returns A promise that resolves when the directory has been created. + */ + ipcMain.handle('mkdir', async (event, path: string): Promise => { + return new Promise((resolve, reject) => { + fs.mkdir(join(userSpacePath, path), { recursive: true }, (err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + }) + + /** + * Removes a directory in the user data directory. + * @param event - The event object. + * @param path - The path of the directory to remove. + * @returns A promise that resolves when the directory is removed successfully. + */ + ipcMain.handle('rmdir', async (event, path: string): Promise => { + return new Promise((resolve, reject) => { + fs.rmdir(join(userSpacePath, path), { recursive: true }, (err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + }) + + /** + * Lists the files in a directory in the user data directory. + * @param event - The event object. + * @param path - The path of the directory to list files from. + * @returns A promise that resolves with an array of file names. + */ + ipcMain.handle( + 'listFiles', + async (event, path: string): Promise => { + return new Promise((resolve, reject) => { + fs.readdir(join(userSpacePath, path), (err, files) => { + if (err) { + reject(err) + } else { + resolve(files) + } + }) + }) + } + ) + + /** + * 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 fullPath = join(userSpacePath, 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.debug( + `Delete file ${filePath} from ${fullPath} result: ${result}` + ) + }) + + return result + }) +} diff --git a/server/handlers/plugin.ts b/server/handlers/plugin.ts new file mode 100644 index 000000000..22bf253e6 --- /dev/null +++ b/server/handlers/plugin.ts @@ -0,0 +1,118 @@ +import { app, ipcMain } from "electron"; +import { readdirSync, rmdir, writeFileSync } from "fs"; +import { ModuleManager } from "../managers/module"; +import { join, extname } from "path"; +import { PluginManager } from "../managers/plugin"; +import { WindowManager } from "../managers/window"; +import { manifest, tarball } from "pacote"; + +export function handlePluginIPCs() { + /** + * Invokes a function from a plugin module in main node process. + * @param _event - The IPC event object. + * @param modulePath - The path to the plugin module. + * @param method - The name of the function to invoke. + * @param args - The arguments to pass to the function. + * @returns The result of the invoked function. + */ + ipcMain.handle( + "invokePluginFunc", + async (_event, modulePath, method, ...args) => { + const module = require( + /* webpackIgnore: true */ join( + app.getPath("userData"), + "plugins", + modulePath + ) + ); + ModuleManager.instance.setModule(modulePath, module); + + if (typeof module[method] === "function") { + return module[method](...args); + } else { + console.debug(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.error(err); + ModuleManager.instance.clearImportedModules(); + + // just relaunch if packaged, should launch manually in development mode + if (app.isPackaged) { + app.relaunch(); + app.exit(); + } else { + for (const modulePath in ModuleManager.instance.requiredModules) { + delete require.cache[ + require.resolve( + join(app.getPath("userData"), "plugins", modulePath) + ) + ]; + } + PluginManager.instance.setupPlugins(); + WindowManager.instance.currentWindow?.reload(); + } + }); + }); + + /** + * Installs a remote plugin by downloading its tarball and writing it to a tgz file. + * @param _event - The IPC event object. + * @param pluginName - The name of the remote plugin to install. + * @returns A Promise that resolves to the path of the installed plugin file. + */ + ipcMain.handle("installRemotePlugin", async (_event, pluginName) => { + const destination = join( + app.getPath("userData"), + pluginName.replace(/^@.*\//, "") + ".tgz" + ); + return manifest(pluginName) + .then(async (manifest: any) => { + await tarball(manifest._resolved).then((data: Buffer) => { + writeFileSync(destination, data); + }); + }) + .then(() => destination); + }); +} diff --git a/server/icons/icon.png b/server/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..289f99ded85e1e3cc91b17af6b607d2cafd97cd7 GIT binary patch literal 38651 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 + PluginManager.instance.migratePlugins() + PluginManager.instance.setupPlugins() + setupMenu() + handleIPCs() +}) + +/** + * Handles various IPC messages from the renderer process. + */ +function handleIPCs() { + handleFsIPCs() + handleDownloaderIPCs() + handlePluginIPCs() +} diff --git a/server/managers/download.ts b/server/managers/download.ts new file mode 100644 index 000000000..08c089b74 --- /dev/null +++ b/server/managers/download.ts @@ -0,0 +1,24 @@ +import { Request } from "request"; + +/** + * Manages file downloads and network requests. + */ +export class DownloadManager { + public networkRequests: Record = {}; + + public static instance: DownloadManager = new DownloadManager(); + + constructor() { + if (DownloadManager.instance) { + return DownloadManager.instance; + } + } + /** + * Sets a network request for a specific file. + * @param {string} fileName - The name of the file. + * @param {Request | undefined} request - The network request to set, or undefined to clear the request. + */ + setRequest(fileName: string, request: Request | undefined) { + this.networkRequests[fileName] = request; + } +} diff --git a/server/managers/module.ts b/server/managers/module.ts new file mode 100644 index 000000000..43dda0fb6 --- /dev/null +++ b/server/managers/module.ts @@ -0,0 +1,33 @@ +import { dispose } from "../utils/disposable"; + +/** + * Manages imported modules. + */ +export class ModuleManager { + public requiredModules: Record = {}; + + public static instance: ModuleManager = new ModuleManager(); + + constructor() { + if (ModuleManager.instance) { + return ModuleManager.instance; + } + } + + /** + * Sets a module. + * @param {string} moduleName - The name of the module. + * @param {any | undefined} nodule - The module to set, or undefined to clear the module. + */ + setModule(moduleName: string, nodule: any | undefined) { + this.requiredModules[moduleName] = nodule; + } + + /** + * Clears all imported modules. + */ + clearImportedModules() { + dispose(this.requiredModules); + this.requiredModules = {}; + } +} diff --git a/server/managers/plugin.ts b/server/managers/plugin.ts new file mode 100644 index 000000000..227eab34e --- /dev/null +++ b/server/managers/plugin.ts @@ -0,0 +1,60 @@ +import { app } from "electron"; +import { init } from "../core/plugin/index"; +import { join } from "path"; +import { rmdir } from "fs"; +import Store from "electron-store"; + +/** + * Manages plugin installation and migration. + */ +export class PluginManager { + public static instance: PluginManager = new PluginManager(); + + constructor() { + if (PluginManager.instance) { + return PluginManager.instance; + } + } + + /** + * Sets up the plugins by initializing the `plugins` module with the `confirmInstall` and `pluginsPath` options. + * The `confirmInstall` function always returns `true` to allow plugin installation. + * The `pluginsPath` option specifies the path to install plugins to. + */ + setupPlugins() { + init({ + // Function to check from the main process that user wants to install a plugin + confirmInstall: async (_plugins: string[]) => { + return true; + }, + // Path to install plugin to + pluginsPath: join(app.getPath("userData"), "plugins"), + }); + } + + /** + * Migrates the plugins by deleting the `plugins` directory in the user data path. + * If the `migrated_version` key in the `Store` object does not match the current app version, + * the function deletes the `plugins` directory and sets the `migrated_version` key to the current app version. + * @returns A Promise that resolves when the migration is complete. + */ + migratePlugins() { + return new Promise((resolve) => { + const store = new Store(); + if (store.get("migrated_version") !== app.getVersion()) { + console.debug("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.error(err); + store.set("migrated_version", app.getVersion()); + console.debug("migrate plugins done"); + resolve(undefined); + }); + } else { + resolve(undefined); + } + }); + } +} diff --git a/server/managers/window.ts b/server/managers/window.ts new file mode 100644 index 000000000..c930dd5ec --- /dev/null +++ b/server/managers/window.ts @@ -0,0 +1,37 @@ +import { BrowserWindow } from "electron"; + +/** + * Manages the current window instance. + */ +export class WindowManager { + public static instance: WindowManager = new WindowManager(); + public currentWindow?: BrowserWindow; + + constructor() { + if (WindowManager.instance) { + return WindowManager.instance; + } + } + + /** + * Creates a new window instance. + * @param {Electron.BrowserWindowConstructorOptions} options - The options to create the window with. + * @returns The created window instance. + */ + createWindow(options?: Electron.BrowserWindowConstructorOptions | undefined) { + this.currentWindow = new BrowserWindow({ + width: 1200, + minWidth: 800, + height: 800, + show: false, + trafficLightPosition: { + x: 10, + y: 15, + }, + titleBarStyle: "hidden", + vibrancy: "sidebar", + ...options, + }); + return this.currentWindow; + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 000000000..8185f37d9 --- /dev/null +++ b/server/package.json @@ -0,0 +1,97 @@ +{ + "name": "jan-server", + "version": "0.1.3", + "main": "./build/main.js", + "author": "Jan ", + "license": "MIT", + "homepage": "https://github.com/janhq/jan/tree/main/electron", + "description": "Use offline LLMs with your own data. Run open source models like Llama2 or Falcon on your internal computers/servers.", + "build": { + "appId": "jan.ai.app", + "productName": "Jan", + "files": [ + "renderer/**/*", + "build/*.{js,map}", + "build/**/*.{js,map}", + "core/pre-install", + "core/plugin-manager/facade" + ], + "asarUnpack": [ + "core/pre-install" + ], + "publish": [ + { + "provider": "github", + "owner": "janhq", + "repo": "jan" + } + ], + "extends": null, + "mac": { + "type": "distribution", + "entitlements": "./entitlements.mac.plist", + "entitlementsInherit": "./entitlements.mac.plist", + "notarize": { + "teamId": "YT49P7GXG4" + }, + "icon": "icons/icon.png" + }, + "linux": { + "target": [ + "deb" + ], + "category": "Utility", + "icon": "icons/" + }, + "win": { + "icon": "icons/icon.png" + }, + "artifactName": "jan-${os}-${arch}-${version}.${ext}" + }, + "scripts": { + "lint": "eslint . --ext \".js,.jsx,.ts,.tsx\"", + "test:e2e": "playwright test --workers=1", + "dev": "tsc -p . && electron .", + "build": "run-script-os", + "build:test": "run-script-os", + "build:test:darwin": "tsc -p . && electron-builder -p never -m --dir", + "build:test:win32": "tsc -p . && electron-builder -p never -w --dir", + "build:test:linux": "tsc -p . && electron-builder -p never -l --dir", + "build:darwin": "tsc -p . && electron-builder -p never -m", + "build:win32": "tsc -p . && electron-builder -p never -w", + "build:linux": "tsc -p . && electron-builder -p never --linux deb", + "build:publish": "run-script-os", + "build:publish:darwin": "tsc -p . && electron-builder -p onTagOrDraft -m --x64 --arm64", + "build:publish:win32": "tsc -p . && electron-builder -p onTagOrDraft -w", + "build:publish:linux": "tsc -p . && electron-builder -p onTagOrDraft -l deb" + }, + "dependencies": { + "@npmcli/arborist": "^7.1.0", + "@types/express": "^4.17.21", + "@types/request": "^2.48.12", + "@uiball/loaders": "^1.3.0", + "electron-store": "^8.1.0", + "electron-updater": "^6.1.4", + "express": "^4.18.2", + "pacote": "^17.0.4", + "request": "^2.88.2", + "request-progress": "^3.0.0", + "use-debounce": "^9.0.4" + }, + "devDependencies": { + "@electron/notarize": "^2.1.0", + "@playwright/test": "^1.38.1", + "@types/npmcli__arborist": "^5.6.4", + "@types/pacote": "^11.1.7", + "@typescript-eslint/eslint-plugin": "^6.7.3", + "@typescript-eslint/parser": "^6.7.3", + "electron": "26.2.1", + "electron-builder": "^24.6.4", + "electron-playwright-helpers": "^1.6.0", + "eslint-plugin-react": "^7.33.2", + "run-script-os": "^1.1.6" + }, + "installConfig": { + "hoistingLimits": "workspaces" + } +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 000000000..3cc218f93 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "noImplicitAny": true, + "sourceMap": true, + "strict": true, + "outDir": "./build", + "rootDir": "./", + "noEmitOnError": true, + "esModuleInterop": true, + "baseUrl": ".", + "allowJs": true, + "skipLibCheck": true, + "paths": { "*": ["node_modules/*"] }, + "typeRoots": ["node_modules/@types"] + }, + "include": ["./**/*.ts"], + "exclude": ["core", "build", "dist", "tests", "node_modules"] +} diff --git a/server/utils/disposable.ts b/server/utils/disposable.ts new file mode 100644 index 000000000..462f7e3e5 --- /dev/null +++ b/server/utils/disposable.ts @@ -0,0 +1,8 @@ +export function dispose(requiredModules: Record) { + for (const key in requiredModules) { + const module = requiredModules[key]; + if (typeof module["dispose"] === "function") { + module["dispose"](); + } + } +} diff --git a/server/utils/menu.ts b/server/utils/menu.ts new file mode 100644 index 000000000..65e009aef --- /dev/null +++ b/server/utils/menu.ts @@ -0,0 +1,111 @@ +// @ts-nocheck +const { app, Menu, dialog } = require("electron"); +const isMac = process.platform === "darwin"; +const { autoUpdater } = require("electron-updater"); +import { compareSemanticVersions } from "./versionDiff"; + +const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [ + ...(isMac + ? [ + { + label: app.name, + submenu: [ + { role: "about" }, + { + label: "Check for Updates...", + click: () => + autoUpdater.checkForUpdatesAndNotify().then((e) => { + if ( + !e || + compareSemanticVersions( + app.getVersion(), + e.updateInfo.version + ) >= 0 + ) + dialog.showMessageBox({ + message: `There are currently no updates available.`, + }); + }), + }, + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { role: "quit" }, + ], + }, + ] + : []), + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + ...(isMac + ? [ + { role: "pasteAndMatchStyle" }, + { role: "delete" }, + { role: "selectAll" }, + { type: "separator" }, + { + label: "Speech", + submenu: [{ role: "startSpeaking" }, { role: "stopSpeaking" }], + }, + ] + : [{ role: "delete" }, { type: "separator" }, { role: "selectAll" }]), + ], + }, + { + label: "View", + submenu: [ + { role: "reload" }, + { role: "forceReload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + { + label: "Window", + submenu: [ + { role: "minimize" }, + { role: "zoom" }, + ...(isMac + ? [ + { type: "separator" }, + { role: "front" }, + { type: "separator" }, + { role: "window" }, + ] + : [{ role: "close" }]), + ], + }, + { + role: "help", + submenu: [ + { + label: "Learn More", + click: async () => { + const { shell } = require("electron"); + await shell.openExternal("https://jan.ai/"); + }, + }, + ], + }, +]; + +export const setupMenu = () => { + const menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); +}; diff --git a/server/utils/versionDiff.ts b/server/utils/versionDiff.ts new file mode 100644 index 000000000..25934e87f --- /dev/null +++ b/server/utils/versionDiff.ts @@ -0,0 +1,21 @@ +export const compareSemanticVersions = (a: string, b: string) => { + + // 1. Split the strings into their parts. + const a1 = a.split('.'); + const b1 = b.split('.'); + // 2. Contingency in case there's a 4th or 5th version + const len = Math.min(a1.length, b1.length); + // 3. Look through each version number and compare. + for (let i = 0; i < len; i++) { + const a2 = +a1[ i ] || 0; + const b2 = +b1[ i ] || 0; + + if (a2 !== b2) { + return a2 > b2 ? 1 : -1; + } + } + + // 4. We hit this if the all checked versions so far are equal + // + return b1.length - a1.length; +}; \ No newline at end of file From 64a58d1e15242402caaa1bd0aff2e2cc5b312732 Mon Sep 17 00:00:00 2001 From: Linh Tran Date: Wed, 29 Nov 2023 09:29:04 +0700 Subject: [PATCH 02/85] WIP: fs adapter calling server API --- Makefile | 2 +- core/src/fs.ts | 143 +++++++++++++++++++++++++++++++++++++++----- server/main.ts | 35 ++++++----- server/package.json | 3 +- 4 files changed, 147 insertions(+), 36 deletions(-) diff --git a/Makefile b/Makefile index af416304c..e0f6cb274 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ install-and-build: build-uikit ifeq ($(OS),Windows_NT) powershell -Command "yarn config set network-timeout 300000; \ $$env:NITRO_VERSION = Get-Content .\\plugins\\inference-plugin\\nitro\\version.txt; \ - Write-Output \"Nitro version: $$env:NITRO_VERSION\"; yarn build:core; yarn install; yarn build:plugins" + Write-Output \"Nitro version: $$env:NITRO_VERSION\"; yarn build:core; yarn install; yarn build:plugins" else yarn build:core yarn install diff --git a/core/src/fs.ts b/core/src/fs.ts index 2c94a2ce8..b8722e1a6 100644 --- a/core/src/fs.ts +++ b/core/src/fs.ts @@ -1,59 +1,170 @@ +const fetchRetry = require("fetch-retry")(global.fetch); + +const PORT = 1337; +const LOCAL_HOST = "127.0.0.1"; +const JAN_HTTP_SERVER_URL = `http://${LOCAL_HOST}:${PORT}`; +const JAN_FS_API = `${JAN_HTTP_SERVER_URL}/fs`; /** * Writes data to a file at the specified path. * @param {string} path - The path to the file. * @param {string} data - The data to write to the file. * @returns {Promise} A Promise that resolves when the file is written successfully. */ -const writeFile: (path: string, data: string) => Promise = (path, data) => - window.coreAPI?.writeFile(path, data) ?? - window.electronAPI?.writeFile(path, data); - +const writeFile = (path: string, data: string): Promise => { + return fetchRetry(JAN_FS_API, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + op: 'writeFile', + path, + data + }), + retries: 3, + retryDelay: 500, + }).catch((err: any) => { + console.error(err); + throw new Error(`writeFile: ${path} failed`); + }) +} + /** * Checks whether the path is a directory. * @param path - The path to check. * @returns {boolean} A boolean indicating whether the path is a directory. */ -const isDirectory = (path: string): Promise => - window.coreAPI?.isDirectory(path) ?? window.electronAPI?.isDirectory(path); +const isDirectory = (path: string): Promise => { + return fetchRetry(JAN_FS_API, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + op: 'isDirectory', + path, + }), + retries: 3, + retryDelay: 500, + }).catch((err: any) => { + console.error(err); + throw new Error(`isDirectory: ${path} failed`); + }) +} /** * Reads the contents of a file at the specified path. * @param {string} path - The path of the file to read. * @returns {Promise} A Promise that resolves with the contents of the file. */ -const readFile: (path: string) => Promise = (path) => - window.coreAPI?.readFile(path) ?? window.electronAPI?.readFile(path); +const readFile = (path: string): Promise => { + return fetchRetry(JAN_FS_API, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + op: 'readFile', + path, + }), + retries: 3, + retryDelay: 500, + }).catch((err: any) => { + console.error(err); + throw new Error(`readFile: ${path} failed`); + }) +} /** * List the directory files * @param {string} path - The path of the directory to list files. * @returns {Promise} A Promise that resolves with the contents of the directory. */ -const listFiles: (path: string) => Promise = (path) => - window.coreAPI?.listFiles(path) ?? window.electronAPI?.listFiles(path); +const listFiles = (path: string): Promise => { + return fetchRetry(JAN_FS_API, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + op: 'listFiles', + path, + }), + retries: 3, + retryDelay: 500, + }).catch((err: any) => { + console.error(err); + throw new Error(`listFiles: ${path} failed`); + }) +} /** * Creates a directory at the specified path. * @param {string} path - The path of the directory to create. * @returns {Promise} A Promise that resolves when the directory is created successfully. */ -const mkdir: (path: string) => Promise = (path) => - window.coreAPI?.mkdir(path) ?? window.electronAPI?.mkdir(path); +const mkdir = (path: string): Promise => { + return fetchRetry(JAN_FS_API, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + op: 'mkdir', + path, + }), + retries: 3, + retryDelay: 500, + }).catch((err: any) => { + console.error(err); + throw new Error(`mkdir: ${path} failed`); + }) +} /** * Removes a directory at the specified path. * @param {string} path - The path of the directory to remove. * @returns {Promise} A Promise that resolves when the directory is removed successfully. */ -const rmdir: (path: string) => Promise = (path) => - window.coreAPI?.rmdir(path) ?? window.electronAPI?.rmdir(path); +const rmdir = (path: string): Promise => { + return fetchRetry(JAN_FS_API, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + op: 'rmdir', + path, + }), + retries: 3, + retryDelay: 500, + }).catch((err: any) => { + console.error(err); + throw new Error(`rmdir: ${path} failed`); + }) +} /** * Deletes a file from the local file system. * @param {string} path - The path of the file to delete. * @returns {Promise} A Promise that resolves when the file is deleted. */ -const deleteFile: (path: string) => Promise = (path) => - window.coreAPI?.deleteFile(path) ?? window.electronAPI?.deleteFile(path); +const deleteFile = (path: string): Promise => { + return fetchRetry(JAN_FS_API, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + op: 'deleteFile', + path, + }), + retries: 3, + retryDelay: 500, + }).catch((err: any) => { + console.error(err); + throw new Error(`deleteFile: ${path} failed`); + }) +} export const fs = { isDirectory, diff --git a/server/main.ts b/server/main.ts index a4f4df1bb..5ba2045c1 100644 --- a/server/main.ts +++ b/server/main.ts @@ -1,32 +1,31 @@ -import { join } from 'path' import { setupMenu } from './utils/menu' -import { handleFsIPCs } from './handlers/fs' import app from 'express' - +import bodyParser from 'body-parser' +import fs from 'fs' /** * Managers **/ import { ModuleManager } from './managers/module' import { PluginManager } from './managers/plugin' -/** - * IPC Handlers - **/ -import { handleDownloaderIPCs } from './handlers/download' -import { handlePluginIPCs } from './handlers/plugin' -app().listen(6969, ()=>{ +const server = app() +server.use(bodyParser) + +const USER_ROOT_DIR = '.data' +server.post("fs", (req, res) => { + let op = req.body.op; + switch(op){ + case 'readFile': + fs.readFile(req.body.path, ()=>{}) + case 'writeFile': + fs.writeFile(req.body.path, Buffer.from(req.body.data, "base64"), ()=>{}) + } +}) + +server.listen(1337, ()=>{ PluginManager.instance.migratePlugins() PluginManager.instance.setupPlugins() setupMenu() - handleIPCs() }) -/** - * Handles various IPC messages from the renderer process. - */ -function handleIPCs() { - handleFsIPCs() - handleDownloaderIPCs() - handlePluginIPCs() -} diff --git a/server/package.json b/server/package.json index 8185f37d9..9523fa000 100644 --- a/server/package.json +++ b/server/package.json @@ -67,7 +67,6 @@ }, "dependencies": { "@npmcli/arborist": "^7.1.0", - "@types/express": "^4.17.21", "@types/request": "^2.48.12", "@uiball/loaders": "^1.3.0", "electron-store": "^8.1.0", @@ -81,6 +80,8 @@ "devDependencies": { "@electron/notarize": "^2.1.0", "@playwright/test": "^1.38.1", + "@types/body-parser": "^1.19.5", + "@types/express": "^4.17.21", "@types/npmcli__arborist": "^5.6.4", "@types/pacote": "^11.1.7", "@typescript-eslint/eslint-plugin": "^6.7.3", From e5f57b853bd334f724ef059b138fedd47228f04c Mon Sep 17 00:00:00 2001 From: Linh Tran Date: Fri, 1 Dec 2023 09:35:51 +0700 Subject: [PATCH 03/85] boilerplate for express server --- server/core/plugin/facade.ts | 30 --- server/core/plugin/globals.ts | 36 ---- server/core/plugin/index.ts | 149 -------------- server/core/plugin/plugin.ts | 213 --------------------- server/core/plugin/router.ts | 97 ---------- server/core/plugin/store.ts | 131 ------------- server/{core/pre-install => data}/.gitkeep | 0 server/handlers/download.ts | 108 ----------- server/handlers/fs.ts | 156 --------------- server/handlers/plugin.ts | 118 ------------ server/lib/.gitkeep | 0 server/main.ts | 43 ++--- server/managers/download.ts | 24 --- server/managers/module.ts | 33 ---- server/managers/plugin.ts | 60 ------ server/managers/window.ts | 37 ---- server/nodemon.json | 5 + server/package.json | 24 +-- server/tsconfig.json | 2 + server/utils/disposable.ts | 8 - server/utils/menu.ts | 111 ----------- server/utils/versionDiff.ts | 21 -- server/v1/assistants/index.ts | 5 + server/v1/chat/index.ts | 5 + server/v1/index.ts | 20 ++ server/v1/models/downloadModel.ts | 5 + server/v1/models/index.ts | 18 ++ server/v1/threads/index.ts | 5 + 28 files changed, 90 insertions(+), 1374 deletions(-) delete mode 100644 server/core/plugin/facade.ts delete mode 100644 server/core/plugin/globals.ts delete mode 100644 server/core/plugin/index.ts delete mode 100644 server/core/plugin/plugin.ts delete mode 100644 server/core/plugin/router.ts delete mode 100644 server/core/plugin/store.ts rename server/{core/pre-install => data}/.gitkeep (100%) delete mode 100644 server/handlers/download.ts delete mode 100644 server/handlers/fs.ts delete mode 100644 server/handlers/plugin.ts create mode 100644 server/lib/.gitkeep delete mode 100644 server/managers/download.ts delete mode 100644 server/managers/module.ts delete mode 100644 server/managers/plugin.ts delete mode 100644 server/managers/window.ts create mode 100644 server/nodemon.json delete mode 100644 server/utils/disposable.ts delete mode 100644 server/utils/menu.ts delete mode 100644 server/utils/versionDiff.ts create mode 100644 server/v1/assistants/index.ts create mode 100644 server/v1/chat/index.ts create mode 100644 server/v1/index.ts create mode 100644 server/v1/models/downloadModel.ts create mode 100644 server/v1/models/index.ts create mode 100644 server/v1/threads/index.ts diff --git a/server/core/plugin/facade.ts b/server/core/plugin/facade.ts deleted file mode 100644 index bd1089109..000000000 --- a/server/core/plugin/facade.ts +++ /dev/null @@ -1,30 +0,0 @@ -const { ipcRenderer, contextBridge } = require("electron"); - -export function useFacade() { - const interfaces = { - install(plugins: any[]) { - return ipcRenderer.invoke("pluggable:install", plugins); - }, - uninstall(plugins: any[], reload: boolean) { - return ipcRenderer.invoke("pluggable:uninstall", plugins, reload); - }, - getActive() { - return ipcRenderer.invoke("pluggable:getActivePlugins"); - }, - update(plugins: any[], reload: boolean) { - return ipcRenderer.invoke("pluggable:update", plugins, reload); - }, - updatesAvailable(plugin: any) { - return ipcRenderer.invoke("pluggable:updatesAvailable", plugin); - }, - toggleActive(plugin: any, active: boolean) { - return ipcRenderer.invoke("pluggable:togglePluginActive", plugin, active); - }, - }; - - if (contextBridge) { - contextBridge.exposeInMainWorld("pluggableElectronIpc", interfaces); - } - - return interfaces; -} diff --git a/server/core/plugin/globals.ts b/server/core/plugin/globals.ts deleted file mode 100644 index 69df7925c..000000000 --- a/server/core/plugin/globals.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { existsSync, mkdirSync, writeFileSync } from "fs"; -import { join, resolve } from "path"; - -export let pluginsPath: string | undefined = undefined; - -/** - * @private - * Set path to plugins directory and create the directory if it does not exist. - * @param {string} plgPath path to plugins directory - */ -export function setPluginsPath(plgPath: string) { - // Create folder if it does not exist - let plgDir; - try { - plgDir = resolve(plgPath); - if (plgDir.length < 2) throw new Error(); - - if (!existsSync(plgDir)) mkdirSync(plgDir); - - const pluginsJson = join(plgDir, "plugins.json"); - if (!existsSync(pluginsJson)) writeFileSync(pluginsJson, "{}", "utf8"); - - pluginsPath = plgDir; - } catch (error) { - throw new Error("Invalid path provided to the plugins folder"); - } -} - -/** - * @private - * Get the path to the plugins.json file. - * @returns location of plugins.json - */ -export function getPluginsFile() { - return join(pluginsPath ?? "", "plugins.json"); -} \ No newline at end of file diff --git a/server/core/plugin/index.ts b/server/core/plugin/index.ts deleted file mode 100644 index e8c64747b..000000000 --- a/server/core/plugin/index.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { readFileSync } from "fs"; -import { protocol } from "electron"; -import { normalize } from "path"; - -import Plugin from "./plugin"; -import { - getAllPlugins, - removePlugin, - persistPlugins, - installPlugins, - getPlugin, - getActivePlugins, - addPlugin, -} from "./store"; -import { - pluginsPath as storedPluginsPath, - setPluginsPath, - getPluginsFile, -} from "./globals"; -import router from "./router"; - -/** - * Sets up the required communication between the main and renderer processes. - * Additionally sets the plugins up using {@link usePlugins} if a pluginsPath is provided. - * @param {Object} options configuration for setting up the renderer facade. - * @param {confirmInstall} [options.confirmInstall] Function to validate that a plugin should be installed. - * @param {Boolean} [options.useFacade=true] Whether to make a facade to the plugins available in the renderer. - * @param {string} [options.pluginsPath] Optional path to the plugins folder. - * @returns {pluginManager|Object} A set of functions used to manage the plugin lifecycle if usePlugins is provided. - * @function - */ -export function init(options: any) { - if ( - !Object.prototype.hasOwnProperty.call(options, "useFacade") || - options.useFacade - ) { - // Enable IPC to be used by the facade - router(); - } - - // Create plugins protocol to serve plugins to renderer - registerPluginProtocol(); - - // perform full setup if pluginsPath is provided - if (options.pluginsPath) { - return usePlugins(options.pluginsPath); - } - - return {}; -} - -/** - * Create plugins protocol to provide plugins to renderer - * @private - * @returns {boolean} Whether the protocol registration was successful - */ -function registerPluginProtocol() { - return protocol.registerFileProtocol("plugin", (request, callback) => { - const entry = request.url.substr(8); - const url = normalize(storedPluginsPath + entry); - callback({ path: url }); - }); -} - -/** - * Set Pluggable Electron up to run from the pluginPath folder if it is provided and - * load plugins persisted in that folder. - * @param {string} pluginsPath Path to the plugins folder. Required if not yet set up. - * @returns {pluginManager} A set of functions used to manage the plugin lifecycle. - */ -export function usePlugins(pluginsPath: string) { - if (!pluginsPath) - throw Error( - "A path to the plugins folder is required to use Pluggable Electron" - ); - // Store the path to the plugins folder - setPluginsPath(pluginsPath); - - // Remove any registered plugins - for (const plugin of getAllPlugins()) { - if (plugin.name) removePlugin(plugin.name, false); - } - - // Read plugin list from plugins folder - const plugins = JSON.parse(readFileSync(getPluginsFile(), "utf-8")); - try { - // Create and store a Plugin instance for each plugin in list - for (const p in plugins) { - loadPlugin(plugins[p]); - } - persistPlugins(); - } catch (error) { - // Throw meaningful error if plugin loading fails - throw new Error( - "Could not successfully rebuild list of installed plugins.\n" + - error + - "\nPlease check the plugins.json file in the plugins folder." - ); - } - - // Return the plugin lifecycle functions - return getStore(); -} - -/** - * Check the given plugin object. If it is marked for uninstalling, the plugin files are removed. - * Otherwise a Plugin instance for the provided object is created and added to the store. - * @private - * @param {Object} plg Plugin info - */ -function loadPlugin(plg: any) { - // Create new plugin, populate it with plg details and save it to the store - const plugin = new Plugin(); - - for (const key in plg) { - if (Object.prototype.hasOwnProperty.call(plg, key)) { - // Use Object.defineProperty to set the properties as writable - Object.defineProperty(plugin, key, { - value: plg[key], - writable: true, - enumerable: true, - configurable: true, - }); - } - } - - addPlugin(plugin, false); - plugin.subscribe("pe-persist", persistPlugins); -} - -/** - * Returns the publicly available store functions. - * @returns {pluginManager} A set of functions used to manage the plugin lifecycle. - */ -export function getStore() { - if (!storedPluginsPath) { - throw new Error( - "The plugin path has not yet been set up. Please run usePlugins before accessing the store" - ); - } - - return { - installPlugins, - getPlugin, - getAllPlugins, - getActivePlugins, - removePlugin, - }; -} diff --git a/server/core/plugin/plugin.ts b/server/core/plugin/plugin.ts deleted file mode 100644 index f0fc073d7..000000000 --- a/server/core/plugin/plugin.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { rmdir } from "fs/promises"; -import { resolve, join } from "path"; -import { manifest, extract } from "pacote"; -import * as Arborist from "@npmcli/arborist"; - -import { pluginsPath } from "./globals"; - -/** - * An NPM package that can be used as a Pluggable Electron plugin. - * Used to hold all the information and functions necessary to handle the plugin lifecycle. - */ -class Plugin { - /** - * @property {string} origin Original specification provided to fetch the package. - * @property {Object} installOptions Options provided to pacote when fetching the manifest. - * @property {name} name The name of the plugin as defined in the manifest. - * @property {string} url Electron URL where the package can be accessed. - * @property {string} version Version of the package as defined in the manifest. - * @property {Array} activationPoints List of {@link ./Execution-API#activationPoints|activation points}. - * @property {string} main The entry point as defined in the main entry of the manifest. - * @property {string} description The description of plugin as defined in the manifest. - * @property {string} icon The icon of plugin as defined in the manifest. - */ - origin?: string; - installOptions: any; - name?: string; - url?: string; - version?: string; - activationPoints?: Array; - main?: string; - description?: string; - icon?: string; - - /** @private */ - _active = false; - - /** - * @private - * @property {Object.} #listeners A list of callbacks to be executed when the Plugin is updated. - */ - listeners: Record void> = {}; - - /** - * Set installOptions with defaults for options that have not been provided. - * @param {string} [origin] Original specification provided to fetch the package. - * @param {Object} [options] Options provided to pacote when fetching the manifest. - */ - constructor(origin?: string, options = {}) { - const defaultOpts = { - version: false, - fullMetadata: false, - Arborist, - }; - - this.origin = origin; - this.installOptions = { ...defaultOpts, ...options }; - } - - /** - * Package name with version number. - * @type {string} - */ - get specifier() { - return ( - this.origin + - (this.installOptions.version ? "@" + this.installOptions.version : "") - ); - } - - /** - * Whether the plugin should be registered with its activation points. - * @type {boolean} - */ - get active() { - return this._active; - } - - /** - * Set Package details based on it's manifest - * @returns {Promise.} Resolves to true when the action completed - */ - async getManifest() { - // Get the package's manifest (package.json object) - try { - const mnf = await manifest(this.specifier, this.installOptions); - - // set the Package properties based on the it's manifest - this.name = mnf.name; - this.version = mnf.version; - this.activationPoints = mnf.activationPoints - ? (mnf.activationPoints as string[]) - : undefined; - this.main = mnf.main; - this.description = mnf.description; - this.icon = mnf.icon as any; - } catch (error) { - throw new Error( - `Package ${this.origin} does not contain a valid manifest: ${error}` - ); - } - - return true; - } - - /** - * Extract plugin to plugins folder. - * @returns {Promise.} This plugin - * @private - */ - async _install() { - try { - // import the manifest details - await this.getManifest(); - - // Install the package in a child folder of the given folder - await extract( - this.specifier, - join(pluginsPath ?? "", this.name ?? ""), - this.installOptions - ); - - if (!Array.isArray(this.activationPoints)) - throw new Error("The plugin does not contain any activation points"); - - // Set the url using the custom plugins protocol - this.url = `plugin://${this.name}/${this.main}`; - - this.emitUpdate(); - } catch (err) { - // Ensure the plugin is not stored and the folder is removed if the installation fails - this.setActive(false); - throw err; - } - - return [this]; - } - - /** - * Subscribe to updates of this plugin - * @param {string} name name of the callback to register - * @param {callback} cb The function to execute on update - */ - subscribe(name: string, cb: () => void) { - this.listeners[name] = cb; - } - - /** - * Remove subscription - * @param {string} name name of the callback to remove - */ - unsubscribe(name: string) { - delete this.listeners[name]; - } - - /** - * Execute listeners - */ - emitUpdate() { - for (const cb in this.listeners) { - this.listeners[cb].call(null, this); - } - } - - /** - * Check for updates and install if available. - * @param {string} version The version to update to. - * @returns {boolean} Whether an update was performed. - */ - async update(version = false) { - if (await this.isUpdateAvailable()) { - this.installOptions.version = version; - await this._install(); - return true; - } - - return false; - } - - /** - * Check if a new version of the plugin is available at the origin. - * @returns the latest available version if a new version is available or false if not. - */ - async isUpdateAvailable() { - if (this.origin) { - const mnf = await manifest(this.origin); - return mnf.version !== this.version ? mnf.version : false; - } - } - - /** - * Remove plugin and refresh renderers. - * @returns {Promise} - */ - async uninstall() { - const plgPath = resolve(pluginsPath ?? "", this.name ?? ""); - await rmdir(plgPath, { recursive: true }); - - this.emitUpdate(); - } - - /** - * Set a plugin's active state. This determines if a plugin should be loaded on initialisation. - * @param {boolean} active State to set _active to - * @returns {Plugin} This plugin - */ - setActive(active: boolean) { - this._active = active; - this.emitUpdate(); - return this; - } -} - -export default Plugin; diff --git a/server/core/plugin/router.ts b/server/core/plugin/router.ts deleted file mode 100644 index 09c79485b..000000000 --- a/server/core/plugin/router.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { ipcMain, webContents } from "electron"; - -import { - getPlugin, - getActivePlugins, - installPlugins, - removePlugin, - getAllPlugins, -} from "./store"; -import { pluginsPath } from "./globals"; -import Plugin from "./plugin"; - -// Throw an error if pluginsPath has not yet been provided by usePlugins. -const checkPluginsPath = () => { - if (!pluginsPath) - throw Error("Path to plugins folder has not yet been set up."); -}; -let active = false; -/** - * Provide the renderer process access to the plugins. - **/ -export default function () { - if (active) return; - // Register IPC route to install a plugin - ipcMain.handle("pluggable:install", async (e, plugins) => { - checkPluginsPath(); - - // Install and activate all provided plugins - const installed = await installPlugins(plugins); - return JSON.parse(JSON.stringify(installed)); - }); - - // Register IPC route to uninstall a plugin - ipcMain.handle("pluggable:uninstall", async (e, plugins, reload) => { - checkPluginsPath(); - - // Uninstall all provided plugins - for (const plg of plugins) { - const plugin = getPlugin(plg); - await plugin.uninstall(); - if (plugin.name) removePlugin(plugin.name); - } - - // Reload all renderer pages if needed - reload && webContents.getAllWebContents().forEach((wc) => wc.reload()); - return true; - }); - - // Register IPC route to update a plugin - ipcMain.handle("pluggable:update", async (e, plugins, reload) => { - checkPluginsPath(); - - // Update all provided plugins - const updated: Plugin[] = []; - for (const plg of plugins) { - const plugin = getPlugin(plg); - const res = await plugin.update(); - if (res) updated.push(plugin); - } - - // Reload all renderer pages if needed - if (updated.length && reload) - webContents.getAllWebContents().forEach((wc) => wc.reload()); - - return JSON.parse(JSON.stringify(updated)); - }); - - // Register IPC route to check if updates are available for a plugin - ipcMain.handle("pluggable:updatesAvailable", (e, names) => { - checkPluginsPath(); - - const plugins = names - ? names.map((name: string) => getPlugin(name)) - : getAllPlugins(); - - const updates: Record = {}; - for (const plugin of plugins) { - updates[plugin.name] = plugin.isUpdateAvailable(); - } - return updates; - }); - - // Register IPC route to get the list of active plugins - ipcMain.handle("pluggable:getActivePlugins", () => { - checkPluginsPath(); - return JSON.parse(JSON.stringify(getActivePlugins())); - }); - - // Register IPC route to toggle the active state of a plugin - ipcMain.handle("pluggable:togglePluginActive", (e, plg, active) => { - checkPluginsPath(); - const plugin = getPlugin(plg); - return JSON.parse(JSON.stringify(plugin.setActive(active))); - }); - - active = true; -} diff --git a/server/core/plugin/store.ts b/server/core/plugin/store.ts deleted file mode 100644 index cfd25e5ca..000000000 --- a/server/core/plugin/store.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Provides access to the plugins stored by Pluggable Electron - * @typedef {Object} pluginManager - * @prop {getPlugin} getPlugin - * @prop {getAllPlugins} getAllPlugins - * @prop {getActivePlugins} getActivePlugins - * @prop {installPlugins} installPlugins - * @prop {removePlugin} removePlugin - */ - -import { writeFileSync } from "fs"; -import Plugin from "./plugin"; -import { getPluginsFile } from "./globals"; - -/** - * @module store - * @private - */ - -/** - * Register of installed plugins - * @type {Object.} plugin - List of installed plugins - */ -const plugins: Record = {}; - -/** - * Get a plugin from the stored plugins. - * @param {string} name Name of the plugin to retrieve - * @returns {Plugin} Retrieved plugin - * @alias pluginManager.getPlugin - */ -export function getPlugin(name: string) { - if (!Object.prototype.hasOwnProperty.call(plugins, name)) { - throw new Error(`Plugin ${name} does not exist`); - } - - return plugins[name]; -} - -/** - * Get list of all plugin objects. - * @returns {Array.} All plugin objects - * @alias pluginManager.getAllPlugins - */ -export function getAllPlugins() { - return Object.values(plugins); -} - -/** - * Get list of active plugin objects. - * @returns {Array.} Active plugin objects - * @alias pluginManager.getActivePlugins - */ -export function getActivePlugins() { - return Object.values(plugins).filter((plugin) => plugin.active); -} - -/** - * Remove plugin from store and maybe save stored plugins to file - * @param {string} name Name of the plugin to remove - * @param {boolean} persist Whether to save the changes to plugins to file - * @returns {boolean} Whether the delete was successful - * @alias pluginManager.removePlugin - */ -export function removePlugin(name: string, persist = true) { - const del = delete plugins[name]; - if (persist) persistPlugins(); - return del; -} - -/** - * Add plugin to store and maybe save stored plugins to file - * @param {Plugin} plugin Plugin to add to store - * @param {boolean} persist Whether to save the changes to plugins to file - * @returns {void} - */ -export function addPlugin(plugin: Plugin, persist = true) { - if (plugin.name) plugins[plugin.name] = plugin; - if (persist) { - persistPlugins(); - plugin.subscribe("pe-persist", persistPlugins); - } -} - -/** - * Save stored plugins to file - * @returns {void} - */ -export function persistPlugins() { - const persistData: Record = {}; - for (const name in plugins) { - persistData[name] = plugins[name]; - } - writeFileSync(getPluginsFile(), JSON.stringify(persistData), "utf8"); -} - -/** - * Create and install a new plugin for the given specifier. - * @param {Array.} plugins A list of NPM specifiers, or installation configuration objects. - * @param {boolean} [store=true] Whether to store the installed plugins in the store - * @returns {Promise.>} New plugin - * @alias pluginManager.installPlugins - */ -export async function installPlugins(plugins: any, store = true) { - const installed: Plugin[] = []; - for (const plg of plugins) { - // Set install options and activation based on input type - const isObject = typeof plg === "object"; - const spec = isObject ? [plg.specifier, plg] : [plg]; - const activate = isObject ? plg.activate !== false : true; - - // Install and possibly activate plugin - const plugin = new Plugin(...spec); - await plugin._install(); - if (activate) plugin.setActive(true); - - // Add plugin to store if needed - if (store) addPlugin(plugin); - installed.push(plugin); - } - - // Return list of all installed plugins - return installed; -} - -/** - * @typedef {Object.} installOptions The {@link https://www.npmjs.com/package/pacote|pacote} - * options used to install the plugin with some extra options. - * @param {string} specifier the NPM specifier that identifies the package. - * @param {boolean} [activate] Whether this plugin should be activated after installation. Defaults to true. - */ diff --git a/server/core/pre-install/.gitkeep b/server/data/.gitkeep similarity index 100% rename from server/core/pre-install/.gitkeep rename to server/data/.gitkeep diff --git a/server/handlers/download.ts b/server/handlers/download.ts deleted file mode 100644 index 3a1fc36d1..000000000 --- a/server/handlers/download.ts +++ /dev/null @@ -1,108 +0,0 @@ -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.debug( - `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 = join(app.getPath('home'), 'jan') - const destination = resolve(userDataPath, fileName) - const rq = request(url) - - progress(rq, {}) - .on('progress', function (state: any) { - WindowManager?.instance.currentWindow?.webContents.send( - 'FILE_DOWNLOAD_UPDATE', - { - ...state, - fileName, - } - ) - }) - .on('error', function (err: Error) { - WindowManager?.instance.currentWindow?.webContents.send( - 'FILE_DOWNLOAD_ERROR', - { - fileName, - err, - } - ) - }) - .on('end', function () { - if (DownloadManager.instance.networkRequests[fileName]) { - WindowManager?.instance.currentWindow?.webContents.send( - 'FILE_DOWNLOAD_COMPLETE', - { - fileName, - } - ) - DownloadManager.instance.setRequest(fileName, undefined) - } else { - WindowManager?.instance.currentWindow?.webContents.send( - 'FILE_DOWNLOAD_ERROR', - { - fileName, - err: 'Download cancelled', - } - ) - } - }) - .pipe(createWriteStream(destination)) - - DownloadManager.instance.setRequest(fileName, rq) - }) -} diff --git a/server/handlers/fs.ts b/server/handlers/fs.ts deleted file mode 100644 index c1e8a85e4..000000000 --- a/server/handlers/fs.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { app, ipcMain } from 'electron' -import * as fs from 'fs' -import { join } from 'path' - -/** - * Handles file system operations. - */ -export function handleFsIPCs() { - const userSpacePath = join(app.getPath('home'), 'jan') - - /** - * Gets the path to the user data directory. - * @param event - The event object. - * @returns A promise that resolves with the path to the user data directory. - */ - ipcMain.handle( - 'getUserSpace', - (): Promise => Promise.resolve(userSpacePath) - ) - - /** - * Checks whether the path is a directory. - * @param event - The event object. - * @param path - The path to check. - * @returns A promise that resolves with a boolean indicating whether the path is a directory. - */ - ipcMain.handle('isDirectory', (_event, path: string): Promise => { - const fullPath = join(userSpacePath, path) - return Promise.resolve( - fs.existsSync(fullPath) && fs.lstatSync(fullPath).isDirectory() - ) - }) - - /** - * Reads a file from the user data directory. - * @param event - The event object. - * @param path - The path of the file to read. - * @returns A promise that resolves with the contents of the file. - */ - ipcMain.handle('readFile', async (event, path: string): Promise => { - return new Promise((resolve, reject) => { - fs.readFile(join(userSpacePath, path), 'utf8', (err, data) => { - if (err) { - reject(err) - } else { - resolve(data) - } - }) - }) - }) - - /** - * Writes data to a file in the user data directory. - * @param event - The event object. - * @param path - The path of the file to write to. - * @param data - The data to write to the file. - * @returns A promise that resolves when the file has been written. - */ - ipcMain.handle( - 'writeFile', - async (event, path: string, data: string): Promise => { - return new Promise((resolve, reject) => { - fs.writeFile(join(userSpacePath, path), data, 'utf8', (err) => { - if (err) { - reject(err) - } else { - resolve() - } - }) - }) - } - ) - - /** - * Creates a directory in the user data directory. - * @param event - The event object. - * @param path - The path of the directory to create. - * @returns A promise that resolves when the directory has been created. - */ - ipcMain.handle('mkdir', async (event, path: string): Promise => { - return new Promise((resolve, reject) => { - fs.mkdir(join(userSpacePath, path), { recursive: true }, (err) => { - if (err) { - reject(err) - } else { - resolve() - } - }) - }) - }) - - /** - * Removes a directory in the user data directory. - * @param event - The event object. - * @param path - The path of the directory to remove. - * @returns A promise that resolves when the directory is removed successfully. - */ - ipcMain.handle('rmdir', async (event, path: string): Promise => { - return new Promise((resolve, reject) => { - fs.rmdir(join(userSpacePath, path), { recursive: true }, (err) => { - if (err) { - reject(err) - } else { - resolve() - } - }) - }) - }) - - /** - * Lists the files in a directory in the user data directory. - * @param event - The event object. - * @param path - The path of the directory to list files from. - * @returns A promise that resolves with an array of file names. - */ - ipcMain.handle( - 'listFiles', - async (event, path: string): Promise => { - return new Promise((resolve, reject) => { - fs.readdir(join(userSpacePath, path), (err, files) => { - if (err) { - reject(err) - } else { - resolve(files) - } - }) - }) - } - ) - - /** - * 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 fullPath = join(userSpacePath, 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.debug( - `Delete file ${filePath} from ${fullPath} result: ${result}` - ) - }) - - return result - }) -} diff --git a/server/handlers/plugin.ts b/server/handlers/plugin.ts deleted file mode 100644 index 22bf253e6..000000000 --- a/server/handlers/plugin.ts +++ /dev/null @@ -1,118 +0,0 @@ -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.debug(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.error(err); - ModuleManager.instance.clearImportedModules(); - - // just relaunch if packaged, should launch manually in development mode - if (app.isPackaged) { - app.relaunch(); - app.exit(); - } else { - for (const modulePath in ModuleManager.instance.requiredModules) { - delete require.cache[ - require.resolve( - join(app.getPath("userData"), "plugins", modulePath) - ) - ]; - } - PluginManager.instance.setupPlugins(); - WindowManager.instance.currentWindow?.reload(); - } - }); - }); - - /** - * Installs a remote plugin by downloading its tarball and writing it to a tgz file. - * @param _event - The IPC event object. - * @param pluginName - The name of the remote plugin to install. - * @returns A Promise that resolves to the path of the installed plugin file. - */ - ipcMain.handle("installRemotePlugin", async (_event, pluginName) => { - const destination = join( - app.getPath("userData"), - pluginName.replace(/^@.*\//, "") + ".tgz" - ); - return manifest(pluginName) - .then(async (manifest: any) => { - await tarball(manifest._resolved).then((data: Buffer) => { - writeFileSync(destination, data); - }); - }) - .then(() => destination); - }); -} diff --git a/server/lib/.gitkeep b/server/lib/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/server/main.ts b/server/main.ts index 5ba2045c1..582af5c61 100644 --- a/server/main.ts +++ b/server/main.ts @@ -1,31 +1,28 @@ -import { setupMenu } from './utils/menu' -import app from 'express' +import express from 'express' import bodyParser from 'body-parser' import fs from 'fs' -/** - * Managers - **/ -import { ModuleManager } from './managers/module' -import { PluginManager } from './managers/plugin' +import v1API from './v1' +const JAN_API_PORT = 1337; -const server = app() -server.use(bodyParser) +const server = express() +server.use(bodyParser.urlencoded()) +server.use(bodyParser.json()) const USER_ROOT_DIR = '.data' -server.post("fs", (req, res) => { - let op = req.body.op; - switch(op){ - case 'readFile': - fs.readFile(req.body.path, ()=>{}) - case 'writeFile': - fs.writeFile(req.body.path, Buffer.from(req.body.data, "base64"), ()=>{}) - } -}) - -server.listen(1337, ()=>{ - PluginManager.instance.migratePlugins() - PluginManager.instance.setupPlugins() - setupMenu() +server.use("/v1", v1API) + +// server.post("fs", (req, res) => { +// let op = req.body.op; +// switch(op){ +// case 'readFile': +// fs.readFile(req.body.path, ()=>{}) +// case 'writeFile': +// fs.writeFile(req.body.path, Buffer.from(req.body.data, "base64"), ()=>{}) +// } +// }) + +server.listen(JAN_API_PORT, () => { + console.log(`JAN API listening at: http://localhost:${JAN_API_PORT}`); }) diff --git a/server/managers/download.ts b/server/managers/download.ts deleted file mode 100644 index 08c089b74..000000000 --- a/server/managers/download.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Request } from "request"; - -/** - * Manages file downloads and network requests. - */ -export class DownloadManager { - public networkRequests: Record = {}; - - public static instance: DownloadManager = new DownloadManager(); - - constructor() { - if (DownloadManager.instance) { - return DownloadManager.instance; - } - } - /** - * Sets a network request for a specific file. - * @param {string} fileName - The name of the file. - * @param {Request | undefined} request - The network request to set, or undefined to clear the request. - */ - setRequest(fileName: string, request: Request | undefined) { - this.networkRequests[fileName] = request; - } -} diff --git a/server/managers/module.ts b/server/managers/module.ts deleted file mode 100644 index 43dda0fb6..000000000 --- a/server/managers/module.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { dispose } from "../utils/disposable"; - -/** - * Manages imported modules. - */ -export class ModuleManager { - public requiredModules: Record = {}; - - public static instance: ModuleManager = new ModuleManager(); - - constructor() { - if (ModuleManager.instance) { - return ModuleManager.instance; - } - } - - /** - * Sets a module. - * @param {string} moduleName - The name of the module. - * @param {any | undefined} nodule - The module to set, or undefined to clear the module. - */ - setModule(moduleName: string, nodule: any | undefined) { - this.requiredModules[moduleName] = nodule; - } - - /** - * Clears all imported modules. - */ - clearImportedModules() { - dispose(this.requiredModules); - this.requiredModules = {}; - } -} diff --git a/server/managers/plugin.ts b/server/managers/plugin.ts deleted file mode 100644 index 227eab34e..000000000 --- a/server/managers/plugin.ts +++ /dev/null @@ -1,60 +0,0 @@ -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.debug("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.error(err); - store.set("migrated_version", app.getVersion()); - console.debug("migrate plugins done"); - resolve(undefined); - }); - } else { - resolve(undefined); - } - }); - } -} diff --git a/server/managers/window.ts b/server/managers/window.ts deleted file mode 100644 index c930dd5ec..000000000 --- a/server/managers/window.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { BrowserWindow } from "electron"; - -/** - * Manages the current window instance. - */ -export class WindowManager { - public static instance: WindowManager = new WindowManager(); - public currentWindow?: BrowserWindow; - - constructor() { - if (WindowManager.instance) { - return WindowManager.instance; - } - } - - /** - * Creates a new window instance. - * @param {Electron.BrowserWindowConstructorOptions} options - The options to create the window with. - * @returns The created window instance. - */ - createWindow(options?: Electron.BrowserWindowConstructorOptions | undefined) { - this.currentWindow = new BrowserWindow({ - width: 1200, - minWidth: 800, - height: 800, - show: false, - trafficLightPosition: { - x: 10, - y: 15, - }, - titleBarStyle: "hidden", - vibrancy: "sidebar", - ...options, - }); - return this.currentWindow; - } -} diff --git a/server/nodemon.json b/server/nodemon.json new file mode 100644 index 000000000..0ea41ca96 --- /dev/null +++ b/server/nodemon.json @@ -0,0 +1,5 @@ +{ + "watch": ["main.ts", "v1"], + "ext": "ts, json", + "exec": "tsc && node ./build/main.js" +} \ No newline at end of file diff --git a/server/package.json b/server/package.json index 9523fa000..08c19ee31 100644 --- a/server/package.json +++ b/server/package.json @@ -51,26 +51,15 @@ "scripts": { "lint": "eslint . --ext \".js,.jsx,.ts,.tsx\"", "test:e2e": "playwright test --workers=1", - "dev": "tsc -p . && electron .", - "build": "run-script-os", - "build:test": "run-script-os", - "build:test:darwin": "tsc -p . && electron-builder -p never -m --dir", - "build:test:win32": "tsc -p . && electron-builder -p never -w --dir", - "build:test:linux": "tsc -p . && electron-builder -p never -l --dir", - "build:darwin": "tsc -p . && electron-builder -p never -m", - "build:win32": "tsc -p . && electron-builder -p never -w", - "build:linux": "tsc -p . && electron-builder -p never --linux deb", - "build:publish": "run-script-os", - "build:publish:darwin": "tsc -p . && electron-builder -p onTagOrDraft -m --x64 --arm64", - "build:publish:win32": "tsc -p . && electron-builder -p onTagOrDraft -w", - "build:publish:linux": "tsc -p . && electron-builder -p onTagOrDraft -l deb" + "dev": "nodemon .", + "build": "tsc", + "build:test": "", + "build:publish": "" }, "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", "express": "^4.18.2", "pacote": "^17.0.4", "request": "^2.88.2", @@ -78,7 +67,6 @@ "use-debounce": "^9.0.4" }, "devDependencies": { - "@electron/notarize": "^2.1.0", "@playwright/test": "^1.38.1", "@types/body-parser": "^1.19.5", "@types/express": "^4.17.21", @@ -86,10 +74,8 @@ "@types/pacote": "^11.1.7", "@typescript-eslint/eslint-plugin": "^6.7.3", "@typescript-eslint/parser": "^6.7.3", - "electron": "26.2.1", - "electron-builder": "^24.6.4", - "electron-playwright-helpers": "^1.6.0", "eslint-plugin-react": "^7.33.2", + "nodemon": "^3.0.1", "run-script-os": "^1.1.6" }, "installConfig": { diff --git a/server/tsconfig.json b/server/tsconfig.json index 3cc218f93..3363fdba6 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -15,6 +15,8 @@ "paths": { "*": ["node_modules/*"] }, "typeRoots": ["node_modules/@types"] }, + // "sourceMap": true, + "include": ["./**/*.ts"], "exclude": ["core", "build", "dist", "tests", "node_modules"] } diff --git a/server/utils/disposable.ts b/server/utils/disposable.ts deleted file mode 100644 index 462f7e3e5..000000000 --- a/server/utils/disposable.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function dispose(requiredModules: Record) { - for (const key in requiredModules) { - const module = requiredModules[key]; - if (typeof module["dispose"] === "function") { - module["dispose"](); - } - } -} diff --git a/server/utils/menu.ts b/server/utils/menu.ts deleted file mode 100644 index 65e009aef..000000000 --- a/server/utils/menu.ts +++ /dev/null @@ -1,111 +0,0 @@ -// @ts-nocheck -const { app, Menu, dialog } = require("electron"); -const isMac = process.platform === "darwin"; -const { autoUpdater } = require("electron-updater"); -import { compareSemanticVersions } from "./versionDiff"; - -const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [ - ...(isMac - ? [ - { - label: app.name, - submenu: [ - { role: "about" }, - { - label: "Check for Updates...", - click: () => - autoUpdater.checkForUpdatesAndNotify().then((e) => { - if ( - !e || - compareSemanticVersions( - app.getVersion(), - e.updateInfo.version - ) >= 0 - ) - dialog.showMessageBox({ - message: `There are currently no updates available.`, - }); - }), - }, - { type: "separator" }, - { role: "services" }, - { type: "separator" }, - { role: "hide" }, - { role: "hideOthers" }, - { role: "unhide" }, - { type: "separator" }, - { role: "quit" }, - ], - }, - ] - : []), - { - label: "Edit", - submenu: [ - { role: "undo" }, - { role: "redo" }, - { type: "separator" }, - { role: "cut" }, - { role: "copy" }, - { role: "paste" }, - ...(isMac - ? [ - { role: "pasteAndMatchStyle" }, - { role: "delete" }, - { role: "selectAll" }, - { type: "separator" }, - { - label: "Speech", - submenu: [{ role: "startSpeaking" }, { role: "stopSpeaking" }], - }, - ] - : [{ role: "delete" }, { type: "separator" }, { role: "selectAll" }]), - ], - }, - { - label: "View", - submenu: [ - { role: "reload" }, - { role: "forceReload" }, - { role: "toggleDevTools" }, - { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn" }, - { role: "zoomOut" }, - { type: "separator" }, - { role: "togglefullscreen" }, - ], - }, - { - label: "Window", - submenu: [ - { role: "minimize" }, - { role: "zoom" }, - ...(isMac - ? [ - { type: "separator" }, - { role: "front" }, - { type: "separator" }, - { role: "window" }, - ] - : [{ role: "close" }]), - ], - }, - { - role: "help", - submenu: [ - { - label: "Learn More", - click: async () => { - const { shell } = require("electron"); - await shell.openExternal("https://jan.ai/"); - }, - }, - ], - }, -]; - -export const setupMenu = () => { - const menu = Menu.buildFromTemplate(template); - Menu.setApplicationMenu(menu); -}; diff --git a/server/utils/versionDiff.ts b/server/utils/versionDiff.ts deleted file mode 100644 index 25934e87f..000000000 --- a/server/utils/versionDiff.ts +++ /dev/null @@ -1,21 +0,0 @@ -export const compareSemanticVersions = (a: string, b: string) => { - - // 1. Split the strings into their parts. - const a1 = a.split('.'); - const b1 = b.split('.'); - // 2. Contingency in case there's a 4th or 5th version - const len = Math.min(a1.length, b1.length); - // 3. Look through each version number and compare. - for (let i = 0; i < len; i++) { - const a2 = +a1[ i ] || 0; - const b2 = +b1[ i ] || 0; - - if (a2 !== b2) { - return a2 > b2 ? 1 : -1; - } - } - - // 4. We hit this if the all checked versions so far are equal - // - return b1.length - a1.length; -}; \ No newline at end of file diff --git a/server/v1/assistants/index.ts b/server/v1/assistants/index.ts new file mode 100644 index 000000000..7b51b801f --- /dev/null +++ b/server/v1/assistants/index.ts @@ -0,0 +1,5 @@ +import { Request, Response } from 'express' + +export default function route(req: Request, res: Response){ + +} \ No newline at end of file diff --git a/server/v1/chat/index.ts b/server/v1/chat/index.ts new file mode 100644 index 000000000..7b51b801f --- /dev/null +++ b/server/v1/chat/index.ts @@ -0,0 +1,5 @@ +import { Request, Response } from 'express' + +export default function route(req: Request, res: Response){ + +} \ No newline at end of file diff --git a/server/v1/index.ts b/server/v1/index.ts new file mode 100644 index 000000000..7528b917d --- /dev/null +++ b/server/v1/index.ts @@ -0,0 +1,20 @@ +import { Request, Response } from 'express' + +import assistantsAPI from './assistants' +import chatCompletionAPI from './chat' +import modelsAPI from './models' +import threadsAPI from './threads' + +export default function route(req: Request, res: Response){ + console.log(req.path.split("/")[1]) + switch (req.path.split("/")[1]){ + case 'assistants': + assistantsAPI(req, res) + case 'chat': + chatCompletionAPI(req, res) + case 'models': + modelsAPI(req, res) + case 'threads': + threadsAPI(req, res) + } +} \ No newline at end of file diff --git a/server/v1/models/downloadModel.ts b/server/v1/models/downloadModel.ts new file mode 100644 index 000000000..89db0cfce --- /dev/null +++ b/server/v1/models/downloadModel.ts @@ -0,0 +1,5 @@ +import { Request, Response } from 'express' + +export default function controller(req: Request, res: Response){ + +} \ No newline at end of file diff --git a/server/v1/models/index.ts b/server/v1/models/index.ts new file mode 100644 index 000000000..091e46283 --- /dev/null +++ b/server/v1/models/index.ts @@ -0,0 +1,18 @@ +import { Request, Response } from 'express' + +import downloadModelController from './downloadModel' + +function getModelController(req: Request, res: Response){ + +} + +export default function route(req: Request, res: Response){ + switch(req.method){ + case 'get': + getModelController(req, res) + break; + case 'post': + downloadModelController(req, res) + break; + } +} \ No newline at end of file diff --git a/server/v1/threads/index.ts b/server/v1/threads/index.ts new file mode 100644 index 000000000..7b51b801f --- /dev/null +++ b/server/v1/threads/index.ts @@ -0,0 +1,5 @@ +import { Request, Response } from 'express' + +export default function route(req: Request, res: Response){ + +} \ No newline at end of file From 01d3fb2f4952158ad92ed58ce3f979e1839da067 Mon Sep 17 00:00:00 2001 From: tikikun Date: Fri, 1 Dec 2023 14:05:33 +0700 Subject: [PATCH 04/85] docs: add engine --- docs/docs/specs/engineering/engine.md | 6 ++++++ docs/sidebars.js | 1 + 2 files changed, 7 insertions(+) create mode 100644 docs/docs/specs/engineering/engine.md diff --git a/docs/docs/specs/engineering/engine.md b/docs/docs/specs/engineering/engine.md new file mode 100644 index 000000000..13db10bf6 --- /dev/null +++ b/docs/docs/specs/engineering/engine.md @@ -0,0 +1,6 @@ +--- +title: Engine +slug: /specs/engine +--- + +# Hello world diff --git a/docs/sidebars.js b/docs/sidebars.js index edef458cd..384f47e9d 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -81,6 +81,7 @@ const sidebars = { items: [ "specs/engineering/chats", "specs/engineering/models", + "specs/engineering/engine", "specs/engineering/threads", "specs/engineering/messages", "specs/engineering/assistants", From 5403d9a8e5d9f1c4e2217b14f3a4c1af2351d07f Mon Sep 17 00:00:00 2001 From: tikikun Date: Fri, 1 Dec 2023 14:39:00 +0700 Subject: [PATCH 05/85] docs: change to correct naming of engine and model params --- docs/docs/specs/engineering/models.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/specs/engineering/models.md b/docs/docs/specs/engineering/models.md index c47a62bab..283c8e37e 100644 --- a/docs/docs/specs/engineering/models.md +++ b/docs/docs/specs/engineering/models.md @@ -64,13 +64,13 @@ Here's a standard example `model.json` for a GGUF model. "description": null, // Defaults to null "state": enum[null, "downloading", "ready", "starting", "stopping", ...] "format": "ggufv3", // Defaults to "ggufv3" -"settings": { // Models are initialized with settings +"engine_parameters": { // Engine "ctx_len": 2048, "ngl": 100, "embedding": true, "n_parallel": 4, }, -"parameters": { // Models are called parameters +"model_parameters": { // Models are called parameters "stream": true, "max_tokens": 2048, "stop": [""], // This usually can be left blank, only used with specific need from model author From 7a64b453653ba6e335210e09ed843041ce439fd3 Mon Sep 17 00:00:00 2001 From: tikikun Date: Fri, 1 Dec 2023 17:20:07 +0700 Subject: [PATCH 06/85] docs: add docs for engine, makeup for models docs --- docs/docs/specs/engineering/engine.md | 57 ++++++++++++++++++++++++++- docs/docs/specs/engineering/models.md | 2 +- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/docs/docs/specs/engineering/engine.md b/docs/docs/specs/engineering/engine.md index 13db10bf6..c0db37e4d 100644 --- a/docs/docs/specs/engineering/engine.md +++ b/docs/docs/specs/engineering/engine.md @@ -1,6 +1,59 @@ --- -title: Engine +title: Engine Specifications slug: /specs/engine --- -# Hello world +:::caution + +Currently Under Development + +::: + +## Overview + +In the Jan application, engines serve as primary entities with the following capabilities: + +- Ability to add or remove engines via the installation of extensions. +- Dependence on engines for conducting [inference](https://en.wikipedia.org/wiki/Inference_engine). + +## Folder Structure + +- Default parameters for engines are stored in JSON files located in the `/engines` folder. +- These parameter files are named uniquely with `engine_id`. +- Engines are referenced directly using `engine_id` in the `model.json` file. + +```yaml +jan/ + engines/ + nitro.json + openai.json + ..... +``` + +## Engine Default Parameter Files + +- Each inference engine requires default parameters to function in cases where user-provided parameters are absent. +- These parameters are stored in JSON files, structured as simple key-value pairs. + +### Example + +Here is an example of an engine file for `engine_id` `nitro`: + +```js +{ + "llama_model_path": "/model/llama-2-7b-model.gguf", + "ctx_len": 512, + "ngl": 100, + "embedding": false, + "n_parallel": 1, + "cont_batching": false +} +``` + +For detailed engine parameters, refer to: [Nitro's Model Settings](https://nitro.jan.ai/features/load-unload#table-of-parameters) + +## Adding an Engine + +- Engine parameter files are automatically generated upon installing an `inference-extension` in the Jan application. + +--- diff --git a/docs/docs/specs/engineering/models.md b/docs/docs/specs/engineering/models.md index 283c8e37e..a977ce1d4 100644 --- a/docs/docs/specs/engineering/models.md +++ b/docs/docs/specs/engineering/models.md @@ -64,7 +64,7 @@ Here's a standard example `model.json` for a GGUF model. "description": null, // Defaults to null "state": enum[null, "downloading", "ready", "starting", "stopping", ...] "format": "ggufv3", // Defaults to "ggufv3" -"engine_parameters": { // Engine +"engine_parameters": { // Engine parameters inside model.json can override the value inside the base engine.json "ctx_len": 2048, "ngl": 100, "embedding": true, From bb6cb1396e986acc795be3d03ecccd768b091e50 Mon Sep 17 00:00:00 2001 From: tikikun Date: Fri, 1 Dec 2023 17:20:25 +0700 Subject: [PATCH 07/85] rename title --- docs/docs/specs/engineering/engine.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/specs/engineering/engine.md b/docs/docs/specs/engineering/engine.md index c0db37e4d..17e01ebd0 100644 --- a/docs/docs/specs/engineering/engine.md +++ b/docs/docs/specs/engineering/engine.md @@ -1,5 +1,5 @@ --- -title: Engine Specifications +title: Engine slug: /specs/engine --- From 97fcafe61b4b7a1a0874cb7d033b5b68dfd522b1 Mon Sep 17 00:00:00 2001 From: tikikun Date: Fri, 1 Dec 2023 17:24:54 +0700 Subject: [PATCH 08/85] docs: rewrite overview --- docs/docs/specs/engineering/engine.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/docs/specs/engineering/engine.md b/docs/docs/specs/engineering/engine.md index 17e01ebd0..a416bc820 100644 --- a/docs/docs/specs/engineering/engine.md +++ b/docs/docs/specs/engineering/engine.md @@ -13,8 +13,9 @@ Currently Under Development In the Jan application, engines serve as primary entities with the following capabilities: -- Ability to add or remove engines via the installation of extensions. -- Dependence on engines for conducting [inference](https://en.wikipedia.org/wiki/Inference_engine). +- Engine will be installed through `inference-extensions`. +- Models will depend on engines to do [inference](https://en.wikipedia.org/wiki/Inference_engine). +- Engine configuration and required metadata will be stored in a json file. ## Folder Structure From 242204df244fb171a54472023e49d7580aa882d5 Mon Sep 17 00:00:00 2001 From: tikikun Date: Fri, 1 Dec 2023 17:25:52 +0700 Subject: [PATCH 09/85] update models.md --- docs/docs/specs/engineering/models.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/docs/specs/engineering/models.md b/docs/docs/specs/engineering/models.md index a977ce1d4..517b9fefe 100644 --- a/docs/docs/specs/engineering/models.md +++ b/docs/docs/specs/engineering/models.md @@ -51,9 +51,9 @@ jan/ # Jan root folder Here's a standard example `model.json` for a GGUF model. -- `source_url`: https://huggingface.co/TheBloke/zephyr-7B-beta-GGUF/. ```js +{ "id": "zephyr-7b", // Defaults to foldername "object": "model", // Defaults to "model" "source_url": "https://huggingface.co/TheBloke/zephyr-7B-beta-GGUF/blob/main/zephyr-7b-beta.Q4_K_M.gguf", @@ -64,8 +64,8 @@ Here's a standard example `model.json` for a GGUF model. "description": null, // Defaults to null "state": enum[null, "downloading", "ready", "starting", "stopping", ...] "format": "ggufv3", // Defaults to "ggufv3" -"engine_parameters": { // Engine parameters inside model.json can override the value inside the base engine.json - "ctx_len": 2048, +"engine_parameters": { // Engine parameters inside model.json can override + "ctx_len": 2048, // the value inside the base engine.json "ngl": 100, "embedding": true, "n_parallel": 4, @@ -83,9 +83,10 @@ Here's a standard example `model.json` for a GGUF model. "assets": [ // Defaults to current dir "file://.../zephyr-7b-q4_k_m.bin", ] +} ``` -The model settings in the example can be found at: [Nitro's model settings](https://nitro.jan.ai/features/load-unload#table-of-parameters) +The engine parameters in the example can be found at: [Nitro's model settings](https://nitro.jan.ai/features/load-unload#table-of-parameters) The model parameters in the example can be found at: [Nitro's model parameters](https://nitro.jan.ai/api-reference#tag/Chat-Completion) From bd185c633af82c57855e6a1ee40714d164ff40d8 Mon Sep 17 00:00:00 2001 From: tikikun Date: Fri, 1 Dec 2023 17:35:26 +0700 Subject: [PATCH 10/85] add prompt template --- docs/docs/specs/engineering/engine.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs/specs/engineering/engine.md b/docs/docs/specs/engineering/engine.md index a416bc820..a70bec840 100644 --- a/docs/docs/specs/engineering/engine.md +++ b/docs/docs/specs/engineering/engine.md @@ -48,6 +48,7 @@ Here is an example of an engine file for `engine_id` `nitro`: "embedding": false, "n_parallel": 1, "cont_batching": false + "prompt_template": "<|im_start|>system\n{system_message}<|im_end|>\n<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant" } ``` From f5c19bccc5b05f26e34a2e753d2943e34a29d3fa Mon Sep 17 00:00:00 2001 From: Linh Tran Date: Fri, 1 Dec 2023 23:02:35 +0700 Subject: [PATCH 11/85] Switch to fastify + add model CRUD impl --- server/data/models/.gitkeep | 0 server/data/threads/.gitkeep | 0 server/main.ts | 29 ++++--------- server/package.json | 1 + server/v1/assistants/index.ts | 11 +++-- server/v1/chat/index.ts | 14 ++++-- server/v1/index.ts | 47 +++++++++++++------- server/v1/models/downloadModel.ts | 24 +++++++++-- server/v1/models/index.ts | 71 +++++++++++++++++++++++++------ server/v1/models/modelOp.ts | 11 +++++ server/v1/threads/index.ts | 9 ++-- 11 files changed, 154 insertions(+), 63 deletions(-) create mode 100644 server/data/models/.gitkeep create mode 100644 server/data/threads/.gitkeep create mode 100644 server/v1/models/modelOp.ts diff --git a/server/data/models/.gitkeep b/server/data/models/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/server/data/threads/.gitkeep b/server/data/threads/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/server/main.ts b/server/main.ts index 582af5c61..5466a27d8 100644 --- a/server/main.ts +++ b/server/main.ts @@ -1,28 +1,17 @@ -import express from 'express' -import bodyParser from 'body-parser' -import fs from 'fs' +import fastify from 'fastify' + import v1API from './v1' - const JAN_API_PORT = 1337; - -const server = express() -server.use(bodyParser.urlencoded()) -server.use(bodyParser.json()) +const server = fastify() const USER_ROOT_DIR = '.data' -server.use("/v1", v1API) +server.register(v1API, {prefix: "/api/v1"}) -// server.post("fs", (req, res) => { -// let op = req.body.op; -// switch(op){ -// case 'readFile': -// fs.readFile(req.body.path, ()=>{}) -// case 'writeFile': -// fs.writeFile(req.body.path, Buffer.from(req.body.data, "base64"), ()=>{}) -// } -// }) -server.listen(JAN_API_PORT, () => { - console.log(`JAN API listening at: http://localhost:${JAN_API_PORT}`); +server.listen({ + port: JAN_API_PORT, + host: "0.0.0.0" +}).then(()=>{ + console.log(`JAN API listening at: http://0.0.0.0:${JAN_API_PORT}`); }) diff --git a/server/package.json b/server/package.json index 08c19ee31..405f2bf5b 100644 --- a/server/package.json +++ b/server/package.json @@ -75,6 +75,7 @@ "@typescript-eslint/eslint-plugin": "^6.7.3", "@typescript-eslint/parser": "^6.7.3", "eslint-plugin-react": "^7.33.2", + "fastify": "^4.24.3", "nodemon": "^3.0.1", "run-script-os": "^1.1.6" }, diff --git a/server/v1/assistants/index.ts b/server/v1/assistants/index.ts index 7b51b801f..c722195d0 100644 --- a/server/v1/assistants/index.ts +++ b/server/v1/assistants/index.ts @@ -1,5 +1,8 @@ -import { Request, Response } from 'express' +import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify' -export default function route(req: Request, res: Response){ - -} \ No newline at end of file +const router: FastifyPluginAsync = async (app: FastifyInstance, opts: FastifyPluginOptions) => { + //TODO: Add controllers for assistants here + // app.get("/", controller) + // app.post("/", controller) +} +export default router; \ No newline at end of file diff --git a/server/v1/chat/index.ts b/server/v1/chat/index.ts index 7b51b801f..cb5fbf120 100644 --- a/server/v1/chat/index.ts +++ b/server/v1/chat/index.ts @@ -1,5 +1,11 @@ -import { Request, Response } from 'express' +import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify' -export default function route(req: Request, res: Response){ - -} \ No newline at end of file +const router: FastifyPluginAsync = async (app: FastifyInstance, opts: FastifyPluginOptions) => { + //TODO: Add controllers for here + // app.get("/", controller) + + app.post("/", (req, res) => { + req.body + }) +} +export default router; \ No newline at end of file diff --git a/server/v1/index.ts b/server/v1/index.ts index 7528b917d..8a153cbaf 100644 --- a/server/v1/index.ts +++ b/server/v1/index.ts @@ -1,20 +1,37 @@ -import { Request, Response } from 'express' - import assistantsAPI from './assistants' import chatCompletionAPI from './chat' import modelsAPI from './models' import threadsAPI from './threads' -export default function route(req: Request, res: Response){ - console.log(req.path.split("/")[1]) - switch (req.path.split("/")[1]){ - case 'assistants': - assistantsAPI(req, res) - case 'chat': - chatCompletionAPI(req, res) - case 'models': - modelsAPI(req, res) - case 'threads': - threadsAPI(req, res) - } -} \ No newline at end of file +import { FastifyInstance, FastifyPluginAsync } from 'fastify' + +const router: FastifyPluginAsync = async (app: FastifyInstance, opts) => { + app.register( + assistantsAPI, + { + prefix: "/assisstants" + } + ) + + app.register( + chatCompletionAPI, + { + prefix: "/chat/completion" + } + ) + + app.register( + modelsAPI, + { + prefix: "/models" + } + ) + + app.register( + threadsAPI, + { + prefix: "/threads" + } + ) +} +export default router; \ No newline at end of file diff --git a/server/v1/models/downloadModel.ts b/server/v1/models/downloadModel.ts index 89db0cfce..d564a2207 100644 --- a/server/v1/models/downloadModel.ts +++ b/server/v1/models/downloadModel.ts @@ -1,5 +1,23 @@ -import { Request, Response } from 'express' +import { RouteHandlerMethod, FastifyRequest, FastifyReply } from 'fastify' +import { MODEL_FOLDER_PATH } from "./index" +import fs from 'fs/promises' -export default function controller(req: Request, res: Response){ +const controller: RouteHandlerMethod = async (req: FastifyRequest, res: FastifyReply) => { + //TODO: download models impl + //Mirror logic from JanModelExtension.downloadModel? + let model = req.body.model; -} \ No newline at end of file + // Fetching logic + // const directoryPath = join(MODEL_FOLDER_PATH, model.id) + // await fs.mkdir(directoryPath) + + // const path = join(directoryPath, model.id) + // downloadFile(model.source_url, path) + // TODO: Different model downloader from different model vendor + + res.status(200).send({ + status: "Ok" + }) +} + +export default controller; \ No newline at end of file diff --git a/server/v1/models/index.ts b/server/v1/models/index.ts index 091e46283..22c551300 100644 --- a/server/v1/models/index.ts +++ b/server/v1/models/index.ts @@ -1,18 +1,61 @@ -import { Request, Response } from 'express' -import downloadModelController from './downloadModel' +export const MODEL_FOLDER_PATH = "./data/models" +export const _modelMetadataFileName = 'model.json' -function getModelController(req: Request, res: Response){ - -} +import fs from 'fs/promises' +import { Model } from '@janhq/core' +import { join } from 'path' -export default function route(req: Request, res: Response){ - switch(req.method){ - case 'get': - getModelController(req, res) - break; - case 'post': - downloadModelController(req, res) - break; +// map string => model object +let modelIndex = new Map(); +async function buildModelIndex(){ + let modelIds = await fs.readdir(MODEL_FOLDER_PATH); + // TODO: read modelFolders to get model info, mirror JanModelExtension? + try{ + for(let modelId in modelIds){ + let path = join(MODEL_FOLDER_PATH, modelId) + let fileData = await fs.readFile(join(path, _modelMetadataFileName)) + modelIndex.set(modelId, JSON.parse(fileData.toString("utf-8")) as Model) + } } -} \ No newline at end of file + catch(err){ + console.error("build model index failed. ", err); + } +} +buildModelIndex() + +import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify' +import downloadModelController from './downloadModel' +import { startModel, stopModel } from './modelOp' + +const router: FastifyPluginAsync = async (app: FastifyInstance, opts: FastifyPluginOptions) => { + //TODO: Add controllers declaration here + + ///////////// CRUD //////////////// + // Model listing + app.get("/", async (req, res) => { + res.status(200).send( + modelIndex.values() + ) + }) + + // Retrieve model info + app.get("/:id", (req, res) => { + res.status(200).send( + modelIndex.get(req.params.id) + ) + }) + + // Delete model + app.delete("/:id", (req, res) => { + modelIndex.delete(req.params) + + // TODO: delete on disk + }) + + ///////////// Other ops //////////////// + app.post("/", downloadModelController) + app.put("/start", startModel) + app.put("/stop", stopModel) +} +export default router; \ No newline at end of file diff --git a/server/v1/models/modelOp.ts b/server/v1/models/modelOp.ts new file mode 100644 index 000000000..f2c7ffe75 --- /dev/null +++ b/server/v1/models/modelOp.ts @@ -0,0 +1,11 @@ +import {FastifyRequest, FastifyReply} from 'fastify' + +export async function startModel(req: FastifyRequest, res: FastifyReply): Promise { + + +} + +export async function stopModel(req: FastifyRequest, res: FastifyReply): Promise { + + +} \ No newline at end of file diff --git a/server/v1/threads/index.ts b/server/v1/threads/index.ts index 7b51b801f..e63f9e8d8 100644 --- a/server/v1/threads/index.ts +++ b/server/v1/threads/index.ts @@ -1,5 +1,8 @@ -import { Request, Response } from 'express' +import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify' -export default function route(req: Request, res: Response){ +const router: FastifyPluginAsync = async (app: FastifyInstance, opts: FastifyPluginOptions) => { + //TODO: Add controllers declaration here -} \ No newline at end of file + // app.get() +} +export default router; \ No newline at end of file From e5a440fc8f26dca602b070590688aa8f835564e1 Mon Sep 17 00:00:00 2001 From: hiento09 <136591877+hiento09@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:30:03 +0700 Subject: [PATCH 12/85] Refactor build:extension command (#822) * Refactor build:extenstions command * Enable parallel build extension * build extension on windows, set job name similar to folder name * windows build extension write logs to console for debugging --------- Co-authored-by: Hien To Co-authored-by: Hien To --- package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index c2754d3ad..2a4a7fa85 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,10 @@ "build:web": "yarn workspace jan-web build && cpx \"web/out/**\" \"electron/renderer/\"", "build:electron": "cpx \"models/**\" \"electron/models/\" && yarn workspace jan build", "build:electron:test": "yarn workspace jan build:test", - "build:extensions": "rimraf ./electron/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./extensions/conversational-extension && npm install && npm run build:publish\" \"cd ./extensions/inference-extension && npm install && npm run build:publish\" \"cd ./extensions/model-extension && npm install && npm run build:publish\" \"cd ./extensions/monitoring-extension && npm install && npm run build:publish\" \"cd ./extensions/assistant-extension && npm install && npm run build:publish\"", + "build:extensions:windows": "rimraf ./electron/pre-install/*.tgz && powershell -command \"$jobs = Get-ChildItem -Path './extensions' -Directory | ForEach-Object { Start-Job -Name ($_.Name) -ScriptBlock { param($_dir); try { Set-Location $_dir; npm install; npm run build:publish; Write-Output 'Build successful in ' + $_dir } catch { Write-Error 'Error in ' + $_dir; throw } } -ArgumentList $_.FullName }; $jobs | Wait-Job; $jobs | ForEach-Object { Receive-Job -Job $_ -Keep } | ForEach-Object { Write-Host $_ }; $failed = $jobs | Where-Object { $_.State -ne 'Completed' -or $_.ChildJobs[0].JobStateInfo.State -ne 'Completed' }; if ($failed) { Exit 1 }\"", + "build:extensions:linux": "rimraf ./electron/pre-install/*.tgz && find ./extensions -mindepth 1 -maxdepth 1 -type d -print0 | xargs -0 -n 1 -P 4 -I {} sh -c 'cd {} && npm install && npm run build:publish'", + "build:extensions:darwin": "rimraf ./electron/pre-install/*.tgz && find ./extensions -mindepth 1 -maxdepth 1 -type d -print0 | xargs -0 -n 1 -P 4 -I {} sh -c 'cd {} && npm install && npm run build:publish'", + "build:extensions": "run-script-os", "build:test": "yarn build:web && yarn workspace jan build:test", "build": "yarn build:web && yarn build:electron", "build:publish": "yarn build:web && yarn workspace jan build:publish" From a4c394dfa81d350c8b9d6fca73c0694968594f2e Mon Sep 17 00:00:00 2001 From: tikikun Date: Mon, 4 Dec 2023 10:49:54 +0700 Subject: [PATCH 13/85] add engine_id field --- docs/docs/specs/engineering/models.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs/specs/engineering/models.md b/docs/docs/specs/engineering/models.md index 517b9fefe..1cdc1cd1c 100644 --- a/docs/docs/specs/engineering/models.md +++ b/docs/docs/specs/engineering/models.md @@ -64,6 +64,7 @@ Here's a standard example `model.json` for a GGUF model. "description": null, // Defaults to null "state": enum[null, "downloading", "ready", "starting", "stopping", ...] "format": "ggufv3", // Defaults to "ggufv3" +"egine_id": "nitro", // engine_id specified in jan/engine folder "engine_parameters": { // Engine parameters inside model.json can override "ctx_len": 2048, // the value inside the base engine.json "ngl": 100, From b556e421d199c84d285eabed1b9697644db2f401 Mon Sep 17 00:00:00 2001 From: tikikun Date: Mon, 4 Dec 2023 10:51:24 +0700 Subject: [PATCH 14/85] add engine_id field --- docs/docs/specs/engineering/models.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/specs/engineering/models.md b/docs/docs/specs/engineering/models.md index 1cdc1cd1c..6f53812c4 100644 --- a/docs/docs/specs/engineering/models.md +++ b/docs/docs/specs/engineering/models.md @@ -64,7 +64,7 @@ Here's a standard example `model.json` for a GGUF model. "description": null, // Defaults to null "state": enum[null, "downloading", "ready", "starting", "stopping", ...] "format": "ggufv3", // Defaults to "ggufv3" -"egine_id": "nitro", // engine_id specified in jan/engine folder +"egine": "nitro", // engine_id specified in jan/engine folder "engine_parameters": { // Engine parameters inside model.json can override "ctx_len": 2048, // the value inside the base engine.json "ngl": 100, From 424b00338e59f3f00b996f7248db668584bbe756 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Mon, 4 Dec 2023 10:55:47 +0700 Subject: [PATCH 15/85] feat: revamp thread screen (#802) * Make thread screen as default screen * Blank state when user have not any model * Cleanup topbar thread screen * Improve style right panel * Add instructions right panel * Styling thread list history * Resolve conflict * Default title new thread * Fix trigger panel sidebar * Make default right panel false when no activethread * Fix CI test * chore: assistant instruction with system prompt * Fix title and blank state explore the hub * Claenup style thread screen and add buble message for assitant * Remove unused import * Styling more menus on thread list and right panel, and make max height textarea 400 pixel * Finished revamp ui thread * Finished system monitor UI * Style box running models * Make animate right panel more smooth * Add status arround textarea for starting model info * Temporary disable hide left panel * chore: system resource monitoring update * copy nits * chore: typo * Reverse icon chevron accordion * Move my models into setting page --------- Co-authored-by: Louis Co-authored-by: 0xSage --- core/src/types/index.ts | 7 +- electron/managers/window.ts | 18 +- electron/tests/explore.e2e.spec.ts | 42 ++-- electron/tests/main.e2e.spec.ts | 64 +++---- electron/tests/my-models.e2e.spec.ts | 41 ---- electron/tests/navigation.e2e.spec.ts | 51 ++--- electron/tests/settings.e2e.spec.ts | 42 ++-- electron/tests/system-monitor.e2e.spec.ts | 41 ++++ extensions/assistant-extension/src/index.ts | 6 +- uikit/package.json | 2 + uikit/src/badge/styles.scss | 2 +- uikit/src/command/styles.scss | 2 +- uikit/src/index.ts | 1 + uikit/src/input/index.tsx | 2 +- uikit/src/main.scss | 5 +- uikit/src/select/index.tsx | 139 ++++++++++++++ uikit/src/select/styles.scss | 31 +++ web/app/layout.tsx | 2 +- web/app/page.tsx | 18 +- web/constants/screens.ts | 8 +- web/containers/CardSidebar/index.tsx | 122 ++++++------ web/containers/DropdownListSidebar/index.tsx | 168 ++++++++-------- .../BottomBar/DownloadingState/index.tsx | 4 - web/containers/Layout/BottomBar/index.tsx | 6 +- web/containers/Layout/Ribbon/index.tsx | 60 +++--- .../CommandListDownloadedModel/index.tsx | 4 +- .../Layout/TopBar/CommandSearch/index.tsx | 53 +----- web/containers/Layout/TopBar/index.tsx | 81 +++++++- web/containers/ModalCancelDownload/index.tsx | 1 - web/containers/Shortcut/index.tsx | 2 +- web/containers/Toast/index.tsx | 2 +- web/helpers/atoms/ChatMessage.atom.ts | 2 +- web/helpers/atoms/SystemBar.atom.ts | 3 + web/hooks/useClickOutside.ts | 42 ++++ web/hooks/useCreateNewThread.ts | 16 +- web/hooks/useDeleteConversation.ts | 10 +- web/hooks/useDownloadModel.ts | 2 +- web/hooks/useGetSystemResources.ts | 11 +- web/hooks/useMainViewState.ts | 2 +- web/hooks/useSendChatMessage.ts | 51 +++-- web/package.json | 2 + web/screens/Chat/ChatBody/index.tsx | 58 +++++- web/screens/Chat/ChatItem/index.tsx | 5 +- web/screens/Chat/MessageToolbar/index.tsx | 32 +--- web/screens/Chat/Sidebar/index.tsx | 115 ++++++++--- web/screens/Chat/SimpleTextMessage/index.tsx | 54 ++++-- web/screens/Chat/ThreadList/index.tsx | 113 +++++++---- web/screens/Chat/index.tsx | 146 ++++++-------- .../ExploreModelItemHeader/index.tsx | 2 +- .../ExploreModels/ModelVersionItem/index.tsx | 2 +- web/screens/ExploreModels/index.tsx | 2 +- web/screens/MyModels/BlankState/index.tsx | 77 -------- web/screens/MyModels/index.tsx | 180 ------------------ .../ExtensionsCatalog/index.tsx | 2 +- web/screens/Settings/Models/Row.tsx | 137 +++++++++++++ web/screens/Settings/Models/index.tsx | 65 +++++++ web/screens/Settings/index.tsx | 45 ++++- web/screens/SystemMonitor/index.tsx | 117 ++++++++++++ web/screens/Welcome/index.tsx | 74 ------- web/styles/components/message.scss | 6 +- 60 files changed, 1435 insertions(+), 965 deletions(-) delete mode 100644 electron/tests/my-models.e2e.spec.ts create mode 100644 electron/tests/system-monitor.e2e.spec.ts create mode 100644 uikit/src/select/index.tsx create mode 100644 uikit/src/select/styles.scss create mode 100644 web/hooks/useClickOutside.ts delete mode 100644 web/screens/MyModels/BlankState/index.tsx delete mode 100644 web/screens/MyModels/index.tsx create mode 100644 web/screens/Settings/Models/Row.tsx create mode 100644 web/screens/Settings/Models/index.tsx create mode 100644 web/screens/SystemMonitor/index.tsx delete mode 100644 web/screens/Welcome/index.tsx diff --git a/core/src/types/index.ts b/core/src/types/index.ts index bbd1e98de..7580c2432 100644 --- a/core/src/types/index.ts +++ b/core/src/types/index.ts @@ -143,6 +143,7 @@ export type ThreadAssistantInfo = { assistant_id: string; assistant_name: string; model: ModelInfo; + instructions?: string; }; /** @@ -288,13 +289,13 @@ export type Assistant = { /** Represents the name of the object. */ name: string; /** Represents the description of the object. */ - description: string; + description?: string; /** Represents the model of the object. */ model: string; /** Represents the instructions for the object. */ - instructions: string; + instructions?: string; /** Represents the tools associated with the object. */ - tools: any; + tools?: any; /** Represents the file identifiers associated with the object. */ file_ids: string[]; /** Represents the metadata of the object. */ diff --git a/electron/managers/window.ts b/electron/managers/window.ts index c930dd5ec..0d5a0eaf4 100644 --- a/electron/managers/window.ts +++ b/electron/managers/window.ts @@ -1,15 +1,15 @@ -import { BrowserWindow } from "electron"; +import { BrowserWindow } from 'electron' /** * Manages the current window instance. */ export class WindowManager { - public static instance: WindowManager = new WindowManager(); - public currentWindow?: BrowserWindow; + public static instance: WindowManager = new WindowManager() + public currentWindow?: BrowserWindow constructor() { if (WindowManager.instance) { - return WindowManager.instance; + return WindowManager.instance } } @@ -21,17 +21,17 @@ export class WindowManager { createWindow(options?: Electron.BrowserWindowConstructorOptions | undefined) { this.currentWindow = new BrowserWindow({ width: 1200, - minWidth: 800, + minWidth: 1200, height: 800, show: false, trafficLightPosition: { x: 10, y: 15, }, - titleBarStyle: "hidden", - vibrancy: "sidebar", + titleBarStyle: 'hidden', + vibrancy: 'sidebar', ...options, - }); - return this.currentWindow; + }) + return this.currentWindow } } diff --git a/electron/tests/explore.e2e.spec.ts b/electron/tests/explore.e2e.spec.ts index 5a4412cb3..77eb3dbda 100644 --- a/electron/tests/explore.e2e.spec.ts +++ b/electron/tests/explore.e2e.spec.ts @@ -1,41 +1,41 @@ -import { _electron as electron } from "playwright"; -import { ElectronApplication, Page, expect, test } from "@playwright/test"; +import { _electron as electron } from 'playwright' +import { ElectronApplication, Page, expect, test } from '@playwright/test' import { findLatestBuild, parseElectronApp, stubDialog, -} from "electron-playwright-helpers"; +} from 'electron-playwright-helpers' -let electronApp: ElectronApplication; -let page: Page; +let electronApp: ElectronApplication +let page: Page test.beforeAll(async () => { - process.env.CI = "e2e"; + process.env.CI = 'e2e' - const latestBuild = findLatestBuild("dist"); - expect(latestBuild).toBeTruthy(); + const latestBuild = findLatestBuild('dist') + expect(latestBuild).toBeTruthy() // parse the packaged Electron app and find paths and other info - const appInfo = parseElectronApp(latestBuild); - expect(appInfo).toBeTruthy(); + const appInfo = parseElectronApp(latestBuild) + expect(appInfo).toBeTruthy() electronApp = await electron.launch({ args: [appInfo.main], // main file from package.json executablePath: appInfo.executable, // path to the Electron executable - }); - await stubDialog(electronApp, "showMessageBox", { response: 1 }); + }) + await stubDialog(electronApp, 'showMessageBox', { response: 1 }) - page = await electronApp.firstWindow(); -}); + page = await electronApp.firstWindow() +}) test.afterAll(async () => { - await electronApp.close(); - await page.close(); -}); + await electronApp.close() + await page.close() +}) -test("explores models", async () => { - await page.getByTestId("Explore Models").first().click(); - await page.getByTestId("testid-explore-models").isVisible(); +test('explores models', async () => { + await page.getByTestId('Hub').first().click() + await page.getByTestId('testid-explore-models').isVisible() // More test cases here... -}); +}) diff --git a/electron/tests/main.e2e.spec.ts b/electron/tests/main.e2e.spec.ts index d6df31ca4..1a5bfe696 100644 --- a/electron/tests/main.e2e.spec.ts +++ b/electron/tests/main.e2e.spec.ts @@ -1,55 +1,55 @@ -import { _electron as electron } from "playwright"; -import { ElectronApplication, Page, expect, test } from "@playwright/test"; +import { _electron as electron } from 'playwright' +import { ElectronApplication, Page, expect, test } from '@playwright/test' import { findLatestBuild, parseElectronApp, stubDialog, -} from "electron-playwright-helpers"; +} from 'electron-playwright-helpers' -let electronApp: ElectronApplication; -let page: Page; +let electronApp: ElectronApplication +let page: Page test.beforeAll(async () => { - process.env.CI = "e2e"; + process.env.CI = 'e2e' - const latestBuild = findLatestBuild("dist"); - expect(latestBuild).toBeTruthy(); + const latestBuild = findLatestBuild('dist') + expect(latestBuild).toBeTruthy() // parse the packaged Electron app and find paths and other info - const appInfo = parseElectronApp(latestBuild); - expect(appInfo).toBeTruthy(); - expect(appInfo.asar).toBe(true); - expect(appInfo.executable).toBeTruthy(); - expect(appInfo.main).toBeTruthy(); - expect(appInfo.name).toBe("jan"); - expect(appInfo.packageJson).toBeTruthy(); - expect(appInfo.packageJson.name).toBe("jan"); - expect(appInfo.platform).toBeTruthy(); - expect(appInfo.platform).toBe(process.platform); - expect(appInfo.resourcesDir).toBeTruthy(); + const appInfo = parseElectronApp(latestBuild) + expect(appInfo).toBeTruthy() + expect(appInfo.asar).toBe(true) + expect(appInfo.executable).toBeTruthy() + expect(appInfo.main).toBeTruthy() + expect(appInfo.name).toBe('jan') + expect(appInfo.packageJson).toBeTruthy() + expect(appInfo.packageJson.name).toBe('jan') + expect(appInfo.platform).toBeTruthy() + expect(appInfo.platform).toBe(process.platform) + expect(appInfo.resourcesDir).toBeTruthy() electronApp = await electron.launch({ args: [appInfo.main], // main file from package.json executablePath: appInfo.executable, // path to the Electron executable - }); - await stubDialog(electronApp, "showMessageBox", { response: 1 }); + }) + await stubDialog(electronApp, 'showMessageBox', { response: 1 }) - page = await electronApp.firstWindow(); -}); + page = await electronApp.firstWindow() +}) test.afterAll(async () => { - await electronApp.close(); - await page.close(); -}); + await electronApp.close() + await page.close() +}) -test("renders the home page", async () => { - expect(page).toBeDefined(); +test('renders the home page', async () => { + expect(page).toBeDefined() // Welcome text is available const welcomeText = await page - .getByTestId("testid-welcome-title") + .getByTestId('testid-welcome-title') .first() - .isVisible(); - expect(welcomeText).toBe(false); -}); + .isVisible() + expect(welcomeText).toBe(false) +}) diff --git a/electron/tests/my-models.e2e.spec.ts b/electron/tests/my-models.e2e.spec.ts deleted file mode 100644 index a3355fb33..000000000 --- a/electron/tests/my-models.e2e.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { _electron as electron } from "playwright"; -import { ElectronApplication, Page, expect, test } from "@playwright/test"; - -import { - findLatestBuild, - parseElectronApp, - stubDialog, -} from "electron-playwright-helpers"; - -let electronApp: ElectronApplication; -let page: Page; - -test.beforeAll(async () => { - process.env.CI = "e2e"; - - const latestBuild = findLatestBuild("dist"); - expect(latestBuild).toBeTruthy(); - - // parse the packaged Electron app and find paths and other info - const appInfo = parseElectronApp(latestBuild); - expect(appInfo).toBeTruthy(); - - electronApp = await electron.launch({ - args: [appInfo.main], // main file from package.json - executablePath: appInfo.executable, // path to the Electron executable - }); - await stubDialog(electronApp, "showMessageBox", { response: 1 }); - - page = await electronApp.firstWindow(); -}); - -test.afterAll(async () => { - await electronApp.close(); - await page.close(); -}); - -test("shows my models", async () => { - await page.getByTestId("My Models").first().click(); - await page.getByTestId("testid-my-models").isVisible(); - // More test cases here... -}); diff --git a/electron/tests/navigation.e2e.spec.ts b/electron/tests/navigation.e2e.spec.ts index 104333650..2f4f7b767 100644 --- a/electron/tests/navigation.e2e.spec.ts +++ b/electron/tests/navigation.e2e.spec.ts @@ -1,43 +1,43 @@ -import { _electron as electron } from "playwright"; -import { ElectronApplication, Page, expect, test } from "@playwright/test"; +import { _electron as electron } from 'playwright' +import { ElectronApplication, Page, expect, test } from '@playwright/test' import { findLatestBuild, parseElectronApp, stubDialog, -} from "electron-playwright-helpers"; +} from 'electron-playwright-helpers' -let electronApp: ElectronApplication; -let page: Page; +let electronApp: ElectronApplication +let page: Page test.beforeAll(async () => { - process.env.CI = "e2e"; + process.env.CI = 'e2e' - const latestBuild = findLatestBuild("dist"); - expect(latestBuild).toBeTruthy(); + const latestBuild = findLatestBuild('dist') + expect(latestBuild).toBeTruthy() // parse the packaged Electron app and find paths and other info - const appInfo = parseElectronApp(latestBuild); - expect(appInfo).toBeTruthy(); + const appInfo = parseElectronApp(latestBuild) + expect(appInfo).toBeTruthy() electronApp = await electron.launch({ args: [appInfo.main], // main file from package.json executablePath: appInfo.executable, // path to the Electron executable - }); - await stubDialog(electronApp, "showMessageBox", { response: 1 }); + }) + await stubDialog(electronApp, 'showMessageBox', { response: 1 }) - page = await electronApp.firstWindow(); -}); + page = await electronApp.firstWindow() +}) test.afterAll(async () => { - await electronApp.close(); - await page.close(); -}); + await electronApp.close() + await page.close() +}) -test("renders left navigation panel", async () => { +test('renders left navigation panel', async () => { // Chat section should be there - const chatSection = await page.getByTestId("Chat").first().isVisible(); - expect(chatSection).toBe(false); + const chatSection = await page.getByTestId('Chat').first().isVisible() + expect(chatSection).toBe(false) // Home actions /* Disable unstable feature tests @@ -45,7 +45,10 @@ test("renders left navigation panel", async () => { ** Enable back when it is whitelisted */ - const myModelsBtn = await page.getByTestId("My Models").first().isEnabled(); - const settingsBtn = await page.getByTestId("Settings").first().isEnabled(); - expect([myModelsBtn, settingsBtn].filter((e) => !e).length).toBe(0); -}); + const systemMonitorBtn = await page + .getByTestId('System Monitor') + .first() + .isEnabled() + const settingsBtn = await page.getByTestId('Settings').first().isEnabled() + expect([systemMonitorBtn, settingsBtn].filter((e) => !e).length).toBe(0) +}) diff --git a/electron/tests/settings.e2e.spec.ts b/electron/tests/settings.e2e.spec.ts index 2f8d6465b..798504c70 100644 --- a/electron/tests/settings.e2e.spec.ts +++ b/electron/tests/settings.e2e.spec.ts @@ -1,40 +1,40 @@ -import { _electron as electron } from "playwright"; -import { ElectronApplication, Page, expect, test } from "@playwright/test"; +import { _electron as electron } from 'playwright' +import { ElectronApplication, Page, expect, test } from '@playwright/test' import { findLatestBuild, parseElectronApp, stubDialog, -} from "electron-playwright-helpers"; +} from 'electron-playwright-helpers' -let electronApp: ElectronApplication; -let page: Page; +let electronApp: ElectronApplication +let page: Page test.beforeAll(async () => { - process.env.CI = "e2e"; + process.env.CI = 'e2e' - const latestBuild = findLatestBuild("dist"); - expect(latestBuild).toBeTruthy(); + const latestBuild = findLatestBuild('dist') + expect(latestBuild).toBeTruthy() // parse the packaged Electron app and find paths and other info - const appInfo = parseElectronApp(latestBuild); - expect(appInfo).toBeTruthy(); + const appInfo = parseElectronApp(latestBuild) + expect(appInfo).toBeTruthy() electronApp = await electron.launch({ args: [appInfo.main], // main file from package.json executablePath: appInfo.executable, // path to the Electron executable - }); - await stubDialog(electronApp, "showMessageBox", { response: 1 }); + }) + await stubDialog(electronApp, 'showMessageBox', { response: 1 }) - page = await electronApp.firstWindow(); -}); + page = await electronApp.firstWindow() +}) test.afterAll(async () => { - await electronApp.close(); - await page.close(); -}); + await electronApp.close() + await page.close() +}) -test("shows settings", async () => { - await page.getByTestId("Settings").first().click(); - await page.getByTestId("testid-setting-description").isVisible(); -}); +test('shows settings', async () => { + await page.getByTestId('Settings').first().click() + await page.getByTestId('testid-setting-description').isVisible() +}) diff --git a/electron/tests/system-monitor.e2e.spec.ts b/electron/tests/system-monitor.e2e.spec.ts new file mode 100644 index 000000000..747a8ae18 --- /dev/null +++ b/electron/tests/system-monitor.e2e.spec.ts @@ -0,0 +1,41 @@ +import { _electron as electron } from 'playwright' +import { ElectronApplication, Page, expect, test } from '@playwright/test' + +import { + findLatestBuild, + parseElectronApp, + stubDialog, +} from 'electron-playwright-helpers' + +let electronApp: ElectronApplication +let page: Page + +test.beforeAll(async () => { + process.env.CI = 'e2e' + + const latestBuild = findLatestBuild('dist') + expect(latestBuild).toBeTruthy() + + // parse the packaged Electron app and find paths and other info + const appInfo = parseElectronApp(latestBuild) + expect(appInfo).toBeTruthy() + + electronApp = await electron.launch({ + args: [appInfo.main], // main file from package.json + executablePath: appInfo.executable, // path to the Electron executable + }) + await stubDialog(electronApp, 'showMessageBox', { response: 1 }) + + page = await electronApp.firstWindow() +}) + +test.afterAll(async () => { + await electronApp.close() + await page.close() +}) + +test('shows system monitor', async () => { + await page.getByTestId('System Monitor').first().click() + await page.getByTestId('testid-system-monitor').isVisible() + // More test cases here... +}) diff --git a/extensions/assistant-extension/src/index.ts b/extensions/assistant-extension/src/index.ts index 7321a0660..8d01021b7 100644 --- a/extensions/assistant-extension/src/index.ts +++ b/extensions/assistant-extension/src/index.ts @@ -89,12 +89,12 @@ export default class JanAssistantExtension implements AssistantExtension { private async createJanAssistant(): Promise { const janAssistant: Assistant = { avatar: "", - thread_location: undefined, // TODO: make this property ? + thread_location: undefined, id: "jan", object: "assistant", // TODO: maybe we can set default value for this? created_at: Date.now(), - name: "Jan Assistant", - description: "Just Jan Assistant", + name: "Jan", + description: "A default assistant that can use all downloaded models", model: "*", instructions: "Your name is Jan.", tools: undefined, diff --git a/uikit/package.json b/uikit/package.json index dd67be599..a96b5d37e 100644 --- a/uikit/package.json +++ b/uikit/package.json @@ -20,9 +20,11 @@ "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-context": "^1.0.1", "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-toast": "^1.1.5", diff --git a/uikit/src/badge/styles.scss b/uikit/src/badge/styles.scss index e5a783d88..cf8e52c8b 100644 --- a/uikit/src/badge/styles.scss +++ b/uikit/src/badge/styles.scss @@ -6,7 +6,7 @@ } &-success { - @apply border-transparent bg-green-500 text-green-900 hover:bg-green-500/80; + @apply border-transparent bg-green-100 text-green-600; } &-secondary { diff --git a/uikit/src/command/styles.scss b/uikit/src/command/styles.scss index 80171ef50..a832792d6 100644 --- a/uikit/src/command/styles.scss +++ b/uikit/src/command/styles.scss @@ -25,7 +25,7 @@ } &-list-item { - @apply text-foreground aria-selected:bg-primary relative flex cursor-pointer select-none items-center rounded-md px-2 py-2 text-sm outline-none; + @apply text-foreground aria-selected:bg-secondary relative flex cursor-pointer select-none items-center rounded-md px-2 py-2 text-sm outline-none; } &-empty { diff --git a/uikit/src/index.ts b/uikit/src/index.ts index 67c3af93f..067752de0 100644 --- a/uikit/src/index.ts +++ b/uikit/src/index.ts @@ -10,3 +10,4 @@ export * from './tooltip' export * from './modal' export * from './command' export * from './textarea' +export * from './select' diff --git a/uikit/src/input/index.tsx b/uikit/src/input/index.tsx index 8d90ab232..9b7808055 100644 --- a/uikit/src/input/index.tsx +++ b/uikit/src/input/index.tsx @@ -9,7 +9,7 @@ const Input = forwardRef( return ( diff --git a/uikit/src/main.scss b/uikit/src/main.scss index 562e09532..1eca363b4 100644 --- a/uikit/src/main.scss +++ b/uikit/src/main.scss @@ -14,6 +14,7 @@ @import './modal/styles.scss'; @import './command/styles.scss'; @import './textarea/styles.scss'; +@import './select/styles.scss'; .animate-spin { animation: spin 1s linear infinite; @@ -104,7 +105,3 @@ --secondary-foreground: 210 20% 98%; } } - -:is(p) { - @apply text-muted-foreground; -} diff --git a/uikit/src/select/index.tsx b/uikit/src/select/index.tsx new file mode 100644 index 000000000..9bee7a153 --- /dev/null +++ b/uikit/src/select/index.tsx @@ -0,0 +1,139 @@ +'use client' + +import * as React from 'react' +import { + CaretSortIcon, + // CheckIcon, + ChevronDownIcon, + ChevronUpIcon, +} from '@radix-ui/react-icons' + +import * as SelectPrimitive from '@radix-ui/react-select' + +import { twMerge } from 'tailwind-merge' + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {/* + + + + */} + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/uikit/src/select/styles.scss b/uikit/src/select/styles.scss new file mode 100644 index 000000000..a0bf625f0 --- /dev/null +++ b/uikit/src/select/styles.scss @@ -0,0 +1,31 @@ +.select { + @apply ring-offset-background placeholder:text-muted-foreground border-border flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1; + + &-caret { + @apply h-4 w-4 opacity-50; + } + + &-scroll-up-button { + @apply flex cursor-default items-center justify-center py-1; + } + + &-scroll-down-button { + @apply flex cursor-default items-center justify-center py-1; + } + + &-label { + @apply px-2 py-1.5 text-sm font-semibold; + } + + &-item { + @apply hover:bg-secondary relative my-1 block w-full cursor-pointer select-none items-center rounded-sm px-4 py-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50; + } + + &-trigger-viewport { + @apply w-full py-1; + } + + &-content { + @apply bg-background border-border relative z-50 mt-1 block max-h-96 w-full min-w-[8rem] overflow-hidden rounded-md border shadow-md; + } +} diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 38dee2056..c62390ba5 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -15,7 +15,7 @@ export const metadata: Metadata = { export default function RootLayout({ children }: PropsWithChildren) { return ( - +
{children} diff --git a/web/app/page.tsx b/web/app/page.tsx index 20abda6f9..cae3262a7 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -8,29 +8,25 @@ import { useMainViewState } from '@/hooks/useMainViewState' import ChatScreen from '@/screens/Chat' import ExploreModelsScreen from '@/screens/ExploreModels' -import MyModelsScreen from '@/screens/MyModels' + import SettingsScreen from '@/screens/Settings' -import WelcomeScreen from '@/screens/Welcome' +import SystemMonitorScreen from '@/screens/SystemMonitor' export default function Page() { const { mainViewState } = useMainViewState() let children = null switch (mainViewState) { - case MainViewState.Welcome: - children = - break - - case MainViewState.ExploreModels: + case MainViewState.Hub: children = break - case MainViewState.MyModels: - children = + case MainViewState.Settings: + children = break - case MainViewState.Setting: - children = + case MainViewState.SystemMonitor: + children = break default: diff --git a/web/constants/screens.ts b/web/constants/screens.ts index 76ad6fab5..19f82aaac 100644 --- a/web/constants/screens.ts +++ b/web/constants/screens.ts @@ -1,7 +1,7 @@ export enum MainViewState { - Welcome, - ExploreModels, + Hub, MyModels, - Setting, - Chat, + Settings, + Thread, + SystemMonitor, } diff --git a/web/containers/CardSidebar/index.tsx b/web/containers/CardSidebar/index.tsx index 42f975aaf..38264e457 100644 --- a/web/containers/CardSidebar/index.tsx +++ b/web/containers/CardSidebar/index.tsx @@ -1,13 +1,15 @@ -import { ReactNode, useState } from 'react' -import { Fragment } from 'react' +import { ReactNode, useState, useRef } from 'react' -import { Menu, Transition } from '@headlessui/react' import { ChevronDownIcon, - EllipsisVerticalIcon, -} from '@heroicons/react/20/solid' + MoreVerticalIcon, + FolderOpenIcon, + Code2Icon, +} from 'lucide-react' import { twMerge } from 'tailwind-merge' +import { useClickOutside } from '@/hooks/useClickOutside' + interface Props { children: ReactNode title: string @@ -21,65 +23,75 @@ export default function CardSidebar({ onViewJsonClick, }: Props) { const [show, setShow] = useState(true) + const [more, setMore] = useState(false) + const [menu, setMenu] = useState(null) + const [toggle, setToggle] = useState(null) + + useClickOutside(() => setMore(false), null, [menu, toggle]) return ( -
-
+
+
- - - Open options - - setMore(!more)} + > + +
+ {more && ( +
- - - {({ active }) => ( - onRevealInFinderClick(title)} - className={twMerge( - active ? 'bg-gray-50' : '', - 'block cursor-pointer px-3 py-1 text-xs leading-6 text-gray-900' - )} - > - Reveal in finder - - )} - - - {({ active }) => ( - onViewJsonClick(title)} - className={twMerge( - active ? 'bg-gray-50' : '', - 'block cursor-pointer px-3 py-1 text-xs leading-6 text-gray-900' - )} - > - View a JSON - - )} - - - - +
{ + onRevealInFinderClick(title) + setMore(false) + }} + > + + + Reveal in Finder + +
+
{ + onViewJsonClick(title) + setMore(false) + }} + > + + + View as JSON + +
+
+ )}
{show &&
{children}
}
diff --git a/web/containers/DropdownListSidebar/index.tsx b/web/containers/DropdownListSidebar/index.tsx index b159a131e..589847fdf 100644 --- a/web/containers/DropdownListSidebar/index.tsx +++ b/web/containers/DropdownListSidebar/index.tsx @@ -1,104 +1,114 @@ -import { Fragment, useEffect, useState } from 'react' - -import { Listbox, Transition } from '@headlessui/react' -import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid' +import { useEffect, useState } from 'react' import { Model } from '@janhq/core' -import { atom, useSetAtom } from 'jotai' +import { + Button, + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@janhq/uikit' + +import { atom, useAtomValue, useSetAtom } from 'jotai' + +import { MonitorIcon } from 'lucide-react' + import { twMerge } from 'tailwind-merge' +import { MainViewState } from '@/constants/screens' + import { getDownloadedModels } from '@/hooks/useGetDownloadedModels' +import { useMainViewState } from '@/hooks/useMainViewState' + +import { toGigabytes } from '@/utils/converter' + +import { activeThreadAtom } from '@/helpers/atoms/Conversation.atom' + export const selectedModelAtom = atom(undefined) export default function DropdownListSidebar() { const [downloadedModels, setDownloadedModels] = useState([]) - const [selected, setSelected] = useState() const setSelectedModel = useSetAtom(selectedModelAtom) + const activeThread = useAtomValue(activeThreadAtom) + const [selected, setSelected] = useState() + const { setMainViewState } = useMainViewState() useEffect(() => { getDownloadedModels().then((downloadedModels) => { setDownloadedModels(downloadedModels) - if (downloadedModels.length > 0) { - setSelected(downloadedModels[0]) - setSelectedModel(downloadedModels[0]) + setSelected( + downloadedModels.filter( + (x) => x.id === activeThread?.assistants[0].model.id + )[0] || downloadedModels[0] + ) + setSelectedModel( + downloadedModels.filter( + (x) => x.id === activeThread?.assistants[0].model.id + )[0] || downloadedModels[0] + ) } }) - }, []) - - if (!selected) return null + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeThread]) return ( - { - setSelected(model) - setSelectedModel(model) + ) } diff --git a/web/containers/Layout/BottomBar/DownloadingState/index.tsx b/web/containers/Layout/BottomBar/DownloadingState/index.tsx index 1aad0fb1c..0648508d0 100644 --- a/web/containers/Layout/BottomBar/DownloadingState/index.tsx +++ b/web/containers/Layout/BottomBar/DownloadingState/index.tsx @@ -12,18 +12,14 @@ import { ModalTrigger, } from '@janhq/uikit' -import { useAtomValue } from 'jotai' - import { useDownloadState } from '@/hooks/useDownloadState' import { formatDownloadPercentage } from '@/utils/converter' import { extensionManager } from '@/extension' -import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom' export default function DownloadingState() { const { downloadStates } = useDownloadState() - const models = useAtomValue(downloadingModelsAtom) const totalCurrentProgress = downloadStates .map((a) => a.size.transferred + a.size.transferred) diff --git a/web/containers/Layout/BottomBar/index.tsx b/web/containers/Layout/BottomBar/index.tsx index 1a264da02..fb0ef5ed6 100644 --- a/web/containers/Layout/BottomBar/index.tsx +++ b/web/containers/Layout/BottomBar/index.tsx @@ -30,7 +30,7 @@ const BottomBar = () => { const { downloadStates } = useDownloadState() return ( -
+
{progress && progress > 0 ? ( @@ -49,7 +49,7 @@ const BottomBar = () => { name="Active model:" value={ activeModel?.id || ( - +   to show your model @@ -63,7 +63,7 @@ const BottomBar = () => { diff --git a/web/containers/Layout/Ribbon/index.tsx b/web/containers/Layout/Ribbon/index.tsx index 6babadb9d..fa6d53193 100644 --- a/web/containers/Layout/Ribbon/index.tsx +++ b/web/containers/Layout/Ribbon/index.tsx @@ -1,5 +1,3 @@ -import { useContext } from 'react' - import { Tooltip, TooltipContent, @@ -11,9 +9,8 @@ import { motion as m } from 'framer-motion' import { MessageCircleIcon, SettingsIcon, - DatabaseIcon, - CpuIcon, - BookOpenIcon, + MonitorIcon, + LayoutGridIcon, } from 'lucide-react' import { twMerge } from 'tailwind-merge' @@ -34,36 +31,51 @@ export default function RibbonNav() { const primaryMenus = [ { - name: 'Getting Started', - icon: , - state: MainViewState.Welcome, + name: 'Thread', + icon: ( + + ), + state: MainViewState.Thread, }, { - name: 'Chat', - icon: , - state: MainViewState.Chat, + name: 'Hub', + icon: ( + + ), + state: MainViewState.Hub, }, ] const secondaryMenus = [ { - name: 'Explore Models', - icon: , - state: MainViewState.ExploreModels, - }, - { - name: 'My Models', - icon: , - state: MainViewState.MyModels, + name: 'System Monitor', + icon: ( + + ), + state: MainViewState.SystemMonitor, }, { name: 'Settings', - icon: , - state: MainViewState.Setting, + icon: ( + + ), + state: MainViewState.Settings, }, ] return ( -
+
@@ -90,7 +102,7 @@ export default function RibbonNav() {
{isActive && ( )} @@ -126,7 +138,7 @@ export default function RibbonNav() {
{isActive && ( )} diff --git a/web/containers/Layout/TopBar/CommandListDownloadedModel/index.tsx b/web/containers/Layout/TopBar/CommandListDownloadedModel/index.tsx index 0fb278080..d0ea6b26b 100644 --- a/web/containers/Layout/TopBar/CommandListDownloadedModel/index.tsx +++ b/web/containers/Layout/TopBar/CommandListDownloadedModel/index.tsx @@ -85,12 +85,12 @@ export default function CommandListDownloadedModel() { { - setMainViewState(MainViewState.ExploreModels) + setMainViewState(MainViewState.Hub) setOpen(false) }} > - Explore Models + Explore The Hub diff --git a/web/containers/Layout/TopBar/CommandSearch/index.tsx b/web/containers/Layout/TopBar/CommandSearch/index.tsx index 2e20ff583..d83feb22e 100644 --- a/web/containers/Layout/TopBar/CommandSearch/index.tsx +++ b/web/containers/Layout/TopBar/CommandSearch/index.tsx @@ -1,7 +1,6 @@ import { Fragment, useState, useEffect } from 'react' import { - Button, CommandModal, CommandEmpty, CommandGroup, @@ -11,14 +10,7 @@ import { CommandList, } from '@janhq/uikit' -import { useAtomValue, useSetAtom } from 'jotai' -import { - MessageCircleIcon, - SettingsIcon, - DatabaseIcon, - CpuIcon, - BookOpenIcon, -} from 'lucide-react' +import { MessageCircleIcon, SettingsIcon, LayoutGridIcon } from 'lucide-react' import ShortCut from '@/containers/Shortcut' @@ -26,43 +18,27 @@ import { MainViewState } from '@/constants/screens' import { useMainViewState } from '@/hooks/useMainViewState' -import { showRightSideBarAtom } from '@/screens/Chat/Sidebar' - -import { activeThreadAtom } from '@/helpers/atoms/Conversation.atom' - export default function CommandSearch() { const { setMainViewState } = useMainViewState() const [open, setOpen] = useState(false) - const setShowRightSideBar = useSetAtom(showRightSideBarAtom) - const activeThread = useAtomValue(activeThreadAtom) const menus = [ - { - name: 'Getting Started', - icon: , - state: MainViewState.Welcome, - }, { name: 'Chat', icon: ( ), - state: MainViewState.Chat, + state: MainViewState.Thread, }, { - name: 'Explore Models', - icon: , - state: MainViewState.ExploreModels, - }, - { - name: 'My Models', - icon: , - state: MainViewState.MyModels, + name: 'Hub', + icon: , + state: MainViewState.Hub, }, { name: 'Settings', icon: , - state: MainViewState.Setting, + state: MainViewState.Settings, shortcut: , }, ] @@ -75,7 +51,7 @@ export default function CommandSearch() { } if (e.key === ',' && (e.metaKey || e.ctrlKey)) { e.preventDefault() - setMainViewState(MainViewState.Setting) + setMainViewState(MainViewState.Settings) } } document.addEventListener('keydown', down) @@ -85,7 +61,8 @@ export default function CommandSearch() { return ( -
+ {/* Temporary disable view search input until we have proper UI placement, but we keep function cmd + K for showing list page */} + {/*
-
- +
*/} @@ -124,15 +100,6 @@ export default function CommandSearch() { - {activeThread && ( - - )} ) } diff --git a/web/containers/Layout/TopBar/index.tsx b/web/containers/Layout/TopBar/index.tsx index 5ab4ebc84..aa7912bd3 100644 --- a/web/containers/Layout/TopBar/index.tsx +++ b/web/containers/Layout/TopBar/index.tsx @@ -1,21 +1,86 @@ +import { useAtomValue, useSetAtom } from 'jotai' +import { PanelLeftIcon, PenSquareIcon, PanelRightIcon } from 'lucide-react' + import CommandListDownloadedModel from '@/containers/Layout/TopBar/CommandListDownloadedModel' import CommandSearch from '@/containers/Layout/TopBar/CommandSearch' +import { MainViewState } from '@/constants/screens' + +import { useCreateNewThread } from '@/hooks/useCreateNewThread' +import useGetAssistants from '@/hooks/useGetAssistants' import { useMainViewState } from '@/hooks/useMainViewState' +import { showRightSideBarAtom } from '@/screens/Chat/Sidebar' + +import { activeThreadAtom } from '@/helpers/atoms/Conversation.atom' + const TopBar = () => { - const { viewStateName } = useMainViewState() + const activeThread = useAtomValue(activeThreadAtom) + const { mainViewState } = useMainViewState() + const { requestCreateNewThread } = useCreateNewThread() + const { assistants } = useGetAssistants() + const setShowRightSideBar = useSetAtom(showRightSideBarAtom) + + const titleScreen = (viewStateName: MainViewState) => { + switch (viewStateName) { + case MainViewState.Thread: + return activeThread ? activeThread?.title : 'New Thread' + + default: + return MainViewState[viewStateName]?.replace(/([A-Z])/g, ' $1').trim() + } + } + + const onCreateConversationClick = async () => { + if (assistants.length === 0) { + alert('No assistant available') + return + } + requestCreateNewThread(assistants[0]) + } return ( -
+
+ {mainViewState === MainViewState.Thread && ( +
+ )}
-
- - {viewStateName.replace(/([A-Z])/g, ' $1').trim()} - -
+ {mainViewState === MainViewState.Thread ? ( +
+
+
+ +
+
+ +
+
+ + {titleScreen(mainViewState)} + + {activeThread && ( +
setShowRightSideBar((show) => !show)} + > + +
+ )} +
+ ) : ( +
+ + {titleScreen(mainViewState)} + +
+ )} - {/* Command without trigger interface */}
diff --git a/web/containers/ModalCancelDownload/index.tsx b/web/containers/ModalCancelDownload/index.tsx index 8619c543c..4153b89ee 100644 --- a/web/containers/ModalCancelDownload/index.tsx +++ b/web/containers/ModalCancelDownload/index.tsx @@ -35,7 +35,6 @@ export default function ModalCancelDownload({ model, isFromList }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps [model.id] ) - const models = useAtomValue(downloadingModelsAtom) const downloadState = useAtomValue(downloadAtom) const cancelText = `Cancel ${formatDownloadPercentage(downloadState.percent)}` diff --git a/web/containers/Shortcut/index.tsx b/web/containers/Shortcut/index.tsx index 67a5f8d0c..ae93a827e 100644 --- a/web/containers/Shortcut/index.tsx +++ b/web/containers/Shortcut/index.tsx @@ -14,7 +14,7 @@ export default function ShortCut(props: { menu: string }) { } return ( -
+

{getSymbol(os) + ' + ' + menu}

) diff --git a/web/containers/Toast/index.tsx b/web/containers/Toast/index.tsx index 50f1f0f29..c5e5f03da 100644 --- a/web/containers/Toast/index.tsx +++ b/web/containers/Toast/index.tsx @@ -16,7 +16,7 @@ export function toaster(props: Props) { return (
{ const newData: Record = { ...get(chatMessages), } - newData[id] = newData[id].filter((e) => e.role === ChatCompletionRole.System) + newData[id] = newData[id]?.filter((e) => e.role === ChatCompletionRole.System) set(chatMessages, newData) }) diff --git a/web/helpers/atoms/SystemBar.atom.ts b/web/helpers/atoms/SystemBar.atom.ts index 9b44c2e92..aa5e77d58 100644 --- a/web/helpers/atoms/SystemBar.atom.ts +++ b/web/helpers/atoms/SystemBar.atom.ts @@ -1,3 +1,6 @@ import { atom } from 'jotai' export const totalRamAtom = atom(0) +export const usedRamAtom = atom(0) + +export const cpuUsageAtom = atom(0) diff --git a/web/hooks/useClickOutside.ts b/web/hooks/useClickOutside.ts new file mode 100644 index 000000000..4e8e5d2c3 --- /dev/null +++ b/web/hooks/useClickOutside.ts @@ -0,0 +1,42 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useEffect, useRef } from 'react' + +const DEFAULT_EVENTS = ['mousedown', 'touchstart'] + +export function useClickOutside( + handler: () => void, + events?: string[] | null, + nodes?: (HTMLElement | null)[] +) { + const ref = useRef() + + useEffect(() => { + const listener = (event: any) => { + const { target } = event ?? {} + if (Array.isArray(nodes)) { + const shouldIgnore = + target?.hasAttribute('data-ignore-outside-clicks') || + (!document.body.contains(target) && target.tagName !== 'HTML') + const shouldTrigger = nodes.every( + (node) => !!node && !event.composedPath().includes(node) + ) + shouldTrigger && !shouldIgnore && handler() + } else if (ref.current && !ref.current.contains(target)) { + handler() + } + } + + ;(events || DEFAULT_EVENTS).forEach((fn) => + document.addEventListener(fn, listener) + ) + + return () => { + ;(events || DEFAULT_EVENTS).forEach((fn) => + document.removeEventListener(fn, listener) + ) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ref, handler, nodes]) + + return ref +} diff --git a/web/hooks/useCreateNewThread.ts b/web/hooks/useCreateNewThread.ts index 9ccecee7a..7526feb49 100644 --- a/web/hooks/useCreateNewThread.ts +++ b/web/hooks/useCreateNewThread.ts @@ -40,7 +40,6 @@ export const useCreateNewThread = () => { const setActiveThreadId = useSetAtom(setActiveThreadIdAtom) const [threadStates, setThreadStates] = useAtom(threadStatesAtom) const threads = useAtomValue(threadsAtom) - const activeThread = useAtomValue(activeThreadAtom) const updateThread = useSetAtom(updateThreadAtom) const requestCreateNewThread = async (assistant: Assistant) => { @@ -69,6 +68,7 @@ export const useCreateNewThread = () => { stream: false, }, }, + instructions: assistant.instructions, } const threadId = generateThreadId(assistant.id) const thread: Thread = { @@ -93,20 +93,18 @@ export const useCreateNewThread = () => { setActiveThreadId(thread.id) } - function updateThreadTitle(title: string) { - if (!activeThread) return - const updatedConv: Thread = { - ...activeThread, - title, + function updateThreadMetadata(thread: Thread) { + const updatedThread: Thread = { + ...thread, } - updateThread(updatedConv) + updateThread(updatedThread) extensionManager .get(ExtensionType.Conversational) - ?.saveThread(updatedConv) + ?.saveThread(updatedThread) } return { requestCreateNewThread, - updateThreadTitle, + updateThreadMetadata, } } diff --git a/web/hooks/useDeleteConversation.ts b/web/hooks/useDeleteConversation.ts index 1cfceebcf..b02796b10 100644 --- a/web/hooks/useDeleteConversation.ts +++ b/web/hooks/useDeleteConversation.ts @@ -17,7 +17,6 @@ import { } from '@/helpers/atoms/ChatMessage.atom' import { threadsAtom, - getActiveThreadIdAtom, setActiveThreadIdAtom, } from '@/helpers/atoms/Conversation.atom' @@ -25,14 +24,13 @@ export default function useDeleteThread() { const { activeModel } = useActiveModel() const [threads, setThreads] = useAtom(threadsAtom) const setCurrentPrompt = useSetAtom(currentPromptAtom) - const activeThreadId = useAtomValue(getActiveThreadIdAtom) const messages = useAtomValue(getCurrentChatMessagesAtom) const setActiveConvoId = useSetAtom(setActiveThreadIdAtom) const deleteMessages = useSetAtom(deleteConversationMessage) const cleanMessages = useSetAtom(cleanConversationMessages) - const cleanThread = async () => { + const cleanThread = async (activeThreadId: string) => { if (activeThreadId) { const thread = threads.filter((c) => c.id === activeThreadId)[0] cleanMessages(activeThreadId) @@ -46,7 +44,7 @@ export default function useDeleteThread() { } } - const deleteThread = async () => { + const deleteThread = async (activeThreadId: string) => { if (!activeThreadId) { alert('No active thread') return @@ -60,8 +58,8 @@ export default function useDeleteThread() { deleteMessages(activeThreadId) setCurrentPrompt('') toaster({ - title: 'Chat successfully deleted.', - description: `Chat with ${activeModel?.name} has been successfully deleted.`, + title: 'Thread successfully deleted.', + description: `Thread with ${activeModel?.name} has been successfully deleted.`, }) if (availableThreads.length > 0) { setActiveConvoId(availableThreads[0].id) diff --git a/web/hooks/useDownloadModel.ts b/web/hooks/useDownloadModel.ts index 6bcffdaed..b91ac2a57 100644 --- a/web/hooks/useDownloadModel.ts +++ b/web/hooks/useDownloadModel.ts @@ -1,6 +1,6 @@ import { Model, ExtensionType, ModelExtension } from '@janhq/core' -import { useAtom, useAtomValue } from 'jotai' +import { useAtom } from 'jotai' import { useDownloadState } from './useDownloadState' diff --git a/web/hooks/useGetSystemResources.ts b/web/hooks/useGetSystemResources.ts index ef4b2ef08..e2de61519 100644 --- a/web/hooks/useGetSystemResources.ts +++ b/web/hooks/useGetSystemResources.ts @@ -6,12 +6,18 @@ import { MonitoringExtension } from '@janhq/core' import { useSetAtom } from 'jotai' import { extensionManager } from '@/extension/ExtensionManager' -import { totalRamAtom } from '@/helpers/atoms/SystemBar.atom' +import { + cpuUsageAtom, + totalRamAtom, + usedRamAtom, +} from '@/helpers/atoms/SystemBar.atom' export default function useGetSystemResources() { const [ram, setRam] = useState(0) const [cpu, setCPU] = useState(0) const setTotalRam = useSetAtom(totalRamAtom) + const setUsedRam = useSetAtom(usedRamAtom) + const setCpuUsage = useSetAtom(cpuUsageAtom) const getSystemResources = async () => { if ( @@ -27,10 +33,12 @@ export default function useGetSystemResources() { const ram = (resourceInfor?.mem?.active ?? 0) / (resourceInfor?.mem?.total ?? 1) + if (resourceInfor?.mem?.active) setUsedRam(resourceInfor.mem.active) if (resourceInfor?.mem?.total) setTotalRam(resourceInfor.mem.total) setRam(Math.round(ram * 100)) setCPU(Math.round(currentLoadInfor?.currentLoad ?? 0)) + setCpuUsage(Math.round(currentLoadInfor?.currentLoad ?? 0)) } useEffect(() => { @@ -45,6 +53,7 @@ export default function useGetSystemResources() { // clean up interval return () => clearInterval(intervalId) + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) return { diff --git a/web/hooks/useMainViewState.ts b/web/hooks/useMainViewState.ts index 3dccbb704..91c1a1c4d 100644 --- a/web/hooks/useMainViewState.ts +++ b/web/hooks/useMainViewState.ts @@ -2,7 +2,7 @@ import { atom, useAtom } from 'jotai' import { MainViewState } from '@/constants/screens' -const currentMainViewState = atom(MainViewState.Welcome) +const currentMainViewState = atom(MainViewState.Thread) export function useMainViewState() { const [mainViewState, setMainViewState] = useAtom(currentMainViewState) diff --git a/web/hooks/useSendChatMessage.ts b/web/hooks/useSendChatMessage.ts index 6b60a0e04..9cf61969d 100644 --- a/web/hooks/useSendChatMessage.ts +++ b/web/hooks/useSendChatMessage.ts @@ -1,3 +1,5 @@ +import { useState } from 'react' + import { ChatCompletionMessage, ChatCompletionRole, @@ -10,7 +12,7 @@ import { ThreadMessage, events, } from '@janhq/core' -import { ConversationalExtension, InferenceExtension } from '@janhq/core' +import { ConversationalExtension } from '@janhq/core' import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { ulid } from 'ulid' @@ -44,6 +46,7 @@ export default function useSendChatMessage() { const { activeModel } = useActiveModel() const selectedModel = useAtomValue(selectedModelAtom) const { startModel } = useActiveModel() + const [queuedMessage, setQueuedMessage] = useState(false) const sendChatMessage = async () => { if (!currentPrompt || currentPrompt.trim().length === 0) { @@ -61,14 +64,15 @@ export default function useSendChatMessage() { } const assistantId = activeThread.assistants[0].assistant_id ?? '' const assistantName = activeThread.assistants[0].assistant_name ?? '' + const instructions = activeThread.assistants[0].instructions ?? '' const updatedThread: Thread = { ...activeThread, isFinishInit: true, - title: `${activeThread.assistants[0].assistant_name} with ${selectedModel.name}`, assistants: [ { assistant_id: assistantId, assistant_name: assistantName, + instructions: instructions, model: { id: selectedModel.id, settings: selectedModel.settings, @@ -90,18 +94,29 @@ export default function useSendChatMessage() { const prompt = currentPrompt.trim() setCurrentPrompt('') - const messages: ChatCompletionMessage[] = currentMessages - .map((msg) => ({ - role: msg.role, - content: msg.content[0]?.text.value ?? '', - })) - .concat([ - { - role: ChatCompletionRole.User, - content: prompt, - } as ChatCompletionMessage, - ]) - console.debug(`Sending messages: ${JSON.stringify(messages, null, 2)}`) + const messages: ChatCompletionMessage[] = [ + activeThread.assistants[0]?.instructions, + ] + .map((instructions) => { + const systemMessage: ChatCompletionMessage = { + role: ChatCompletionRole.System, + content: instructions, + } + return systemMessage + }) + .concat( + currentMessages + .map((msg) => ({ + role: msg.role, + content: msg.content[0]?.text.value ?? '', + })) + .concat([ + { + role: ChatCompletionRole.User, + content: prompt, + } as ChatCompletionMessage, + ]) + ) const msgId = ulid() const messageRequest: MessageRequest = { id: msgId, @@ -136,17 +151,17 @@ export default function useSendChatMessage() { ?.addNewMessage(threadMessage) const modelId = selectedModel?.id ?? activeThread.assistants[0].model.id + if (activeModel?.id !== modelId) { - toaster({ - title: 'Message queued.', - description: 'It will be sent once the model is done loading', - }) + setQueuedMessage(true) await startModel(modelId) + setQueuedMessage(false) } events.emit(EventName.OnMessageSent, messageRequest) } return { sendChatMessage, + queuedMessage, } } diff --git a/web/package.json b/web/package.json index 16522cace..922bc556a 100644 --- a/web/package.json +++ b/web/package.json @@ -33,6 +33,7 @@ "react-dom": "18.2.0", "react-hook-form": "^7.47.0", "react-hot-toast": "^2.4.1", + "react-scroll-to-bottom": "^4.2.0", "react-toastify": "^9.1.3", "sass": "^1.69.4", "tailwind-merge": "^2.0.0", @@ -48,6 +49,7 @@ "@types/node": "20.8.10", "@types/react": "18.2.34", "@types/react-dom": "18.2.14", + "@types/react-scroll-to-bottom": "^4.2.4", "@types/uuid": "^9.0.6", "@typescript-eslint/eslint-plugin": "^6.8.0", "@typescript-eslint/parser": "^6.8.0", diff --git a/web/screens/Chat/ChatBody/index.tsx b/web/screens/Chat/ChatBody/index.tsx index 10d008661..0a92b7a6c 100644 --- a/web/screens/Chat/ChatBody/index.tsx +++ b/web/screens/Chat/ChatBody/index.tsx @@ -1,17 +1,65 @@ +import { Fragment } from 'react' + +import ScrollToBottom from 'react-scroll-to-bottom' + +import { Button } from '@janhq/uikit' import { useAtomValue } from 'jotai' +import LogoMark from '@/containers/Brand/Logo/Mark' + +import { MainViewState } from '@/constants/screens' + +import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels' + +import { useMainViewState } from '@/hooks/useMainViewState' + import ChatItem from '../ChatItem' import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' const ChatBody: React.FC = () => { const messages = useAtomValue(getCurrentChatMessagesAtom) + const { downloadedModels } = useGetDownloadedModels() + const { setMainViewState } = useMainViewState() + + if (downloadedModels.length === 0) + return ( +
+ +

Welcome!

+

You need to download your first model

+ +
+ ) + return ( -
- {messages.map((message) => ( - - ))} -
+ + {messages.length === 0 ? ( +
+ +

How can I help you?

+
+ ) : ( + + {messages.map((message) => ( + + ))} + + )} +
) } diff --git a/web/screens/Chat/ChatItem/index.tsx b/web/screens/Chat/ChatItem/index.tsx index 5f192d436..fcc6cbab5 100644 --- a/web/screens/Chat/ChatItem/index.tsx +++ b/web/screens/Chat/ChatItem/index.tsx @@ -7,10 +7,7 @@ import SimpleTextMessage from '../SimpleTextMessage' type Ref = HTMLDivElement const ChatItem = forwardRef((message, ref) => ( -
+
)) diff --git a/web/screens/Chat/MessageToolbar/index.tsx b/web/screens/Chat/MessageToolbar/index.tsx index 5fe432e62..5380c7e29 100644 --- a/web/screens/Chat/MessageToolbar/index.tsx +++ b/web/screens/Chat/MessageToolbar/index.tsx @@ -1,7 +1,4 @@ -import { useMemo } from 'react' - import { - ChatCompletionRole, ChatCompletionMessage, EventName, MessageRequest, @@ -11,8 +8,8 @@ import { events, } from '@janhq/core' import { ConversationalExtension, InferenceExtension } from '@janhq/core' -import { atom, useAtomValue, useSetAtom } from 'jotai' -import { RefreshCcw, ClipboardCopy, Trash2Icon, StopCircle } from 'lucide-react' +import { useAtomValue, useSetAtom } from 'jotai' +import { RefreshCcw, Copy, Trash2Icon, StopCircle } from 'lucide-react' import { twMerge } from 'tailwind-merge' @@ -23,21 +20,17 @@ import { deleteMessageAtom, getCurrentChatMessagesAtom, } from '@/helpers/atoms/ChatMessage.atom' -import { - activeThreadAtom, - threadStatesAtom, -} from '@/helpers/atoms/Conversation.atom' +import { activeThreadAtom } from '@/helpers/atoms/Conversation.atom' const MessageToolbar = ({ message }: { message: ThreadMessage }) => { const deleteMessage = useSetAtom(deleteMessageAtom) const thread = useAtomValue(activeThreadAtom) const messages = useAtomValue(getCurrentChatMessagesAtom) - const threadStateAtom = useMemo( - () => atom((get) => get(threadStatesAtom)[thread?.id ?? '']), - [thread?.id] - ) - const threadState = useAtomValue(threadStateAtom) - + // const threadStateAtom = useMemo( + // () => atom((get) => get(threadStatesAtom)[thread?.id ?? '']), + // [thread?.id] + // ) + // const threadState = useAtomValue(threadStateAtom) const stopInference = async () => { await extensionManager .get(ExtensionType.Inference) @@ -51,12 +44,7 @@ const MessageToolbar = ({ message }: { message: ThreadMessage }) => { } return ( -
+
{message.status === MessageStatus.Pending && (
{ }) }} > - +
(false) +export const showRightSideBarAtom = atom(true) export default function Sidebar() { const showing = useAtomValue(showRightSideBarAtom) const activeThread = useAtomValue(activeThreadAtom) const selectedModel = useAtomValue(selectedModelAtom) - const { updateThreadTitle } = useCreateNewThread() + const { updateThreadMetadata } = useCreateNewThread() const onReviewInFinderClick = async (type: string) => { if (!activeThread) return if (!activeThread.isFinishInit) { - alert('Thread is not ready') + alert('Thread is not started yet') return } @@ -56,7 +61,7 @@ export default function Sidebar() { const onViewJsonClick = async (type: string) => { if (!activeThread) return if (!activeThread.isFinishInit) { - alert('Thread is not ready') + alert('Thread is not started yet') return } @@ -87,44 +92,104 @@ export default function Sidebar() { return (
-
+
- - updateThreadTitle(title ?? '')} - /> +
+
+ + { + if (activeThread) + updateThreadMetadata({ + ...activeThread, + title: e.target.value || '', + }) + }} + /> +
+
+ + + {activeThread?.id || '-'} + +
+
- +
+
+ + + {activeThread?.assistants[0].assistant_name ?? '-'} + +
+
+ +