Merge branch 'dev' into local-server-documentation

This commit is contained in:
Henry 2024-02-16 06:58:47 +07:00 committed by GitHub
commit 8366964faa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
188 changed files with 4286 additions and 2993 deletions

View File

@ -1,4 +1,4 @@
{
"name": "jan",
"image": "node:20"
}
"name": "jan",
"image": "node:20"
}

View File

@ -1,5 +1,6 @@
name: Jan Electron Linter & Test
on:
workflow_dispatch:
push:
branches:
- main

View File

@ -32,7 +32,10 @@ COPY --from=builder /app/node_modules ./node_modules/
COPY --from=builder /app/yarn.lock ./yarn.lock
# Copy the package.json, yarn.lock, and build output of server yarn space to leverage Docker cache
COPY --from=builder /app/core ./core/
COPY --from=builder /app/server ./server/
RUN cd core && yarn install && yarn run build
RUN yarn workspace @janhq/server install && yarn workspace @janhq/server build
COPY --from=builder /app/docs/openapi ./docs/openapi/
# Copy pre-install dependencies

View File

@ -56,7 +56,10 @@ COPY --from=builder /app/node_modules ./node_modules/
COPY --from=builder /app/yarn.lock ./yarn.lock
# Copy the package.json, yarn.lock, and build output of server yarn space to leverage Docker cache
COPY --from=builder /app/core ./core/
COPY --from=builder /app/server ./server/
RUN cd core && yarn install && yarn run build
RUN yarn workspace @janhq/server install && yarn workspace @janhq/server build
COPY --from=builder /app/docs/openapi ./docs/openapi/
# Copy pre-install dependencies

View File

@ -76,31 +76,31 @@ Jan is an open-source ChatGPT alternative that runs 100% offline on your compute
<tr style="text-align:center">
<td style="text-align:center"><b>Experimental (Nightly Build)</b></td>
<td style="text-align:center">
<a href='https://delta.jan.ai/latest/jan-win-x64-0.4.6-264.exe'>
<a href='https://delta.jan.ai/latest/jan-win-x64-0.4.6-271.exe'>
<img src='./docs/static/img/windows.png' style="height:14px; width: 14px" />
<b>jan.exe</b>
</a>
</td>
<td style="text-align:center">
<a href='https://delta.jan.ai/latest/jan-mac-x64-0.4.6-264.dmg'>
<a href='https://delta.jan.ai/latest/jan-mac-x64-0.4.6-271.dmg'>
<img src='./docs/static/img/mac.png' style="height:15px; width: 15px" />
<b>Intel</b>
</a>
</td>
<td style="text-align:center">
<a href='https://delta.jan.ai/latest/jan-mac-arm64-0.4.6-264.dmg'>
<a href='https://delta.jan.ai/latest/jan-mac-arm64-0.4.6-271.dmg'>
<img src='./docs/static/img/mac.png' style="height:15px; width: 15px" />
<b>M1/M2</b>
</a>
</td>
<td style="text-align:center">
<a href='https://delta.jan.ai/latest/jan-linux-amd64-0.4.6-264.deb'>
<a href='https://delta.jan.ai/latest/jan-linux-amd64-0.4.6-271.deb'>
<img src='./docs/static/img/linux.png' style="height:14px; width: 14px" />
<b>jan.deb</b>
</a>
</td>
<td style="text-align:center">
<a href='https://delta.jan.ai/latest/jan-linux-x86_64-0.4.6-264.AppImage'>
<a href='https://delta.jan.ai/latest/jan-linux-x86_64-0.4.6-271.AppImage'>
<img src='./docs/static/img/linux.png' style="height:14px; width: 14px" />
<b>jan.AppImage</b>
</a>
@ -167,6 +167,7 @@ To reset your installation:
- Clear Application cache in `~/Library/Caches/jan`
## Requirements for running Jan
- MacOS: 13 or higher
- Windows:
- Windows 10 or higher
@ -194,17 +195,17 @@ Contributions are welcome! Please read the [CONTRIBUTING.md](CONTRIBUTING.md) fi
1. **Clone the repository and prepare:**
```bash
git clone https://github.com/janhq/jan
cd jan
git checkout -b DESIRED_BRANCH
```
```bash
git clone https://github.com/janhq/jan
cd jan
git checkout -b DESIRED_BRANCH
```
2. **Run development and use Jan Desktop**
```bash
make dev
```
```bash
make dev
```
This will start the development server and open the desktop app.
@ -222,14 +223,15 @@ This will build the app MacOS m1/m2 for production (with code signing already do
- Supported OS: Linux, WSL2 Docker
- Pre-requisites:
- `docker` and `docker compose`, follow instruction [here](https://docs.docker.com/engine/install/ubuntu/)
- Docker Engine and Docker Compose are required to run Jan in Docker mode. Follow the [instructions](https://docs.docker.com/engine/install/ubuntu/) below to get started with Docker Engine on Ubuntu.
```bash
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh ./get-docker.sh --dry-run
```
- `nvidia-driver` and `nvidia-docker2`, follow instruction [here](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) (If you want to run with GPU mode)
- If you intend to run Jan in GPU mode, you need to install `nvidia-driver` and `nvidia-docker2`. Follow the instruction [here](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) for installation.
- Run Jan in Docker mode
@ -241,7 +243,7 @@ This will build the app MacOS m1/m2 for production (with code signing already do
- **Option 2**: Run Jan in GPU mode
- **Step 1**: Check cuda compatibility with your nvidia driver by running `nvidia-smi` and check the cuda version in the output
- **Step 1**: Check CUDA compatibility with your NVIDIA driver by running `nvidia-smi` and check the CUDA version in the output
```bash
nvidia-smi
@ -274,7 +276,7 @@ This will build the app MacOS m1/m2 for production (with code signing already do
|=======================================================================================|
```
- **Step 2**: Go to https://catalog.ngc.nvidia.com/orgs/nvidia/containers/cuda/tags and find the smallest minor version of image tag that matches the cuda version from the output of `nvidia-smi` (e.g. 12.1 -> 12.1.0)
- **Step 2**: Visit [NVIDIA NGC Catalog ](https://catalog.ngc.nvidia.com/orgs/nvidia/containers/cuda/tags) and find the smallest minor version of image tag that matches your CUDA version (e.g., 12.1 -> 12.1.0)
- **Step 3**: Update the `Dockerfile.gpu` line number 5 with the latest minor version of the image tag from step 2 (e.g. change `FROM nvidia/cuda:12.2.0-runtime-ubuntu22.04 AS base` to `FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04 AS base`)
@ -286,6 +288,7 @@ This will build the app MacOS m1/m2 for production (with code signing already do
```
This will start the web server and you can access Jan at `http://localhost:3000`.
> Note: Currently, Docker mode is only work for development and localhost, production is not supported yet. RAG feature is not supported in Docker mode yet.
## Acknowledgements

View File

@ -4,4 +4,4 @@ module.exports = {
moduleNameMapper: {
'@/(.*)': '<rootDir>/src/$1',
},
}
}

View File

@ -54,7 +54,8 @@ export default [
'url',
'http',
'os',
'util'
'util',
'child_process',
],
watch: {
include: 'src/node/**',

View File

@ -1,15 +1,22 @@
/**
* Native Route APIs
* @description Enum of all the routes exposed by the app
*/
export enum NativeRoute {
openExternalUrl = 'openExternalUrl',
openAppDirectory = 'openAppDirectory',
openFileExplore = 'openFileExplorer',
selectDirectory = 'selectDirectory',
relaunch = 'relaunch',
}
/**
* App Route APIs
* @description Enum of all the routes exposed by the app
*/
export enum AppRoute {
openExternalUrl = 'openExternalUrl',
openAppDirectory = 'openAppDirectory',
openFileExplore = 'openFileExplorer',
selectDirectory = 'selectDirectory',
getAppConfigurations = 'getAppConfigurations',
updateAppConfiguration = 'updateAppConfiguration',
relaunch = 'relaunch',
joinPath = 'joinPath',
isSubdirectory = 'isSubdirectory',
baseName = 'baseName',
@ -69,6 +76,10 @@ export enum FileManagerRoute {
export type ApiFunction = (...args: any[]) => any
export type NativeRouteFunctions = {
[K in NativeRoute]: ApiFunction
}
export type AppRouteFunctions = {
[K in AppRoute]: ApiFunction
}
@ -97,7 +108,8 @@ export type FileManagerRouteFunctions = {
[K in FileManagerRoute]: ApiFunction
}
export type APIFunctions = AppRouteFunctions &
export type APIFunctions = NativeRouteFunctions &
AppRouteFunctions &
AppEventFunctions &
DownloadRouteFunctions &
DownloadEventFunctions &
@ -105,11 +117,13 @@ export type APIFunctions = AppRouteFunctions &
FileSystemRouteFunctions &
FileManagerRoute
export const APIRoutes = [
export const CoreRoutes = [
...Object.values(AppRoute),
...Object.values(DownloadRoute),
...Object.values(ExtensionRoute),
...Object.values(FileSystemRoute),
...Object.values(FileManagerRoute),
]
export const APIRoutes = [...CoreRoutes, ...Object.values(NativeRoute)]
export const APIEvents = [...Object.values(AppEvent), ...Object.values(DownloadEvent)]

View File

@ -1,13 +1,13 @@
export enum ExtensionTypeEnum {
Assistant = "assistant",
Conversational = "conversational",
Inference = "inference",
Model = "model",
SystemMonitoring = "systemMonitoring",
Assistant = 'assistant',
Conversational = 'conversational',
Inference = 'inference',
Model = 'model',
SystemMonitoring = 'systemMonitoring',
}
export interface ExtensionType {
type(): ExtensionTypeEnum | undefined;
type(): ExtensionTypeEnum | undefined
}
/**
* Represents a base extension.
@ -20,16 +20,16 @@ export abstract class BaseExtension implements ExtensionType {
* Undefined means its not extending any known extension by the application.
*/
type(): ExtensionTypeEnum | undefined {
return undefined;
return undefined
}
/**
* Called when the extension is loaded.
* Any initialization logic for the extension should be put here.
*/
abstract onLoad(): void;
abstract onLoad(): void
/**
* Called when the extension is unloaded.
* Any cleanup logic for the extension should be put here.
*/
abstract onUnload(): void;
abstract onUnload(): void
}

View File

@ -1,5 +1,5 @@
import { Assistant, AssistantInterface } from "../index";
import { BaseExtension, ExtensionTypeEnum } from "../extension";
import { Assistant, AssistantInterface } from '../index'
import { BaseExtension, ExtensionTypeEnum } from '../extension'
/**
* Assistant extension for managing assistants.
@ -10,10 +10,10 @@ export abstract class AssistantExtension extends BaseExtension implements Assist
* Assistant extension type.
*/
type(): ExtensionTypeEnum | undefined {
return ExtensionTypeEnum.Assistant;
return ExtensionTypeEnum.Assistant
}
abstract createAssistant(assistant: Assistant): Promise<void>;
abstract deleteAssistant(assistant: Assistant): Promise<void>;
abstract getAssistants(): Promise<Assistant[]>;
abstract createAssistant(assistant: Assistant): Promise<void>
abstract deleteAssistant(assistant: Assistant): Promise<void>
abstract getAssistants(): Promise<Assistant[]>
}

View File

@ -14,7 +14,7 @@ export abstract class ConversationalExtension
* Conversation extension type.
*/
type(): ExtensionTypeEnum | undefined {
return ExtensionTypeEnum.Conversational;
return ExtensionTypeEnum.Conversational
}
abstract getThreads(): Promise<Thread[]>

View File

@ -2,24 +2,24 @@
* Conversational extension. Persists and retrieves conversations.
* @module
*/
export { ConversationalExtension } from "./conversational";
export { ConversationalExtension } from './conversational'
/**
* Inference extension. Start, stop and inference models.
*/
export { InferenceExtension } from "./inference";
export { InferenceExtension } from './inference'
/**
* Monitoring extension for system monitoring.
*/
export { MonitoringExtension } from "./monitoring";
export { MonitoringExtension } from './monitoring'
/**
* Assistant extension for managing assistants.
*/
export { AssistantExtension } from "./assistant";
export { AssistantExtension } from './assistant'
/**
* Model extension for managing models.
*/
export { ModelExtension } from "./model";
export { ModelExtension } from './model'

View File

@ -1,5 +1,5 @@
import { InferenceInterface, MessageRequest, ThreadMessage } from "../index";
import { BaseExtension, ExtensionTypeEnum } from "../extension";
import { InferenceInterface, MessageRequest, ThreadMessage } from '../index'
import { BaseExtension, ExtensionTypeEnum } from '../extension'
/**
* Inference extension. Start, stop and inference models.
@ -9,8 +9,8 @@ export abstract class InferenceExtension extends BaseExtension implements Infere
* Inference extension type.
*/
type(): ExtensionTypeEnum | undefined {
return ExtensionTypeEnum.Inference;
return ExtensionTypeEnum.Inference
}
abstract inference(data: MessageRequest): Promise<ThreadMessage>;
abstract inference(data: MessageRequest): Promise<ThreadMessage>
}

View File

@ -1,5 +1,5 @@
import { BaseExtension, ExtensionTypeEnum } from "../extension";
import { Model, ModelInterface } from "../index";
import { BaseExtension, ExtensionTypeEnum } from '../extension'
import { Model, ModelInterface } from '../index'
/**
* Model extension for managing models.
@ -9,16 +9,16 @@ export abstract class ModelExtension extends BaseExtension implements ModelInter
* Model extension type.
*/
type(): ExtensionTypeEnum | undefined {
return ExtensionTypeEnum.Model;
return ExtensionTypeEnum.Model
}
abstract downloadModel(
model: Model,
network?: { proxy: string; ignoreSSL?: boolean },
): Promise<void>;
abstract cancelModelDownload(modelId: string): Promise<void>;
abstract deleteModel(modelId: string): Promise<void>;
abstract saveModel(model: Model): Promise<void>;
abstract getDownloadedModels(): Promise<Model[]>;
abstract getConfiguredModels(): Promise<Model[]>;
network?: { proxy: string; ignoreSSL?: boolean }
): Promise<void>
abstract cancelModelDownload(modelId: string): Promise<void>
abstract deleteModel(modelId: string): Promise<void>
abstract saveModel(model: Model): Promise<void>
abstract getDownloadedModels(): Promise<Model[]>
abstract getConfiguredModels(): Promise<Model[]>
}

View File

@ -1,5 +1,5 @@
import { BaseExtension, ExtensionTypeEnum } from "../extension";
import { MonitoringInterface } from "../index";
import { BaseExtension, ExtensionTypeEnum } from '../extension'
import { MonitoringInterface } from '../index'
/**
* Monitoring extension for system monitoring.
@ -10,9 +10,9 @@ export abstract class MonitoringExtension extends BaseExtension implements Monit
* Monitoring extension type.
*/
type(): ExtensionTypeEnum | undefined {
return ExtensionTypeEnum.SystemMonitoring;
return ExtensionTypeEnum.SystemMonitoring
}
abstract getResourcesInfo(): Promise<any>;
abstract getCurrentLoad(): Promise<any>;
abstract getResourcesInfo(): Promise<any>
abstract getCurrentLoad(): Promise<any>
}

View File

@ -38,3 +38,10 @@ export * from './extension'
* @module
*/
export * from './extensions/index'
/**
* Declare global object
*/
declare global {
var core: any | undefined
}

View File

@ -0,0 +1,43 @@
import {
AppRoute,
DownloadRoute,
ExtensionRoute,
FileManagerRoute,
FileSystemRoute,
} from '../../../api'
import { Downloader } from '../processors/download'
import { FileSystem } from '../processors/fs'
import { Extension } from '../processors/extension'
import { FSExt } from '../processors/fsExt'
import { App } from '../processors/app'
export class RequestAdapter {
downloader: Downloader
fileSystem: FileSystem
extension: Extension
fsExt: FSExt
app: App
constructor(observer?: Function) {
this.downloader = new Downloader(observer)
this.fileSystem = new FileSystem()
this.extension = new Extension()
this.fsExt = new FSExt()
this.app = new App()
}
// TODO: Clearer Factory pattern here
process(route: string, ...args: any) {
if (route in DownloadRoute) {
return this.downloader.process(route, ...args)
} else if (route in FileSystemRoute) {
return this.fileSystem.process(route, ...args)
} else if (route in ExtensionRoute) {
return this.extension.process(route, ...args)
} else if (route in FileManagerRoute) {
return this.fsExt.process(route, ...args)
} else if (route in AppRoute) {
return this.app.process(route, ...args)
}
}
}

View File

@ -0,0 +1,23 @@
import { CoreRoutes } from '../../../api'
import { RequestAdapter } from './adapter'
export type Handler = (route: string, args: any) => any
export class RequestHandler {
handler: Handler
adataper: RequestAdapter
constructor(handler: Handler, observer?: Function) {
this.handler = handler
this.adataper = new RequestAdapter(observer)
}
handle() {
CoreRoutes.map((route) => {
this.handler(route, async (...args: any[]) => {
const values = await this.adataper.process(route, ...args)
return values
})
})
}
}

View File

@ -1,2 +1,3 @@
export * from './HttpServer'
export * from './routes'
export * from './restful/v1'
export * from './common/handler'

View File

@ -0,0 +1,3 @@
export abstract class Processor {
abstract process(key: string, ...args: any[]): any
}

View File

@ -0,0 +1,97 @@
import { basename, isAbsolute, join, relative } from 'path'
import { AppRoute } from '../../../api'
import { Processor } from './Processor'
import { getAppConfigurations as appConfiguration, updateAppConfiguration } from '../../helper'
import { log as writeLog, logServer as writeServerLog } from '../../helper/log'
import { appResourcePath } from '../../helper/path'
export class App implements Processor {
observer?: Function
constructor(observer?: Function) {
this.observer = observer
}
process(key: string, ...args: any[]): any {
const instance = this as any
const func = instance[key]
return func(...args)
}
/**
* Joins multiple paths together, respect to the current OS.
*/
joinPath(args: any[]) {
return join(...args)
}
/**
* Checks if the given path is a subdirectory of the given directory.
*
* @param _event - The IPC event object.
* @param from - The path to check.
* @param to - The directory to check against.
*
* @returns {Promise<boolean>} - A promise that resolves with the result.
*/
isSubdirectory(from: any, to: any) {
const rel = relative(from, to)
const isSubdir = rel && !rel.startsWith('..') && !isAbsolute(rel)
if (isSubdir === '') return false
else return isSubdir
}
/**
* Retrieve basename from given path, respect to the current OS.
*/
baseName(args: any) {
return basename(args)
}
/**
* Log message to log file.
*/
log(args: any) {
writeLog(args)
}
/**
* Log message to log file.
*/
logServer(args: any) {
writeServerLog(args)
}
getAppConfigurations() {
return appConfiguration()
}
async updateAppConfiguration(args: any) {
await updateAppConfiguration(args)
}
/**
* Start Jan API Server.
*/
async startServer(args?: any) {
const { startServer } = require('@janhq/server')
return startServer({
host: args?.host,
port: args?.port,
isCorsEnabled: args?.isCorsEnabled,
isVerboseEnabled: args?.isVerboseEnabled,
schemaPath: join(await appResourcePath(), 'docs', 'openapi', 'jan.yaml'),
baseDir: join(await appResourcePath(), 'docs', 'openapi'),
})
}
/**
* Stop Jan API Server.
*/
stopServer() {
const { stopServer } = require('@janhq/server')
return stopServer()
}
}

View File

@ -0,0 +1,106 @@
import { resolve, sep } from 'path'
import { DownloadEvent } from '../../../api'
import { normalizeFilePath } from '../../helper/path'
import { getJanDataFolderPath } from '../../helper'
import { DownloadManager } from '../../helper/download'
import { createWriteStream, renameSync } from 'fs'
import { Processor } from './Processor'
import { DownloadState } from '../../../types'
export class Downloader implements Processor {
observer?: Function
constructor(observer?: Function) {
this.observer = observer
}
process(key: string, ...args: any[]): any {
const instance = this as any
const func = instance[key]
return func(this.observer, ...args)
}
downloadFile(observer: any, url: string, localPath: string, network: any) {
const request = require('request')
const progress = require('request-progress')
const strictSSL = !network?.ignoreSSL
const proxy = network?.proxy?.startsWith('http') ? network.proxy : undefined
if (typeof localPath === 'string') {
localPath = normalizeFilePath(localPath)
}
const array = localPath.split(sep)
const fileName = array.pop() ?? ''
const modelId = array.pop() ?? ''
const destination = resolve(getJanDataFolderPath(), localPath)
const rq = request({ url, strictSSL, proxy })
// Put request to download manager instance
DownloadManager.instance.setRequest(localPath, rq)
// Downloading file to a temp file first
const downloadingTempFile = `${destination}.download`
progress(rq, {})
.on('progress', (state: any) => {
const downloadState: DownloadState = {
...state,
modelId,
fileName,
downloadState: 'downloading',
}
console.log('progress: ', downloadState)
observer?.(DownloadEvent.onFileDownloadUpdate, downloadState)
DownloadManager.instance.downloadProgressMap[modelId] = downloadState
})
.on('error', (error: Error) => {
const currentDownloadState = DownloadManager.instance.downloadProgressMap[modelId]
const downloadState: DownloadState = {
...currentDownloadState,
error: error.message,
downloadState: 'error',
}
if (currentDownloadState) {
DownloadManager.instance.downloadProgressMap[modelId] = downloadState
}
observer?.(DownloadEvent.onFileDownloadError, downloadState)
})
.on('end', () => {
const currentDownloadState = DownloadManager.instance.downloadProgressMap[modelId]
if (currentDownloadState && DownloadManager.instance.networkRequests[localPath]) {
// Finished downloading, rename temp file to actual file
renameSync(downloadingTempFile, destination)
const downloadState: DownloadState = {
...currentDownloadState,
downloadState: 'end',
}
observer?.(DownloadEvent.onFileDownloadSuccess, downloadState)
DownloadManager.instance.downloadProgressMap[modelId] = downloadState
}
})
.pipe(createWriteStream(downloadingTempFile))
}
abortDownload(observer: any, fileName: string) {
const rq = DownloadManager.instance.networkRequests[fileName]
if (rq) {
DownloadManager.instance.networkRequests[fileName] = undefined
rq?.abort()
} else {
observer?.(DownloadEvent.onFileDownloadError, {
fileName,
error: 'aborted',
})
}
}
resumeDownload(observer: any, fileName: any) {
DownloadManager.instance.networkRequests[fileName]?.resume()
}
pauseDownload(observer: any, fileName: any) {
DownloadManager.instance.networkRequests[fileName]?.pause()
}
}

View File

@ -0,0 +1,88 @@
import { readdirSync } from 'fs'
import { join, extname } from 'path'
import { Processor } from './Processor'
import { ModuleManager } from '../../helper/module'
import { getJanExtensionsPath as getPath } from '../../helper'
import {
getActiveExtensions as getExtensions,
getExtension,
removeExtension,
installExtensions,
} from '../../extension/store'
import { appResourcePath } from '../../helper/path'
export class Extension implements Processor {
observer?: Function
constructor(observer?: Function) {
this.observer = observer
}
process(key: string, ...args: any[]): any {
const instance = this as any
const func = instance[key]
return func(...args)
}
invokeExtensionFunc(modulePath: string, method: string, ...params: any[]) {
const module = require(join(getPath(), modulePath))
ModuleManager.instance.setModule(modulePath, module)
if (typeof module[method] === 'function') {
return module[method](...params)
} else {
console.debug(module[method])
console.error(`Function "${method}" does not exist in the module.`)
}
}
/**
* Returns the paths of the base extensions.
* @returns An array of paths to the base extensions.
*/
async baseExtensions() {
const baseExtensionPath = join(await appResourcePath(), 'pre-install')
return readdirSync(baseExtensionPath)
.filter((file) => extname(file) === '.tgz')
.map((file) => join(baseExtensionPath, file))
}
/**MARK: Extension Manager handlers */
async installExtension(extensions: any) {
// Install and activate all provided extensions
const installed = await installExtensions(extensions)
return JSON.parse(JSON.stringify(installed))
}
// Register IPC route to uninstall a extension
async uninstallExtension(extensions: any) {
// Uninstall all provided extensions
for (const ext of extensions) {
const extension = getExtension(ext)
await extension.uninstall()
if (extension.name) removeExtension(extension.name)
}
// Reload all renderer pages if needed
return true
}
// Register IPC route to update a extension
async updateExtension(extensions: any) {
// Update all provided extensions
const updated: any[] = []
for (const ext of extensions) {
const extension = getExtension(ext)
const res = await extension.update()
if (res) updated.push(extension)
}
// Reload all renderer pages if needed
return JSON.parse(JSON.stringify(updated))
}
getActiveExtensions() {
return JSON.parse(JSON.stringify(getExtensions()))
}
}

View File

@ -0,0 +1,25 @@
import { join } from 'path'
import { normalizeFilePath } from '../../helper/path'
import { getJanDataFolderPath } from '../../helper'
import { Processor } from './Processor'
export class FileSystem implements Processor {
observer?: Function
private static moduleName = 'fs'
constructor(observer?: Function) {
this.observer = observer
}
process(route: string, ...args: any[]): any {
return import(FileSystem.moduleName).then((mdl) =>
mdl[route](
...args.map((arg: any) =>
typeof arg === 'string' && (arg.startsWith(`file:/`) || arg.startsWith(`file:\\`))
? join(getJanDataFolderPath(), normalizeFilePath(arg))
: arg
)
)
)
}
}

View File

@ -0,0 +1,78 @@
import { join } from 'path'
import fs from 'fs'
import { FileManagerRoute } from '../../../api'
import { appResourcePath, normalizeFilePath } from '../../helper/path'
import { getJanDataFolderPath, getJanDataFolderPath as getPath } from '../../helper'
import { Processor } from './Processor'
import { FileStat } from '../../../types'
export class FSExt implements Processor {
observer?: Function
constructor(observer?: Function) {
this.observer = observer
}
process(key: string, ...args: any): any {
const instance = this as any
const func = instance[key]
return func(...args)
}
// Handles the 'syncFile' IPC event. This event is triggered to synchronize a file from a source path to a destination path.
syncFile(src: string, dest: string) {
const reflect = require('@alumna/reflect')
return reflect({
src,
dest,
recursive: true,
delete: false,
overwrite: true,
errorOnExist: false,
})
}
// Handles the 'getJanDataFolderPath' IPC event. This event is triggered to get the user space path.
getJanDataFolderPath() {
return Promise.resolve(getPath())
}
// Handles the 'getResourcePath' IPC event. This event is triggered to get the resource path.
getResourcePath() {
return appResourcePath()
}
// Handles the 'getUserHomePath' IPC event. This event is triggered to get the user home path.
getUserHomePath() {
return process.env[process.platform == 'win32' ? 'USERPROFILE' : 'HOME']
}
// handle fs is directory here
fileStat(path: string) {
const normalizedPath = normalizeFilePath(path)
const fullPath = join(getJanDataFolderPath(), normalizedPath)
const isExist = fs.existsSync(fullPath)
if (!isExist) return undefined
const isDirectory = fs.lstatSync(fullPath).isDirectory()
const size = fs.statSync(fullPath).size
const fileStat: FileStat = {
isDirectory,
size,
}
return fileStat
}
writeBlob(path: string, data: any) {
try {
const normalizedPath = normalizeFilePath(path)
const dataBuffer = Buffer.from(data, 'base64')
fs.writeFileSync(join(getJanDataFolderPath(), normalizedPath), dataBuffer)
} catch (err) {
console.error(`writeFile ${path} result: ${err}`)
}
}
}

View File

@ -0,0 +1,23 @@
import { DownloadRoute } from '../../../../api'
import { DownloadManager } from '../../../helper/download'
import { HttpServer } from '../../HttpServer'
export const downloadRouter = async (app: HttpServer) => {
app.get(`/download/${DownloadRoute.getDownloadProgress}/:modelId`, async (req, res) => {
const modelId = req.params.modelId
console.debug(`Getting download progress for model ${modelId}`)
console.debug(
`All Download progress: ${JSON.stringify(DownloadManager.instance.downloadProgressMap)}`
)
// check if null DownloadManager.instance.downloadProgressMap
if (!DownloadManager.instance.downloadProgressMap[modelId]) {
return res.status(404).send({
message: 'Download progress not found',
})
} else {
return res.status(200).send(DownloadManager.instance.downloadProgressMap[modelId])
}
})
}

View File

@ -0,0 +1,13 @@
import { HttpServer } from '../../HttpServer'
import { Handler, RequestHandler } from '../../common/handler'
export function handleRequests(app: HttpServer) {
const restWrapper: Handler = (route: string, listener: (...args: any[]) => any) => {
app.post(`/app/${route}`, async (request: any, reply: any) => {
const args = JSON.parse(request.body) as any[]
reply.send(JSON.stringify(await listener(...args)))
})
}
const handler = new RequestHandler(restWrapper)
handler.handle()
}

View File

@ -1,22 +1,24 @@
import { AppRoute } from '../../../api'
import { HttpServer } from '../HttpServer'
import { basename, join } from 'path'
import {
chatCompletions,
deleteBuilder,
downloadModel,
getBuilder,
retrieveBuilder,
} from '../common/builder'
createMessage,
createThread,
getMessages,
retrieveMesasge,
updateThread,
} from './helper/builder'
import { JanApiRouteConfiguration } from '../common/configuration'
import { startModel, stopModel } from '../common/startStopModel'
import { JanApiRouteConfiguration } from './helper/configuration'
import { startModel, stopModel } from './helper/startStopModel'
import { ModelSettingParams } from '../../../types'
import { getJanDataFolderPath } from '../../utils'
import { normalizeFilePath } from '../../path'
export const commonRouter = async (app: HttpServer) => {
// Common Routes
// Read & Delete :: Threads | Models | Assistants
Object.keys(JanApiRouteConfiguration).forEach((key) => {
app.get(`/${key}`, async (_request) => getBuilder(JanApiRouteConfiguration[key]))
@ -29,7 +31,24 @@ export const commonRouter = async (app: HttpServer) => {
)
})
// Download Model Routes
// Threads
app.post(`/threads/`, async (req, res) => createThread(req.body))
app.get(`/threads/:threadId/messages`, async (req, res) => getMessages(req.params.threadId))
app.get(`/threads/:threadId/messages/:messageId`, async (req, res) =>
retrieveMesasge(req.params.threadId, req.params.messageId)
)
app.post(`/threads/:threadId/messages`, async (req, res) =>
createMessage(req.params.threadId as any, req.body as any)
)
app.patch(`/threads/:threadId`, async (request: any) =>
updateThread(request.params.threadId, request.body)
)
// Models
app.get(`/models/download/:modelId`, async (request: any) =>
downloadModel(request.params.modelId, {
ignoreSSL: request.query.ignoreSSL === 'true',
@ -48,24 +67,6 @@ export const commonRouter = async (app: HttpServer) => {
app.put(`/models/:modelId/stop`, async (request: any) => stopModel(request.params.modelId))
// Chat Completion Routes
// Chat Completion
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[]
const paths = args[0].map((arg: string) =>
typeof arg === 'string' && (arg.startsWith(`file:/`) || arg.startsWith(`file:\\`))
? join(getJanDataFolderPath(), normalizeFilePath(arg))
: arg
)
reply.send(JSON.stringify(join(...paths)))
})
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])))
})
}

View File

@ -1,10 +1,11 @@
import fs from 'fs'
import { JanApiRouteConfiguration, RouteConfiguration } from './configuration'
import { join } from 'path'
import { ContentType, MessageStatus, Model, ThreadMessage } from './../../../index'
import { getEngineConfiguration, getJanDataFolderPath } from '../../utils'
import { ContentType, MessageStatus, Model, ThreadMessage } from '../../../../index'
import { getEngineConfiguration, getJanDataFolderPath } from '../../../helper'
import { DEFAULT_CHAT_COMPLETION_URL } from './consts'
// TODO: Refactor these
export const getBuilder = async (configuration: RouteConfiguration) => {
const directoryPath = join(getJanDataFolderPath(), configuration.dirName)
try {

View File

@ -1,9 +1,9 @@
import fs from 'fs'
import { join } from 'path'
import { getJanDataFolderPath, getJanExtensionsPath, getSystemResourceInfo } from '../../utils'
import { logServer } from '../../log'
import { getJanDataFolderPath, getJanExtensionsPath, getSystemResourceInfo } from '../../../helper'
import { logServer } from '../../../helper/log'
import { ChildProcessWithoutNullStreams, spawn } from 'child_process'
import { Model, ModelSettingParams, PromptTemplate } from '../../../types'
import { Model, ModelSettingParams, PromptTemplate } from '../../../../types'
import {
LOCAL_HOST,
NITRO_DEFAULT_PORT,

View File

@ -0,0 +1,16 @@
import { HttpServer } from '../HttpServer'
import { commonRouter } from './common'
import { downloadRouter } from './app/download'
import { handleRequests } from './app/handlers'
export const v1Router = async (app: HttpServer) => {
// MARK: Public API Routes
app.register(commonRouter)
// MARK: Internal Application Routes
handleRequests(app)
// Expanded route for tracking download progress
// TODO: Replace by Observer Wrapper (ZeroMQ / Vanilla Websocket)
app.register(downloadRouter)
}

View File

@ -1,112 +0,0 @@
import { DownloadRoute } from '../../../api'
import { join } from 'path'
import { DownloadManager } from '../../download'
import { HttpServer } from '../HttpServer'
import { createWriteStream } from 'fs'
import { getJanDataFolderPath } from '../../utils'
import { normalizeFilePath } from '../../path'
import { DownloadState } from '../../../types'
export const downloadRouter = async (app: HttpServer) => {
app.get(`/${DownloadRoute.getDownloadProgress}/:modelId`, async (req, res) => {
const modelId = req.params.modelId
console.debug(`Getting download progress for model ${modelId}`)
console.debug(
`All Download progress: ${JSON.stringify(DownloadManager.instance.downloadProgressMap)}`
)
// check if null DownloadManager.instance.downloadProgressMap
if (!DownloadManager.instance.downloadProgressMap[modelId]) {
return res.status(404).send({
message: 'Download progress not found',
})
} else {
return res.status(200).send(DownloadManager.instance.downloadProgressMap[modelId])
}
})
app.post(`/${DownloadRoute.downloadFile}`, async (req, res) => {
const strictSSL = !(req.query.ignoreSSL === 'true')
const proxy = req.query.proxy?.startsWith('http') ? req.query.proxy : undefined
const body = JSON.parse(req.body as any)
const normalizedArgs = body.map((arg: any) => {
if (typeof arg === 'string' && arg.startsWith('file:')) {
return join(getJanDataFolderPath(), normalizeFilePath(arg))
}
return arg
})
const localPath = normalizedArgs[1]
const array = localPath.split('/')
const fileName = array.pop() ?? ''
const modelId = array.pop() ?? ''
console.debug('downloadFile', normalizedArgs, fileName, modelId)
const request = require('request')
const progress = require('request-progress')
const rq = request({ url: normalizedArgs[0], strictSSL, proxy })
progress(rq, {})
.on('progress', function (state: any) {
const downloadProps: DownloadState = {
...state,
modelId,
fileName,
downloadState: 'downloading',
}
console.debug(`Download ${modelId} onProgress`, downloadProps)
DownloadManager.instance.downloadProgressMap[modelId] = downloadProps
})
.on('error', function (err: Error) {
console.debug(`Download ${modelId} onError`, err.message)
const currentDownloadState = DownloadManager.instance.downloadProgressMap[modelId]
if (currentDownloadState) {
DownloadManager.instance.downloadProgressMap[modelId] = {
...currentDownloadState,
downloadState: 'error',
}
}
})
.on('end', function () {
console.debug(`Download ${modelId} onEnd`)
const currentDownloadState = DownloadManager.instance.downloadProgressMap[modelId]
if (currentDownloadState) {
if (currentDownloadState.downloadState === 'downloading') {
// if the previous state is downloading, then set the state to end (success)
DownloadManager.instance.downloadProgressMap[modelId] = {
...currentDownloadState,
downloadState: 'end',
}
}
}
})
.pipe(createWriteStream(normalizedArgs[1]))
DownloadManager.instance.setRequest(localPath, rq)
res.status(200).send({ message: 'Download started' })
})
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.startsWith('file:')) {
return join(getJanDataFolderPath(), normalizeFilePath(arg))
}
return arg
})
const localPath = normalizedArgs[0]
const fileName = localPath.split('/').pop() ?? ''
const rq = DownloadManager.instance.networkRequests[fileName]
DownloadManager.instance.networkRequests[fileName] = undefined
rq?.abort()
if (rq) {
res.status(200).send({ message: 'Download aborted' })
} else {
res.status(404).send({ message: 'Download not found' })
}
})
}

View File

@ -1,49 +0,0 @@
import { join, extname } from 'path'
import { ExtensionRoute } from '../../../api/index'
import { ModuleManager } from '../../module'
import { getActiveExtensions, installExtensions } from '../../extension/store'
import { HttpServer } from '../HttpServer'
import { readdirSync } from 'fs'
import { getJanExtensionsPath } from '../../utils'
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) => {
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(getJanExtensionsPath(), 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.`)
}
})
}

View File

@ -1,29 +0,0 @@
import { FileManagerRoute } from '../../../api'
import { HttpServer } from '../../index'
import { join } from 'path'
export const fileManagerRouter = async (app: HttpServer) => {
app.post(`/fs/${FileManagerRoute.syncFile}`, async (request: any, reply: any) => {
const reflect = require('@alumna/reflect')
const args = JSON.parse(request.body)
return reflect({
src: args[0],
dest: args[1],
recursive: true,
delete: false,
overwrite: true,
errorOnExist: false,
})
})
app.post(`/fs/${FileManagerRoute.getJanDataFolderPath}`, async (request: any, reply: any) =>
global.core.appPath()
)
app.post(`/fs/${FileManagerRoute.getResourcePath}`, async (request: any, reply: any) =>
join(global.core.appPath(), '../../..')
)
app.post(`/app/${FileManagerRoute.getUserHomePath}`, async (request: any, reply: any) => {})
app.post(`/fs/${FileManagerRoute.fileStat}`, async (request: any, reply: any) => {})
}

View File

@ -1,40 +0,0 @@
import { FileManagerRoute, FileSystemRoute } from '../../../api'
import { join } from 'path'
import { HttpServer } from '../HttpServer'
import { getJanDataFolderPath } from '../../utils'
import { normalizeFilePath } from '../../path'
import { writeFileSync } from 'fs'
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.startsWith(`file:/`) || arg.startsWith(`file:\\`))
? join(getJanDataFolderPath(), normalizeFilePath(arg))
: arg
)
)
})
res.status(200).send(result)
} catch (ex) {
console.log(ex)
}
})
})
app.post(`/${FileManagerRoute.writeBlob}`, async (request: any, reply: any) => {
try {
const args = JSON.parse(request.body) as any[]
console.log('writeBlob:', args[0])
const dataBuffer = Buffer.from(args[1], 'base64')
writeFileSync(args[0], dataBuffer)
} catch (err) {
console.error(`writeFile ${request.body} result: ${err}`)
}
})
}

View File

@ -1,6 +0,0 @@
export * from './download'
export * from './extension'
export * from './fs'
export * from './thread'
export * from './common'
export * from './v1'

View File

@ -1,30 +0,0 @@
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),
)
}

View File

@ -1,28 +0,0 @@
import { HttpServer } from '../HttpServer'
import { commonRouter } from './common'
import { threadRouter } from './thread'
import { fsRouter } from './fs'
import { extensionRouter } from './extension'
import { downloadRouter } from './download'
import { fileManagerRouter } from './fileManager'
export const v1Router = async (app: HttpServer) => {
// MARK: External Routes
app.register(commonRouter)
app.register(threadRouter, {
prefix: '/threads',
})
// MARK: Internal Application Routes
app.register(fsRouter, {
prefix: '/fs',
})
app.register(fileManagerRouter)
app.register(extensionRouter, {
prefix: '/extension',
})
app.register(downloadRouter, {
prefix: '/download',
})
}

View File

@ -104,7 +104,7 @@ export default class Extension {
await pacote.extract(
this.specifier,
join(ExtensionManager.instance.getExtensionsPath() ?? '', this.name ?? ''),
this.installOptions,
this.installOptions
)
// Set the url using the custom extensions protocol

View File

@ -1,6 +1,6 @@
import { writeFileSync } from "fs";
import Extension from "./extension";
import { ExtensionManager } from "./manager";
import { writeFileSync } from 'fs'
import Extension from './extension'
import { ExtensionManager } from './manager'
/**
* @module store
@ -11,7 +11,7 @@ import { ExtensionManager } from "./manager";
* Register of installed extensions
* @type {Object.<string, Extension>} extension - List of installed extensions
*/
const extensions: Record<string, Extension> = {};
const extensions: Record<string, Extension> = {}
/**
* Get a extension from the stored extensions.
@ -21,10 +21,10 @@ const extensions: Record<string, Extension> = {};
*/
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]
}
/**
@ -33,7 +33,7 @@ export function getExtension(name: string) {
* @alias extensionManager.getAllExtensions
*/
export function getAllExtensions() {
return Object.values(extensions);
return Object.values(extensions)
}
/**
@ -42,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)
}
/**
@ -53,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
}
/**
@ -65,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)
}
}
@ -77,14 +77,11 @@ export function addExtension(extension: Extension, persist = true) {
* @returns {void}
*/
export function persistExtensions() {
const persistData: Record<string, Extension> = {};
const persistData: Record<string, Extension> = {}
for (const name in extensions) {
persistData[name] = extensions[name];
persistData[name] = extensions[name]
}
writeFileSync(
ExtensionManager.instance.getExtensionsFile(),
JSON.stringify(persistData),
);
writeFileSync(ExtensionManager.instance.getExtensionsFile(), JSON.stringify(persistData))
}
/**
@ -94,26 +91,29 @@ export function persistExtensions() {
* @returns {Promise.<Array.<Extension>>} New extension
* @alias extensionManager.installExtensions
*/
export async function installExtensions(extensions: any, store = true) {
const installed: Extension[] = [];
export async function installExtensions(extensions: any) {
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)
if (!extension.origin) {
continue
}
await extension._install()
if (activate) extension.setActive(true)
// Add extension to store if needed
if (store) addExtension(extension);
installed.push(extension);
addExtension(extension)
installed.push(extension)
}
// Return list of all installed extensions
return installed;
return installed
}
/**

View File

@ -2,7 +2,7 @@ import { AppConfiguration, SystemResourceInfo } from '../../types'
import { join } from 'path'
import fs from 'fs'
import os from 'os'
import { log, logServer } from '../log'
import { log, logServer } from './log'
import childProcess from 'child_process'
// TODO: move this to core
@ -56,34 +56,6 @@ export const updateAppConfiguration = (configuration: AppConfiguration): Promise
return Promise.resolve()
}
/**
* Utility function to get server log path
*
* @returns {string} The log path.
*/
export const getServerLogPath = (): string => {
const appConfigurations = getAppConfigurations()
const logFolderPath = join(appConfigurations.data_folder, 'logs')
if (!fs.existsSync(logFolderPath)) {
fs.mkdirSync(logFolderPath, { recursive: true })
}
return join(logFolderPath, 'server.log')
}
/**
* Utility function to get app log path
*
* @returns {string} The log path.
*/
export const getAppLogPath = (): string => {
const appConfigurations = getAppConfigurations()
const logFolderPath = join(appConfigurations.data_folder, 'logs')
if (!fs.existsSync(logFolderPath)) {
fs.mkdirSync(logFolderPath, { recursive: true })
}
return join(logFolderPath, 'app.log')
}
/**
* Utility function to get data folder path
*
@ -146,18 +118,6 @@ const exec = async (command: string): Promise<string> => {
})
}
export const getSystemResourceInfo = async (): Promise<SystemResourceInfo> => {
const cpu = await physicalCpuCount()
const message = `[NITRO]::CPU informations - ${cpu}`
log(message)
logServer(message)
return {
numCpuPhysicalCore: cpu,
memAvailable: 0, // TODO: this should not be 0
}
}
export const getEngineConfiguration = async (engineId: string) => {
if (engineId !== 'openai') {
return undefined
@ -167,3 +127,31 @@ export const getEngineConfiguration = async (engineId: string) => {
const data = fs.readFileSync(filePath, 'utf-8')
return JSON.parse(data)
}
/**
* Utility function to get server log path
*
* @returns {string} The log path.
*/
export const getServerLogPath = (): string => {
const appConfigurations = getAppConfigurations()
const logFolderPath = join(appConfigurations.data_folder, 'logs')
if (!fs.existsSync(logFolderPath)) {
fs.mkdirSync(logFolderPath, { recursive: true })
}
return join(logFolderPath, 'server.log')
}
/**
* Utility function to get app log path
*
* @returns {string} The log path.
*/
export const getAppLogPath = (): string => {
const appConfigurations = getAppConfigurations()
const logFolderPath = join(appConfigurations.data_folder, 'logs')
if (!fs.existsSync(logFolderPath)) {
fs.mkdirSync(logFolderPath, { recursive: true })
}
return join(logFolderPath, 'app.log')
}

View File

@ -1,4 +1,4 @@
import { DownloadState } from '../types'
import { DownloadState } from '../../types'
/**
* Manages file downloads and network requests.

View File

@ -0,0 +1,6 @@
export * from './config'
export * from './download'
export * from './log'
export * from './module'
export * from './path'
export * from './resource'

View File

@ -1,6 +1,6 @@
import fs from 'fs'
import util from 'util'
import { getAppLogPath, getServerLogPath } from './utils'
import { getAppLogPath, getServerLogPath } from './config'
export const log = (message: string) => {
const path = getAppLogPath()

View File

@ -0,0 +1,35 @@
import { join } from 'path'
/**
* Normalize file path
* Remove all file protocol prefix
* @param path
* @returns
*/
export function normalizeFilePath(path: string): string {
return path.replace(/^(file:[\\/]+)([^:\s]+)$/, '$2')
}
export async function appResourcePath(): Promise<string> {
let electron: any = undefined
try {
const moduleName = 'electron'
electron = await import(moduleName)
} catch (err) {
console.error('Electron is not available')
}
// electron
if (electron && electron.protocol) {
let appPath = join(electron.app.getAppPath(), '..', 'app.asar.unpacked')
if (!electron.app.isPackaged) {
// for development mode
appPath = join(electron.app.getAppPath())
}
return appPath
}
// server
return join(global.core.appPath(), '../../..')
}

View File

@ -0,0 +1,15 @@
import { SystemResourceInfo } from '../../types'
import { physicalCpuCount } from './config'
import { log, logServer } from './log'
export const getSystemResourceInfo = async (): Promise<SystemResourceInfo> => {
const cpu = await physicalCpuCount()
const message = `[NITRO]::CPU informations - ${cpu}`
log(message)
logServer(message)
return {
numCpuPhysicalCore: cpu,
memAvailable: 0, // TODO: this should not be 0
}
}

View File

@ -2,9 +2,5 @@ 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'
export * from './log'
export * from './utils'
export * from './path'
export * from './helper'

View File

@ -1,9 +0,0 @@
/**
* Normalize file path
* Remove all file protocol prefix
* @param path
* @returns
*/
export function normalizeFilePath(path: string): string {
return path.replace(/^(file:[\\/]+)([^:\s]+)$/, "$2");
}

View File

@ -2,7 +2,6 @@
* The `EventName` enumeration contains the names of all the available events in the Jan platform.
*/
export enum AssistantEvent {
/** The `OnAssistantsUpdate` event is emitted when the assistant list is updated. */
OnAssistantsUpdate = 'OnAssistantsUpdate',
}
/** The `OnAssistantsUpdate` event is emitted when the assistant list is updated. */
OnAssistantsUpdate = 'OnAssistantsUpdate',
}

View File

@ -5,7 +5,7 @@ export type FileStat = {
export type DownloadState = {
modelId: string
filename: string
fileName: string
time: DownloadTime
speed: number
percent: number

View File

@ -1,3 +1,4 @@
export * from './messageEntity'
export * from './messageInterface'
export * from './messageEvent'
export * from './messageRequestType'

View File

@ -27,6 +27,8 @@ export type ThreadMessage = {
updated: number
/** The additional metadata of this message. **/
metadata?: Record<string, unknown>
type?: string
}
/**
@ -56,6 +58,8 @@ export type MessageRequest = {
/** The thread of this message is belong to. **/
// TODO: deprecate threadId field
thread?: Thread
type?: string
}
/**

View File

@ -0,0 +1,5 @@
export enum MessageRequestType {
Thread = 'Thread',
Assistant = 'Assistant',
Summary = 'Summary',
}

View File

@ -10,7 +10,7 @@ export interface ModelInterface {
* @param network - Optional object to specify proxy/whether to ignore SSL certificates.
* @returns A Promise that resolves when the model has been downloaded.
*/
downloadModel(model: Model, network?: { ignoreSSL?: boolean, proxy?: string }): Promise<void>
downloadModel(model: Model, network?: { ignoreSSL?: boolean; proxy?: string }): Promise<void>
/**
* Cancels the download of a specific model.

View File

@ -1,4 +1,4 @@
import { normalizeFilePath } from "../../src/node/path";
import { normalizeFilePath } from "../../src/node/helper/path";
describe("Test file normalize", () => {
test("returns no file protocol prefix on Unix", async () => {

View File

@ -1,6 +1,3 @@
{
"extends": [
"tslint-config-standard",
"tslint-config-prettier"
]
}
"extends": ["tslint-config-standard", "tslint-config-prettier"]
}

View File

@ -11,7 +11,7 @@ namchuai:
url: https://github.com/namchuai
image_url: https://avatars.githubusercontent.com/u/10397206?v=4
email: james@jan.ai
hiro-v:
name: Hiro Vuong
title: MLE
@ -60,4 +60,17 @@ automaticcat:
url: https://github.com/tikikun
image_url: https://avatars.githubusercontent.com/u/22268502?v=4
email: alan@jan.ai
hieu-jan:
name: Henry Ho
title: Software Engineer
url: https://github.com/hieu-jan
image_url: https://avatars.githubusercontent.com/u/150573299?v=4
email: hieu@jan.ai
0xsage:
name: Nicole Zhu
title: Co-Founder
url: https://github.com/0xsage
image_url: https://avatars.githubusercontent.com/u/69952136?v=4
email: nicole@jan.ai

View File

@ -0,0 +1,102 @@
---
title: Docker
slug: /install/docker
description: Jan is a ChatGPT-alternative that runs on your own computer, with a local API server.
keywords:
[
Jan AI,
Jan,
ChatGPT alternative,
local AI,
private AI,
conversational AI,
no-subscription fee,
large language model,
docker installation,
]
---
# Installing Jan using Docker
## Installation
### Pre-requisites
:::note
**Supported OS**: Linux, WSL2 Docker
:::
- Docker Engine and Docker Compose are required to run Jan in Docker mode. Follow the [instructions](https://docs.docker.com/engine/install/ubuntu/) below to get started with Docker Engine on Ubuntu.
```bash
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh ./get-docker.sh --dry-run
```
- If you intend to run Jan in GPU mode, you need to install `nvidia-driver` and `nvidia-docker2`. Follow the instruction [here](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) for installation.
### Instructions
- Run Jan in Docker mode
- **Option 1**: Run Jan in CPU mode
```bash
docker compose --profile cpu up -d
```
- **Option 2**: Run Jan in GPU mode
- **Step 1**: Check CUDA compatibility with your NVIDIA driver by running `nvidia-smi` and check the CUDA version in the output
```bash
nvidia-smi
# Output
+---------------------------------------------------------------------------------------+
| NVIDIA-SMI 531.18 Driver Version: 531.18 CUDA Version: 12.1 |
|-----------------------------------------+----------------------+----------------------+
| GPU Name TCC/WDDM | Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|=========================================+======================+======================|
| 0 NVIDIA GeForce RTX 4070 Ti WDDM | 00000000:01:00.0 On | N/A |
| 0% 44C P8 16W / 285W| 1481MiB / 12282MiB | 2% Default |
| | | N/A |
+-----------------------------------------+----------------------+----------------------+
| 1 NVIDIA GeForce GTX 1660 Ti WDDM | 00000000:02:00.0 Off | N/A |
| 0% 49C P8 14W / 120W| 0MiB / 6144MiB | 0% Default |
| | | N/A |
+-----------------------------------------+----------------------+----------------------+
| 2 NVIDIA GeForce GTX 1660 Ti WDDM | 00000000:05:00.0 Off | N/A |
| 29% 38C P8 11W / 120W| 0MiB / 6144MiB | 0% Default |
| | | N/A |
+-----------------------------------------+----------------------+----------------------+
+---------------------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=======================================================================================|
```
- **Step 2**: Visit [NVIDIA NGC Catalog ](https://catalog.ngc.nvidia.com/orgs/nvidia/containers/cuda/tags) and find the smallest minor version of image tag that matches your CUDA version (e.g., 12.1 -> 12.1.0)
- **Step 3**: Update the `Dockerfile.gpu` line number 5 with the latest minor version of the image tag from step 2 (e.g. change `FROM nvidia/cuda:12.2.0-runtime-ubuntu22.04 AS base` to `FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04 AS base`)
- **Step 4**: Run command to start Jan in GPU mode
```bash
# GPU mode
docker compose --profile gpu up -d
```
This will start the web server and you can access Jan at `http://localhost:3000`.
:::warning
- Docker mode is currently only suitable for development and localhost. Production is not supported yet, and the RAG feature is not available in Docker mode.
:::

View File

@ -188,4 +188,6 @@ Troubleshooting tips:
2. If the issue persists, ensure your (V)RAM is accessible by the application. Some folks have virtual RAM and need additional configuration.
3. Get help in [Jan Discord](https://discord.gg/mY69SZaMaC).
3. If you are facing issues with the installation of RTX issues, please update the NVIDIA driver that supports CUDA 11.7 or higher. Ensure that the CUDA path is added to the environment variable.
4. Get help in [Jan Discord](https://discord.gg/mY69SZaMaC).

View File

@ -1,8 +0,0 @@
{
"semi": false,
"singleQuote": true,
"quoteProps": "consistent",
"trailingComma": "es5",
"endOfLine": "auto",
"plugins": ["prettier-plugin-tailwindcss"]
}

View File

@ -1,173 +0,0 @@
import { app, ipcMain, dialog, shell } from 'electron'
import { join, basename, relative as getRelative, isAbsolute } from 'path'
import { WindowManager } from './../managers/window'
import { getResourcePath } from './../utils/path'
import { AppRoute, AppConfiguration } from '@janhq/core'
import { ServerConfig, startServer, stopServer } from '@janhq/server'
import {
ModuleManager,
getJanDataFolderPath,
getJanExtensionsPath,
init,
log,
logServer,
getAppConfigurations,
updateAppConfiguration,
} from '@janhq/core/node'
export function handleAppIPCs() {
/**
* Handles the "openAppDirectory" IPC message by opening the app's user data directory.
* The `shell.openPath` method is used to open the directory in the user's default file explorer.
* @param _event - The IPC event object.
*/
ipcMain.handle(AppRoute.openAppDirectory, async (_event) => {
shell.openPath(getJanDataFolderPath())
})
/**
* Opens a URL in the user's default browser.
* @param _event - The IPC event object.
* @param url - The URL to open.
*/
ipcMain.handle(AppRoute.openExternalUrl, async (_event, url) => {
shell.openExternal(url)
})
/**
* Opens a URL in the user's default browser.
* @param _event - The IPC event object.
* @param url - The URL to open.
*/
ipcMain.handle(AppRoute.openFileExplore, async (_event, url) => {
shell.openPath(url)
})
/**
* Joins multiple paths together, respect to the current OS.
*/
ipcMain.handle(AppRoute.joinPath, async (_event, paths: string[]) =>
join(...paths)
)
/**
* Checks if the given path is a subdirectory of the given directory.
*
* @param _event - The IPC event object.
* @param from - The path to check.
* @param to - The directory to check against.
*
* @returns {Promise<boolean>} - A promise that resolves with the result.
*/
ipcMain.handle(
AppRoute.isSubdirectory,
async (_event, from: string, to: string) => {
const relative = getRelative(from, to)
const isSubdir =
relative && !relative.startsWith('..') && !isAbsolute(relative)
if (isSubdir === '') return false
else return isSubdir
}
)
/**
* Retrieve basename from given path, respect to the current OS.
*/
ipcMain.handle(AppRoute.baseName, async (_event, path: string) =>
basename(path)
)
/**
* Start Jan API Server.
*/
ipcMain.handle(AppRoute.startServer, async (_event, configs?: ServerConfig) =>
startServer({
host: configs?.host,
port: configs?.port,
isCorsEnabled: configs?.isCorsEnabled,
isVerboseEnabled: configs?.isVerboseEnabled,
schemaPath: app.isPackaged
? join(getResourcePath(), 'docs', 'openapi', 'jan.yaml')
: undefined,
baseDir: app.isPackaged
? join(getResourcePath(), 'docs', 'openapi')
: undefined,
})
)
/**
* Stop Jan API Server.
*/
ipcMain.handle(AppRoute.stopServer, stopServer)
/**
* Relaunches the app in production - reload window in development.
* @param _event - The IPC event object.
* @param url - The URL to reload.
*/
ipcMain.handle(AppRoute.relaunch, async (_event) => {
ModuleManager.instance.clearImportedModules()
if (app.isPackaged) {
app.relaunch()
app.exit()
} else {
for (const modulePath in ModuleManager.instance.requiredModules) {
delete require.cache[
require.resolve(join(getJanExtensionsPath(), modulePath))
]
}
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: getJanExtensionsPath(),
})
WindowManager.instance.currentWindow?.reload()
}
})
/**
* Log message to log file.
*/
ipcMain.handle(AppRoute.log, async (_event, message) => log(message))
/**
* Log message to log file.
*/
ipcMain.handle(AppRoute.logServer, async (_event, message) =>
logServer(message)
)
ipcMain.handle(AppRoute.selectDirectory, async () => {
const mainWindow = WindowManager.instance.currentWindow
if (!mainWindow) {
console.error('No main window found')
return
}
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
title: 'Select a folder',
buttonLabel: 'Select Folder',
properties: ['openDirectory', 'createDirectory'],
})
if (canceled) {
return
} else {
return filePaths[0]
}
})
ipcMain.handle(AppRoute.getAppConfigurations, async () =>
getAppConfigurations()
)
ipcMain.handle(
AppRoute.updateAppConfiguration,
async (_event, appConfiguration: AppConfiguration) => {
await updateAppConfiguration(appConfiguration)
}
)
}

View File

@ -0,0 +1,25 @@
import { Handler, RequestHandler } from '@janhq/core/node'
import { ipcMain } from 'electron'
import { WindowManager } from '../managers/window'
export function injectHandler() {
const ipcWrapper: Handler = (
route: string,
listener: (...args: any[]) => any
) => {
return ipcMain.handle(route, async (event, ...args: any[]) => {
return listener(...args)
})
}
const handler = new RequestHandler(
ipcWrapper,
(channel: string, args: any) => {
return WindowManager.instance.currentWindow?.webContents.send(
channel,
args
)
}
)
handler.handle()
}

View File

@ -1,132 +0,0 @@
import { ipcMain } from 'electron'
import { resolve } 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,
getJanDataFolderPath,
normalizeFilePath,
} from '@janhq/core/node'
export function handleDownloaderIPCs() {
/**
* Handles the "pauseDownload" IPC message by pausing the download associated with the provided fileName.
* @param _event - The IPC event object.
* @param fileName - The name of the file being downloaded.
*/
ipcMain.handle(DownloadRoute.pauseDownload, async (_event, fileName) => {
DownloadManager.instance.networkRequests[fileName]?.pause()
})
/**
* Handles the "resumeDownload" IPC message by resuming the download associated with the provided fileName.
* @param _event - The IPC event object.
* @param fileName - The name of the file being downloaded.
*/
ipcMain.handle(DownloadRoute.resumeDownload, async (_event, fileName) => {
DownloadManager.instance.networkRequests[fileName]?.resume()
})
/**
* Handles the "abortDownload" IPC message by aborting the download associated with the provided fileName.
* The network request associated with the fileName is then removed from the networkRequests object.
* @param _event - The IPC event object.
* @param fileName - The name of the file being downloaded.
*/
ipcMain.handle(DownloadRoute.abortDownload, async (_event, fileName) => {
const rq = DownloadManager.instance.networkRequests[fileName]
if (rq) {
DownloadManager.instance.networkRequests[fileName] = undefined
rq?.abort()
} else {
WindowManager?.instance.currentWindow?.webContents.send(
DownloadEvent.onFileDownloadError,
{
fileName,
err: { message: 'aborted' },
}
)
}
})
/**
* Downloads a file from a given URL.
* @param _event - The IPC event object.
* @param url - The URL to download the file from.
* @param fileName - The name to give the downloaded file.
*/
ipcMain.handle(
DownloadRoute.downloadFile,
async (_event, url, localPath, network) => {
const strictSSL = !network?.ignoreSSL
const proxy = network?.proxy?.startsWith('http')
? network.proxy
: undefined
if (typeof localPath === 'string') {
localPath = normalizeFilePath(localPath)
}
const array = localPath.split('/')
const fileName = array.pop() ?? ''
const modelId = array.pop() ?? ''
const destination = resolve(getJanDataFolderPath(), localPath)
const rq = request({ url, strictSSL, proxy })
// Put request to download manager instance
DownloadManager.instance.setRequest(localPath, rq)
// Downloading file to a temp file first
const downloadingTempFile = `${destination}.download`
progress(rq, {})
.on('progress', function (state: any) {
WindowManager?.instance.currentWindow?.webContents.send(
DownloadEvent.onFileDownloadUpdate,
{
...state,
fileName,
modelId,
}
)
})
.on('error', function (err: Error) {
WindowManager?.instance.currentWindow?.webContents.send(
DownloadEvent.onFileDownloadError,
{
fileName,
err,
modelId,
}
)
})
.on('end', function () {
if (DownloadManager.instance.networkRequests[localPath]) {
// Finished downloading, rename temp file to actual file
renameSync(downloadingTempFile, destination)
WindowManager?.instance.currentWindow?.webContents.send(
DownloadEvent.onFileDownloadSuccess,
{
fileName,
modelId,
}
)
DownloadManager.instance.setRequest(localPath, undefined)
} else {
WindowManager?.instance.currentWindow?.webContents.send(
DownloadEvent.onFileDownloadError,
{
fileName,
modelId,
err: { message: 'aborted' },
}
)
}
})
.pipe(createWriteStream(downloadingTempFile))
}
)
}

View File

@ -1,104 +0,0 @@
import { ipcMain, webContents } from 'electron'
import { readdirSync } from 'fs'
import { join, extname } from 'path'
import {
installExtensions,
getExtension,
removeExtension,
getActiveExtensions,
ModuleManager,
getJanExtensionsPath,
} from '@janhq/core/node'
import { getResourcePath } from './../utils/path'
import { ExtensionRoute } from '@janhq/core'
export function handleExtensionIPCs() {
/**MARK: General handlers */
/**
* Invokes a function from a extension module in main node process.
* @param _event - The IPC event object.
* @param modulePath - The path to the extension module.
* @param method - The name of the function to invoke.
* @param args - The arguments to pass to the function.
* @returns The result of the invoked function.
*/
ipcMain.handle(
ExtensionRoute.invokeExtensionFunc,
async (_event, modulePath, method, ...args) => {
const module = require(
/* webpackIgnore: true */ join(getJanExtensionsPath(), modulePath)
)
ModuleManager.instance.setModule(modulePath, module)
if (typeof module[method] === 'function') {
return module[method](...args)
} else {
console.debug(module[method])
console.error(`Function "${method}" does not exist in the module.`)
}
}
)
/**
* Returns the paths of the base extensions.
* @param _event - The IPC event object.
* @returns An array of paths to the base extensions.
*/
ipcMain.handle(ExtensionRoute.baseExtensions, async (_event) => {
const baseExtensionPath = join(getResourcePath(), 'pre-install')
return readdirSync(baseExtensionPath)
.filter((file) => extname(file) === '.tgz')
.map((file) => join(baseExtensionPath, file))
})
/**MARK: Extension Manager handlers */
ipcMain.handle(ExtensionRoute.installExtension, async (e, extensions) => {
// Install and activate all provided extensions
const installed = await installExtensions(extensions)
return JSON.parse(JSON.stringify(installed))
})
// Register IPC route to uninstall a extension
ipcMain.handle(
ExtensionRoute.uninstallExtension,
async (e, extensions, reload) => {
// Uninstall all provided extensions
for (const ext of extensions) {
const extension = getExtension(ext)
await extension.uninstall()
if (extension.name) removeExtension(extension.name)
}
// Reload all renderer pages if needed
reload && webContents.getAllWebContents().forEach((wc) => wc.reload())
return true
}
)
// Register IPC route to update a extension
ipcMain.handle(
ExtensionRoute.updateExtension,
async (e, extensions, reload) => {
// Update all provided extensions
const updated: any[] = []
for (const ext of extensions) {
const extension = getExtension(ext)
const res = await extension.update()
if (res) updated.push(extension)
}
// Reload all renderer pages if needed
if (updated.length && reload)
webContents.getAllWebContents().forEach((wc) => wc.reload())
return JSON.parse(JSON.stringify(updated))
}
)
// Register IPC route to get the list of active extensions
ipcMain.handle(ExtensionRoute.getActiveExtensions, () => {
return JSON.parse(JSON.stringify(getActiveExtensions()))
})
}

View File

@ -1,83 +0,0 @@
import { ipcMain, app } from 'electron'
// @ts-ignore
import reflect from '@alumna/reflect'
import { FileManagerRoute, FileStat } from '@janhq/core'
import { getResourcePath } from './../utils/path'
import fs from 'fs'
import { join } from 'path'
import { getJanDataFolderPath, normalizeFilePath } from '@janhq/core/node'
/**
* Handles file system extensions operations.
*/
export function handleFileMangerIPCs() {
// Handles the 'syncFile' IPC event. This event is triggered to synchronize a file from a source path to a destination path.
ipcMain.handle(
FileManagerRoute.syncFile,
async (_event, src: string, dest: string) => {
return reflect({
src,
dest,
recursive: true,
delete: false,
overwrite: true,
errorOnExist: false,
})
}
)
// Handles the 'getJanDataFolderPath' IPC event. This event is triggered to get the user space path.
ipcMain.handle(
FileManagerRoute.getJanDataFolderPath,
(): Promise<string> => Promise.resolve(getJanDataFolderPath())
)
// Handles the 'getResourcePath' IPC event. This event is triggered to get the resource path.
ipcMain.handle(FileManagerRoute.getResourcePath, async (_event) =>
getResourcePath()
)
// Handles the 'getUserHomePath' IPC event. This event is triggered to get the user home path.
ipcMain.handle(FileManagerRoute.getUserHomePath, async (_event) =>
app.getPath('home')
)
// handle fs is directory here
ipcMain.handle(
FileManagerRoute.fileStat,
async (_event, path: string): Promise<FileStat | undefined> => {
const normalizedPath = normalizeFilePath(path)
const fullPath = join(getJanDataFolderPath(), normalizedPath)
const isExist = fs.existsSync(fullPath)
if (!isExist) return undefined
const isDirectory = fs.lstatSync(fullPath).isDirectory()
const size = fs.statSync(fullPath).size
const fileStat: FileStat = {
isDirectory,
size,
}
return fileStat
}
)
ipcMain.handle(
FileManagerRoute.writeBlob,
async (_event, path: string, data: string): Promise<void> => {
try {
const normalizedPath = normalizeFilePath(path)
const dataBuffer = Buffer.from(data, 'base64')
fs.writeFileSync(
join(getJanDataFolderPath(), normalizedPath),
dataBuffer
)
} catch (err) {
console.error(`writeFile ${path} result: ${err}`)
}
}
)
}

View File

@ -1,25 +0,0 @@
import { ipcMain } from 'electron'
import { getJanDataFolderPath, normalizeFilePath } from '@janhq/core/node'
import { FileSystemRoute } from '@janhq/core'
import { join } from 'path'
/**
* Handles file system operations.
*/
export function handleFsIPCs() {
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.startsWith(`file:/`) || arg.startsWith(`file:\\`))
? join(getJanDataFolderPath(), normalizeFilePath(arg))
: arg
)
)
)
})
})
}

View File

@ -0,0 +1,86 @@
import { app, ipcMain, dialog, shell } from 'electron'
import { join } from 'path'
import { WindowManager } from '../managers/window'
import {
ModuleManager,
getJanDataFolderPath,
getJanExtensionsPath,
init,
} from '@janhq/core/node'
import { NativeRoute } from '@janhq/core'
export function handleAppIPCs() {
/**
* Handles the "openAppDirectory" IPC message by opening the app's user data directory.
* The `shell.openPath` method is used to open the directory in the user's default file explorer.
* @param _event - The IPC event object.
*/
ipcMain.handle(NativeRoute.openAppDirectory, async (_event) => {
shell.openPath(getJanDataFolderPath())
})
/**
* Opens a URL in the user's default browser.
* @param _event - The IPC event object.
* @param url - The URL to open.
*/
ipcMain.handle(NativeRoute.openExternalUrl, async (_event, url) => {
shell.openExternal(url)
})
/**
* Opens a URL in the user's default browser.
* @param _event - The IPC event object.
* @param url - The URL to open.
*/
ipcMain.handle(NativeRoute.openFileExplore, async (_event, url) => {
shell.openPath(url)
})
/**
* Relaunches the app in production - reload window in development.
* @param _event - The IPC event object.
* @param url - The URL to reload.
*/
ipcMain.handle(NativeRoute.relaunch, async (_event) => {
ModuleManager.instance.clearImportedModules()
if (app.isPackaged) {
app.relaunch()
app.exit()
} else {
for (const modulePath in ModuleManager.instance.requiredModules) {
delete require.cache[
require.resolve(join(getJanExtensionsPath(), modulePath))
]
}
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: getJanExtensionsPath(),
})
WindowManager.instance.currentWindow?.reload()
}
})
ipcMain.handle(NativeRoute.selectDirectory, async () => {
const mainWindow = WindowManager.instance.currentWindow
if (!mainWindow) {
console.error('No main window found')
return
}
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
title: 'Select a folder',
buttonLabel: 'Select Folder',
properties: ['openDirectory', 'createDirectory'],
})
if (canceled) {
return
} else {
return filePaths[0]
}
})
}

View File

@ -36,7 +36,7 @@ export function handleAppUpdates() {
autoUpdater.on('error', (info: any) => {
WindowManager.instance.currentWindow?.webContents.send(
AppEvent.onAppUpdateDownloadError,
{}
info
)
})

View File

@ -1,4 +1,4 @@
import { app, BrowserWindow } from 'electron'
import { app, BrowserWindow, shell } from 'electron'
import { join } from 'path'
/**
* Managers
@ -9,12 +9,9 @@ import { log } 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 { injectHandler } from './handlers/common'
import { handleAppUpdates } from './handlers/update'
import { handleFsIPCs } from './handlers/fs'
import { handleAppIPCs } from './handlers/native'
/**
* Utils
@ -25,25 +22,12 @@ import { migrateExtensions } from './utils/migration'
import { cleanUpAndQuit } from './utils/clean'
import { setupExtensions } from './utils/extension'
import { setupCore } from './utils/setup'
import { setupReactDevTool } from './utils/dev'
import { cleanLogs } from './utils/log'
app
.whenReady()
.then(async () => {
if (!app.isPackaged) {
// Which means you're running from source code
const { default: installExtension, REACT_DEVELOPER_TOOLS } = await import(
'electron-devtools-installer'
) // Don't use import on top level, since the installer package is dev-only
try {
const name = installExtension(REACT_DEVELOPER_TOOLS)
console.log(`Added Extension: ${name}`)
} catch (err) {
console.log('An error occurred while installing devtools:')
console.error(err)
// Only log the error and don't throw it because it's not critical
}
}
})
.then(setupReactDevTool)
.then(setupCore)
.then(createUserSpace)
.then(migrateExtensions)
@ -59,6 +43,7 @@ app
}
})
})
.then(() => cleanLogs())
app.once('window-all-closed', () => {
cleanUpAndQuit()
@ -92,7 +77,7 @@ function createMainWindow() {
/* Open external links in the default browser */
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
require('electron').shell.openExternal(url)
shell.openExternal(url)
return { action: 'deny' }
})
@ -104,11 +89,11 @@ function createMainWindow() {
* Handles various IPC messages from the renderer process.
*/
function handleIPCs() {
handleFsIPCs()
handleDownloaderIPCs()
handleExtensionIPCs()
// Inject core handlers for IPCs
injectHandler()
// Handle native IPCs
handleAppIPCs()
handleFileMangerIPCs()
}
/*

View File

@ -9,7 +9,9 @@ const file3 = args[2]
// check that all arguments are present and throw error instead
if (!file1 || !file2 || !file3) {
throw new Error('Please provide 3 file paths as arguments: path to file1, to file2 and destination path')
throw new Error(
'Please provide 3 file paths as arguments: path to file1, to file2 and destination path'
)
}
const doc1 = yaml.load(fs.readFileSync(file1, 'utf8'))

View File

@ -11,7 +11,6 @@
"productName": "Jan",
"files": [
"renderer/**/*",
"build/*.{js,map}",
"build/**/*.{js,map}",
"pre-install",
"models/**/*",
@ -64,11 +63,11 @@
"build:test:darwin": "tsc -p . && electron-builder -p never -m --dir",
"build:test:win32": "tsc -p . && electron-builder -p never -w --dir",
"build:test:linux": "tsc -p . && electron-builder -p never -l --dir",
"build:darwin": "tsc -p . && electron-builder -p never -m --x64 --arm64",
"build:darwin": "tsc -p . && electron-builder -p never -m",
"build:win32": "tsc -p . && electron-builder -p never -w",
"build:linux": "tsc -p . && electron-builder -p never -l deb -l AppImage",
"build:publish": "yarn copy:assets && run-script-os",
"build:publish:darwin": "tsc -p . && electron-builder -p always -m --x64 --arm64",
"build:publish:darwin": "tsc -p . && electron-builder -p always -m",
"build:publish:win32": "tsc -p . && electron-builder -p always -w",
"build:publish:linux": "tsc -p . && electron-builder -p always -l deb -l AppImage"
},
@ -77,7 +76,6 @@
"@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",
@ -86,8 +84,6 @@
"pacote": "^17.0.4",
"request": "^2.88.2",
"request-progress": "^3.0.0",
"rimraf": "^5.0.5",
"typescript": "^5.2.2",
"ulid": "^2.3.0",
"use-debounce": "^9.0.4"
},
@ -96,6 +92,7 @@
"@playwright/test": "^1.38.1",
"@types/npmcli__arborist": "^5.6.4",
"@types/pacote": "^11.1.7",
"@types/request": "^2.48.12",
"@typescript-eslint/eslint-plugin": "^6.7.3",
"@typescript-eslint/parser": "^6.7.3",
"electron": "28.0.0",
@ -103,7 +100,9 @@
"electron-devtools-installer": "^3.2.0",
"electron-playwright-helpers": "^1.6.0",
"eslint-plugin-react": "^7.33.2",
"run-script-os": "^1.1.6"
"rimraf": "^5.0.5",
"run-script-os": "^1.1.6",
"typescript": "^5.2.2"
},
"installConfig": {
"hoistingLimits": "workspaces"

View File

@ -3,14 +3,12 @@ import { PlaywrightTestConfig } from '@playwright/test'
const config: PlaywrightTestConfig = {
testDir: './tests/e2e',
retries: 0,
globalTimeout: 300000,
globalTimeout: 350000,
use: {
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'retain-on-failure',
},
reporter: [['html', { outputFolder: './playwright-report' }]],
}
export default config

View File

@ -1,44 +1,48 @@
const { exec } = require('child_process');
const { exec } = require('child_process')
function sign({
path,
name,
certUrl,
clientId,
tenantId,
clientSecret,
certName,
timestampServer,
version,
}) {
return new Promise((resolve, reject) => {
const command = `azuresigntool.exe sign -kvu "${certUrl}" -kvi "${clientId}" -kvt "${tenantId}" -kvs "${clientSecret}" -kvc "${certName}" -tr "${timestampServer}" -v "${path}"`
function sign({ path, name, certUrl, clientId, tenantId, clientSecret, certName, timestampServer, version }) {
return new Promise((resolve, reject) => {
const command = `azuresigntool.exe sign -kvu "${certUrl}" -kvi "${clientId}" -kvt "${tenantId}" -kvs "${clientSecret}" -kvc "${certName}" -tr "${timestampServer}" -v "${path}"`;
exec(command, (error, stdout, stderr) => {
if (error) {
console.error(`Error: ${error}`);
return reject(error);
}
console.log(`stdout: ${stdout}`);
console.error(`stderr: ${stderr}`);
resolve();
});
});
exec(command, (error, stdout, stderr) => {
if (error) {
console.error(`Error: ${error}`)
return reject(error)
}
console.log(`stdout: ${stdout}`)
console.error(`stderr: ${stderr}`)
resolve()
})
})
}
exports.default = async function (options) {
const certUrl = process.env.AZURE_KEY_VAULT_URI
const clientId = process.env.AZURE_CLIENT_ID
const tenantId = process.env.AZURE_TENANT_ID
const clientSecret = process.env.AZURE_CLIENT_SECRET
const certName = process.env.AZURE_CERT_NAME
const timestampServer = 'http://timestamp.globalsign.com/tsa/r6advanced1'
exports.default = async function(options) {
const certUrl = process.env.AZURE_KEY_VAULT_URI;
const clientId = process.env.AZURE_CLIENT_ID;
const tenantId = process.env.AZURE_TENANT_ID;
const clientSecret = process.env.AZURE_CLIENT_SECRET;
const certName = process.env.AZURE_CERT_NAME;
const timestampServer = 'http://timestamp.globalsign.com/tsa/r6advanced1';
await sign({
path: options.path,
name: "jan-win-x64",
certUrl,
clientId,
tenantId,
clientSecret,
certName,
timestampServer,
version: options.version
});
};
await sign({
path: options.path,
name: 'jan-win-x64',
certUrl,
clientId,
tenantId,
clientSecret,
certName,
timestampServer,
version: options.version,
})
}

View File

@ -0,0 +1,4 @@
export const Constants = {
VIDEO_DIR: './playwright-video',
TIMEOUT: '300000',
}

View File

@ -0,0 +1,119 @@
import {
_electron as electron,
BrowserContext,
ElectronApplication,
expect,
Page,
test as base,
} from '@playwright/test'
import {
ElectronAppInfo,
findLatestBuild,
parseElectronApp,
stubDialog,
} from 'electron-playwright-helpers'
import { Constants } from './constants'
import { HubPage } from '../pages/hubPage'
import { CommonActions } from '../pages/commonActions'
export let electronApp: ElectronApplication
export let page: Page
export let appInfo: ElectronAppInfo
export const TIMEOUT = parseInt(process.env.TEST_TIMEOUT || Constants.TIMEOUT)
export async function setupElectron() {
process.env.CI = 'e2e'
const latestBuild = findLatestBuild('dist')
expect(latestBuild).toBeTruthy()
// parse the packaged Electron app and find paths and other info
appInfo = parseElectronApp(latestBuild)
expect(appInfo).toBeTruthy()
electronApp = await electron.launch({
args: [appInfo.main], // main file from package.json
executablePath: appInfo.executable, // path to the Electron executable
// recordVideo: { dir: Constants.VIDEO_DIR }, // Specify the directory for video recordings
})
await stubDialog(electronApp, 'showMessageBox', { response: 1 })
page = await electronApp.firstWindow({
timeout: TIMEOUT,
})
}
export async function teardownElectron() {
await page.close()
await electronApp.close()
}
/**
* this fixture is needed to record and attach videos / screenshot on failed tests when
* tests are run in serial mode (i.e. browser is not closed between tests)
*/
export const test = base.extend<
{
commonActions: CommonActions
hubPage: HubPage
attachVideoPage: Page
attachScreenshotsToReport: void
},
{ createVideoContext: BrowserContext }
>({
commonActions: async ({ request }, use, testInfo) => {
await use(new CommonActions(page, testInfo))
},
hubPage: async ({ commonActions }, use) => {
await use(new HubPage(page, commonActions))
},
createVideoContext: [
async ({ playwright }, use) => {
const context = electronApp.context()
await use(context)
},
{ scope: 'worker' },
],
attachVideoPage: [
async ({ createVideoContext }, use, testInfo) => {
await use(page)
if (testInfo.status !== testInfo.expectedStatus) {
const path = await createVideoContext.pages()[0].video()?.path()
await createVideoContext.close()
await testInfo.attach('video', {
path: path,
})
}
},
{ scope: 'test', auto: true },
],
attachScreenshotsToReport: [
async ({ commonActions }, use, testInfo) => {
await use()
// After the test, we can check whether the test passed or failed.
if (testInfo.status !== testInfo.expectedStatus) {
await commonActions.takeScreenshot('')
}
},
{ auto: true },
],
})
test.setTimeout(TIMEOUT)
test.beforeAll(async () => {
await setupElectron()
await page.waitForSelector('img[alt="Jan - Logo"]', {
state: 'visible',
timeout: TIMEOUT,
})
})
test.afterAll(async () => {
// temporally disabling this due to the config for parallel testing WIP
// teardownElectron()
})

View File

@ -1,34 +1,19 @@
import {
page,
test,
setupElectron,
teardownElectron,
TIMEOUT,
} from '../pages/basePage'
import { test, appInfo } from '../config/fixtures'
import { expect } from '@playwright/test'
test.beforeAll(async () => {
const appInfo = await setupElectron()
expect(appInfo.asar).toBe(true)
expect(appInfo.executable).toBeTruthy()
expect(appInfo.main).toBeTruthy()
expect(appInfo.name).toBe('jan')
expect(appInfo.packageJson).toBeTruthy()
expect(appInfo.packageJson.name).toBe('jan')
expect(appInfo.platform).toBeTruthy()
expect(appInfo.platform).toBe(process.platform)
expect(appInfo.resourcesDir).toBeTruthy()
})
test.afterAll(async () => {
await teardownElectron()
})
test('explores hub', async () => {
await page.getByTestId('Hub').first().click({
timeout: TIMEOUT,
})
await page.getByTestId('hub-container-test-id').isVisible({
timeout: TIMEOUT,
expect(appInfo).toMatchObject({
asar: true,
executable: expect.anything(),
main: expect.anything(),
name: 'jan',
packageJson: expect.objectContaining({ name: 'jan' }),
platform: process.platform,
resourcesDir: expect.anything(),
})
})
test('explores hub', async ({ hubPage }) => {
await hubPage.navigateByMenu()
await hubPage.verifyContainerVisible()
})

View File

@ -1,19 +1,5 @@
import { expect } from '@playwright/test'
import {
page,
setupElectron,
TIMEOUT,
test,
teardownElectron,
} from '../pages/basePage'
test.beforeAll(async () => {
await setupElectron()
})
test.afterAll(async () => {
await teardownElectron()
})
import { page, test, TIMEOUT } from '../config/fixtures'
test('renders left navigation panel', async () => {
const systemMonitorBtn = await page

View File

@ -1,23 +1,11 @@
import { expect } from '@playwright/test'
import {
setupElectron,
teardownElectron,
test,
page,
TIMEOUT,
} from '../pages/basePage'
test.beforeAll(async () => {
await setupElectron()
})
test.afterAll(async () => {
await teardownElectron()
})
import { test, page, TIMEOUT } from '../config/fixtures'
test('shows settings', async () => {
await page.getByTestId('Settings').first().click({ timeout: TIMEOUT })
await page.getByTestId('Settings').first().click({
timeout: TIMEOUT,
})
const settingDescription = page.getByTestId('testid-setting-description')
await expect(settingDescription).toBeVisible({ timeout: TIMEOUT })
})

View File

@ -1,67 +1,49 @@
import {
expect,
test as base,
_electron as electron,
ElectronApplication,
Page,
} from '@playwright/test'
import {
findLatestBuild,
parseElectronApp,
stubDialog,
} from 'electron-playwright-helpers'
import { Page, expect } from '@playwright/test'
import { CommonActions } from './commonActions'
import { TIMEOUT } from '../config/fixtures'
export const TIMEOUT: number = parseInt(process.env.TEST_TIMEOUT || '300000')
export class BasePage {
menuId: string
export let electronApp: ElectronApplication
export let page: Page
constructor(
protected readonly page: Page,
readonly action: CommonActions,
protected containerId: string
) {}
export async function setupElectron() {
process.env.CI = 'e2e'
public getValue(key: string) {
return this.action.getValue(key)
}
const latestBuild = findLatestBuild('dist')
expect(latestBuild).toBeTruthy()
public setValue(key: string, value: string) {
this.action.setValue(key, value)
}
// parse the packaged Electron app and find paths and other info
const appInfo = parseElectronApp(latestBuild)
expect(appInfo).toBeTruthy()
async takeScreenshot(name: string = '') {
await this.action.takeScreenshot(name)
}
electronApp = await electron.launch({
args: [appInfo.main], // main file from package.json
executablePath: appInfo.executable, // path to the Electron executable
})
await stubDialog(electronApp, 'showMessageBox', { response: 1 })
async navigateByMenu() {
await this.page.getByTestId(this.menuId).first().click()
}
page = await electronApp.firstWindow({
timeout: TIMEOUT,
})
// Return appInfo for future use
return appInfo
async verifyContainerVisible() {
const container = this.page.getByTestId(this.containerId)
expect(container.isVisible()).toBeTruthy()
}
async waitUpdateLoader() {
await this.isElementVisible('img[alt="Jan - Logo"]')
}
//wait and find a specific element with it's selector and return Visible
async isElementVisible(selector: any) {
let isVisible = true
await this.page
.waitForSelector(selector, { state: 'visible', timeout: TIMEOUT })
.catch(() => {
isVisible = false
})
return isVisible
}
}
export async function teardownElectron() {
await page.close()
await electronApp.close()
}
export const test = base.extend<{
attachScreenshotsToReport: void
}>({
attachScreenshotsToReport: [
async ({ request }, use, testInfo) => {
await use()
// After the test, we can check whether the test passed or failed.
if (testInfo.status !== testInfo.expectedStatus) {
const screenshot = await page.screenshot()
await testInfo.attach('screenshot', {
body: screenshot,
contentType: 'image/png',
})
}
},
{ auto: true },
],
})
test.setTimeout(TIMEOUT)

View File

@ -0,0 +1,34 @@
import { Page, TestInfo } from '@playwright/test'
import { page } from '../config/fixtures'
export class CommonActions {
private testData = new Map<string, string>()
constructor(
public page: Page,
public testInfo: TestInfo
) {}
async takeScreenshot(name: string) {
const screenshot = await page.screenshot({
fullPage: true,
})
const attachmentName = `${this.testInfo.title}_${name || new Date().toISOString().slice(5, 19).replace(/[-:]/g, '').replace('T', '_')}`
await this.testInfo.attach(attachmentName.replace(/\s+/g, ''), {
body: screenshot,
contentType: 'image/png',
})
}
async hooks() {
console.log('hook from the scenario page')
}
setValue(key: string, value: string) {
this.testData.set(key, value)
}
getValue(key: string) {
return this.testData.get(key)
}
}

View File

@ -0,0 +1,15 @@
import { Page } from '@playwright/test'
import { BasePage } from './basePage'
import { CommonActions } from './commonActions'
export class HubPage extends BasePage {
readonly menuId: string = 'Hub'
static readonly containerId: string = 'hub-container-test-id'
constructor(
public page: Page,
readonly action: CommonActions
) {
super(page, action, HubPage.containerId)
}
}

18
electron/utils/dev.ts Normal file
View File

@ -0,0 +1,18 @@
import { app } from 'electron'
export const setupReactDevTool = async () => {
if (!app.isPackaged) {
// Which means you're running from source code
const { default: installExtension, REACT_DEVELOPER_TOOLS } = await import(
'electron-devtools-installer'
) // Don't use import on top level, since the installer package is dev-only
try {
const name = await installExtension(REACT_DEVELOPER_TOOLS)
console.log(`Added Extension: ${name}`)
} catch (err) {
console.log('An error occurred while installing devtools:')
console.error(err)
// Only log the error and don't throw it because it's not critical
}
}
}

View File

@ -1,8 +1,8 @@
export function dispose(requiredModules: Record<string, any>) {
for (const key in requiredModules) {
const module = requiredModules[key];
if (typeof module["dispose"] === "function") {
module["dispose"]();
const module = requiredModules[key]
if (typeof module['dispose'] === 'function') {
module['dispose']()
}
}
}

67
electron/utils/log.ts Normal file
View File

@ -0,0 +1,67 @@
import { getJanDataFolderPath } from '@janhq/core/node'
import * as fs from 'fs'
import * as path from 'path'
export function cleanLogs(
maxFileSizeBytes?: number | undefined,
daysToKeep?: number | undefined,
delayMs?: number | undefined
): void {
const size = maxFileSizeBytes ?? 1 * 1024 * 1024 // 1 MB
const days = daysToKeep ?? 7 // 7 days
const delays = delayMs ?? 10000 // 10 seconds
const logDirectory = path.join(getJanDataFolderPath(), 'logs')
// Perform log cleaning
const currentDate = new Date()
fs.readdir(logDirectory, (err, files) => {
if (err) {
console.error('Error reading log directory:', err)
return
}
files.forEach((file) => {
const filePath = path.join(logDirectory, file)
fs.stat(filePath, (err, stats) => {
if (err) {
console.error('Error getting file stats:', err)
return
}
// Check size
if (stats.size > size) {
fs.unlink(filePath, (err) => {
if (err) {
console.error('Error deleting log file:', err)
return
}
console.log(
`Deleted log file due to exceeding size limit: ${filePath}`
)
})
} else {
// Check age
const creationDate = new Date(stats.ctime)
const daysDifference = Math.floor(
(currentDate.getTime() - creationDate.getTime()) /
(1000 * 3600 * 24)
)
if (daysDifference > days) {
fs.unlink(filePath, (err) => {
if (err) {
console.error('Error deleting log file:', err)
return
}
console.log(`Deleted old log file: ${filePath}`)
})
}
}
})
})
})
// Schedule the next execution with doubled delays
setTimeout(() => {
cleanLogs(maxFileSizeBytes, daysToKeep, delays * 2)
}, delays)
}

View File

@ -1,8 +1,7 @@
// @ts-nocheck
import { app, Menu, dialog, shell } from 'electron'
import { app, Menu, shell } from 'electron'
const isMac = process.platform === 'darwin'
import { autoUpdater } from 'electron-updater'
import { compareSemanticVersions } from './versionDiff'
const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [
{

View File

@ -1,5 +1,3 @@
import { join } from 'path'
import { app } from 'electron'
import { mkdir } from 'fs-extra'
import { existsSync } from 'fs'
import { getJanDataFolderPath } from '@janhq/core/node'
@ -16,13 +14,3 @@ export async function createUserSpace(): Promise<void> {
}
}
}
export function getResourcePath() {
let appPath = join(app.getAppPath(), '..', 'app.asar.unpacked')
if (!app.isPackaged) {
// for development mode
appPath = join(__dirname, '..', '..')
}
return appPath
}

View File

@ -1,9 +1,9 @@
import { app } from 'electron'
export const setupCore = async () => {
// Setup core api for main process
global.core = {
// Define appPath function for app to retrieve app path globaly
appPath: () => app.getPath('userData')
}
}
// Setup core api for main process
global.core = {
// Define appPath function for app to retrieve app path globaly
appPath: () => app.getPath('userData'),
}
}

View File

@ -1,21 +0,0 @@
export const compareSemanticVersions = (a: string, b: string) => {
// 1. Split the strings into their parts.
const a1 = a.split('.');
const b1 = b.split('.');
// 2. Contingency in case there's a 4th or 5th version
const len = Math.min(a1.length, b1.length);
// 3. Look through each version number and compare.
for (let i = 0; i < len; i++) {
const a2 = +a1[ i ] || 0;
const b2 = +b1[ i ] || 0;
if (a2 !== b2) {
return a2 > b2 ? 1 : -1;
}
}
// 4. We hit this if the all checked versions so far are equal
//
return b1.length - a1.length;
};

View File

@ -1,13 +1,14 @@
{
"name": "@janhq/assistant-extension",
"version": "1.0.0",
"version": "1.0.1",
"description": "This extension enables assistants, including Jan, a default assistant that can call all downloaded models",
"main": "dist/index.js",
"node": "dist/node/index.js",
"author": "Jan <service@jan.ai>",
"license": "AGPL-3.0",
"scripts": {
"build": "tsc --module commonjs && rollup -c rollup.config.ts",
"clean:modules": "rimraf node_modules/pdf-parse/test && cd node_modules/pdf-parse/lib/pdf.js && rimraf v1.9.426 v1.10.88 v2.0.550",
"build": "yarn clean:modules && tsc --module commonjs && rollup -c rollup.config.ts",
"build:publish:linux": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../pre-install",
"build:publish:darwin": "rimraf *.tgz --glob && npm run build && ../../.github/scripts/auto-sign.sh && npm pack && cpx *.tgz ../../pre-install",
"build:publish:win32": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../pre-install",
@ -25,7 +26,7 @@
"rollup-plugin-define": "^1.0.1",
"rollup-plugin-sourcemaps": "^0.6.3",
"rollup-plugin-typescript2": "^0.36.0",
"typescript": "^5.3.3",
"typescript": "^5.2.2",
"run-script-os": "^1.1.6"
},
"dependencies": {
@ -44,9 +45,6 @@
],
"bundleDependencies": [
"@janhq/core",
"@langchain/community",
"hnswlib-node",
"langchain",
"pdf-parse"
"hnswlib-node"
]
}

View File

@ -1,22 +1,22 @@
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import sourceMaps from "rollup-plugin-sourcemaps";
import typescript from "rollup-plugin-typescript2";
import json from "@rollup/plugin-json";
import replace from "@rollup/plugin-replace";
import resolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import sourceMaps from 'rollup-plugin-sourcemaps'
import typescript from 'rollup-plugin-typescript2'
import json from '@rollup/plugin-json'
import replace from '@rollup/plugin-replace'
const packageJson = require("./package.json");
const packageJson = require('./package.json')
const pkg = require("./package.json");
const pkg = require('./package.json')
export default [
{
input: `src/index.ts`,
output: [{ file: pkg.main, format: "es", sourcemap: true }],
output: [{ file: pkg.main, format: 'es', sourcemap: true }],
// Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash')
external: [],
watch: {
include: "src/**",
include: 'src/**',
},
plugins: [
replace({
@ -35,7 +35,7 @@ export default [
// which external modules to include in the bundle
// https://github.com/rollup/rollup-plugin-node-resolve#usage
resolve({
extensions: [".js", ".ts", ".svelte"],
extensions: ['.js', '.ts', '.svelte'],
}),
// Resolve source maps to the original source
@ -44,18 +44,11 @@ export default [
},
{
input: `src/node/index.ts`,
output: [{ dir: "dist/node", format: "cjs", sourcemap: false }],
output: [{ dir: 'dist/node', format: 'cjs', sourcemap: false }],
// Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash')
external: [
"@janhq/core/node",
"@langchain/community",
"langchain",
"langsmith",
"path",
"hnswlib-node",
],
external: ['@janhq/core/node', 'path', 'hnswlib-node'],
watch: {
include: "src/node/**",
include: 'src/node/**',
},
// inlineDynamicImports: true,
plugins: [
@ -71,11 +64,11 @@ export default [
// which external modules to include in the bundle
// https://github.com/rollup/rollup-plugin-node-resolve#usage
resolve({
extensions: [".ts", ".js", ".json"],
extensions: ['.ts', '.js', '.json'],
}),
// Resolve source maps to the original source
// sourceMaps(),
],
},
];
]

View File

@ -1,3 +1,3 @@
declare const NODE: string;
declare const EXTENSION_NAME: string;
declare const VERSION: string;
declare const NODE: string
declare const EXTENSION_NAME: string
declare const VERSION: string

View File

@ -10,145 +10,168 @@ import {
executeOnMain,
AssistantExtension,
AssistantEvent,
} from "@janhq/core";
} from '@janhq/core'
export default class JanAssistantExtension extends AssistantExtension {
private static readonly _homeDir = "file://assistants";
private static readonly _homeDir = 'file://assistants'
private static readonly _threadDir = 'file://threads'
controller = new AbortController();
isCancelled = false;
retrievalThreadId: string | undefined = undefined;
controller = new AbortController()
isCancelled = false
retrievalThreadId: string | undefined = undefined
async onLoad() {
// making the assistant directory
const assistantDirExist = await fs.existsSync(
JanAssistantExtension._homeDir
);
)
if (
localStorage.getItem(`${EXTENSION_NAME}-version`) !== VERSION ||
!assistantDirExist
) {
if (!assistantDirExist)
await fs.mkdirSync(JanAssistantExtension._homeDir);
if (!assistantDirExist) await fs.mkdirSync(JanAssistantExtension._homeDir)
// Write assistant metadata
await this.createJanAssistant();
await this.createJanAssistant()
// Finished migration
localStorage.setItem(`${EXTENSION_NAME}-version`, VERSION);
localStorage.setItem(`${EXTENSION_NAME}-version`, VERSION)
// Update the assistant list
events.emit(AssistantEvent.OnAssistantsUpdate, {});
events.emit(AssistantEvent.OnAssistantsUpdate, {})
}
// Events subscription
events.on(MessageEvent.OnMessageSent, (data: MessageRequest) =>
JanAssistantExtension.handleMessageRequest(data, this)
);
)
events.on(InferenceEvent.OnInferenceStopped, () => {
JanAssistantExtension.handleInferenceStopped(this);
});
JanAssistantExtension.handleInferenceStopped(this)
})
}
private static async handleInferenceStopped(instance: JanAssistantExtension) {
instance.isCancelled = true;
instance.controller?.abort();
instance.isCancelled = true
instance.controller?.abort()
}
private static async handleMessageRequest(
data: MessageRequest,
instance: JanAssistantExtension
) {
instance.isCancelled = false;
instance.controller = new AbortController();
instance.isCancelled = false
instance.controller = new AbortController()
if (
data.model?.engine !== InferenceEngine.tool_retrieval_enabled ||
!data.messages ||
// TODO: Since the engine is defined, its unsafe to assume that assistant tools are defined
// That could lead to an issue where thread stuck at generating response
!data.thread?.assistants[0]?.tools
) {
return;
return
}
const latestMessage = data.messages[data.messages.length - 1];
const latestMessage = data.messages[data.messages.length - 1]
// Ingest the document if needed
// 1. Ingest the document if needed
if (
latestMessage &&
latestMessage.content &&
typeof latestMessage.content !== "string"
typeof latestMessage.content !== 'string' &&
latestMessage.content.length > 1
) {
const docFile = latestMessage.content[1]?.doc_url?.url;
const docFile = latestMessage.content[1]?.doc_url?.url
if (docFile) {
await executeOnMain(
NODE,
"toolRetrievalIngestNewDocument",
'toolRetrievalIngestNewDocument',
docFile,
data.model?.proxyEngine
);
)
}
} else if (
// Check whether we need to ingest document or not
// Otherwise wrong context will be sent
!(await fs.existsSync(
await joinPath([
JanAssistantExtension._threadDir,
data.threadId,
'memory',
])
))
) {
// No document ingested, reroute the result to inference engine
const output = {
...data,
model: {
...data.model,
engine: data.model.proxyEngine,
},
}
events.emit(MessageEvent.OnMessageSent, output)
return
}
// Load agent on thread changed
// 2. Load agent on thread changed
if (instance.retrievalThreadId !== data.threadId) {
await executeOnMain(NODE, "toolRetrievalLoadThreadMemory", data.threadId);
await executeOnMain(NODE, 'toolRetrievalLoadThreadMemory', data.threadId)
instance.retrievalThreadId = data.threadId;
instance.retrievalThreadId = data.threadId
// Update the text splitter
await executeOnMain(
NODE,
"toolRetrievalUpdateTextSplitter",
'toolRetrievalUpdateTextSplitter',
data.thread.assistants[0].tools[0]?.settings?.chunk_size ?? 4000,
data.thread.assistants[0].tools[0]?.settings?.chunk_overlap ?? 200
);
)
}
// 3. Using the retrieval template with the result and query
if (latestMessage.content) {
const prompt =
typeof latestMessage.content === "string"
typeof latestMessage.content === 'string'
? latestMessage.content
: latestMessage.content[0].text;
: latestMessage.content[0].text
// Retrieve the result
console.debug("toolRetrievalQuery", latestMessage.content);
const retrievalResult = await executeOnMain(
NODE,
"toolRetrievalQueryResult",
'toolRetrievalQueryResult',
prompt
);
)
console.debug('toolRetrievalQueryResult', retrievalResult)
// Update the message content
// Using the retrieval template with the result and query
if (data.thread?.assistants[0].tools)
// Update message content
if (data.thread?.assistants[0]?.tools && retrievalResult)
data.messages[data.messages.length - 1].content =
data.thread.assistants[0].tools[0].settings?.retrieval_template
?.replace("{CONTEXT}", retrievalResult)
.replace("{QUESTION}", prompt);
?.replace('{CONTEXT}', retrievalResult)
.replace('{QUESTION}', prompt)
}
// Filter out all the messages that are not text
data.messages = data.messages.map((message) => {
if (
message.content &&
typeof message.content !== "string" &&
typeof message.content !== 'string' &&
(message.content.length ?? 0) > 0
) {
return {
...message,
content: [message.content[0]],
};
}
}
return message;
});
return message
})
// Reroute the result to inference engine
// 4. Reroute the result to inference engine
const output = {
...data,
model: {
...data.model,
engine: data.model.proxyEngine,
},
};
events.emit(MessageEvent.OnMessageSent, output);
}
events.emit(MessageEvent.OnMessageSent, output)
}
/**
@ -160,107 +183,107 @@ export default class JanAssistantExtension extends AssistantExtension {
const assistantDir = await joinPath([
JanAssistantExtension._homeDir,
assistant.id,
]);
if (!(await fs.existsSync(assistantDir))) await fs.mkdirSync(assistantDir);
])
if (!(await fs.existsSync(assistantDir))) await fs.mkdirSync(assistantDir)
// store the assistant metadata json
const assistantMetadataPath = await joinPath([
assistantDir,
"assistant.json",
]);
'assistant.json',
])
try {
await fs.writeFileSync(
assistantMetadataPath,
JSON.stringify(assistant, null, 2)
);
)
} catch (err) {
console.error(err);
console.error(err)
}
}
async getAssistants(): Promise<Assistant[]> {
// get all the assistant directories
// get all the assistant metadata json
const results: Assistant[] = [];
const results: Assistant[] = []
const allFileName: string[] = await fs.readdirSync(
JanAssistantExtension._homeDir
);
)
for (const fileName of allFileName) {
const filePath = await joinPath([
JanAssistantExtension._homeDir,
fileName,
]);
])
if (filePath.includes(".DS_Store")) continue;
if (filePath.includes('.DS_Store')) continue
const jsonFiles: string[] = (await fs.readdirSync(filePath)).filter(
(file: string) => file === "assistant.json"
);
(file: string) => file === 'assistant.json'
)
if (jsonFiles.length !== 1) {
// has more than one assistant file -> ignore
continue;
continue
}
const content = await fs.readFileSync(
await joinPath([filePath, jsonFiles[0]]),
"utf-8"
);
'utf-8'
)
const assistant: Assistant =
typeof content === "object" ? content : JSON.parse(content);
typeof content === 'object' ? content : JSON.parse(content)
results.push(assistant);
results.push(assistant)
}
return results;
return results
}
async deleteAssistant(assistant: Assistant): Promise<void> {
if (assistant.id === "jan") {
return Promise.reject("Cannot delete Jan Assistant");
if (assistant.id === 'jan') {
return Promise.reject('Cannot delete Jan Assistant')
}
// remove the directory
const assistantDir = await joinPath([
JanAssistantExtension._homeDir,
assistant.id,
]);
await fs.rmdirSync(assistantDir);
return Promise.resolve();
])
await fs.rmdirSync(assistantDir)
return Promise.resolve()
}
private async createJanAssistant(): Promise<void> {
const janAssistant: Assistant = {
avatar: "",
avatar: '',
thread_location: undefined,
id: "jan",
object: "assistant",
id: 'jan',
object: 'assistant',
created_at: Date.now(),
name: "Jan",
description: "A default assistant that can use all downloaded models",
model: "*",
instructions: "",
name: 'Jan',
description: 'A default assistant that can use all downloaded models',
model: '*',
instructions: '',
tools: [
{
type: "retrieval",
type: 'retrieval',
enabled: false,
settings: {
top_k: 2,
chunk_size: 1024,
chunk_overlap: 64,
retrieval_template: `Use the following pieces of context to answer the question at the end. If you don't know the answer, just say that you don't know, don't try to make up an answer.
----------------
CONTEXT: {CONTEXT}
----------------
QUESTION: {QUESTION}
----------------
Helpful Answer:`,
----------------
CONTEXT: {CONTEXT}
----------------
QUESTION: {QUESTION}
----------------
Helpful Answer:`,
},
},
],
file_ids: [],
metadata: undefined,
};
}
await this.createAssistant(janAssistant);
await this.createAssistant(janAssistant)
}
}

View File

@ -1,13 +1,13 @@
import fs from "fs";
import path from "path";
import { getJanDataFolderPath } from "@janhq/core/node";
import fs from 'fs'
import path from 'path'
import { getJanDataFolderPath } from '@janhq/core/node'
// Sec: Do not send engine settings over requests
// Read it manually instead
export const readEmbeddingEngine = (engineName: string) => {
const engineSettings = fs.readFileSync(
path.join(getJanDataFolderPath(), "engines", `${engineName}.json`),
"utf-8",
);
return JSON.parse(engineSettings);
};
path.join(getJanDataFolderPath(), 'engines', `${engineName}.json`),
'utf-8'
)
return JSON.parse(engineSettings)
}

Some files were not shown because too many files have changed in this diff Show More