diff --git a/Makefile b/Makefile index b0f5ed543..a61ae92bb 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ ifeq ($(OS),Windows_NT) yarn config set network-timeout 300000 endif yarn build:core + yarn build:server yarn install yarn build:extensions diff --git a/README.md b/README.md index f5761903a..7a925eceb 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Jan is an open-source ChatGPT alternative that runs 100% offline on your compute Experimental (Nighlty Build) - + Github action artifactory @@ -81,16 +81,14 @@ Download the latest version of Jan at https://jan.ai/ or visit the **[GitHub Rel ## Demo -

- -

+![Demo](/demo.gif) - -_Video: Jan v0.4.0 on Mac Air M2, 16GB Ventura_ +_Realtime Video: Jan v0.4.3-nightly on a Mac M1, 16GB Sonoma 14_ ## Quicklinks + #### Jan + - [Jan website](https://jan.ai/) - [Jan Github](https://github.com/janhq/jan) - [User Guides](https://jan.ai/docs) @@ -98,8 +96,10 @@ _Video: Jan v0.4.0 on Mac Air M2, 16GB Ventura_ - [API reference](https://jan.ai/api-reference/) - [Specs](https://jan.ai/specs/) -#### Nitro: -Nitro is a high-efficiency C++ inference engine for edge computing, powering Jan. It is lightweight and embeddable, ideal for product integration. +#### Nitro + +Nitro is a high-efficiency C++ inference engine for edge computing. It is lightweight and embeddable, and can be used on its own within your own projects. + - [Nitro Website](https://nitro.jan.ai) - [Nitro Github](https://github.com/janhq/nitro) - [Documentation](https://nitro.jan.ai/docs) @@ -118,21 +118,22 @@ To reset your installation: ``` This will remove all build artifacts and cached files: + - Delete Jan from your `/Applications` folder - Clear Application cache in `/Users/$(whoami)/Library/Caches/jan` 2. Use the following commands to remove any dangling backend processes: - ```sh - ps aux | grep nitro - ``` + ```sh + ps aux | grep nitro + ``` - Look for processes like "nitro" and "nitro_arm_64," and kill them one by one with: + Look for processes like "nitro" and "nitro_arm_64," and kill them one by one with: + + ```sh + kill -9 + ``` - ```sh - kill -9 - ``` - ## Contributing Contributions are welcome! Please read the [CONTRIBUTING.md](CONTRIBUTING.md) file @@ -176,19 +177,22 @@ This will build the app MacOS m1/m2 for production (with code signing already do ## Nightly Build -Nightly build is a process where the software is built automatically every night. This helps in detecting and fixing bugs early in the development cycle. The process for this project is defined in [`.github/workflows/jan-electron-build-nightly.yml`](.github/workflows/jan-electron-build-nightly.yml) - -You can join our Discord server [here](https://discord.gg/FTk2MvZwJH) and go to channel [github-jan](https://discordapp.com/channels/1107178041848909847/1148534730359308298) to monitor the build process. +Our nightly build process for this project is defined in [`.github/workflows/jan-electron-build-nightly.yml`](.github/workflows/jan-electron-build-nightly.yml) The nightly build is triggered at 2:00 AM UTC every day. -The nightly build can be downloaded from the url notified in the Discord channel. Please access the url from the browser and download the build artifacts from there. +Getting on Nightly: + +1. Join our Discord server [here](https://discord.gg/FTk2MvZwJH) and go to channel [github-jan](https://discordapp.com/channels/1107178041848909847/1148534730359308298). +2. Download the build artifacts from the channel. +3. Subsequently, to get the latest nightly, just quit and restart the app. +4. Upon app restart, you will be automatically prompted to update to the latest nightly build. ## Manual Build -Manual build is a process where the software is built manually by the developers. This is usually done when a new feature is implemented or a bug is fixed. The process for this project is defined in [`.github/workflows/jan-electron-build-nightly.yml`](.github/workflows/jan-electron-build-nightly.yml) +Stable releases are triggered by manual builds. This is usually done for new features or a bug fixes. -It is similar to the nightly build process, except that it is triggered manually by the developers. +The process for this project is defined in [`.github/workflows/jan-electron-build-nightly.yml`](.github/workflows/jan-electron-build-nightly.yml) ## Acknowledgements diff --git a/core/package.json b/core/package.json index a599fc3c2..ce0714c27 100644 --- a/core/package.json +++ b/core/package.json @@ -22,6 +22,27 @@ "engines": { "node": ">=6.0.0" }, + "exports": { + ".": "./dist/core.umd.js", + "./sdk": "./dist/core.umd.js", + "./node": "./dist/node/index.cjs.js" + }, + "typesVersions": { + "*": { + ".": [ + "./dist/core.es5.js.map", + "./dist/types/index.d.ts" + ], + "sdk": [ + "./dist/core.es5.js.map", + "./dist/types/index.d.ts" + ], + "node": [ + "./dist/node/index.cjs.js.map", + "./dist/types/node/index.d.ts" + ] + } + }, "scripts": { "lint": "tslint --project tsconfig.json -t codeFrame 'src/**/*.ts' 'test/**/*.ts'", "prebuild": "rimraf dist", diff --git a/core/rollup.config.ts b/core/rollup.config.ts index 5e1762c96..d982db659 100644 --- a/core/rollup.config.ts +++ b/core/rollup.config.ts @@ -8,30 +8,69 @@ const pkg = require('./package.json') const libraryName = 'core' -export default { - input: `src/index.ts`, - output: [ - { file: pkg.main, name: libraryName, format: 'umd', sourcemap: true }, - { file: pkg.module, format: 'es', sourcemap: true }, - ], - // Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash') - external: [], - watch: { - include: 'src/**', - }, - plugins: [ - // Allow json resolution - json(), - // Compile TypeScript files - typescript({ useTsconfigDeclarationDir: true }), - // Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs) - commonjs(), - // Allow node_modules resolution, so you can use 'external' to control - // which external modules to include in the bundle - // https://github.com/rollup/rollup-plugin-node-resolve#usage - resolve(), +export default [ + { + input: `src/index.ts`, + output: [ + { file: pkg.main, name: libraryName, format: 'umd', sourcemap: true }, + { file: pkg.module, format: 'es', sourcemap: true }, + ], + // Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash') + external: ['path'], + watch: { + include: 'src/**', + }, + plugins: [ + // Allow json resolution + json(), + // Compile TypeScript files + typescript({ useTsconfigDeclarationDir: true }), + // Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs) + commonjs(), + // Allow node_modules resolution, so you can use 'external' to control + // which external modules to include in the bundle + // https://github.com/rollup/rollup-plugin-node-resolve#usage + resolve(), - // Resolve source maps to the original source - sourceMaps(), - ], -} + // Resolve source maps to the original source + sourceMaps(), + ], + }, + { + input: `src/node/index.ts`, + output: [{ file: 'dist/node/index.cjs.js', format: 'cjs', sourcemap: true }], + // Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash') + external: [ + 'fs/promises', + 'path', + 'pacote', + '@types/pacote', + '@npmcli/arborist', + 'ulid', + 'node-fetch', + 'fs', + 'request', + 'crypto', + 'url', + 'http', + ], + watch: { + include: 'src/node/**', + }, + plugins: [ + // Allow json resolution + json(), + // Compile TypeScript files + typescript({ useTsconfigDeclarationDir: true }), + // Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs) + commonjs(), + // Allow node_modules resolution, so you can use 'external' to control + // which external modules to include in the bundle + // https://github.com/rollup/rollup-plugin-node-resolve#usage + resolve(), + + // Resolve source maps to the original source + sourceMaps(), + ], + }, +] diff --git a/core/src/api/index.ts b/core/src/api/index.ts index 4f2b45f5f..3cf2693e7 100644 --- a/core/src/api/index.ts +++ b/core/src/api/index.ts @@ -5,12 +5,12 @@ export enum AppRoute { appDataPath = 'appDataPath', appVersion = 'appVersion', - getResourcePath = 'getResourcePath', openExternalUrl = 'openExternalUrl', openAppDirectory = 'openAppDirectory', openFileExplore = 'openFileExplorer', relaunch = 'relaunch', - joinPath = 'joinPath' + joinPath = 'joinPath', + baseName = 'baseName', } export enum AppEvent { @@ -41,20 +41,20 @@ export enum ExtensionRoute { uninstallExtension = 'uninstallExtension', } export enum FileSystemRoute { - appendFile = 'appendFile', - copyFile = 'copyFile', - syncFile = 'syncFile', - deleteFile = 'deleteFile', - exists = 'exists', - getResourcePath = 'getResourcePath', + appendFileSync = 'appendFileSync', + copyFileSync = 'copyFileSync', + unlinkSync = 'unlinkSync', + existsSync = 'existsSync', + readdirSync = 'readdirSync', + mkdirSync = 'mkdirSync', + readFileSync = 'readFileSync', + rmdirSync = 'rmdirSync', + writeFileSync = 'writeFileSync', +} +export enum FileManagerRoute { + synceFile = 'syncFile', getUserSpace = 'getUserSpace', - isDirectory = 'isDirectory', - listFiles = 'listFiles', - mkdir = 'mkdir', - readFile = 'readFile', - readLineByLine = 'readLineByLine', - rmdir = 'rmdir', - writeFile = 'writeFile', + getResourcePath = 'getResourcePath', } export type ApiFunction = (...args: any[]) => any @@ -83,17 +83,23 @@ export type FileSystemRouteFunctions = { [K in FileSystemRoute]: ApiFunction } +export type FileManagerRouteFunctions = { + [K in FileManagerRoute]: ApiFunction +} + export type APIFunctions = AppRouteFunctions & AppEventFunctions & DownloadRouteFunctions & DownloadEventFunctions & ExtensionRouteFunctions & - FileSystemRouteFunctions + FileSystemRouteFunctions & + FileManagerRoute export const APIRoutes = [ ...Object.values(AppRoute), ...Object.values(DownloadRoute), ...Object.values(ExtensionRoute), ...Object.values(FileSystemRoute), + ...Object.values(FileManagerRoute), ] export const APIEvents = [...Object.values(AppEvent), ...Object.values(DownloadEvent)] diff --git a/core/src/core.ts b/core/src/core.ts index 0f20feb1e..2cfd43a39 100644 --- a/core/src/core.ts +++ b/core/src/core.ts @@ -51,6 +51,27 @@ const openFileExplorer: (path: string) => Promise = (path) => */ const joinPath: (paths: string[]) => Promise = (paths) => global.core.api?.joinPath(paths) +/** + * Retrive the basename from an url. + * @param path - The path to retrieve. + * @returns {Promise} A promise that resolves with the basename. + */ +const baseName: (paths: string[]) => Promise = (path) => global.core.api?.baseName(path) + +/** + * Opens an external URL in the default web browser. + * + * @param {string} url - The URL to open. + * @returns {Promise} - A promise that resolves when the URL has been successfully opened. + */ +const openExternalUrl: (url: string) => Promise = (url) => + global.core.api?.openExternalUrl(url) + +/** + * Gets the resource path of the application. + * + * @returns {Promise} - A promise that resolves with the resource path. + */ const getResourcePath: () => Promise = () => global.core.api?.getResourcePath() /** @@ -74,4 +95,6 @@ export { openFileExplorer, getResourcePath, joinPath, + openExternalUrl, + baseName, } diff --git a/core/src/fs.ts b/core/src/fs.ts index 6ada7d3e2..34d915ef1 100644 --- a/core/src/fs.ts +++ b/core/src/fs.ts @@ -1,89 +1,74 @@ /** * 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) => - global.core.api?.writeFile(path, data) - -/** - * 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 => global.core.api?.isDirectory(path) +const writeFileSync = (...args: any[]) => global.core.api?.writeFileSync(...args) /** * 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) => global.core.api?.readFile(path) +const readFileSync = (...args: any[]) => global.core.api?.readFileSync(...args) /** * Check whether the file exists * @param {string} path * @returns {boolean} A boolean indicating whether the path is a file. */ -const exists = (path: string): Promise => global.core.api?.exists(path) +const existsSync = (...args: any[]) => global.core.api?.existsSync(...args) /** * 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) => global.core.api?.listFiles(path) +const readdirSync = (...args: any[]) => global.core.api?.readdirSync(...args) /** * 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) => global.core.api?.mkdir(path) +const mkdirSync = (...args: any[]) => global.core.api?.mkdirSync(...args) /** * 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) => global.core.api?.rmdir(path) +const rmdirSync = (...args: any[]) => + global.core.api?.rmdirSync(...args, { recursive: true, force: true }) /** * 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) => global.core.api?.deleteFile(path) +const unlinkSync = (...args: any[]) => global.core.api?.unlinkSync(...args) /** * Appends data to a file at the specified path. - * @param path path to the file - * @param data data to append */ -const appendFile: (path: string, data: string) => Promise = (path, data) => - global.core.api?.appendFile(path, data) - -const copyFile: (src: string, dest: string) => Promise = (src, dest) => - global.core.api?.copyFile(src, dest) +const appendFileSync = (...args: any[]) => global.core.api?.appendFileSync(...args) +/** + * Synchronizes a file from a source path to a destination path. + * @param {string} src - The source path of the file to be synchronized. + * @param {string} dest - The destination path where the file will be synchronized to. + * @returns {Promise} - A promise that resolves when the file has been successfully synchronized. + */ const syncFile: (src: string, dest: string) => Promise = (src, dest) => global.core.api?.syncFile(src, dest) -/** - * Reads a file line by line. - * @param {string} path - The path of the file to read. - * @returns {Promise} A promise that resolves to the lines of the file. - */ -const readLineByLine: (path: string) => Promise = (path) => - global.core.api?.readLineByLine(path) +/** + * Copy file sync. + */ +const copyFileSync = (...args: any[]) => global.core.api?.copyFileSync(...args) + +// TODO: Export `dummy` fs functions automatically +// Currently adding these manually export const fs = { - isDirectory, - writeFile, - readFile, - exists, - listFiles, - mkdir, - rmdir, - deleteFile, - appendFile, - readLineByLine, - copyFile, + writeFileSync, + readFileSync, + existsSync, + readdirSync, + mkdirSync, + rmdirSync, + unlinkSync, + appendFileSync, + copyFileSync, syncFile, } diff --git a/core/src/node/api/HttpServer.ts b/core/src/node/api/HttpServer.ts new file mode 100644 index 000000000..32d597717 --- /dev/null +++ b/core/src/node/api/HttpServer.ts @@ -0,0 +1,8 @@ +export interface HttpServer { + post: (route: string, handler: (req: any, res: any) => Promise) => void + get: (route: string, handler: (req: any, res: any) => Promise) => void + patch: (route: string, handler: (req: any, res: any) => Promise) => void + put: (route: string, handler: (req: any, res: any) => Promise) => void + delete: (route: string, handler: (req: any, res: any) => Promise) => void + register: (router: any, opts?: any) => void +} diff --git a/core/src/node/api/common/builder.ts b/core/src/node/api/common/builder.ts new file mode 100644 index 000000000..4081e859f --- /dev/null +++ b/core/src/node/api/common/builder.ts @@ -0,0 +1,335 @@ +import fs from 'fs' +import { JanApiRouteConfiguration, RouteConfiguration } from './configuration' +import { join } from 'path' +import { Model, ThreadMessage } from './../../../index' + +import fetch from 'node-fetch' +import { ulid } from 'ulid' +import request from 'request' + +const progress = require('request-progress') +const os = require('os') + +const path = join(os.homedir(), 'jan') + +export const getBuilder = async (configuration: RouteConfiguration) => { + const directoryPath = join(path, configuration.dirName) + + try { + if (!(await fs.existsSync(directoryPath))) { + console.debug('model folder not found') + return [] + } + + const files: string[] = await fs.readdirSync(directoryPath) + + const allDirectories: string[] = [] + for (const file of files) { + if (file === '.DS_Store') continue + allDirectories.push(file) + } + + const readJsonPromises = allDirectories.map(async (dirName) => { + const jsonPath = join(directoryPath, dirName, configuration.metadataFileName) + return await readModelMetadata(jsonPath) + }) + + const results = await Promise.all(readJsonPromises) + const modelData = results + .map((result: any) => { + try { + return JSON.parse(result) + } catch (err) { + console.error(err) + } + }) + .filter((e: any) => !!e) + + return modelData + } catch (err) { + console.error(err) + return [] + } +} + +const readModelMetadata = async (path: string) => { + return fs.readFileSync(path, 'utf-8') +} + +export const retrieveBuilder = async (configuration: RouteConfiguration, id: string) => { + const data = await getBuilder(configuration) + const filteredData = data.filter((d: any) => d.id === id)[0] + + if (!filteredData) { + return {} + } + + return filteredData +} + +export const deleteBuilder = async (configuration: RouteConfiguration, id: string) => { + if (configuration.dirName === 'assistants' && id === 'jan') { + return { + message: 'Cannot delete Jan assistant', + } + } + + const directoryPath = join(path, configuration.dirName) + try { + const data = await retrieveBuilder(configuration, id) + if (!data || !data.keys) { + return { + message: 'Not found', + } + } + + const myPath = join(directoryPath, id) + fs.rmdirSync(myPath, { recursive: true }) + return { + id: id, + object: configuration.delete.object, + deleted: true, + } + } catch (ex) { + console.error(ex) + } +} + +export const getMessages = async (threadId: string) => { + const threadDirPath = join(path, 'threads', threadId) + const messageFile = 'messages.jsonl' + try { + const files: string[] = await fs.readdirSync(threadDirPath) + if (!files.includes(messageFile)) { + throw Error(`${threadDirPath} not contains message file`) + } + + const messageFilePath = join(threadDirPath, messageFile) + + const lines = fs + .readFileSync(messageFilePath, 'utf-8') + .toString() + .split('\n') + .filter((line: any) => line !== '') + + const messages: ThreadMessage[] = [] + lines.forEach((line: string) => { + messages.push(JSON.parse(line) as ThreadMessage) + }) + return messages + } catch (err) { + console.error(err) + return [] + } +} + +export const retrieveMesasge = async (threadId: string, messageId: string) => { + const messages = await getMessages(threadId) + const filteredMessages = messages.filter((m) => m.id === messageId) + if (!filteredMessages || filteredMessages.length === 0) { + return { + message: 'Not found', + } + } + + return filteredMessages[0] +} + +export const createThread = async (thread: any) => { + const threadMetadataFileName = 'thread.json' + // TODO: add validation + if (!thread.assistants || thread.assistants.length === 0) { + return { + message: 'Thread must have at least one assistant', + } + } + + const threadId = generateThreadId(thread.assistants[0].assistant_id) + try { + const updatedThread = { + ...thread, + id: threadId, + created: Date.now(), + updated: Date.now(), + } + const threadDirPath = join(path, 'threads', updatedThread.id) + const threadJsonPath = join(threadDirPath, threadMetadataFileName) + + if (!fs.existsSync(threadDirPath)) { + fs.mkdirSync(threadDirPath) + } + + await fs.writeFileSync(threadJsonPath, JSON.stringify(updatedThread, null, 2)) + return updatedThread + } catch (err) { + return { + error: err, + } + } +} + +export const updateThread = async (threadId: string, thread: any) => { + const threadMetadataFileName = 'thread.json' + const currentThreadData = await retrieveBuilder(JanApiRouteConfiguration.threads, threadId) + if (!currentThreadData) { + return { + message: 'Thread not found', + } + } + // we don't want to update the id and object + delete thread.id + delete thread.object + + const updatedThread = { + ...currentThreadData, + ...thread, + updated: Date.now(), + } + try { + const threadDirPath = join(path, 'threads', updatedThread.id) + const threadJsonPath = join(threadDirPath, threadMetadataFileName) + + await fs.writeFileSync(threadJsonPath, JSON.stringify(updatedThread, null, 2)) + return updatedThread + } catch (err) { + return { + message: err, + } + } +} + +const generateThreadId = (assistantId: string) => { + return `${assistantId}_${(Date.now() / 1000).toFixed(0)}` +} + +export const createMessage = async (threadId: string, message: any) => { + const threadMessagesFileName = 'messages.jsonl' + // TODO: add validation + try { + const msgId = ulid() + const createdAt = Date.now() + const threadMessage: ThreadMessage = { + ...message, + id: msgId, + thread_id: threadId, + created: createdAt, + updated: createdAt, + object: 'thread.message', + } + + const threadDirPath = join(path, 'threads', threadId) + const threadMessagePath = join(threadDirPath, threadMessagesFileName) + + if (!fs.existsSync(threadDirPath)) { + fs.mkdirSync(threadDirPath) + } + fs.appendFileSync(threadMessagePath, JSON.stringify(threadMessage) + '\n') + return threadMessage + } catch (err) { + return { + message: err, + } + } +} + +export const downloadModel = async (modelId: string) => { + const model = await retrieveBuilder(JanApiRouteConfiguration.models, modelId) + if (!model || model.object !== 'model') { + return { + message: 'Model not found', + } + } + + const directoryPath = join(path, 'models', modelId) + if (!fs.existsSync(directoryPath)) { + fs.mkdirSync(directoryPath) + } + + // path to model binary + const modelBinaryPath = join(directoryPath, modelId) + const rq = request(model.source_url) + + progress(rq, {}) + .on('progress', function (state: any) { + console.log('progress', JSON.stringify(state, null, 2)) + }) + .on('error', function (err: Error) { + console.error('error', err) + }) + .on('end', function () { + console.log('end') + }) + .pipe(fs.createWriteStream(modelBinaryPath)) + + return { + message: `Starting download ${modelId}`, + } +} + +export const chatCompletions = async (request: any, reply: any) => { + const modelList = await getBuilder(JanApiRouteConfiguration.models) + const modelId = request.body.model + + const matchedModels = modelList.filter((model: Model) => model.id === modelId) + if (matchedModels.length === 0) { + const error = { + error: { + message: `The model ${request.body.model} does not exist`, + type: 'invalid_request_error', + param: null, + code: 'model_not_found', + }, + } + reply.code(404).send(error) + return + } + + const requestedModel = matchedModels[0] + const engineConfiguration = await getEngineConfiguration(requestedModel.engine) + + let apiKey: string | undefined = undefined + let apiUrl: string = 'http://127.0.0.1:3928/inferences/llamacpp/chat_completion' // default nitro url + + if (engineConfiguration) { + apiKey = engineConfiguration.api_key + apiUrl = engineConfiguration.full_url + } + + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + + const headers: Record = { + 'Content-Type': 'application/json', + } + + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}` + headers['api-key'] = apiKey + } + console.log(apiUrl) + console.log(JSON.stringify(headers)) + const response = await fetch(apiUrl, { + method: 'POST', + headers: headers, + body: JSON.stringify(request.body), + }) + if (response.status !== 200) { + console.error(response) + return + } else { + response.body.pipe(reply.raw) + } +} + +const getEngineConfiguration = async (engineId: string) => { + if (engineId !== 'openai') { + return undefined + } + const directoryPath = join(path, 'engines') + const filePath = join(directoryPath, `${engineId}.json`) + const data = await fs.readFileSync(filePath, 'utf-8') + return JSON.parse(data) +} diff --git a/core/src/node/api/common/configuration.ts b/core/src/node/api/common/configuration.ts new file mode 100644 index 000000000..88e5ffb61 --- /dev/null +++ b/core/src/node/api/common/configuration.ts @@ -0,0 +1,31 @@ +export const JanApiRouteConfiguration: Record = { + models: { + dirName: 'models', + metadataFileName: 'model.json', + delete: { + object: 'model', + }, + }, + assistants: { + dirName: 'assistants', + metadataFileName: 'assistant.json', + delete: { + object: 'assistant', + }, + }, + threads: { + dirName: 'threads', + metadataFileName: 'thread.json', + delete: { + object: 'thread', + }, + }, +} + +export type RouteConfiguration = { + dirName: string + metadataFileName: string + delete: { + object: string + } +} diff --git a/core/src/node/api/index.ts b/core/src/node/api/index.ts new file mode 100644 index 000000000..4c3041ba3 --- /dev/null +++ b/core/src/node/api/index.ts @@ -0,0 +1,2 @@ +export * from './HttpServer' +export * from './routes' diff --git a/core/src/node/api/routes/common.ts b/core/src/node/api/routes/common.ts new file mode 100644 index 000000000..184ca131d --- /dev/null +++ b/core/src/node/api/routes/common.ts @@ -0,0 +1,46 @@ +import { AppRoute } from '../../../api' +import { HttpServer } from '../HttpServer' +import { basename, join } from 'path' +import { + chatCompletions, + deleteBuilder, + downloadModel, + getBuilder, + retrieveBuilder, +} from '../common/builder' + +import { JanApiRouteConfiguration } from '../common/configuration' + +export const commonRouter = async (app: HttpServer) => { + // Common Routes + Object.keys(JanApiRouteConfiguration).forEach((key) => { + app.get(`/${key}`, async (_request) => getBuilder(JanApiRouteConfiguration[key])) + + app.get(`/${key}/:id`, async (request: any) => + retrieveBuilder(JanApiRouteConfiguration[key], request.params.id), + ) + + app.delete(`/${key}/:id`, async (request: any) => + deleteBuilder(JanApiRouteConfiguration[key], request.params.id), + ) + }) + + // Download Model Routes + app.get(`/models/download/:modelId`, async (request: any) => + downloadModel(request.params.modelId), + ) + + // Chat Completion Routes + app.post(`/chat/completions`, async (request: any, reply: any) => chatCompletions(request, reply)) + + // App Routes + app.post(`/app/${AppRoute.joinPath}`, async (request: any, reply: any) => { + const args = JSON.parse(request.body) as any[] + reply.send(JSON.stringify(join(...args[0]))) + }) + + app.post(`/app/${AppRoute.baseName}`, async (request: any, reply: any) => { + const args = JSON.parse(request.body) as any[] + reply.send(JSON.stringify(basename(args[0]))) + }) +} diff --git a/core/src/node/api/routes/download.ts b/core/src/node/api/routes/download.ts new file mode 100644 index 000000000..26945bada --- /dev/null +++ b/core/src/node/api/routes/download.ts @@ -0,0 +1,54 @@ +import { DownloadRoute } from '../../../api' +import { join } from 'path' +import { userSpacePath, DownloadManager, HttpServer } from '../../index' +import { createWriteStream } from 'fs' + +const request = require('request') +const progress = require('request-progress') + +export const downloadRouter = async (app: HttpServer) => { + app.post(`/${DownloadRoute.downloadFile}`, async (req, res) => { + const body = JSON.parse(req.body as any) + const normalizedArgs = body.map((arg: any) => { + if (typeof arg === 'string' && arg.includes('file:/')) { + return join(userSpacePath, arg.replace('file:/', '')) + } + return arg + }) + + const localPath = normalizedArgs[1] + const fileName = localPath.split('/').pop() ?? '' + + const rq = request(normalizedArgs[0]) + progress(rq, {}) + .on('progress', function (state: any) { + console.log('download onProgress', state) + }) + .on('error', function (err: Error) { + console.log('download onError', err) + }) + .on('end', function () { + console.log('download onEnd') + }) + .pipe(createWriteStream(normalizedArgs[1])) + + DownloadManager.instance.setRequest(fileName, rq) + }) + + app.post(`/${DownloadRoute.abortDownload}`, async (req, res) => { + const body = JSON.parse(req.body as any) + const normalizedArgs = body.map((arg: any) => { + if (typeof arg === 'string' && arg.includes('file:/')) { + return join(userSpacePath, arg.replace('file:/', '')) + } + return arg + }) + + const localPath = normalizedArgs[0] + const fileName = localPath.split('/').pop() ?? '' + console.debug('fileName', fileName) + const rq = DownloadManager.instance.networkRequests[fileName] + DownloadManager.instance.networkRequests[fileName] = undefined + rq?.abort() + }) +} diff --git a/core/src/node/api/routes/extension.ts b/core/src/node/api/routes/extension.ts new file mode 100644 index 000000000..abd6a6f0c --- /dev/null +++ b/core/src/node/api/routes/extension.ts @@ -0,0 +1,51 @@ +import { join, extname } from 'path' +import { ExtensionRoute } from '../../../api' +import { + userSpacePath, + ModuleManager, + getActiveExtensions, + installExtensions, + HttpServer, +} from '../../index' +import { readdirSync } from 'fs' + +export const extensionRouter = async (app: HttpServer) => { + // TODO: Share code between node projects + app.post(`/${ExtensionRoute.getActiveExtensions}`, async (req, res) => { + const activeExtensions = await getActiveExtensions() + res.status(200).send(activeExtensions) + }) + + app.post(`/${ExtensionRoute.baseExtensions}`, async (req, res) => { + const baseExtensionPath = join(__dirname, '..', '..', '..', 'pre-install') + const extensions = readdirSync(baseExtensionPath) + .filter((file) => extname(file) === '.tgz') + .map((file) => join(baseExtensionPath, file)) + + res.status(200).send(extensions) + }) + + app.post(`/${ExtensionRoute.installExtension}`, async (req, res) => { + const extensions = req.body as any + const installed = await installExtensions(JSON.parse(extensions)[0]) + return JSON.parse(JSON.stringify(installed)) + }) + + app.post(`/${ExtensionRoute.invokeExtensionFunc}`, async (req, res) => { + const args = JSON.parse(req.body as any) + console.debug(args) + const module = await import(join(userSpacePath, 'extensions', args[0])) + + ModuleManager.instance.setModule(args[0], module) + const method = args[1] + if (typeof module[method] === 'function') { + // remove first item from args + const newArgs = args.slice(2) + console.log(newArgs) + return module[method](...args.slice(2)) + } else { + console.debug(module[method]) + console.error(`Function "${method}" does not exist in the module.`) + } + }) +} diff --git a/core/src/node/api/routes/fs.ts b/core/src/node/api/routes/fs.ts new file mode 100644 index 000000000..f0cf1eeed --- /dev/null +++ b/core/src/node/api/routes/fs.ts @@ -0,0 +1,27 @@ +import { FileSystemRoute } from '../../../api' +import { join } from 'path' +import { HttpServer, userSpacePath } from '../../index' + +export const fsRouter = async (app: HttpServer) => { + const moduleName = 'fs' + // Generate handlers for each fs route + Object.values(FileSystemRoute).forEach((route) => { + app.post(`/${route}`, async (req, res) => { + const body = JSON.parse(req.body as any) + try { + const result = await import(moduleName).then((mdl) => { + return mdl[route]( + ...body.map((arg: any) => + typeof arg === 'string' && arg.includes('file:/') + ? join(userSpacePath, arg.replace('file:/', '')) + : arg, + ), + ) + }) + res.status(200).send(result) + } catch (ex) { + console.log(ex) + } + }) + }) +} diff --git a/core/src/node/api/routes/index.ts b/core/src/node/api/routes/index.ts new file mode 100644 index 000000000..e6edc62f7 --- /dev/null +++ b/core/src/node/api/routes/index.ts @@ -0,0 +1,6 @@ +export * from './download' +export * from './extension' +export * from './fs' +export * from './thread' +export * from './common' +export * from './v1' diff --git a/core/src/node/api/routes/thread.ts b/core/src/node/api/routes/thread.ts new file mode 100644 index 000000000..4066d2716 --- /dev/null +++ b/core/src/node/api/routes/thread.ts @@ -0,0 +1,30 @@ +import { HttpServer } from '../HttpServer' +import { + createMessage, + createThread, + getMessages, + retrieveMesasge, + updateThread, +} from '../common/builder' + +export const threadRouter = async (app: HttpServer) => { + // create thread + app.post(`/`, async (req, res) => createThread(req.body)) + + app.get(`/:threadId/messages`, async (req, res) => getMessages(req.params.threadId)) + + // retrieve message + app.get(`/:threadId/messages/:messageId`, async (req, res) => + retrieveMesasge(req.params.threadId, req.params.messageId), + ) + + // create message + app.post(`/:threadId/messages`, async (req, res) => + createMessage(req.params.threadId as any, req.body as any), + ) + + // modify thread + app.patch(`/:threadId`, async (request: any) => + updateThread(request.params.threadId, request.body), + ) +} diff --git a/core/src/node/api/routes/v1.ts b/core/src/node/api/routes/v1.ts new file mode 100644 index 000000000..4f637e392 --- /dev/null +++ b/core/src/node/api/routes/v1.ts @@ -0,0 +1,21 @@ +import { HttpServer } from '../HttpServer' +import { commonRouter, threadRouter, fsRouter, extensionRouter, downloadRouter } from './index' + +export const v1Router = async (app: HttpServer) => { + // MARK: External Routes + app.register(commonRouter) + app.register(threadRouter, { + prefix: '/thread', + }) + + // MARK: Internal Application Routes + app.register(fsRouter, { + prefix: '/fs', + }) + app.register(extensionRouter, { + prefix: '/extension', + }) + app.register(downloadRouter, { + prefix: '/download', + }) +} diff --git a/electron/managers/download.ts b/core/src/node/download.ts similarity index 100% rename from electron/managers/download.ts rename to core/src/node/download.ts diff --git a/electron/extension/extension.ts b/core/src/node/extension/extension.ts similarity index 90% rename from electron/extension/extension.ts rename to core/src/node/extension/extension.ts index 1bd11611d..0460eca5e 100644 --- a/electron/extension/extension.ts +++ b/core/src/node/extension/extension.ts @@ -1,14 +1,14 @@ -import { rmdir } from 'fs/promises' +import { rmdirSync } from 'fs' import { resolve, join } from 'path' import { manifest, extract } from 'pacote' import * as Arborist from '@npmcli/arborist' -import { ExtensionManager } from './../managers/extension' +import { ExtensionManager } from './manager' /** * An NPM package that can be used as an extension. * Used to hold all the information and functions necessary to handle the extension lifecycle. */ -class Extension { +export default class Extension { /** * @property {string} origin Original specification provided to fetch the package. * @property {Object} installOptions Options provided to pacote when fetching the manifest. @@ -56,10 +56,7 @@ class Extension { * @type {string} */ get specifier() { - return ( - this.origin + - (this.installOptions.version ? '@' + this.installOptions.version : '') - ) + return this.origin + (this.installOptions.version ? '@' + this.installOptions.version : '') } /** @@ -85,9 +82,7 @@ class Extension { this.main = mnf.main this.description = mnf.description } catch (error) { - throw new Error( - `Package ${this.origin} does not contain a valid manifest: ${error}` - ) + throw new Error(`Package ${this.origin} does not contain a valid manifest: ${error}`) } return true @@ -107,7 +102,7 @@ class Extension { await extract( this.specifier, join(ExtensionManager.instance.extensionsPath ?? '', this.name ?? ''), - this.installOptions + this.installOptions, ) // Set the url using the custom extensions protocol @@ -180,11 +175,8 @@ class Extension { * @returns {Promise} */ async uninstall() { - const extPath = resolve( - ExtensionManager.instance.extensionsPath ?? '', - this.name ?? '' - ) - await rmdir(extPath, { recursive: true }) + const extPath = resolve(ExtensionManager.instance.extensionsPath ?? '', this.name ?? '') + await rmdirSync(extPath, { recursive: true }) this.emitUpdate() } @@ -200,5 +192,3 @@ class Extension { return this } } - -export default Extension diff --git a/electron/extension/index.ts b/core/src/node/extension/index.ts similarity index 72% rename from electron/extension/index.ts rename to core/src/node/extension/index.ts index c6a6cc0c0..e685fc0ae 100644 --- a/electron/extension/index.ts +++ b/core/src/node/extension/index.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'fs' -import { protocol } from 'electron' + import { normalize } from 'path' import Extension from './extension' @@ -12,18 +12,8 @@ import { getActiveExtensions, addExtension, } from './store' -import { ExtensionManager } from './../managers/extension' +import { ExtensionManager } from './manager' -/** - * Sets up the required communication between the main and renderer processes. - * Additionally sets the extensions up using {@link useExtensions} if a extensionsPath is provided. - * @param {Object} options configuration for setting up the renderer facade. - * @param {confirmInstall} [options.confirmInstall] Function to validate that a extension should be installed. - * @param {Boolean} [options.useFacade=true] Whether to make a facade to the extensions available in the renderer. - * @param {string} [options.extensionsPath] Optional path to the extensions folder. - * @returns {extensionManager|Object} A set of functions used to manage the extension lifecycle if useExtensions is provided. - * @function - */ export function init(options: any) { // Create extensions protocol to serve extensions to renderer registerExtensionProtocol() @@ -41,13 +31,24 @@ export function init(options: any) { * @private * @returns {boolean} Whether the protocol registration was successful */ -function registerExtensionProtocol() { - return protocol.registerFileProtocol('extension', (request, callback) => { - const entry = request.url.substr('extension://'.length - 1) +async function registerExtensionProtocol() { + let electron: any = undefined - const url = normalize(ExtensionManager.instance.extensionsPath + entry) - callback({ path: url }) - }) + try { + const moduleName = "electron" + electron = await import(moduleName) + } catch (err) { + console.error('Electron is not available') + } + + if (electron) { + return electron.protocol.registerFileProtocol('extension', (request: any, callback: any) => { + const entry = request.url.substr('extension://'.length - 1) + + const url = normalize(ExtensionManager.instance.extensionsPath + entry) + callback({ path: url }) + }) + } } /** @@ -57,8 +58,7 @@ function registerExtensionProtocol() { * @returns {extensionManager} A set of functions used to manage the extension lifecycle. */ export function useExtensions(extensionsPath: string) { - if (!extensionsPath) - throw Error('A path to the extensions folder is required to use extensions') + if (!extensionsPath) throw Error('A path to the extensions folder is required to use extensions') // Store the path to the extensions folder ExtensionManager.instance.setExtensionsPath(extensionsPath) @@ -69,7 +69,7 @@ export function useExtensions(extensionsPath: string) { // Read extension list from extensions folder const extensions = JSON.parse( - readFileSync(ExtensionManager.instance.getExtensionsFile(), 'utf-8') + readFileSync(ExtensionManager.instance.getExtensionsFile(), 'utf-8'), ) try { // Create and store a Extension instance for each extension in list @@ -82,7 +82,7 @@ export function useExtensions(extensionsPath: string) { throw new Error( 'Could not successfully rebuild list of installed extensions.\n' + error + - '\nPlease check the extensions.json file in the extensions folder.' + '\nPlease check the extensions.json file in the extensions folder.', ) } @@ -111,7 +111,6 @@ function loadExtension(ext: any) { }) } } - addExtension(extension, false) extension.subscribe('pe-persist', persistExtensions) } @@ -123,7 +122,7 @@ function loadExtension(ext: any) { export function getStore() { if (!ExtensionManager.instance.extensionsPath) { throw new Error( - 'The extension path has not yet been set up. Please run useExtensions before accessing the store' + 'The extension path has not yet been set up. Please run useExtensions before accessing the store', ) } @@ -134,4 +133,4 @@ export function getStore() { getActiveExtensions, removeExtension, } -} +} \ No newline at end of file diff --git a/core/src/node/extension/manager.ts b/core/src/node/extension/manager.ts new file mode 100644 index 000000000..9173504a7 --- /dev/null +++ b/core/src/node/extension/manager.ts @@ -0,0 +1,61 @@ +import { join, resolve } from "path"; + +import { existsSync, mkdirSync, writeFileSync } from "fs"; +import { init } from "./index"; +import { homedir } from "os" +/** + * Manages extension installation and migration. + */ + +export const userSpacePath = join(homedir(), "jan"); + +export class ExtensionManager { + public static instance: ExtensionManager = new ExtensionManager(); + + extensionsPath: string | undefined = join(userSpacePath, "extensions"); + + constructor() { + if (ExtensionManager.instance) { + return ExtensionManager.instance; + } + } + + /** + * Sets up the extensions by initializing the `extensions` module with the `confirmInstall` and `extensionsPath` options. + * The `confirmInstall` function always returns `true` to allow extension installation. + * The `extensionsPath` option specifies the path to install extensions to. + */ + setupExtensions() { + init({ + // Function to check from the main process that user wants to install a extension + confirmInstall: async (_extensions: string[]) => { + return true; + }, + // Path to install extension to + extensionsPath: join(userSpacePath, "extensions"), + }); + } + + setExtensionsPath(extPath: string) { + // Create folder if it does not exist + let extDir; + try { + extDir = resolve(extPath); + if (extDir.length < 2) throw new Error(); + + if (!existsSync(extDir)) mkdirSync(extDir); + + const extensionsJson = join(extDir, "extensions.json"); + if (!existsSync(extensionsJson)) + writeFileSync(extensionsJson, "{}", "utf8"); + + this.extensionsPath = extDir; + } catch (error) { + throw new Error("Invalid path provided to the extensions folder"); + } + } + + getExtensionsFile() { + return join(this.extensionsPath ?? "", "extensions.json"); + } +} diff --git a/electron/extension/store.ts b/core/src/node/extension/store.ts similarity index 67% rename from electron/extension/store.ts rename to core/src/node/extension/store.ts index 4857ef27a..8e11c2bb2 100644 --- a/electron/extension/store.ts +++ b/core/src/node/extension/store.ts @@ -1,16 +1,6 @@ -/** - * Provides access to the extensions stored by Extension Store - * @typedef {Object} extensionManager - * @prop {getExtension} getExtension - * @prop {getAllExtensions} getAllExtensions - * @prop {getActiveExtensions} getActiveExtensions - * @prop {installExtensions} installExtensions - * @prop {removeExtension} removeExtension - */ - -import { writeFileSync } from 'fs' -import Extension from './extension' -import { ExtensionManager } from './../managers/extension' +import { writeFileSync } from "fs"; +import Extension from "./extension"; +import { ExtensionManager } from "./manager"; /** * @module store @@ -21,7 +11,7 @@ import { ExtensionManager } from './../managers/extension' * Register of installed extensions * @type {Object.} extension - List of installed extensions */ -const extensions: Record = {} +const extensions: Record = {}; /** * Get a extension from the stored extensions. @@ -31,10 +21,10 @@ const extensions: Record = {} */ export function getExtension(name: string) { if (!Object.prototype.hasOwnProperty.call(extensions, name)) { - throw new Error(`Extension ${name} does not exist`) + throw new Error(`Extension ${name} does not exist`); } - return extensions[name] + return extensions[name]; } /** @@ -43,7 +33,7 @@ export function getExtension(name: string) { * @alias extensionManager.getAllExtensions */ export function getAllExtensions() { - return Object.values(extensions) + return Object.values(extensions); } /** @@ -52,7 +42,7 @@ export function getAllExtensions() { * @alias extensionManager.getActiveExtensions */ export function getActiveExtensions() { - return Object.values(extensions).filter((extension) => extension.active) + return Object.values(extensions).filter((extension) => extension.active); } /** @@ -63,9 +53,9 @@ export function getActiveExtensions() { * @alias extensionManager.removeExtension */ export function removeExtension(name: string, persist = true) { - const del = delete extensions[name] - if (persist) persistExtensions() - return del + const del = delete extensions[name]; + if (persist) persistExtensions(); + return del; } /** @@ -75,10 +65,10 @@ export function removeExtension(name: string, persist = true) { * @returns {void} */ export function addExtension(extension: Extension, persist = true) { - if (extension.name) extensions[extension.name] = extension + if (extension.name) extensions[extension.name] = extension; if (persist) { - persistExtensions() - extension.subscribe('pe-persist', persistExtensions) + persistExtensions(); + extension.subscribe("pe-persist", persistExtensions); } } @@ -87,15 +77,15 @@ export function addExtension(extension: Extension, persist = true) { * @returns {void} */ export function persistExtensions() { - const persistData: Record = {} + const persistData: Record = {}; for (const name in extensions) { - persistData[name] = extensions[name] + persistData[name] = extensions[name]; } writeFileSync( ExtensionManager.instance.getExtensionsFile(), JSON.stringify(persistData), - 'utf8' - ) + "utf8" + ); } /** @@ -106,25 +96,25 @@ export function persistExtensions() { * @alias extensionManager.installExtensions */ export async function installExtensions(extensions: any, store = true) { - const installed: Extension[] = [] + const installed: Extension[] = []; for (const ext of extensions) { // Set install options and activation based on input type - const isObject = typeof ext === 'object' - const spec = isObject ? [ext.specifier, ext] : [ext] - const activate = isObject ? ext.activate !== false : true + const isObject = typeof ext === "object"; + const spec = isObject ? [ext.specifier, ext] : [ext]; + const activate = isObject ? ext.activate !== false : true; // Install and possibly activate extension - const extension = new Extension(...spec) - await extension._install() - if (activate) extension.setActive(true) + const extension = new Extension(...spec); + await extension._install(); + if (activate) extension.setActive(true); // Add extension to store if needed - if (store) addExtension(extension) - installed.push(extension) + if (store) addExtension(extension); + installed.push(extension); } // Return list of all installed extensions - return installed + return installed; } /** diff --git a/core/src/node/index.ts b/core/src/node/index.ts new file mode 100644 index 000000000..49c2c3c26 --- /dev/null +++ b/core/src/node/index.ts @@ -0,0 +1,7 @@ +export * from './extension/index' +export * from './extension/extension' +export * from './extension/manager' +export * from './extension/store' +export * from './download' +export * from './module' +export * from './api' diff --git a/electron/managers/module.ts b/core/src/node/module.ts similarity index 59% rename from electron/managers/module.ts rename to core/src/node/module.ts index dc16d0d22..2201fb400 100644 --- a/electron/managers/module.ts +++ b/core/src/node/module.ts @@ -1,16 +1,14 @@ -import { dispose } from "./../utils/disposable"; - /** * Manages imported modules. */ export class ModuleManager { - public requiredModules: Record = {}; + public requiredModules: Record = {} - public static instance: ModuleManager = new ModuleManager(); + public static instance: ModuleManager = new ModuleManager() constructor() { if (ModuleManager.instance) { - return ModuleManager.instance; + return ModuleManager.instance } } @@ -20,14 +18,13 @@ export class ModuleManager { * @param {any | undefined} nodule - The module to set, or undefined to clear the module. */ setModule(moduleName: string, nodule: any | undefined) { - this.requiredModules[moduleName] = nodule; + this.requiredModules[moduleName] = nodule } /** * Clears all imported modules. */ clearImportedModules() { - dispose(this.requiredModules); - this.requiredModules = {}; + this.requiredModules = {} } } diff --git a/core/tsconfig.json b/core/tsconfig.json index 19b2d29ad..81991f46a 100644 --- a/core/tsconfig.json +++ b/core/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "moduleResolution": "node", "target": "es5", - "module": "es2015", + "module": "ES2020", "lib": ["es2015", "es2016", "es2017", "dom"], "strict": true, "sourceMap": true, diff --git a/demo.gif b/demo.gif new file mode 100644 index 000000000..f5af39e12 Binary files /dev/null and b/demo.gif differ diff --git a/electron/handlers/app.ts b/electron/handlers/app.ts index ff88cd8f1..726ed612e 100644 --- a/electron/handlers/app.ts +++ b/electron/handlers/app.ts @@ -1,11 +1,9 @@ import { app, ipcMain, shell, nativeTheme } from 'electron' -import { ModuleManager } from './../managers/module' -import { join } from 'path' -import { ExtensionManager } from './../managers/extension' +import { join, basename } from 'path' import { WindowManager } from './../managers/window' import { userSpacePath } from './../utils/path' import { AppRoute } from '@janhq/core' -import { getResourcePath } from './../utils/path' +import { ExtensionManager, ModuleManager } from '@janhq/core/node' export function handleAppIPCs() { /** @@ -26,10 +24,6 @@ export function handleAppIPCs() { shell.openPath(userSpacePath) }) - ipcMain.handle(AppRoute.getResourcePath, async (_event) => { - return getResourcePath() - }) - /** * Opens a URL in the user's default browser. * @param _event - The IPC event object. @@ -55,6 +49,13 @@ export function handleAppIPCs() { join(...paths) ) + /** + * Retrieve basename from given path, respect to the current OS. + */ + ipcMain.handle(AppRoute.baseName, async (_event, path: string) => + basename(path) + ) + /** * Relaunches the app in production - reload window in development. * @param _event - The IPC event object. diff --git a/electron/handlers/download.ts b/electron/handlers/download.ts index 145174ac2..621d85043 100644 --- a/electron/handlers/download.ts +++ b/electron/handlers/download.ts @@ -1,11 +1,11 @@ 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, renameSync } from 'fs' import { DownloadEvent, DownloadRoute } from '@janhq/core' const progress = require('request-progress') +import { DownloadManager } from '@janhq/core/node' export function handleDownloaderIPCs() { /** @@ -46,6 +46,12 @@ export function handleDownloaderIPCs() { */ ipcMain.handle(DownloadRoute.downloadFile, async (_event, url, fileName) => { const userDataPath = join(app.getPath('home'), 'jan') + if ( + typeof fileName === 'string' && + (fileName.includes('file:/') || fileName.includes('file:\\')) + ) { + fileName = fileName.replace('file:/', '').replace('file:\\', '') + } const destination = resolve(userDataPath, fileName) const rq = request(url) // downloading file to a temp file first diff --git a/electron/handlers/extension.ts b/electron/handlers/extension.ts index f89206eac..ad8b59a99 100644 --- a/electron/handlers/extension.ts +++ b/electron/handlers/extension.ts @@ -1,15 +1,15 @@ import { ipcMain, webContents } from 'electron' import { readdirSync } from 'fs' -import { ModuleManager } from './../managers/module' import { join, extname } from 'path' + import { - getActiveExtensions, - getAllExtensions, installExtensions, -} from './../extension/store' -import { getExtension } from './../extension/store' -import { removeExtension } from './../extension/store' -import Extension from './../extension/extension' + getExtension, + removeExtension, + getActiveExtensions, + ModuleManager +} from '@janhq/core/node' + import { getResourcePath, userSpacePath } from './../utils/path' import { ExtensionRoute } from '@janhq/core' @@ -81,7 +81,7 @@ export function handleExtensionIPCs() { ExtensionRoute.updateExtension, async (e, extensions, reload) => { // Update all provided extensions - const updated: Extension[] = [] + const updated: any[] = [] for (const ext of extensions) { const extension = getExtension(ext) const res = await extension.update() diff --git a/electron/handlers/fileManager.ts b/electron/handlers/fileManager.ts new file mode 100644 index 000000000..2a78deaf9 --- /dev/null +++ b/electron/handlers/fileManager.ts @@ -0,0 +1,37 @@ +import { ipcMain } from 'electron' +// @ts-ignore +import reflect from '@alumna/reflect' + +import { FileManagerRoute } from '@janhq/core' +import { userSpacePath, getResourcePath } from './../utils/path' + +/** + * Handles file system extensions operations. + */ +export function handleFileMangerIPCs() { + // Handles the 'synceFile' IPC event. This event is triggered to synchronize a file from a source path to a destination path. + ipcMain.handle( + FileManagerRoute.synceFile, + async (_event, src: string, dest: string) => { + return reflect({ + src, + dest, + recursive: true, + delete: false, + overwrite: true, + errorOnExist: false, + }) + } + ) + + // Handles the 'getUserSpace' IPC event. This event is triggered to get the user space path. + ipcMain.handle( + FileManagerRoute.getUserSpace, + (): Promise => Promise.resolve(userSpacePath) + ) + + // Handles the 'getResourcePath' IPC event. This event is triggered to get the resource path. + ipcMain.handle(FileManagerRoute.getResourcePath, async (_event) => { + return getResourcePath() + }) +} diff --git a/electron/handlers/fs.ts b/electron/handlers/fs.ts index f8ac089e4..8f7e434cc 100644 --- a/electron/handlers/fs.ts +++ b/electron/handlers/fs.ts @@ -1,238 +1,32 @@ import { ipcMain } from 'electron' -import * as fs from 'fs' -import fse from 'fs-extra' -import { join } from 'path' -import readline from 'readline' -import { userSpacePath } from './../utils/path' -import { FileSystemRoute } from '@janhq/core' -const reflect = require('@alumna/reflect') +import { FileSystemRoute } from '@janhq/core' +import { userSpacePath } from '../utils/path' +import { join } from 'path' /** * Handles file system operations. */ export function handleFsIPCs() { - /** - * 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( - FileSystemRoute.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( - FileSystemRoute.isDirectory, - (_event, path: string): Promise => { - const fullPath = join(userSpacePath, path) - return Promise.resolve( - fs.existsSync(fullPath) && fs.lstatSync(fullPath).isDirectory() + const moduleName = 'fs' + Object.values(FileSystemRoute).forEach((route) => { + ipcMain.handle(route, async (event, ...args) => { + return import(moduleName).then((mdl) => + mdl[route]( + ...args.map((arg) => + typeof arg === 'string' && + (arg.includes(`file:/`) || arg.includes(`file:\\`)) + ? join( + userSpacePath, + arg + .replace(`file://`, '') + .replace(`file:/`, '') + .replace(`file:\\\\`, '') + .replace(`file:\\`, '') + ) + : arg + ) + ) ) - } - ) - - /** - * 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( - FileSystemRoute.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) - } - }) - }) - } - ) - - /** - * Checks whether a file exists in the user data directory. - * @param event - The event object. - * @param path - The path of the file to check. - * @returns A promise that resolves with a boolean indicating whether the file exists. - */ - ipcMain.handle(FileSystemRoute.exists, async (_event, path: string) => { - return new Promise((resolve, reject) => { - const fullPath = join(userSpacePath, path) - fs.existsSync(fullPath) ? resolve(true) : resolve(false) }) }) - - /** - * 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( - FileSystemRoute.writeFile, - async (event, path: string, data: string): Promise => { - try { - await fs.writeFileSync(join(userSpacePath, path), data, 'utf8') - } catch (err) { - console.error(`writeFile ${path} result: ${err}`) - } - } - ) - - /** - * 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( - FileSystemRoute.mkdir, - async (event, path: string): Promise => { - try { - fs.mkdirSync(join(userSpacePath, path), { recursive: true }) - } catch (err) { - console.error(`mkdir ${path} result: ${err}`) - } - } - ) - - /** - * 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( - FileSystemRoute.rmdir, - async (event, path: string): Promise => { - try { - await fs.rmSync(join(userSpacePath, path), { recursive: true }) - } catch (err) { - console.error(`rmdir ${path} result: ${err}`) - } - } - ) - - /** - * 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( - FileSystemRoute.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(FileSystemRoute.deleteFile, async (_event, filePath) => { - try { - await fs.unlinkSync(join(userSpacePath, filePath)) - } catch (err) { - console.error(`unlink ${filePath} result: ${err}`) - } - }) - - /** - * Appends data to a file in the user data directory. - * @param event - The event object. - * @param path - The path of the file to append to. - * @param data - The data to append to the file. - * @returns A promise that resolves when the file has been written. - */ - ipcMain.handle( - FileSystemRoute.appendFile, - async (_event, path: string, data: string) => { - try { - await fs.appendFileSync(join(userSpacePath, path), data, 'utf8') - } catch (err) { - console.error(`appendFile ${path} result: ${err}`) - } - } - ) - - ipcMain.handle( - FileSystemRoute.syncFile, - async (_event, src: string, dest: string) => { - console.debug(`Copying file from ${src} to ${dest}`) - - return reflect({ - src, - dest, - recursive: true, - delete: false, - overwrite: true, - errorOnExist: false, - }) - } - ) - - ipcMain.handle( - FileSystemRoute.copyFile, - async (_event, src: string, dest: string) => { - console.debug(`Copying file from ${src} to ${dest}`) - - return fse.copySync(src, dest, { - overwrite: false, - recursive: true, - errorOnExist: false, - }) - } - ) - - /** - * Reads a file line by line. - * @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( - FileSystemRoute.readLineByLine, - async (_event, path: string) => { - const fullPath = join(userSpacePath, path) - - return new Promise((res, rej) => { - try { - const readInterface = readline.createInterface({ - input: fs.createReadStream(fullPath), - }) - const lines: any = [] - readInterface - .on('line', function (line) { - lines.push(line) - }) - .on('close', function () { - res(lines) - }) - } catch (err) { - rej(err) - } - }) - } - ) } diff --git a/electron/main.ts b/electron/main.ts index 542875312..fae3a1ffa 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -7,27 +7,34 @@ import { createUserSpace } from './utils/path' * Managers **/ import { WindowManager } from './managers/window' -import { ModuleManager } from './managers/module' -import { ExtensionManager } from './managers/extension' +import { ExtensionManager, ModuleManager } from '@janhq/core/node' /** * IPC Handlers **/ import { handleDownloaderIPCs } from './handlers/download' import { handleExtensionIPCs } from './handlers/extension' +import { handleFileMangerIPCs } from './handlers/fileManager' import { handleAppIPCs } from './handlers/app' import { handleAppUpdates } from './handlers/update' import { handleFsIPCs } from './handlers/fs' +import { migrateExtensions } from './utils/migration' + +/** + * Server + */ +import { startServer } from '@janhq/server' app .whenReady() .then(createUserSpace) - .then(ExtensionManager.instance.migrateExtensions) + .then(migrateExtensions) .then(ExtensionManager.instance.setupExtensions) .then(setupMenu) .then(handleIPCs) .then(handleAppUpdates) .then(createMainWindow) + .then(startServer) .then(() => { app.on('activate', () => { if (!BrowserWindow.getAllWindows().length) { @@ -80,4 +87,5 @@ function handleIPCs() { handleDownloaderIPCs() handleExtensionIPCs() handleAppIPCs() + handleFileMangerIPCs() } diff --git a/electron/managers/extension.ts b/electron/managers/extension.ts deleted file mode 100644 index 7eef24877..000000000 --- a/electron/managers/extension.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { app } from 'electron' -import { init } from './../extension' -import { join, resolve } from 'path' -import { rmdir } from 'fs' -import Store from 'electron-store' -import { existsSync, mkdirSync, writeFileSync } from 'fs' -import { userSpacePath } from './../utils/path' -/** - * Manages extension installation and migration. - */ -export class ExtensionManager { - public static instance: ExtensionManager = new ExtensionManager() - - extensionsPath: string | undefined = undefined - - constructor() { - if (ExtensionManager.instance) { - return ExtensionManager.instance - } - } - - /** - * Sets up the extensions by initializing the `extensions` module with the `confirmInstall` and `extensionsPath` options. - * The `confirmInstall` function always returns `true` to allow extension installation. - * The `extensionsPath` option specifies the path to install extensions to. - */ - setupExtensions() { - init({ - // Function to check from the main process that user wants to install a extension - confirmInstall: async (_extensions: string[]) => { - return true - }, - // Path to install extension to - extensionsPath: join(userSpacePath, 'extensions'), - }) - } - - /** - * Migrates the extensions by deleting the `extensions` directory in the user data path. - * If the `migrated_version` key in the `Store` object does not match the current app version, - * the function deletes the `extensions` directory and sets the `migrated_version` key to the current app version. - * @returns A Promise that resolves when the migration is complete. - */ - migrateExtensions() { - return new Promise((resolve) => { - const store = new Store() - if (store.get('migrated_version') !== app.getVersion()) { - console.debug('start migration:', store.get('migrated_version')) - const fullPath = join(userSpacePath, 'extensions') - - rmdir(fullPath, { recursive: true }, function (err) { - if (err) console.error(err) - store.set('migrated_version', app.getVersion()) - console.debug('migrate extensions done') - resolve(undefined) - }) - } else { - resolve(undefined) - } - }) - } - - setExtensionsPath(extPath: string) { - // Create folder if it does not exist - let extDir - try { - extDir = resolve(extPath) - if (extDir.length < 2) throw new Error() - - if (!existsSync(extDir)) mkdirSync(extDir) - - const extensionsJson = join(extDir, 'extensions.json') - if (!existsSync(extensionsJson)) - writeFileSync(extensionsJson, '{}', 'utf8') - - this.extensionsPath = extDir - } catch (error) { - throw new Error('Invalid path provided to the extensions folder') - } - } - - getExtensionsFile() { - return join(this.extensionsPath ?? '', 'extensions.json') - } -} diff --git a/electron/package.json b/electron/package.json index 864934d56..3cc5e7680 100644 --- a/electron/package.json +++ b/electron/package.json @@ -72,15 +72,20 @@ "dependencies": { "@alumna/reflect": "^1.1.3", "@janhq/core": "link:./core", + "@janhq/server": "link:./server", "@npmcli/arborist": "^7.1.0", "@types/request": "^2.48.12", "@uiball/loaders": "^1.3.0", "electron-store": "^8.1.0", "electron-updater": "^6.1.7", "fs-extra": "^11.2.0", + "node-fetch": "2", "pacote": "^17.0.4", "request": "^2.88.2", "request-progress": "^3.0.0", + "rimraf": "^5.0.5", + "typescript": "^5.3.3", + "ulid": "^2.3.0", "use-debounce": "^9.0.4" }, "devDependencies": { diff --git a/electron/tsconfig.json b/electron/tsconfig.json index 3cc218f93..11c9d8577 100644 --- a/electron/tsconfig.json +++ b/electron/tsconfig.json @@ -15,6 +15,9 @@ "paths": { "*": ["node_modules/*"] }, "typeRoots": ["node_modules/@types"] }, + "ts-node": { + "esm": true + }, "include": ["./**/*.ts"], "exclude": ["core", "build", "dist", "tests", "node_modules"] } diff --git a/electron/utils/menu.ts b/electron/utils/menu.ts index 65e009aef..97a2afd98 100644 --- a/electron/utils/menu.ts +++ b/electron/utils/menu.ts @@ -1,5 +1,5 @@ // @ts-nocheck -const { app, Menu, dialog } = require("electron"); +import { app, Menu, dialog, shell } from "electron"; const isMac = process.platform === "darwin"; const { autoUpdater } = require("electron-updater"); import { compareSemanticVersions } from "./versionDiff"; @@ -97,7 +97,6 @@ const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [ { label: "Learn More", click: async () => { - const { shell } = require("electron"); await shell.openExternal("https://jan.ai/"); }, }, diff --git a/electron/utils/migration.ts b/electron/utils/migration.ts new file mode 100644 index 000000000..cf66270c6 --- /dev/null +++ b/electron/utils/migration.ts @@ -0,0 +1,30 @@ +import { app } from 'electron' +import { join } from 'path' + +import { rmdir } from 'fs' +import Store from 'electron-store' +import { userSpacePath } from './path' +/** + * Migrates the extensions by deleting the `extensions` directory in the user data path. + * If the `migrated_version` key in the `Store` object does not match the current app version, + * the function deletes the `extensions` directory and sets the `migrated_version` key to the current app version. + * @returns A Promise that resolves when the migration is complete. + */ +export function migrateExtensions() { + return new Promise((resolve) => { + const store = new Store() + if (store.get('migrated_version') !== app.getVersion()) { + console.debug('start migration:', store.get('migrated_version')) + const fullPath = join(userSpacePath, 'extensions') + + rmdir(fullPath, { recursive: true }, function (err) { + if (err) console.error(err) + store.set('migrated_version', app.getVersion()) + console.debug('migrate extensions done') + resolve(undefined) + }) + } else { + resolve(undefined) + } + }) +} diff --git a/extensions/assistant-extension/src/index.ts b/extensions/assistant-extension/src/index.ts index 9b48ce6c9..0c5745b3d 100644 --- a/extensions/assistant-extension/src/index.ts +++ b/extensions/assistant-extension/src/index.ts @@ -3,17 +3,18 @@ import { AssistantExtension } from "@janhq/core"; import { join } from "path"; export default class JanAssistantExtension implements AssistantExtension { - private static readonly _homeDir = "assistants"; + private static readonly _homeDir = "file://assistants"; type(): ExtensionType { return ExtensionType.Assistant; } - onLoad(): void { + async onLoad() { // making the assistant directory - fs.mkdir(JanAssistantExtension._homeDir).then(() => { - this.createJanAssistant(); - }); + if (!(await fs.existsSync(JanAssistantExtension._homeDir))) + fs.mkdirSync(JanAssistantExtension._homeDir).then(() => { + this.createJanAssistant(); + }); } /** @@ -23,12 +24,12 @@ export default class JanAssistantExtension implements AssistantExtension { async createAssistant(assistant: Assistant): Promise { const assistantDir = join(JanAssistantExtension._homeDir, assistant.id); - await fs.mkdir(assistantDir); + if (!(await fs.existsSync(assistantDir))) await fs.mkdirSync(assistantDir); // store the assistant metadata json const assistantMetadataPath = join(assistantDir, "assistant.json"); try { - await fs.writeFile( + await fs.writeFileSync( assistantMetadataPath, JSON.stringify(assistant, null, 2) ); @@ -41,18 +42,14 @@ export default class JanAssistantExtension implements AssistantExtension { // get all the assistant directories // get all the assistant metadata json const results: Assistant[] = []; - const allFileName: string[] = await fs.listFiles( + const allFileName: string[] = await fs.readdirSync( JanAssistantExtension._homeDir ); for (const fileName of allFileName) { const filePath = join(JanAssistantExtension._homeDir, fileName); - const isDirectory = await fs.isDirectory(filePath); - if (!isDirectory) { - // if not a directory, ignore - continue; - } - const jsonFiles: string[] = (await fs.listFiles(filePath)).filter( + if (filePath.includes(".DS_Store")) continue; + const jsonFiles: string[] = (await fs.readdirSync(filePath)).filter( (file: string) => file === "assistant.json" ); @@ -61,9 +58,12 @@ export default class JanAssistantExtension implements AssistantExtension { continue; } - const assistant: Assistant = JSON.parse( - await fs.readFile(join(filePath, jsonFiles[0])) + const content = await fs.readFileSync( + join(filePath, jsonFiles[0]), + "utf-8" ); + const assistant: Assistant = + typeof content === "object" ? content : JSON.parse(content); results.push(assistant); } @@ -78,7 +78,7 @@ export default class JanAssistantExtension implements AssistantExtension { // remove the directory const assistantDir = join(JanAssistantExtension._homeDir, assistant.id); - await fs.rmdir(assistantDir); + await fs.rmdirSync(assistantDir); return Promise.resolve(); } diff --git a/extensions/conversational-extension/src/index.ts b/extensions/conversational-extension/src/index.ts index 8aae791e8..ac31351df 100644 --- a/extensions/conversational-extension/src/index.ts +++ b/extensions/conversational-extension/src/index.ts @@ -9,7 +9,7 @@ import { Thread, ThreadMessage } from '@janhq/core' export default class JSONConversationalExtension implements ConversationalExtension { - private static readonly _homeDir = 'threads' + private static readonly _homeDir = 'file://threads' private static readonly _threadInfoFileName = 'thread.json' private static readonly _threadMessagesFileName = 'messages.jsonl' @@ -23,8 +23,9 @@ export default class JSONConversationalExtension /** * Called when the extension is loaded. */ - onLoad() { - fs.mkdir(JSONConversationalExtension._homeDir) + async onLoad() { + if (!(await fs.existsSync(JSONConversationalExtension._homeDir))) + await fs.mkdirSync(JSONConversationalExtension._homeDir) console.debug('JSONConversationalExtension loaded') } @@ -47,7 +48,9 @@ export default class JSONConversationalExtension const convos = promiseResults .map((result) => { if (result.status === 'fulfilled') { - return JSON.parse(result.value) as Thread + return typeof result.value === 'object' + ? result.value + : JSON.parse(result.value) } }) .filter((convo) => convo != null) @@ -76,8 +79,11 @@ export default class JSONConversationalExtension threadDirPath, JSONConversationalExtension._threadInfoFileName, ]) - await fs.mkdir(threadDirPath) - await fs.writeFile(threadJsonPath, JSON.stringify(thread, null, 2)) + if (!(await fs.existsSync(threadDirPath))) { + await fs.mkdirSync(threadDirPath) + } + + await fs.writeFileSync(threadJsonPath, JSON.stringify(thread)) Promise.resolve() } catch (err) { Promise.reject(err) @@ -89,8 +95,9 @@ export default class JSONConversationalExtension * @param threadId The ID of the thread to delete. */ async deleteThread(threadId: string): Promise { - return fs.rmdir( - await joinPath([JSONConversationalExtension._homeDir, `${threadId}`]) + return fs.rmdirSync( + await joinPath([JSONConversationalExtension._homeDir, `${threadId}`]), + { recursive: true } ) } @@ -104,8 +111,9 @@ export default class JSONConversationalExtension threadDirPath, JSONConversationalExtension._threadMessagesFileName, ]) - await fs.mkdir(threadDirPath) - await fs.appendFile(threadMessagePath, JSON.stringify(message) + '\n') + if (!(await fs.existsSync(threadDirPath))) + await fs.mkdirSync(threadDirPath) + await fs.appendFileSync(threadMessagePath, JSON.stringify(message) + '\n') Promise.resolve() } catch (err) { Promise.reject(err) @@ -125,8 +133,9 @@ export default class JSONConversationalExtension threadDirPath, JSONConversationalExtension._threadMessagesFileName, ]) - await fs.mkdir(threadDirPath) - await fs.writeFile( + if (!(await fs.existsSync(threadDirPath))) + await fs.mkdirSync(threadDirPath) + await fs.writeFileSync( threadMessagePath, messages.map((msg) => JSON.stringify(msg)).join('\n') + (messages.length ? '\n' : '') @@ -143,12 +152,13 @@ export default class JSONConversationalExtension * @returns data of the thread */ private async readThread(threadDirName: string): Promise { - return fs.readFile( + return fs.readFileSync( await joinPath([ JSONConversationalExtension._homeDir, threadDirName, JSONConversationalExtension._threadInfoFileName, - ]) + ]), + 'utf-8' ) } @@ -157,23 +167,19 @@ export default class JSONConversationalExtension * @private */ private async getValidThreadDirs(): Promise { - const fileInsideThread: string[] = await fs.listFiles( + const fileInsideThread: string[] = await fs.readdirSync( JSONConversationalExtension._homeDir ) const threadDirs: string[] = [] for (let i = 0; i < fileInsideThread.length; i++) { + if (fileInsideThread[i].includes('.DS_Store')) continue const path = await joinPath([ JSONConversationalExtension._homeDir, fileInsideThread[i], ]) - const isDirectory = await fs.isDirectory(path) - if (!isDirectory) { - console.debug(`Ignore ${path} because it is not a directory`) - continue - } - const isHavingThreadInfo = (await fs.listFiles(path)).includes( + const isHavingThreadInfo = (await fs.readdirSync(path)).includes( JSONConversationalExtension._threadInfoFileName ) if (!isHavingThreadInfo) { @@ -192,12 +198,8 @@ export default class JSONConversationalExtension JSONConversationalExtension._homeDir, threadId, ]) - const isDir = await fs.isDirectory(threadDirPath) - if (!isDir) { - throw Error(`${threadDirPath} is not directory`) - } - const files: string[] = await fs.listFiles(threadDirPath) + const files: string[] = await fs.readdirSync(threadDirPath) if ( !files.includes(JSONConversationalExtension._threadMessagesFileName) ) { @@ -209,7 +211,14 @@ export default class JSONConversationalExtension JSONConversationalExtension._threadMessagesFileName, ]) - const result = await fs.readLineByLine(messageFilePath) + const result = await fs + .readFileSync(messageFilePath, 'utf-8') + .then((content) => + content + .toString() + .split('\n') + .filter((line) => line !== '') + ) const messages: ThreadMessage[] = [] result.forEach((line: string) => { diff --git a/extensions/inference-nitro-extension/src/index.ts b/extensions/inference-nitro-extension/src/index.ts index d19f3853c..34e95667b 100644 --- a/extensions/inference-nitro-extension/src/index.ts +++ b/extensions/inference-nitro-extension/src/index.ts @@ -17,9 +17,9 @@ import { ThreadMessage, events, executeOnMain, - getUserSpace, fs, Model, + joinPath, } from "@janhq/core"; import { InferenceExtension } from "@janhq/core"; import { requestInference } from "./helpers/sse"; @@ -32,7 +32,8 @@ import { join } from "path"; * It also subscribes to events emitted by the @janhq/core package and handles new message requests. */ export default class JanInferenceNitroExtension implements InferenceExtension { - private static readonly _homeDir = "engines"; + private static readonly _homeDir = "file://engines"; + private static readonly _settingsDir = "file://settings"; private static readonly _engineMetadataFileName = "nitro.json"; private static _currentModel: Model; @@ -58,8 +59,15 @@ export default class JanInferenceNitroExtension implements InferenceExtension { /** * Subscribes to events emitted by the @janhq/core package. */ - onLoad(): void { - fs.mkdir(JanInferenceNitroExtension._homeDir); + async onLoad() { + if (!(await fs.existsSync(JanInferenceNitroExtension._homeDir))) { + await fs + .mkdirSync(JanInferenceNitroExtension._homeDir) + .catch((err) => console.debug(err)); + } + + if (!(await fs.existsSync(JanInferenceNitroExtension._settingsDir))) + await fs.mkdirSync(JanInferenceNitroExtension._settingsDir); this.writeDefaultEngineSettings(); // Events subscription @@ -78,6 +86,9 @@ export default class JanInferenceNitroExtension implements InferenceExtension { events.on(EventName.OnInferenceStopped, () => { JanInferenceNitroExtension.handleInferenceStopped(this); }); + + // Attempt to fetch nvidia info + await executeOnMain(MODULE, "updateNvidiaInfo", {}); } /** @@ -91,12 +102,12 @@ export default class JanInferenceNitroExtension implements InferenceExtension { JanInferenceNitroExtension._homeDir, JanInferenceNitroExtension._engineMetadataFileName ); - if (await fs.exists(engineFile)) { - JanInferenceNitroExtension._engineSettings = JSON.parse( - await fs.readFile(engineFile) - ); + if (await fs.existsSync(engineFile)) { + const engine = await fs.readFileSync(engineFile, "utf-8"); + JanInferenceNitroExtension._engineSettings = + typeof engine === "object" ? engine : JSON.parse(engine); } else { - await fs.writeFile( + await fs.writeFileSync( engineFile, JSON.stringify(JanInferenceNitroExtension._engineSettings, null, 2) ); @@ -110,8 +121,7 @@ export default class JanInferenceNitroExtension implements InferenceExtension { if (model.engine !== "nitro") { return; } - const userSpacePath = await getUserSpace(); - const modelFullPath = join(userSpacePath, "models", model.id); + const modelFullPath = await joinPath(["models", model.id]); const nitroInitResult = await executeOnMain(MODULE, "initModel", { modelFullPath: modelFullPath, diff --git a/extensions/inference-nitro-extension/src/module.ts b/extensions/inference-nitro-extension/src/module.ts index 37b9e5b3b..4537f801c 100644 --- a/extensions/inference-nitro-extension/src/module.ts +++ b/extensions/inference-nitro-extension/src/module.ts @@ -1,9 +1,11 @@ const fs = require("fs"); +const fsPromises = fs.promises; const path = require("path"); -const { spawn } = require("child_process"); +const { exec, spawn } = require("child_process"); const tcpPortUsed = require("tcp-port-used"); const fetchRetry = require("fetch-retry")(global.fetch); const si = require("systeminformation"); +const { readFileSync, writeFileSync, existsSync } = require("fs"); // The PORT to use for the Nitro subprocess const PORT = 3928; @@ -14,6 +16,27 @@ const NITRO_HTTP_UNLOAD_MODEL_URL = `${NITRO_HTTP_SERVER_URL}/inferences/llamacp const NITRO_HTTP_VALIDATE_MODEL_URL = `${NITRO_HTTP_SERVER_URL}/inferences/llamacpp/modelstatus`; const NITRO_HTTP_KILL_URL = `${NITRO_HTTP_SERVER_URL}/processmanager/destroy`; const SUPPORTED_MODEL_FORMAT = ".gguf"; +const NVIDIA_INFO_FILE = path.join( + require("os").homedir(), + "jan", + "settings", + "settings.json" +); + +const DEFALT_SETTINGS = { + "notify": true, + "run_mode": "cpu", + "nvidia_driver": { + "exist": false, + "version": "" + }, + "cuda": { + "exist": false, + "version": "" + }, + "gpus": [], + "gpu_highest_vram": "" +} // The subprocess instance for Nitro let subprocess = undefined; @@ -29,6 +52,125 @@ function stopModel(): Promise { return killSubprocess(); } +/** + * Validate nvidia and cuda for linux and windows + */ +async function updateNvidiaDriverInfo(): Promise { + exec( + "nvidia-smi --query-gpu=driver_version --format=csv,noheader", + (error, stdout) => { + let data; + try { + data = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf8")); + } catch (error) { + data = DEFALT_SETTINGS; + } + + if (!error) { + const firstLine = stdout.split("\n")[0].trim(); + data["nvidia_driver"].exist = true; + data["nvidia_driver"].version = firstLine; + } else { + data["nvidia_driver"].exist = false; + } + + writeFileSync(NVIDIA_INFO_FILE, JSON.stringify(data, null, 2)); + Promise.resolve(); + } + ); +} + +function checkFileExistenceInPaths(file: string, paths: string[]): boolean { + return paths.some((p) => existsSync(path.join(p, file))); +} + +function updateCudaExistence() { + let files: string[]; + let paths: string[]; + + if (process.platform === "win32") { + files = ["cublas64_12.dll", "cudart64_12.dll", "cublasLt64_12.dll"]; + paths = process.env.PATH ? process.env.PATH.split(path.delimiter) : []; + const nitro_cuda_path = path.join(__dirname, "bin", "win-cuda"); + paths.push(nitro_cuda_path); + } else { + files = ["libcudart.so.12", "libcublas.so.12", "libcublasLt.so.12"]; + paths = process.env.LD_LIBRARY_PATH + ? process.env.LD_LIBRARY_PATH.split(path.delimiter) + : []; + const nitro_cuda_path = path.join(__dirname, "bin", "linux-cuda"); + paths.push(nitro_cuda_path); + paths.push("/usr/lib/x86_64-linux-gnu/"); + } + + let cudaExists = files.every( + (file) => existsSync(file) || checkFileExistenceInPaths(file, paths) + ); + + let data; + try { + data = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf8")); + } catch (error) { + data = DEFALT_SETTINGS; + } + + data["cuda"].exist = cudaExists; + if (cudaExists) { + data.run_mode = "gpu"; + } + writeFileSync(NVIDIA_INFO_FILE, JSON.stringify(data, null, 2)); +} + +async function updateGpuInfo(): Promise { + exec( + "nvidia-smi --query-gpu=index,memory.total --format=csv,noheader,nounits", + (error, stdout) => { + let data; + try { + data = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf8")); + } catch (error) { + data = DEFALT_SETTINGS; + } + + if (!error) { + // Get GPU info and gpu has higher memory first + let highestVram = 0; + let highestVramId = "0"; + let gpus = stdout + .trim() + .split("\n") + .map((line) => { + let [id, vram] = line.split(", "); + vram = vram.replace(/\r/g, ""); + if (parseFloat(vram) > highestVram) { + highestVram = parseFloat(vram); + highestVramId = id; + } + return { id, vram }; + }); + + data["gpus"] = gpus; + data["gpu_highest_vram"] = highestVramId; + } else { + data["gpus"] = []; + } + + writeFileSync(NVIDIA_INFO_FILE, JSON.stringify(data, null, 2)); + Promise.resolve(); + } + ); +} + +async function updateNvidiaInfo() { + if (process.platform !== "darwin") { + await Promise.all([ + updateNvidiaDriverInfo(), + updateCudaExistence(), + updateGpuInfo(), + ]); + } +} + /** * Initializes a Nitro subprocess to load a machine learning model. * @param wrapper - The model wrapper. @@ -38,6 +180,10 @@ function stopModel(): Promise { */ async function initModel(wrapper: any): Promise { currentModelFile = wrapper.modelFullPath; + const janRoot = path.join(require("os").homedir(), "jan"); + if (!currentModelFile.includes(janRoot)) { + currentModelFile = path.join(janRoot, currentModelFile); + } const files: string[] = fs.readdirSync(currentModelFile); // Look for GGUF model file @@ -218,14 +364,26 @@ async function killSubprocess(): Promise { * Using child-process to spawn the process * Should run exactly platform specified Nitro binary version */ +/** + * Spawns a Nitro subprocess. + * @param nitroResourceProbe - The Nitro resource probe. + * @returns A promise that resolves when the Nitro subprocess is started. + */ function spawnNitroProcess(nitroResourceProbe: any): Promise { console.debug("Starting Nitro subprocess..."); return new Promise(async (resolve, reject) => { let binaryFolder = path.join(__dirname, "bin"); // Current directory by default + let cudaVisibleDevices = ""; let binaryName; - if (process.platform === "win32") { - binaryName = "win-start.bat"; + let nvida_info = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf8")); + if (nvida_info["run_mode"] === "cpu") { + binaryFolder = path.join(binaryFolder, "win-cpu"); + } else { + binaryFolder = path.join(binaryFolder, "win-cuda"); + cudaVisibleDevices = nvida_info["gpu_highest_vram"]; + } + binaryName = "nitro.exe"; } else if (process.platform === "darwin") { if (process.arch === "arm64") { binaryFolder = path.join(binaryFolder, "mac-arm64"); @@ -234,13 +392,24 @@ function spawnNitroProcess(nitroResourceProbe: any): Promise { } binaryName = "nitro"; } else { - binaryName = "linux-start.sh"; + let nvida_info = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf8")); + if (nvida_info["run_mode"] === "cpu") { + binaryFolder = path.join(binaryFolder, "win-cpu"); + } else { + binaryFolder = path.join(binaryFolder, "win-cuda"); + cudaVisibleDevices = nvida_info["gpu_highest_vram"]; + } + binaryName = "nitro"; } const binaryPath = path.join(binaryFolder, binaryName); // Execute the binary subprocess = spawn(binaryPath, [1, LOCAL_HOST, PORT], { cwd: binaryFolder, + env: { + ...process.env, + CUDA_VISIBLE_DEVICES: cudaVisibleDevices, + }, }); // Handle subprocess output @@ -272,11 +441,11 @@ function spawnNitroProcess(nitroResourceProbe: any): Promise { function getResourcesInfo(): Promise { return new Promise(async (resolve) => { const cpu = await si.cpu(); - const mem = await si.mem(); + // const mem = await si.mem(); - const response = { + const response: ResourcesInfo = { numCpuPhysicalCore: cpu.physicalCores, - memAvailable: mem.available, + memAvailable: 0, }; resolve(response); }); @@ -292,4 +461,5 @@ module.exports = { stopModel, killSubprocess, dispose, + updateNvidiaInfo, }; diff --git a/extensions/inference-openai-extension/src/index.ts b/extensions/inference-openai-extension/src/index.ts index 80d211d95..246aa124b 100644 --- a/extensions/inference-openai-extension/src/index.ts +++ b/extensions/inference-openai-extension/src/index.ts @@ -29,7 +29,7 @@ import { join } from "path"; * It also subscribes to events emitted by the @janhq/core package and handles new message requests. */ export default class JanInferenceOpenAIExtension implements InferenceExtension { - private static readonly _homeDir = "engines"; + private static readonly _homeDir = "file://engines"; private static readonly _engineMetadataFileName = "openai.json"; private static _currentModel: OpenAIModel; @@ -53,8 +53,13 @@ export default class JanInferenceOpenAIExtension implements InferenceExtension { /** * Subscribes to events emitted by the @janhq/core package. */ - onLoad(): void { - fs.mkdir(JanInferenceOpenAIExtension._homeDir); + async onLoad() { + if (!(await fs.existsSync(JanInferenceOpenAIExtension._homeDir))) { + await fs + .mkdirSync(JanInferenceOpenAIExtension._homeDir) + .catch((err) => console.debug(err)); + } + JanInferenceOpenAIExtension.writeDefaultEngineSettings(); // Events subscription @@ -85,12 +90,12 @@ export default class JanInferenceOpenAIExtension implements InferenceExtension { JanInferenceOpenAIExtension._homeDir, JanInferenceOpenAIExtension._engineMetadataFileName ); - if (await fs.exists(engineFile)) { - JanInferenceOpenAIExtension._engineSettings = JSON.parse( - await fs.readFile(engineFile) - ); + if (await fs.existsSync(engineFile)) { + const engine = await fs.readFileSync(engineFile, "utf-8"); + JanInferenceOpenAIExtension._engineSettings = + typeof engine === "object" ? engine : JSON.parse(engine); } else { - await fs.writeFile( + await fs.writeFileSync( engineFile, JSON.stringify(JanInferenceOpenAIExtension._engineSettings, null, 2) ); diff --git a/extensions/inference-triton-trtllm-extension/src/index.ts b/extensions/inference-triton-trtllm-extension/src/index.ts index 0720ed3ac..aed2f581a 100644 --- a/extensions/inference-triton-trtllm-extension/src/index.ts +++ b/extensions/inference-triton-trtllm-extension/src/index.ts @@ -34,7 +34,7 @@ import { EngineSettings } from "./@types/global"; export default class JanInferenceTritonTrtLLMExtension implements InferenceExtension { - private static readonly _homeDir = "engines"; + private static readonly _homeDir = "file://engines"; private static readonly _engineMetadataFileName = "triton_trtllm.json"; static _currentModel: Model; @@ -57,9 +57,9 @@ export default class JanInferenceTritonTrtLLMExtension /** * Subscribes to events emitted by the @janhq/core package. */ - onLoad(): void { - fs.mkdir(JanInferenceTritonTrtLLMExtension._homeDir); - JanInferenceTritonTrtLLMExtension.writeDefaultEngineSettings(); + async onLoad() { + if (!(await fs.existsSync(JanInferenceTritonTrtLLMExtension._homeDir))) + JanInferenceTritonTrtLLMExtension.writeDefaultEngineSettings(); // Events subscription events.on(EventName.OnMessageSent, (data) => @@ -98,12 +98,12 @@ export default class JanInferenceTritonTrtLLMExtension JanInferenceTritonTrtLLMExtension._homeDir, JanInferenceTritonTrtLLMExtension._engineMetadataFileName ); - if (await fs.exists(engine_json)) { - JanInferenceTritonTrtLLMExtension._engineSettings = JSON.parse( - await fs.readFile(engine_json) - ); + if (await fs.existsSync(engine_json)) { + const engine = await fs.readFileSync(engine_json, "utf-8"); + JanInferenceTritonTrtLLMExtension._engineSettings = + typeof engine === "object" ? engine : JSON.parse(engine); } else { - await fs.writeFile( + await fs.writeFileSync( engine_json, JSON.stringify( JanInferenceTritonTrtLLMExtension._engineSettings, diff --git a/extensions/model-extension/package.json b/extensions/model-extension/package.json index d81326fed..e7f113e49 100644 --- a/extensions/model-extension/package.json +++ b/extensions/model-extension/package.json @@ -1,6 +1,6 @@ { "name": "@janhq/model-extension", - "version": "1.0.16", + "version": "1.0.17", "description": "Model Management Extension provides model exploration and seamless downloads", "main": "dist/index.js", "module": "dist/module.js", diff --git a/extensions/model-extension/src/index.ts b/extensions/model-extension/src/index.ts index 9580afd9b..8c8972dac 100644 --- a/extensions/model-extension/src/index.ts +++ b/extensions/model-extension/src/index.ts @@ -8,14 +8,14 @@ import { InferenceEngine, joinPath, } from '@janhq/core' -import { basename } from 'path' import { ModelExtension, Model } from '@janhq/core' +import { baseName } from '@janhq/core/.' /** * A extension for models */ export default class JanModelExtension implements ModelExtension { - private static readonly _homeDir = 'models' + private static readonly _homeDir = 'file://models' private static readonly _modelMetadataFileName = 'model.json' private static readonly _supportedModelFormat = '.gguf' private static readonly _incompletedModelFileName = '.download' @@ -34,7 +34,7 @@ export default class JanModelExtension implements ModelExtension { * Called when the extension is loaded. * @override */ - onLoad(): void { + async onLoad() { this.copyModelsToHomeDir() } @@ -46,11 +46,11 @@ export default class JanModelExtension implements ModelExtension { private async copyModelsToHomeDir() { try { - if ( - localStorage.getItem(`${EXTENSION_NAME}-version`) === VERSION && - (await fs.exists(JanModelExtension._homeDir)) - ) { - console.debug('Model already migrated') + // list all of the files under the home directory + + if (await fs.existsSync(JanModelExtension._homeDir)) { + // ignore if the model is already downloaded + console.debug('Models already persisted.') return } @@ -62,7 +62,7 @@ export default class JanModelExtension implements ModelExtension { const srcPath = await joinPath([resourePath, 'models']) const userSpace = await getUserSpace() - const destPath = await joinPath([userSpace, JanModelExtension._homeDir]) + const destPath = await joinPath([userSpace, 'models']) await fs.syncFile(srcPath, destPath) @@ -94,11 +94,11 @@ export default class JanModelExtension implements ModelExtension { async downloadModel(model: Model): Promise { // create corresponding directory const modelDirPath = await joinPath([JanModelExtension._homeDir, model.id]) - await fs.mkdir(modelDirPath) + if (!(await fs.existsSync(modelDirPath))) await fs.mkdirSync(modelDirPath) // try to retrieve the download file name from the source url // if it fails, use the model ID as the file name - const extractedFileName = basename(model.source_url) + const extractedFileName = await model.source_url.split('/').pop() const fileName = extractedFileName .toLowerCase() .endsWith(JanModelExtension._supportedModelFormat) @@ -116,11 +116,11 @@ export default class JanModelExtension implements ModelExtension { async cancelModelDownload(modelId: string): Promise { return abortDownload( await joinPath([JanModelExtension._homeDir, modelId, modelId]) - ).then(async () => - fs.deleteFile( + ).then(async () => { + fs.unlinkSync( await joinPath([JanModelExtension._homeDir, modelId, modelId]) ) - ) + }) } /** @@ -133,10 +133,10 @@ export default class JanModelExtension implements ModelExtension { const dirPath = await joinPath([JanModelExtension._homeDir, modelId]) // remove all files under dirPath except model.json - const files = await fs.listFiles(dirPath) + const files = await fs.readdirSync(dirPath) const deletePromises = files.map(async (fileName: string) => { if (fileName !== JanModelExtension._modelMetadataFileName) { - return fs.deleteFile(await joinPath([dirPath, fileName])) + return fs.unlinkSync(await joinPath([dirPath, fileName])) } }) await Promise.allSettled(deletePromises) @@ -158,7 +158,7 @@ export default class JanModelExtension implements ModelExtension { ]) try { - await fs.writeFile(jsonFilePath, JSON.stringify(model, null, 2)) + await fs.writeFileSync(jsonFilePath, JSON.stringify(model, null, 2)) } catch (err) { console.error(err) } @@ -175,7 +175,7 @@ export default class JanModelExtension implements ModelExtension { return true } return await fs - .listFiles(await joinPath([JanModelExtension._homeDir, modelDir])) + .readdirSync(await joinPath([JanModelExtension._homeDir, modelDir])) .then((files: string[]) => { // or model binary exists in the directory // model binary name can match model ID or be a .gguf file and not be an incompleted model file @@ -198,22 +198,17 @@ export default class JanModelExtension implements ModelExtension { selector?: (path: string, model: Model) => Promise ): Promise { try { - const filesUnderJanRoot = await fs.listFiles('') - if (!filesUnderJanRoot.includes(JanModelExtension._homeDir)) { + if (!(await fs.existsSync(JanModelExtension._homeDir))) { console.debug('model folder not found') return [] } - const files: string[] = await fs.listFiles(JanModelExtension._homeDir) + const files: string[] = await fs.readdirSync(JanModelExtension._homeDir) const allDirectories: string[] = [] for (const file of files) { - const isDirectory = await fs.isDirectory( - await joinPath([JanModelExtension._homeDir, file]) - ) - if (isDirectory) { - allDirectories.push(file) - } + if (file === '.DS_Store') continue + allDirectories.push(file) } const readJsonPromises = allDirectories.map(async (dirName) => { @@ -247,6 +242,7 @@ export default class JanModelExtension implements ModelExtension { return undefined } }) + return modelData.filter((e) => !!e) } catch (err) { console.error(err) @@ -255,7 +251,7 @@ export default class JanModelExtension implements ModelExtension { } private readModelMetadata(path: string) { - return fs.readFile(path) + return fs.readFileSync(path, 'utf-8') } /** diff --git a/extensions/monitoring-extension/src/index.ts b/extensions/monitoring-extension/src/index.ts index 2e5e50ffa..f1cbf6dad 100644 --- a/extensions/monitoring-extension/src/index.ts +++ b/extensions/monitoring-extension/src/index.ts @@ -18,7 +18,7 @@ export default class JanMonitoringExtension implements MonitoringExtension { /** * Called when the extension is loaded. */ - onLoad(): void {} + async onLoad() {} /** * Called when the extension is unloaded. diff --git a/extensions/monitoring-extension/src/module.ts b/extensions/monitoring-extension/src/module.ts index 6fe5881c2..cc10ea4f9 100644 --- a/extensions/monitoring-extension/src/module.ts +++ b/extensions/monitoring-extension/src/module.ts @@ -4,11 +4,11 @@ const getResourcesInfo = async () => new Promise(async (resolve) => { const cpu = await si.cpu(); const mem = await si.mem(); - const gpu = await si.graphics(); + // const gpu = await si.graphics(); const response = { cpu, mem, - gpu, + // gpu, }; resolve(response); }); diff --git a/models/codeninja-1.0-7b/model.json b/models/codeninja-1.0-7b/model.json new file mode 100644 index 000000000..fbffaba6b --- /dev/null +++ b/models/codeninja-1.0-7b/model.json @@ -0,0 +1,23 @@ +{ + "source_url": "https://huggingface.co/beowolx/CodeNinja-1.0-OpenChat-7B-GGUF/resolve/main/codeninja-1.0-openchat-7b.Q4_K_M.gguf", + "id": "codeninja-1.0-7b", + "object": "model", + "name": "CodeNinja 7B Q4", + "version": "1.0", + "description": "CodeNinja is finetuned on openchat/openchat-3.5-1210. It is good for codding tasks", + "format": "gguf", + "settings": { + "ctx_len": 4096, + "prompt_template": "GPT4 Correct User: {prompt}<|end_of_turn|>GPT4 Correct Assistant:" + }, + "parameters": { + "max_tokens": 4096 + }, + "metadata": { + "author": "Beowolx", + "tags": ["7B", "Finetuned"], + "size": 4370000000 + }, + "engine": "nitro" + } + \ No newline at end of file diff --git a/models/magicoder-s-ds-7b/model.json b/models/magicoder-s-ds-7b/model.json new file mode 100644 index 000000000..e9b8f2d3b --- /dev/null +++ b/models/magicoder-s-ds-7b/model.json @@ -0,0 +1,23 @@ +{ + "source_url": "https://huggingface.co/TheBloke/Magicoder-S-DS-6.7B-GGUF/resolve/main/magicoder-s-ds-6.7b.Q4_K_M.gguf", + "id": "magicoder-s-ds7b", + "object": "model", + "name": "Magicoder 7B Q4", + "version": "1.0", + "description": "Magicoder is a model family, a novel approach to enlightening LLMs with open-source code snippets for generating low-bias and high-quality instruction data for code.", + "format": "gguf", + "settings": { + "ctx_len": 4096, + "prompt_template": "@@ Instruction\n{prompt}\n@@ Response" + }, + "parameters": { + "max_tokens": 4096 + }, + "metadata": { + "author": "Ise-uiuc", + "tags": ["7B", "Code"], + "size": 4080000000 + }, + "engine": "nitro" + } + \ No newline at end of file diff --git a/models/stealth-v1.2-7b/model.json b/models/stealth-v1.2-7b/model.json new file mode 100644 index 000000000..76264b9c8 --- /dev/null +++ b/models/stealth-v1.2-7b/model.json @@ -0,0 +1,25 @@ +{ + "source_url": "https://huggingface.co/janhq/stealth-v1.2-GGUF/resolve/main/stealth-v1.2.Q4_K_M.gguf", + "id": "stealth-v1.2-7b", + "object": "model", + "name": "Stealth-v1.2 7B Q4", + "version": "1.0", + "description": "This is a new experimental family designed to enhance Mathematical and Logical abilities.", + "format": "gguf", + "settings": { + "ctx_len": 4096, + "prompt_template": "<|im_start|>system\n{system_message}<|im_end|>\n<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant" + }, + "parameters": { + "max_tokens": 4096 + }, + "metadata": { + "author": "Jan", + "tags": [ + "7B", + "Merged" + ], + "size": 4370000000 + }, + "engine": "nitro" + } \ No newline at end of file diff --git a/models/trinity-v1-7b/model.json b/models/trinity-v1-7b/model.json index d792393d0..400ab1028 100644 --- a/models/trinity-v1-7b/model.json +++ b/models/trinity-v1-7b/model.json @@ -4,7 +4,7 @@ "object": "model", "name": "Trinity-v1 7B Q4", "version": "1.0", - "description": "Trinity is an experimental model merge of GreenNodeLM & LeoScorpius using the Slerp method. Recommended for daily assistance purposes.", + "description": "Please use the latest version Trinity v1.2 for the best experience. Trinity is an experimental model merge of GreenNodeLM & LeoScorpius using the Slerp method. Recommended for daily assistance purposes.", "format": "gguf", "settings": { "ctx_len": 4096, diff --git a/package.json b/package.json index c12ae1281..e4a1cae89 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "uikit", "core", "electron", - "web" + "web", + "server" ], "nohoist": [ "uikit", @@ -16,7 +17,9 @@ "electron", "electron/**", "web", - "web/**" + "web/**", + "server", + "server/**" ] }, "scripts": { @@ -28,6 +31,7 @@ "test-local": "yarn lint && yarn build:test && yarn test", "dev:uikit": "yarn workspace @janhq/uikit install && yarn workspace @janhq/uikit dev", "build:uikit": "yarn workspace @janhq/uikit install && yarn workspace @janhq/uikit build", + "build:server": "cd server && yarn install && yarn run build", "build:core": "cd core && yarn install && yarn run build", "build:web": "yarn workspace jan-web build && cpx \"web/out/**\" \"electron/renderer/\"", "build:electron": "cpx \"models/**\" \"electron/models/\" && yarn workspace jan build", diff --git a/server/data/.gitkeep b/server/data/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/server/data/models/.gitkeep b/server/data/models/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/server/data/threads/.gitkeep b/server/data/threads/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/server/icons/icon.png b/server/icons/icon.png deleted file mode 100644 index 289f99ded..000000000 Binary files a/server/icons/icon.png and /dev/null differ diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 000000000..a5fbfe0e6 --- /dev/null +++ b/server/index.ts @@ -0,0 +1,43 @@ +import fastify from "fastify"; +import dotenv from "dotenv"; +import { v1Router } from "@janhq/core/node"; +import path from "path"; + +dotenv.config(); + +const JAN_API_HOST = process.env.JAN_API_HOST || "0.0.0.0"; +const JAN_API_PORT = Number.parseInt(process.env.JAN_API_PORT || "1337"); + +const server = fastify(); +server.register(require("@fastify/cors"), {}); +server.register( + (childContext, _, done) => { + childContext.register(require("@fastify/static"), { + root: + process.env.EXTENSION_ROOT || + path.join(require("os").homedir(), "jan", "extensions"), + wildcard: false, + }); + + done(); + }, + { prefix: "extensions" } +); +server.register(v1Router, { prefix: "/v1" }); + +export const startServer = () => { + server + .listen({ + port: JAN_API_PORT, + host: JAN_API_HOST, + }) + .then(() => { + console.log( + `JAN API listening at: http://${JAN_API_HOST}:${JAN_API_PORT}` + ); + }); +}; + +export const stopServer = () => { + server.close(); +}; diff --git a/server/lib/.gitkeep b/server/lib/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/server/main.ts b/server/main.ts index 707c3fb4b..c3eb69135 100644 --- a/server/main.ts +++ b/server/main.ts @@ -1,19 +1,3 @@ -import fastify from 'fastify' -import dotenv from 'dotenv' -import v1API from './v1' -const server = fastify() - -dotenv.config() -server.register(v1API, {prefix: "/api/v1"}) - - -const JAN_API_PORT = Number.parseInt(process.env.JAN_API_PORT || '1337') -const JAN_API_HOST = process.env.JAN_API_HOST || "0.0.0.0" - -server.listen({ - port: JAN_API_PORT, - host: JAN_API_HOST -}).then(() => { - console.log(`JAN API listening at: http://${JAN_API_HOST}:${JAN_API_PORT}`); -}) +import { startServer } from "./index"; +startServer(); diff --git a/server/package.json b/server/package.json index 1fd06a482..2d350a317 100644 --- a/server/package.json +++ b/server/package.json @@ -1,32 +1,37 @@ { - "name": "jan-server", + "name": "@janhq/server", "version": "0.1.3", - "main": "./build/main.js", + "main": "build/index.js", + "types": "build/index.d.ts", "author": "Jan ", "license": "AGPL-3.0", "homepage": "https://jan.ai", "description": "Use offline LLMs with your own data. Run open source models like Llama2 or Falcon on your internal computers/servers.", - "build": "", + "files": [ + "build/**" + ], "scripts": { "lint": "eslint . --ext \".js,.jsx,.ts,.tsx\"", "test:e2e": "playwright test --workers=1", - "dev": "nodemon .", + "dev": "tsc --watch & node --watch build/main.js", "build": "tsc" }, "dependencies": { + "@fastify/cors": "^8.4.2", + "@fastify/static": "^6.12.0", + "@janhq/core": "link:./core", + "dotenv": "^16.3.1", + "fastify": "^4.24.3", + "request": "^2.88.2", + "request-progress": "^3.0.0" }, "devDependencies": { "@types/body-parser": "^1.19.5", "@types/npmcli__arborist": "^5.6.4", "@typescript-eslint/eslint-plugin": "^6.7.3", "@typescript-eslint/parser": "^6.7.3", - "dotenv": "^16.3.1", "eslint-plugin-react": "^7.33.2", - "fastify": "^4.24.3", - "nodemon": "^3.0.1", - "run-script-os": "^1.1.6" - }, - "installConfig": { - "hoistingLimits": "workspaces" + "run-script-os": "^1.1.6", + "typescript": "^5.2.2" } } diff --git a/server/tsconfig.json b/server/tsconfig.json index 3363fdba6..2c4fc4a64 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -13,10 +13,12 @@ "allowJs": true, "skipLibCheck": true, "paths": { "*": ["node_modules/*"] }, - "typeRoots": ["node_modules/@types"] + "typeRoots": ["node_modules/@types"], + "ignoreDeprecations": "5.0", + "declaration": true }, // "sourceMap": true, - + "include": ["./**/*.ts"], "exclude": ["core", "build", "dist", "tests", "node_modules"] } diff --git a/server/v1/assistants/index.ts b/server/v1/assistants/index.ts deleted file mode 100644 index c722195d0..000000000 --- a/server/v1/assistants/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify' - -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 deleted file mode 100644 index cb5fbf120..000000000 --- a/server/v1/chat/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify' - -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 deleted file mode 100644 index 89d73200b..000000000 --- a/server/v1/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -import assistantsAPI from './assistants' -import chatCompletionAPI from './chat' -import modelsAPI from './models' -import threadsAPI from './threads' - -import { FastifyInstance, FastifyPluginAsync } from 'fastify' - -const router: FastifyPluginAsync = async (app: FastifyInstance, opts) => { - app.register( - assistantsAPI, - { - prefix: "/assistants" - } - ) - - 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 deleted file mode 100644 index d564a2207..000000000 --- a/server/v1/models/downloadModel.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { RouteHandlerMethod, FastifyRequest, FastifyReply } from 'fastify' -import { MODEL_FOLDER_PATH } from "./index" -import fs from 'fs/promises' - -const controller: RouteHandlerMethod = async (req: FastifyRequest, res: FastifyReply) => { - //TODO: download models impl - //Mirror logic from JanModelExtension.downloadModel? - let model = req.body.model; - - // 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 deleted file mode 100644 index 22c551300..000000000 --- a/server/v1/models/index.ts +++ /dev/null @@ -1,61 +0,0 @@ - -export const MODEL_FOLDER_PATH = "./data/models" -export const _modelMetadataFileName = 'model.json' - -import fs from 'fs/promises' -import { Model } from '@janhq/core' -import { join } from 'path' - -// 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) - } - } - 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 deleted file mode 100644 index f2c7ffe75..000000000 --- a/server/v1/models/modelOp.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index e63f9e8d8..000000000 --- a/server/v1/threads/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify' - -const router: FastifyPluginAsync = async (app: FastifyInstance, opts: FastifyPluginOptions) => { - //TODO: Add controllers declaration here - - // app.get() -} -export default router; \ No newline at end of file diff --git a/web/containers/GPUDriverPromptModal/index.tsx b/web/containers/GPUDriverPromptModal/index.tsx new file mode 100644 index 000000000..68efa33d5 --- /dev/null +++ b/web/containers/GPUDriverPromptModal/index.tsx @@ -0,0 +1,84 @@ +import React from 'react' + +import { openExternalUrl } from '@janhq/core' + +import { + ModalClose, + ModalFooter, + ModalContent, + Modal, + ModalTitle, + ModalHeader, + Button, +} from '@janhq/uikit' + +import { useAtom } from 'jotai' + +import { isShowNotificationAtom, useSettings } from '@/hooks/useSettings' + +const GPUDriverPrompt: React.FC = () => { + const [showNotification, setShowNotification] = useAtom( + isShowNotificationAtom + ) + + const { saveSettings } = useSettings() + const onDoNotShowAgainChange = (e: React.ChangeEvent) => { + const isChecked = !e.target.checked + saveSettings({ notify: isChecked }) + } + + const openChanged = () => { + setShowNotification(false) + } + + return ( +
+ + + + Missing Nvidia Driver and Cuda Toolkit + +

+ It seems like you are missing Nvidia Driver or Cuda Toolkit or both. + Please follow the instructions on the{' '} + + openExternalUrl('https://developer.nvidia.com/cuda-toolkit') + } + > + NVidia Cuda Toolkit Installation Page + {' '} + and the{' '} + + openExternalUrl('https://www.nvidia.com/Download/index.aspx') + } + > + Nvidia Driver Installation Page + + . +

+
+ + Don't show again +
+ +
+ + + +
+
+
+
+
+ ) +} +export default GPUDriverPrompt diff --git a/web/containers/Providers/EventListener.tsx b/web/containers/Providers/EventListener.tsx index 046f2ecd2..ff661aacc 100644 --- a/web/containers/Providers/EventListener.tsx +++ b/web/containers/Providers/EventListener.tsx @@ -1,9 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { basename } from 'path' - import { PropsWithChildren, useEffect, useRef } from 'react' +import { baseName } from '@janhq/core' import { useAtomValue, useSetAtom } from 'jotai' import { useDownloadState } from '@/hooks/useDownloadState' @@ -37,10 +36,11 @@ export default function EventListenerWrapper({ children }: PropsWithChildren) { useEffect(() => { if (window && window.electronAPI) { window.electronAPI.onFileDownloadUpdate( - (_event: string, state: any | undefined) => { + async (_event: string, state: any | undefined) => { if (!state) return + const modelName = await baseName(state.fileName) const model = modelsRef.current.find( - (model) => modelBinFileName(model) === basename(state.fileName) + (model) => modelBinFileName(model) === modelName ) if (model) setDownloadState({ @@ -50,25 +50,31 @@ export default function EventListenerWrapper({ children }: PropsWithChildren) { } ) - window.electronAPI.onFileDownloadError((_event: string, state: any) => { - console.error('Download error', state) - const model = modelsRef.current.find( - (model) => modelBinFileName(model) === basename(state.fileName) - ) - if (model) setDownloadStateFailed(model.id) - }) - - window.electronAPI.onFileDownloadSuccess((_event: string, state: any) => { - if (state && state.fileName) { + window.electronAPI.onFileDownloadError( + async (_event: string, state: any) => { + console.error('Download error', state) + const modelName = await baseName(state.fileName) const model = modelsRef.current.find( - (model) => modelBinFileName(model) === basename(state.fileName) + (model) => modelBinFileName(model) === modelName ) - if (model) { - setDownloadStateSuccess(model.id) - setDownloadedModels([...downloadedModelRef.current, model]) + if (model) setDownloadStateFailed(model.id) + } + ) + + window.electronAPI.onFileDownloadSuccess( + async (_event: string, state: any) => { + if (state && state.fileName) { + const modelName = await baseName(state.fileName) + const model = modelsRef.current.find( + async (model) => modelBinFileName(model) === modelName + ) + if (model) { + setDownloadStateSuccess(model.id) + setDownloadedModels([...downloadedModelRef.current, model]) + } } } - }) + ) window.electronAPI.onAppUpdateDownloadUpdate( (_event: string, progress: any) => { diff --git a/web/containers/Providers/index.tsx b/web/containers/Providers/index.tsx index 3decde809..82a855fce 100644 --- a/web/containers/Providers/index.tsx +++ b/web/containers/Providers/index.tsx @@ -8,6 +8,7 @@ import { TooltipProvider } from '@janhq/uikit' import { PostHogProvider } from 'posthog-js/react' +import GPUDriverPrompt from '@/containers/GPUDriverPromptModal' import EventListenerWrapper from '@/containers/Providers/EventListener' import JotaiWrapper from '@/containers/Providers/Jotai' import ThemeWrapper from '@/containers/Providers/Theme' @@ -25,11 +26,11 @@ import { instance } from '@/utils/posthog' import { extensionManager } from '@/extension' const Providers = (props: PropsWithChildren) => { + const { children } = props + const [setupCore, setSetupCore] = useState(false) const [activated, setActivated] = useState(false) - const { children } = props - async function setupExtensions() { // Register all active extensions await extensionManager.registerActive() @@ -74,6 +75,7 @@ const Providers = (props: PropsWithChildren) => { {children} + {!isMac && } diff --git a/web/extension/ExtensionManager.ts b/web/extension/ExtensionManager.ts index 4a53cb491..43847f344 100644 --- a/web/extension/ExtensionManager.ts +++ b/web/extension/ExtensionManager.ts @@ -81,7 +81,10 @@ export class ExtensionManager { */ async activateExtension(extension: Extension) { // Import class - await import(/* webpackIgnore: true */ extension.url).then( + const extensionUrl = window.electronAPI + ? extension.url + : extension.url.replace('extension://', `${API_BASE_URL}/extensions/`) + await import(/* webpackIgnore: true */ extensionUrl).then( (extensionClass) => { // Register class if it has a default export if ( diff --git a/web/hooks/useEngineSettings.ts b/web/hooks/useEngineSettings.ts index 14f32d4b4..258a89aa4 100644 --- a/web/hooks/useEngineSettings.ts +++ b/web/hooks/useEngineSettings.ts @@ -2,11 +2,16 @@ import { fs, joinPath } from '@janhq/core' export const useEngineSettings = () => { const readOpenAISettings = async () => { - const settings = await fs.readFile( - await joinPath(['engines', 'openai.json']) + if ( + !(await fs.existsSync(await joinPath(['file://engines', 'openai.json']))) + ) + return {} + const settings = await fs.readFileSync( + await joinPath(['file://engines', 'openai.json']), + 'utf-8' ) if (settings) { - return JSON.parse(settings) + return typeof settings === 'object' ? settings : JSON.parse(settings) } return {} } @@ -17,8 +22,8 @@ export const useEngineSettings = () => { }) => { const settings = await readOpenAISettings() settings.api_key = apiKey - await fs.writeFile( - await joinPath(['engines', 'openai.json']), + await fs.writeFileSync( + await joinPath(['file://engines', 'openai.json']), JSON.stringify(settings) ) } diff --git a/web/hooks/useSendChatMessage.ts b/web/hooks/useSendChatMessage.ts index fb9761d60..f7643dfea 100644 --- a/web/hooks/useSendChatMessage.ts +++ b/web/hooks/useSendChatMessage.ts @@ -13,7 +13,6 @@ import { events, Model, ConversationalExtension, - ModelRuntimeParams, } from '@janhq/core' import { useAtom, useAtomValue, useSetAtom } from 'jotai' @@ -173,7 +172,7 @@ export default function useSendChatMessage() { updateThreadInitSuccess(activeThread.id) updateThread(updatedThread) - extensionManager + await extensionManager .get(ExtensionType.Conversational) ?.saveThread(updatedThread) } diff --git a/web/hooks/useSettings.ts b/web/hooks/useSettings.ts new file mode 100644 index 000000000..34d123359 --- /dev/null +++ b/web/hooks/useSettings.ts @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react' + +import { fs, joinPath } from '@janhq/core' +import { atom, useAtom } from 'jotai' + +export const isShowNotificationAtom = atom(false) + +export const useSettings = () => { + const [isGPUModeEnabled, setIsGPUModeEnabled] = useState(false) // New state for GPU mode + const [showNotification, setShowNotification] = useAtom( + isShowNotificationAtom + ) + + useEffect(() => { + setTimeout(() => validateSettings, 3000) + }, []) + + const validateSettings = async () => { + readSettings().then((settings) => { + if ( + settings && + settings.notify && + ((settings.nvidia_driver?.exist && !settings.cuda?.exist) || + !settings.nvidia_driver?.exist) + ) { + setShowNotification(true) + } + + // Check if run_mode is 'gpu' or 'cpu' and update state accordingly + setIsGPUModeEnabled(settings?.run_mode === 'gpu') + }) + } + + const readSettings = async () => { + if (!window?.core?.api) { + return + } + const settingsFile = await joinPath(['file://settings', 'settings.json']) + if (await fs.existsSync(settingsFile)) { + const settings = await fs.readFileSync(settingsFile, 'utf-8') + return typeof settings === 'object' ? settings : JSON.parse(settings) + } + return {} + } + const saveSettings = async ({ + runMode, + notify, + }: { + runMode?: string | undefined + notify?: boolean | undefined + }) => { + const settingsFile = await joinPath(['file://settings', 'settings.json']) + const settings = await readSettings() + if (runMode != null) settings.run_mode = runMode + if (notify != null) settings.notify = notify + await fs.writeFileSync(settingsFile, JSON.stringify(settings)) + } + + return { + showNotification, + isGPUModeEnabled, + readSettings, + saveSettings, + setShowNotification, + validateSettings, + } +} diff --git a/web/next.config.js b/web/next.config.js index b5a583f18..455ba70fc 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -32,6 +32,7 @@ const nextConfig = { JSON.stringify(process.env.ANALYTICS_ID) ?? JSON.stringify('xxx'), ANALYTICS_HOST: JSON.stringify(process.env.ANALYTICS_HOST) ?? JSON.stringify('xxx'), + API_BASE_URL: JSON.stringify('http://localhost:1337'), isMac: process.platform === 'darwin', isWindows: process.platform === 'win32', isLinux: process.platform === 'linux', diff --git a/web/screens/Chat/Sidebar/index.tsx b/web/screens/Chat/Sidebar/index.tsx index 35b2eb765..e04f9ff87 100644 --- a/web/screens/Chat/Sidebar/index.tsx +++ b/web/screens/Chat/Sidebar/index.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useContext } from 'react' import { getUserSpace, openFileExplorer, joinPath } from '@janhq/core' @@ -14,6 +14,8 @@ import DropdownListSidebar, { selectedModelAtom, } from '@/containers/DropdownListSidebar' +import { FeatureToggleContext } from '@/context/FeatureToggle' + import { useCreateNewThread } from '@/hooks/useCreateNewThread' import { toSettingParams } from '@/utils/model_param' @@ -35,6 +37,7 @@ const Sidebar: React.FC = () => { const selectedModel = useAtomValue(selectedModelAtom) const { updateThreadMetadata } = useCreateNewThread() const threadStates = useAtomValue(threadStatesAtom) + const { experimentalFeatureEnabed } = useContext(FeatureToggleContext) const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom) const modelSettingParams = toSettingParams(activeModelParams) @@ -51,6 +54,7 @@ const Sidebar: React.FC = () => { let filePath = undefined const assistantId = activeThread.assistants[0]?.assistant_id switch (type) { + case 'Engine': case 'Thread': filePath = await joinPath(['threads', activeThread.id]) break @@ -83,6 +87,7 @@ const Sidebar: React.FC = () => { let filePath = undefined const assistantId = activeThread.assistants[0]?.assistant_id switch (type) { + case 'Engine': case 'Thread': filePath = await joinPath(['threads', activeThread.id, 'thread.json']) break @@ -195,7 +200,7 @@ const Sidebar: React.FC = () => { - {Object.keys(modelSettingParams).length ? ( + {experimentalFeatureEnabed && Object.keys(modelSettingParams).length ? ( { } }, [currentPrompt]) - const onKeyDown = async (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - if (!e.shiftKey) { - e.preventDefault() - if (messages[messages.length - 1]?.status !== MessageStatus.Pending) - sendChatMessage() - else onStopInferenceClick() + const onKeyDown = debounce( + async (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + if (!e.shiftKey) { + e.preventDefault() + if (messages[messages.length - 1]?.status !== MessageStatus.Pending) + sendChatMessage() + else onStopInferenceClick() + } } - } - } + }, + 50, + { leading: false, trailing: true } + ) const onStopInferenceClick = async () => { events.emit(EventName.OnInferenceStopped, {}) diff --git a/web/screens/Settings/Advanced/index.tsx b/web/screens/Settings/Advanced/index.tsx index 4bfc2ee4e..41e903cc8 100644 --- a/web/screens/Settings/Advanced/index.tsx +++ b/web/screens/Settings/Advanced/index.tsx @@ -1,17 +1,58 @@ 'use client' -import { useContext } from 'react' +import { useContext, useEffect, useState } from 'react' import { Switch, Button } from '@janhq/uikit' import { FeatureToggleContext } from '@/context/FeatureToggle' +import { useSettings } from '@/hooks/useSettings' + const Advanced = () => { const { experimentalFeatureEnabed, setExperimentalFeatureEnabled } = useContext(FeatureToggleContext) + const [gpuEnabled, setGpuEnabled] = useState(false) + const { readSettings, saveSettings, validateSettings, setShowNotification } = + useSettings() + + useEffect(() => { + readSettings().then((settings) => { + setGpuEnabled(settings.run_mode === 'gpu') + }) + }, []) return (
+ {/* CPU / GPU switching */} + {!isMac && ( +
+
+
+
NVidia GPU
+
+

+ Enable GPU acceleration for NVidia GPUs. +

+
+ { + if (e === true) { + saveSettings({ runMode: 'gpu' }) + setGpuEnabled(true) + setShowNotification(false) + setTimeout(() => { + validateSettings() + }, 300) + } else { + saveSettings({ runMode: 'cpu' }) + setGpuEnabled(false) + } + }} + /> +
+ )} + {/* Experimental */}
@@ -20,8 +61,7 @@ const Advanced = () => {

- Enable experimental features that may be unstable - tested. + Enable experimental features that may be unstable tested.

{

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