Merge branch 'main' into add/model-list

This commit is contained in:
Hoang Ha 2023-12-28 18:34:02 +07:00 committed by GitHub
commit a2a81e9639
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
83 changed files with 1532 additions and 1076 deletions

View File

@ -18,6 +18,7 @@ ifeq ($(OS),Windows_NT)
yarn config set network-timeout 300000 yarn config set network-timeout 300000
endif endif
yarn build:core yarn build:core
yarn build:server
yarn install yarn install
yarn build:extensions yarn build:extensions

View File

@ -22,6 +22,27 @@
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
}, },
"exports": {
".": "./dist/core.umd.js",
"./sdk": "./dist/core.umd.js",
"./node": "./dist/node/index.cjs.js"
},
"typesVersions": {
"*": {
".": [
"./dist/core.es5.js.map",
"./dist/types/index.d.ts"
],
"sdk": [
"./dist/core.es5.js.map",
"./dist/types/index.d.ts"
],
"node": [
"./dist/node/index.cjs.js.map",
"./dist/types/node/index.d.ts"
]
}
},
"scripts": { "scripts": {
"lint": "tslint --project tsconfig.json -t codeFrame 'src/**/*.ts' 'test/**/*.ts'", "lint": "tslint --project tsconfig.json -t codeFrame 'src/**/*.ts' 'test/**/*.ts'",
"prebuild": "rimraf dist", "prebuild": "rimraf dist",

View File

@ -8,14 +8,15 @@ const pkg = require('./package.json')
const libraryName = 'core' const libraryName = 'core'
export default { export default [
{
input: `src/index.ts`, input: `src/index.ts`,
output: [ output: [
{ file: pkg.main, name: libraryName, format: 'umd', sourcemap: true }, { file: pkg.main, name: libraryName, format: 'umd', sourcemap: true },
{ file: pkg.module, format: 'es', sourcemap: true }, { file: pkg.module, format: 'es', sourcemap: true },
], ],
// Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash') // Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash')
external: [], external: ['path'],
watch: { watch: {
include: 'src/**', include: 'src/**',
}, },
@ -34,4 +35,42 @@ export default {
// Resolve source maps to the original source // Resolve source maps to the original source
sourceMaps(), sourceMaps(),
], ],
} },
{
input: `src/node/index.ts`,
output: [{ file: 'dist/node/index.cjs.js', format: 'cjs', sourcemap: true }],
// Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash')
external: [
'fs/promises',
'path',
'pacote',
'@types/pacote',
'@npmcli/arborist',
'ulid',
'node-fetch',
'fs',
'request',
'crypto',
'url',
'http',
],
watch: {
include: 'src/node/**',
},
plugins: [
// Allow json resolution
json(),
// Compile TypeScript files
typescript({ useTsconfigDeclarationDir: true }),
// Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs)
commonjs(),
// Allow node_modules resolution, so you can use 'external' to control
// which external modules to include in the bundle
// https://github.com/rollup/rollup-plugin-node-resolve#usage
resolve(),
// Resolve source maps to the original source
sourceMaps(),
],
},
]

View File

@ -5,11 +5,11 @@
export enum AppRoute { export enum AppRoute {
appDataPath = 'appDataPath', appDataPath = 'appDataPath',
appVersion = 'appVersion', appVersion = 'appVersion',
getResourcePath = 'getResourcePath',
openExternalUrl = 'openExternalUrl', openExternalUrl = 'openExternalUrl',
openAppDirectory = 'openAppDirectory', openAppDirectory = 'openAppDirectory',
openFileExplore = 'openFileExplorer', openFileExplore = 'openFileExplorer',
relaunch = 'relaunch', relaunch = 'relaunch',
joinPath = 'joinPath'
} }
export enum AppEvent { export enum AppEvent {
@ -40,20 +40,20 @@ export enum ExtensionRoute {
uninstallExtension = 'uninstallExtension', uninstallExtension = 'uninstallExtension',
} }
export enum FileSystemRoute { export enum FileSystemRoute {
appendFile = 'appendFile', appendFileSync = 'appendFileSync',
copyFile = 'copyFile', copyFileSync = 'copyFileSync',
syncFile = 'syncFile', unlinkSync = 'unlinkSync',
deleteFile = 'deleteFile', existsSync = 'existsSync',
exists = 'exists', readdirSync = 'readdirSync',
getResourcePath = 'getResourcePath', mkdirSync = 'mkdirSync',
readFileSync = 'readFileSync',
rmdirSync = 'rmdirSync',
writeFileSync = 'writeFileSync',
}
export enum FileManagerRoute {
synceFile = 'syncFile',
getUserSpace = 'getUserSpace', getUserSpace = 'getUserSpace',
isDirectory = 'isDirectory', getResourcePath = 'getResourcePath',
listFiles = 'listFiles',
mkdir = 'mkdir',
readFile = 'readFile',
readLineByLine = 'readLineByLine',
rmdir = 'rmdir',
writeFile = 'writeFile',
} }
export type ApiFunction = (...args: any[]) => any export type ApiFunction = (...args: any[]) => any
@ -82,17 +82,23 @@ export type FileSystemRouteFunctions = {
[K in FileSystemRoute]: ApiFunction [K in FileSystemRoute]: ApiFunction
} }
export type FileManagerRouteFunctions = {
[K in FileManagerRoute]: ApiFunction
}
export type APIFunctions = AppRouteFunctions & export type APIFunctions = AppRouteFunctions &
AppEventFunctions & AppEventFunctions &
DownloadRouteFunctions & DownloadRouteFunctions &
DownloadEventFunctions & DownloadEventFunctions &
ExtensionRouteFunctions & ExtensionRouteFunctions &
FileSystemRouteFunctions FileSystemRouteFunctions &
FileManagerRoute
export const APIRoutes = [ export const APIRoutes = [
...Object.values(AppRoute), ...Object.values(AppRoute),
...Object.values(DownloadRoute), ...Object.values(DownloadRoute),
...Object.values(ExtensionRoute), ...Object.values(ExtensionRoute),
...Object.values(FileSystemRoute), ...Object.values(FileSystemRoute),
...Object.values(FileManagerRoute),
] ]
export const APIEvents = [...Object.values(AppEvent), ...Object.values(DownloadEvent)] export const APIEvents = [...Object.values(AppEvent), ...Object.values(DownloadEvent)]

View File

@ -44,6 +44,13 @@ const getUserSpace = (): Promise<string> => global.core.api?.getUserSpace()
const openFileExplorer: (path: string) => Promise<any> = (path) => const openFileExplorer: (path: string) => Promise<any> = (path) =>
global.core.api?.openFileExplorer(path) global.core.api?.openFileExplorer(path)
/**
* Joins multiple paths together.
* @param paths - The paths to join.
* @returns {Promise<string>} A promise that resolves with the joined path.
*/
const joinPath: (paths: string[]) => Promise<string> = (paths) => global.core.api?.joinPath(paths)
const getResourcePath: () => Promise<string> = () => global.core.api?.getResourcePath() const getResourcePath: () => Promise<string> = () => global.core.api?.getResourcePath()
/** /**
@ -66,4 +73,5 @@ export {
getUserSpace, getUserSpace,
openFileExplorer, openFileExplorer,
getResourcePath, getResourcePath,
joinPath,
} }

View File

@ -1,89 +1,74 @@
/** /**
* Writes data to a file at the specified path. * Writes data to a file at the specified path.
* @param {string} path - The path to the file.
* @param {string} data - The data to write to the file.
* @returns {Promise<any>} A Promise that resolves when the file is written successfully. * @returns {Promise<any>} A Promise that resolves when the file is written successfully.
*/ */
const writeFile: (path: string, data: string) => Promise<any> = (path, data) => const writeFileSync = (...args: any[]) => global.core.api?.writeFileSync(...args)
global.core.api?.writeFile(path, data)
/**
* Checks whether the path is a directory.
* @param path - The path to check.
* @returns {boolean} A boolean indicating whether the path is a directory.
*/
const isDirectory = (path: string): Promise<boolean> => global.core.api?.isDirectory(path)
/** /**
* Reads the contents of a file at the specified path. * Reads the contents of a file at the specified path.
* @param {string} path - The path of the file to read.
* @returns {Promise<any>} A Promise that resolves with the contents of the file. * @returns {Promise<any>} A Promise that resolves with the contents of the file.
*/ */
const readFile: (path: string) => Promise<any> = (path) => global.core.api?.readFile(path) const readFileSync = (...args: any[]) => global.core.api?.readFileSync(...args)
/** /**
* Check whether the file exists * Check whether the file exists
* @param {string} path * @param {string} path
* @returns {boolean} A boolean indicating whether the path is a file. * @returns {boolean} A boolean indicating whether the path is a file.
*/ */
const exists = (path: string): Promise<boolean> => global.core.api?.exists(path) const existsSync = (...args: any[]) => global.core.api?.existsSync(...args)
/** /**
* List the directory files * List the directory files
* @param {string} path - The path of the directory to list files.
* @returns {Promise<any>} A Promise that resolves with the contents of the directory. * @returns {Promise<any>} A Promise that resolves with the contents of the directory.
*/ */
const listFiles: (path: string) => Promise<any> = (path) => global.core.api?.listFiles(path) const readdirSync = (...args: any[]) => global.core.api?.readdirSync(...args)
/** /**
* Creates a directory at the specified path. * Creates a directory at the specified path.
* @param {string} path - The path of the directory to create.
* @returns {Promise<any>} A Promise that resolves when the directory is created successfully. * @returns {Promise<any>} A Promise that resolves when the directory is created successfully.
*/ */
const mkdir: (path: string) => Promise<any> = (path) => global.core.api?.mkdir(path) const mkdirSync = (...args: any[]) => global.core.api?.mkdirSync(...args)
/** /**
* Removes a directory at the specified path. * Removes a directory at the specified path.
* @param {string} path - The path of the directory to remove.
* @returns {Promise<any>} A Promise that resolves when the directory is removed successfully. * @returns {Promise<any>} A Promise that resolves when the directory is removed successfully.
*/ */
const rmdir: (path: string) => Promise<any> = (path) => global.core.api?.rmdir(path) const rmdirSync = (...args: any[]) =>
global.core.api?.rmdirSync(...args, { recursive: true, force: true })
/** /**
* Deletes a file from the local file system. * Deletes a file from the local file system.
* @param {string} path - The path of the file to delete. * @param {string} path - The path of the file to delete.
* @returns {Promise<any>} A Promise that resolves when the file is deleted. * @returns {Promise<any>} A Promise that resolves when the file is deleted.
*/ */
const deleteFile: (path: string) => Promise<any> = (path) => global.core.api?.deleteFile(path) const unlinkSync = (...args: any[]) => global.core.api?.unlinkSync(...args)
/** /**
* Appends data to a file at the specified path. * Appends data to a file at the specified path.
* @param path path to the file
* @param data data to append
*/ */
const appendFile: (path: string, data: string) => Promise<any> = (path, data) => const appendFileSync = (...args: any[]) => global.core.api?.appendFileSync(...args)
global.core.api?.appendFile(path, data)
const copyFile: (src: string, dest: string) => Promise<any> = (src, dest) =>
global.core.api?.copyFile(src, dest)
/**
* Synchronizes a file from a source path to a destination path.
* @param {string} src - The source path of the file to be synchronized.
* @param {string} dest - The destination path where the file will be synchronized to.
* @returns {Promise<any>} - A promise that resolves when the file has been successfully synchronized.
*/
const syncFile: (src: string, dest: string) => Promise<any> = (src, dest) => const syncFile: (src: string, dest: string) => Promise<any> = (src, dest) =>
global.core.api?.syncFile(src, dest) global.core.api?.syncFile(src, dest)
/**
* Reads a file line by line.
* @param {string} path - The path of the file to read.
* @returns {Promise<any>} A promise that resolves to the lines of the file.
*/
const readLineByLine: (path: string) => Promise<any> = (path) =>
global.core.api?.readLineByLine(path)
/**
* Copy file sync.
*/
const copyFileSync = (...args: any[]) => global.core.api?.copyFileSync(...args)
// TODO: Export `dummy` fs functions automatically
// Currently adding these manually
export const fs = { export const fs = {
isDirectory, writeFileSync,
writeFile, readFileSync,
readFile, existsSync,
exists, readdirSync,
listFiles, mkdirSync,
mkdir, rmdirSync,
rmdir, unlinkSync,
deleteFile, appendFileSync,
appendFile, copyFileSync,
readLineByLine,
copyFile,
syncFile, syncFile,
} }

View File

@ -0,0 +1,8 @@
export interface HttpServer {
post: (route: string, handler: (req: any, res: any) => Promise<any>) => void
get: (route: string, handler: (req: any, res: any) => Promise<any>) => void
patch: (route: string, handler: (req: any, res: any) => Promise<any>) => void
put: (route: string, handler: (req: any, res: any) => Promise<any>) => void
delete: (route: string, handler: (req: any, res: any) => Promise<any>) => void
register: (router: any, opts?: any) => void
}

View File

@ -0,0 +1,335 @@
import fs from 'fs'
import { JanApiRouteConfiguration, RouteConfiguration } from './configuration'
import { join } from 'path'
import { Model, ThreadMessage } from './../../../index'
import fetch from 'node-fetch'
import { ulid } from 'ulid'
import request from 'request'
const progress = require('request-progress')
const os = require('os')
const path = join(os.homedir(), 'jan')
export const getBuilder = async (configuration: RouteConfiguration) => {
const directoryPath = join(path, configuration.dirName)
try {
if (!(await fs.existsSync(directoryPath))) {
console.debug('model folder not found')
return []
}
const files: string[] = await fs.readdirSync(directoryPath)
const allDirectories: string[] = []
for (const file of files) {
if (file === '.DS_Store') continue
allDirectories.push(file)
}
const readJsonPromises = allDirectories.map(async (dirName) => {
const jsonPath = join(directoryPath, dirName, configuration.metadataFileName)
return await readModelMetadata(jsonPath)
})
const results = await Promise.all(readJsonPromises)
const modelData = results
.map((result: any) => {
try {
return JSON.parse(result)
} catch (err) {
console.error(err)
}
})
.filter((e: any) => !!e)
return modelData
} catch (err) {
console.error(err)
return []
}
}
const readModelMetadata = async (path: string) => {
return fs.readFileSync(path, 'utf-8')
}
export const retrieveBuilder = async (configuration: RouteConfiguration, id: string) => {
const data = await getBuilder(configuration)
const filteredData = data.filter((d: any) => d.id === id)[0]
if (!filteredData) {
return {}
}
return filteredData
}
export const deleteBuilder = async (configuration: RouteConfiguration, id: string) => {
if (configuration.dirName === 'assistants' && id === 'jan') {
return {
message: 'Cannot delete Jan assistant',
}
}
const directoryPath = join(path, configuration.dirName)
try {
const data = await retrieveBuilder(configuration, id)
if (!data || !data.keys) {
return {
message: 'Not found',
}
}
const myPath = join(directoryPath, id)
fs.rmdirSync(myPath, { recursive: true })
return {
id: id,
object: configuration.delete.object,
deleted: true,
}
} catch (ex) {
console.error(ex)
}
}
export const getMessages = async (threadId: string) => {
const threadDirPath = join(path, 'threads', threadId)
const messageFile = 'messages.jsonl'
try {
const files: string[] = await fs.readdirSync(threadDirPath)
if (!files.includes(messageFile)) {
throw Error(`${threadDirPath} not contains message file`)
}
const messageFilePath = join(threadDirPath, messageFile)
const lines = fs
.readFileSync(messageFilePath, 'utf-8')
.toString()
.split('\n')
.filter((line: any) => line !== '')
const messages: ThreadMessage[] = []
lines.forEach((line: string) => {
messages.push(JSON.parse(line) as ThreadMessage)
})
return messages
} catch (err) {
console.error(err)
return []
}
}
export const retrieveMesasge = async (threadId: string, messageId: string) => {
const messages = await getMessages(threadId)
const filteredMessages = messages.filter((m) => m.id === messageId)
if (!filteredMessages || filteredMessages.length === 0) {
return {
message: 'Not found',
}
}
return filteredMessages[0]
}
export const createThread = async (thread: any) => {
const threadMetadataFileName = 'thread.json'
// TODO: add validation
if (!thread.assistants || thread.assistants.length === 0) {
return {
message: 'Thread must have at least one assistant',
}
}
const threadId = generateThreadId(thread.assistants[0].assistant_id)
try {
const updatedThread = {
...thread,
id: threadId,
created: Date.now(),
updated: Date.now(),
}
const threadDirPath = join(path, 'threads', updatedThread.id)
const threadJsonPath = join(threadDirPath, threadMetadataFileName)
if (!fs.existsSync(threadDirPath)) {
fs.mkdirSync(threadDirPath)
}
await fs.writeFileSync(threadJsonPath, JSON.stringify(updatedThread, null, 2))
return updatedThread
} catch (err) {
return {
error: err,
}
}
}
export const updateThread = async (threadId: string, thread: any) => {
const threadMetadataFileName = 'thread.json'
const currentThreadData = await retrieveBuilder(JanApiRouteConfiguration.threads, threadId)
if (!currentThreadData) {
return {
message: 'Thread not found',
}
}
// we don't want to update the id and object
delete thread.id
delete thread.object
const updatedThread = {
...currentThreadData,
...thread,
updated: Date.now(),
}
try {
const threadDirPath = join(path, 'threads', updatedThread.id)
const threadJsonPath = join(threadDirPath, threadMetadataFileName)
await fs.writeFileSync(threadJsonPath, JSON.stringify(updatedThread, null, 2))
return updatedThread
} catch (err) {
return {
message: err,
}
}
}
const generateThreadId = (assistantId: string) => {
return `${assistantId}_${(Date.now() / 1000).toFixed(0)}`
}
export const createMessage = async (threadId: string, message: any) => {
const threadMessagesFileName = 'messages.jsonl'
// TODO: add validation
try {
const msgId = ulid()
const createdAt = Date.now()
const threadMessage: ThreadMessage = {
...message,
id: msgId,
thread_id: threadId,
created: createdAt,
updated: createdAt,
object: 'thread.message',
}
const threadDirPath = join(path, 'threads', threadId)
const threadMessagePath = join(threadDirPath, threadMessagesFileName)
if (!fs.existsSync(threadDirPath)) {
fs.mkdirSync(threadDirPath)
}
fs.appendFileSync(threadMessagePath, JSON.stringify(threadMessage) + '\n')
return threadMessage
} catch (err) {
return {
message: err,
}
}
}
export const downloadModel = async (modelId: string) => {
const model = await retrieveBuilder(JanApiRouteConfiguration.models, modelId)
if (!model || model.object !== 'model') {
return {
message: 'Model not found',
}
}
const directoryPath = join(path, 'models', modelId)
if (!fs.existsSync(directoryPath)) {
fs.mkdirSync(directoryPath)
}
// path to model binary
const modelBinaryPath = join(directoryPath, modelId)
const rq = request(model.source_url)
progress(rq, {})
.on('progress', function (state: any) {
console.log('progress', JSON.stringify(state, null, 2))
})
.on('error', function (err: Error) {
console.error('error', err)
})
.on('end', function () {
console.log('end')
})
.pipe(fs.createWriteStream(modelBinaryPath))
return {
message: `Starting download ${modelId}`,
}
}
export const chatCompletions = async (request: any, reply: any) => {
const modelList = await getBuilder(JanApiRouteConfiguration.models)
const modelId = request.body.model
const matchedModels = modelList.filter((model: Model) => model.id === modelId)
if (matchedModels.length === 0) {
const error = {
error: {
message: `The model ${request.body.model} does not exist`,
type: 'invalid_request_error',
param: null,
code: 'model_not_found',
},
}
reply.code(404).send(error)
return
}
const requestedModel = matchedModels[0]
const engineConfiguration = await getEngineConfiguration(requestedModel.engine)
let apiKey: string | undefined = undefined
let apiUrl: string = 'http://127.0.0.1:3928/inferences/llamacpp/chat_completion' // default nitro url
if (engineConfiguration) {
apiKey = engineConfiguration.api_key
apiUrl = engineConfiguration.full_url
}
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
const headers: Record<string, any> = {
'Content-Type': 'application/json',
}
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`
headers['api-key'] = apiKey
}
console.log(apiUrl)
console.log(JSON.stringify(headers))
const response = await fetch(apiUrl, {
method: 'POST',
headers: headers,
body: JSON.stringify(request.body),
})
if (response.status !== 200) {
console.error(response)
return
} else {
response.body.pipe(reply.raw)
}
}
const getEngineConfiguration = async (engineId: string) => {
if (engineId !== 'openai') {
return undefined
}
const directoryPath = join(path, 'engines')
const filePath = join(directoryPath, `${engineId}.json`)
const data = await fs.readFileSync(filePath, 'utf-8')
return JSON.parse(data)
}

View File

@ -0,0 +1,31 @@
export const JanApiRouteConfiguration: Record<string, RouteConfiguration> = {
models: {
dirName: 'models',
metadataFileName: 'model.json',
delete: {
object: 'model',
},
},
assistants: {
dirName: 'assistants',
metadataFileName: 'assistant.json',
delete: {
object: 'assistant',
},
},
threads: {
dirName: 'threads',
metadataFileName: 'thread.json',
delete: {
object: 'thread',
},
},
}
export type RouteConfiguration = {
dirName: string
metadataFileName: string
delete: {
object: string
}
}

View File

@ -0,0 +1,2 @@
export * from './HttpServer'
export * from './routes'

View File

@ -0,0 +1,42 @@
import { AppRoute } from '../../../api'
import { HttpServer } from '../HttpServer'
import { join } from 'path'
import {
chatCompletions,
deleteBuilder,
downloadModel,
getBuilder,
retrieveBuilder,
} from '../common/builder'
import { JanApiRouteConfiguration } from '../common/configuration'
export const commonRouter = async (app: HttpServer) => {
// Common Routes
Object.keys(JanApiRouteConfiguration).forEach((key) => {
app.get(`/${key}`, async (_request) => getBuilder(JanApiRouteConfiguration[key]))
app.get(`/${key}/:id`, async (request: any) =>
retrieveBuilder(JanApiRouteConfiguration[key], request.params.id),
)
app.delete(`/${key}/:id`, async (request: any) =>
deleteBuilder(JanApiRouteConfiguration[key], request.params.id),
)
})
// Download Model Routes
app.get(`/models/download/:modelId`, async (request: any) =>
downloadModel(request.params.modelId),
)
// Chat Completion Routes
app.post(`/chat/completions`, async (request: any, reply: any) => chatCompletions(request, reply))
// App Routes
app.post(`/app/${AppRoute.joinPath}`, async (request: any, reply: any) => {
const args = JSON.parse(request.body) as any[]
console.debug('joinPath: ', ...args[0])
reply.send(JSON.stringify(join(...args[0])))
})
}

View File

@ -0,0 +1,54 @@
import { DownloadRoute } from '../../../api'
import { join } from 'path'
import { userSpacePath, DownloadManager, HttpServer } from '../../index'
import { createWriteStream } from 'fs'
const request = require('request')
const progress = require('request-progress')
export const downloadRouter = async (app: HttpServer) => {
app.post(`/${DownloadRoute.downloadFile}`, async (req, res) => {
const body = JSON.parse(req.body as any)
const normalizedArgs = body.map((arg: any) => {
if (typeof arg === 'string' && arg.includes('file:/')) {
return join(userSpacePath, arg.replace('file:/', ''))
}
return arg
})
const localPath = normalizedArgs[1]
const fileName = localPath.split('/').pop() ?? ''
const rq = request(normalizedArgs[0])
progress(rq, {})
.on('progress', function (state: any) {
console.log('download onProgress', state)
})
.on('error', function (err: Error) {
console.log('download onError', err)
})
.on('end', function () {
console.log('download onEnd')
})
.pipe(createWriteStream(normalizedArgs[1]))
DownloadManager.instance.setRequest(fileName, rq)
})
app.post(`/${DownloadRoute.abortDownload}`, async (req, res) => {
const body = JSON.parse(req.body as any)
const normalizedArgs = body.map((arg: any) => {
if (typeof arg === 'string' && arg.includes('file:/')) {
return join(userSpacePath, arg.replace('file:/', ''))
}
return arg
})
const localPath = normalizedArgs[0]
const fileName = localPath.split('/').pop() ?? ''
console.debug('fileName', fileName)
const rq = DownloadManager.instance.networkRequests[fileName]
DownloadManager.instance.networkRequests[fileName] = undefined
rq?.abort()
})
}

View File

@ -0,0 +1,51 @@
import { join, extname } from 'path'
import { ExtensionRoute } from '../../../api'
import {
userSpacePath,
ModuleManager,
getActiveExtensions,
installExtensions,
HttpServer,
} from '../../index'
import { readdirSync } from 'fs'
export const extensionRouter = async (app: HttpServer) => {
// TODO: Share code between node projects
app.post(`/${ExtensionRoute.getActiveExtensions}`, async (req, res) => {
const activeExtensions = await getActiveExtensions()
res.status(200).send(activeExtensions)
})
app.post(`/${ExtensionRoute.baseExtensions}`, async (req, res) => {
const baseExtensionPath = join(__dirname, '..', '..', '..', 'pre-install')
const extensions = readdirSync(baseExtensionPath)
.filter((file) => extname(file) === '.tgz')
.map((file) => join(baseExtensionPath, file))
res.status(200).send(extensions)
})
app.post(`/${ExtensionRoute.installExtension}`, async (req, res) => {
const extensions = req.body as any
const installed = await installExtensions(JSON.parse(extensions)[0])
return JSON.parse(JSON.stringify(installed))
})
app.post(`/${ExtensionRoute.invokeExtensionFunc}`, async (req, res) => {
const args = JSON.parse(req.body as any)
console.debug(args)
const module = await import(join(userSpacePath, 'extensions', args[0]))
ModuleManager.instance.setModule(args[0], module)
const method = args[1]
if (typeof module[method] === 'function') {
// remove first item from args
const newArgs = args.slice(2)
console.log(newArgs)
return module[method](...args.slice(2))
} else {
console.debug(module[method])
console.error(`Function "${method}" does not exist in the module.`)
}
})
}

View File

@ -0,0 +1,27 @@
import { FileSystemRoute } from '../../../api'
import { join } from 'path'
import { HttpServer, userSpacePath } from '../../index'
export const fsRouter = async (app: HttpServer) => {
const moduleName = 'fs'
// Generate handlers for each fs route
Object.values(FileSystemRoute).forEach((route) => {
app.post(`/${route}`, async (req, res) => {
const body = JSON.parse(req.body as any)
try {
const result = await import(moduleName).then((mdl) => {
return mdl[route](
...body.map((arg: any) =>
typeof arg === 'string' && arg.includes('file:/')
? join(userSpacePath, arg.replace('file:/', ''))
: arg,
),
)
})
res.status(200).send(result)
} catch (ex) {
console.log(ex)
}
})
})
}

View File

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

View File

@ -0,0 +1,30 @@
import { HttpServer } from '../HttpServer'
import {
createMessage,
createThread,
getMessages,
retrieveMesasge,
updateThread,
} from '../common/builder'
export const threadRouter = async (app: HttpServer) => {
// create thread
app.post(`/`, async (req, res) => createThread(req.body))
app.get(`/:threadId/messages`, async (req, res) => getMessages(req.params.threadId))
// retrieve message
app.get(`/:threadId/messages/:messageId`, async (req, res) =>
retrieveMesasge(req.params.threadId, req.params.messageId),
)
// create message
app.post(`/:threadId/messages`, async (req, res) =>
createMessage(req.params.threadId as any, req.body as any),
)
// modify thread
app.patch(`/:threadId`, async (request: any) =>
updateThread(request.params.threadId, request.body),
)
}

View File

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

View File

@ -1,14 +1,14 @@
import { rmdir } from 'fs/promises' import { rmdirSync } from 'fs'
import { resolve, join } from 'path' import { resolve, join } from 'path'
import { manifest, extract } from 'pacote' import { manifest, extract } from 'pacote'
import * as Arborist from '@npmcli/arborist' import * as Arborist from '@npmcli/arborist'
import { ExtensionManager } from './../managers/extension' import { ExtensionManager } from './manager'
/** /**
* An NPM package that can be used as an extension. * An NPM package that can be used as an extension.
* Used to hold all the information and functions necessary to handle the extension lifecycle. * Used to hold all the information and functions necessary to handle the extension lifecycle.
*/ */
class Extension { export default class Extension {
/** /**
* @property {string} origin Original specification provided to fetch the package. * @property {string} origin Original specification provided to fetch the package.
* @property {Object} installOptions Options provided to pacote when fetching the manifest. * @property {Object} installOptions Options provided to pacote when fetching the manifest.
@ -56,10 +56,7 @@ class Extension {
* @type {string} * @type {string}
*/ */
get specifier() { get specifier() {
return ( return this.origin + (this.installOptions.version ? '@' + this.installOptions.version : '')
this.origin +
(this.installOptions.version ? '@' + this.installOptions.version : '')
)
} }
/** /**
@ -85,9 +82,7 @@ class Extension {
this.main = mnf.main this.main = mnf.main
this.description = mnf.description this.description = mnf.description
} catch (error) { } catch (error) {
throw new Error( throw new Error(`Package ${this.origin} does not contain a valid manifest: ${error}`)
`Package ${this.origin} does not contain a valid manifest: ${error}`
)
} }
return true return true
@ -107,7 +102,7 @@ class Extension {
await extract( await extract(
this.specifier, this.specifier,
join(ExtensionManager.instance.extensionsPath ?? '', this.name ?? ''), join(ExtensionManager.instance.extensionsPath ?? '', this.name ?? ''),
this.installOptions this.installOptions,
) )
// Set the url using the custom extensions protocol // Set the url using the custom extensions protocol
@ -180,11 +175,8 @@ class Extension {
* @returns {Promise} * @returns {Promise}
*/ */
async uninstall() { async uninstall() {
const extPath = resolve( const extPath = resolve(ExtensionManager.instance.extensionsPath ?? '', this.name ?? '')
ExtensionManager.instance.extensionsPath ?? '', await rmdirSync(extPath, { recursive: true })
this.name ?? ''
)
await rmdir(extPath, { recursive: true })
this.emitUpdate() this.emitUpdate()
} }
@ -200,5 +192,3 @@ class Extension {
return this return this
} }
} }
export default Extension

View File

@ -1,5 +1,5 @@
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
import { protocol } from 'electron'
import { normalize } from 'path' import { normalize } from 'path'
import Extension from './extension' import Extension from './extension'
@ -12,18 +12,8 @@ import {
getActiveExtensions, getActiveExtensions,
addExtension, addExtension,
} from './store' } from './store'
import { ExtensionManager } from './../managers/extension' import { ExtensionManager } from './manager'
/**
* Sets up the required communication between the main and renderer processes.
* Additionally sets the extensions up using {@link useExtensions} if a extensionsPath is provided.
* @param {Object} options configuration for setting up the renderer facade.
* @param {confirmInstall} [options.confirmInstall] Function to validate that a extension should be installed.
* @param {Boolean} [options.useFacade=true] Whether to make a facade to the extensions available in the renderer.
* @param {string} [options.extensionsPath] Optional path to the extensions folder.
* @returns {extensionManager|Object} A set of functions used to manage the extension lifecycle if useExtensions is provided.
* @function
*/
export function init(options: any) { export function init(options: any) {
// Create extensions protocol to serve extensions to renderer // Create extensions protocol to serve extensions to renderer
registerExtensionProtocol() registerExtensionProtocol()
@ -41,14 +31,25 @@ export function init(options: any) {
* @private * @private
* @returns {boolean} Whether the protocol registration was successful * @returns {boolean} Whether the protocol registration was successful
*/ */
function registerExtensionProtocol() { async function registerExtensionProtocol() {
return protocol.registerFileProtocol('extension', (request, callback) => { let electron: any = undefined
try {
const moduleName = "electron"
electron = await import(moduleName)
} catch (err) {
console.error('Electron is not available')
}
if (electron) {
return electron.protocol.registerFileProtocol('extension', (request: any, callback: any) => {
const entry = request.url.substr('extension://'.length - 1) const entry = request.url.substr('extension://'.length - 1)
const url = normalize(ExtensionManager.instance.extensionsPath + entry) const url = normalize(ExtensionManager.instance.extensionsPath + entry)
callback({ path: url }) callback({ path: url })
}) })
} }
}
/** /**
* Set extensions up to run from the extensionPath folder if it is provided and * Set extensions up to run from the extensionPath folder if it is provided and
@ -57,8 +58,7 @@ function registerExtensionProtocol() {
* @returns {extensionManager} A set of functions used to manage the extension lifecycle. * @returns {extensionManager} A set of functions used to manage the extension lifecycle.
*/ */
export function useExtensions(extensionsPath: string) { export function useExtensions(extensionsPath: string) {
if (!extensionsPath) if (!extensionsPath) throw Error('A path to the extensions folder is required to use extensions')
throw Error('A path to the extensions folder is required to use extensions')
// Store the path to the extensions folder // Store the path to the extensions folder
ExtensionManager.instance.setExtensionsPath(extensionsPath) ExtensionManager.instance.setExtensionsPath(extensionsPath)
@ -69,7 +69,7 @@ export function useExtensions(extensionsPath: string) {
// Read extension list from extensions folder // Read extension list from extensions folder
const extensions = JSON.parse( const extensions = JSON.parse(
readFileSync(ExtensionManager.instance.getExtensionsFile(), 'utf-8') readFileSync(ExtensionManager.instance.getExtensionsFile(), 'utf-8'),
) )
try { try {
// Create and store a Extension instance for each extension in list // Create and store a Extension instance for each extension in list
@ -82,7 +82,7 @@ export function useExtensions(extensionsPath: string) {
throw new Error( throw new Error(
'Could not successfully rebuild list of installed extensions.\n' + 'Could not successfully rebuild list of installed extensions.\n' +
error + error +
'\nPlease check the extensions.json file in the extensions folder.' '\nPlease check the extensions.json file in the extensions folder.',
) )
} }
@ -111,7 +111,6 @@ function loadExtension(ext: any) {
}) })
} }
} }
addExtension(extension, false) addExtension(extension, false)
extension.subscribe('pe-persist', persistExtensions) extension.subscribe('pe-persist', persistExtensions)
} }
@ -123,7 +122,7 @@ function loadExtension(ext: any) {
export function getStore() { export function getStore() {
if (!ExtensionManager.instance.extensionsPath) { if (!ExtensionManager.instance.extensionsPath) {
throw new Error( throw new Error(
'The extension path has not yet been set up. Please run useExtensions before accessing the store' 'The extension path has not yet been set up. Please run useExtensions before accessing the store',
) )
} }

View File

@ -0,0 +1,61 @@
import { join, resolve } from "path";
import { existsSync, mkdirSync, writeFileSync } from "fs";
import { init } from "./index";
import { homedir } from "os"
/**
* Manages extension installation and migration.
*/
export const userSpacePath = join(homedir(), "jan");
export class ExtensionManager {
public static instance: ExtensionManager = new ExtensionManager();
extensionsPath: string | undefined = join(userSpacePath, "extensions");
constructor() {
if (ExtensionManager.instance) {
return ExtensionManager.instance;
}
}
/**
* Sets up the extensions by initializing the `extensions` module with the `confirmInstall` and `extensionsPath` options.
* The `confirmInstall` function always returns `true` to allow extension installation.
* The `extensionsPath` option specifies the path to install extensions to.
*/
setupExtensions() {
init({
// Function to check from the main process that user wants to install a extension
confirmInstall: async (_extensions: string[]) => {
return true;
},
// Path to install extension to
extensionsPath: join(userSpacePath, "extensions"),
});
}
setExtensionsPath(extPath: string) {
// Create folder if it does not exist
let extDir;
try {
extDir = resolve(extPath);
if (extDir.length < 2) throw new Error();
if (!existsSync(extDir)) mkdirSync(extDir);
const extensionsJson = join(extDir, "extensions.json");
if (!existsSync(extensionsJson))
writeFileSync(extensionsJson, "{}", "utf8");
this.extensionsPath = extDir;
} catch (error) {
throw new Error("Invalid path provided to the extensions folder");
}
}
getExtensionsFile() {
return join(this.extensionsPath ?? "", "extensions.json");
}
}

View File

@ -1,16 +1,6 @@
/** import { writeFileSync } from "fs";
* Provides access to the extensions stored by Extension Store import Extension from "./extension";
* @typedef {Object} extensionManager import { ExtensionManager } from "./manager";
* @prop {getExtension} getExtension
* @prop {getAllExtensions} getAllExtensions
* @prop {getActiveExtensions} getActiveExtensions
* @prop {installExtensions} installExtensions
* @prop {removeExtension} removeExtension
*/
import { writeFileSync } from 'fs'
import Extension from './extension'
import { ExtensionManager } from './../managers/extension'
/** /**
* @module store * @module store
@ -21,7 +11,7 @@ import { ExtensionManager } from './../managers/extension'
* Register of installed extensions * Register of installed extensions
* @type {Object.<string, Extension>} extension - List 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. * Get a extension from the stored extensions.
@ -31,10 +21,10 @@ const extensions: Record<string, Extension> = {}
*/ */
export function getExtension(name: string) { export function getExtension(name: string) {
if (!Object.prototype.hasOwnProperty.call(extensions, name)) { if (!Object.prototype.hasOwnProperty.call(extensions, name)) {
throw new Error(`Extension ${name} does not exist`) throw new Error(`Extension ${name} does not exist`);
} }
return extensions[name] return extensions[name];
} }
/** /**
@ -43,7 +33,7 @@ export function getExtension(name: string) {
* @alias extensionManager.getAllExtensions * @alias extensionManager.getAllExtensions
*/ */
export function getAllExtensions() { export function getAllExtensions() {
return Object.values(extensions) return Object.values(extensions);
} }
/** /**
@ -52,7 +42,7 @@ export function getAllExtensions() {
* @alias extensionManager.getActiveExtensions * @alias extensionManager.getActiveExtensions
*/ */
export function getActiveExtensions() { export function getActiveExtensions() {
return Object.values(extensions).filter((extension) => extension.active) return Object.values(extensions).filter((extension) => extension.active);
} }
/** /**
@ -63,9 +53,9 @@ export function getActiveExtensions() {
* @alias extensionManager.removeExtension * @alias extensionManager.removeExtension
*/ */
export function removeExtension(name: string, persist = true) { export function removeExtension(name: string, persist = true) {
const del = delete extensions[name] const del = delete extensions[name];
if (persist) persistExtensions() if (persist) persistExtensions();
return del return del;
} }
/** /**
@ -75,10 +65,10 @@ export function removeExtension(name: string, persist = true) {
* @returns {void} * @returns {void}
*/ */
export function addExtension(extension: Extension, persist = true) { export function addExtension(extension: Extension, persist = true) {
if (extension.name) extensions[extension.name] = extension if (extension.name) extensions[extension.name] = extension;
if (persist) { if (persist) {
persistExtensions() persistExtensions();
extension.subscribe('pe-persist', persistExtensions) extension.subscribe("pe-persist", persistExtensions);
} }
} }
@ -87,15 +77,15 @@ export function addExtension(extension: Extension, persist = true) {
* @returns {void} * @returns {void}
*/ */
export function persistExtensions() { export function persistExtensions() {
const persistData: Record<string, Extension> = {} const persistData: Record<string, Extension> = {};
for (const name in extensions) { for (const name in extensions) {
persistData[name] = extensions[name] persistData[name] = extensions[name];
} }
writeFileSync( writeFileSync(
ExtensionManager.instance.getExtensionsFile(), ExtensionManager.instance.getExtensionsFile(),
JSON.stringify(persistData), JSON.stringify(persistData),
'utf8' "utf8"
) );
} }
/** /**
@ -106,25 +96,25 @@ export function persistExtensions() {
* @alias extensionManager.installExtensions * @alias extensionManager.installExtensions
*/ */
export async function installExtensions(extensions: any, store = true) { export async function installExtensions(extensions: any, store = true) {
const installed: Extension[] = [] const installed: Extension[] = [];
for (const ext of extensions) { for (const ext of extensions) {
// Set install options and activation based on input type // Set install options and activation based on input type
const isObject = typeof ext === 'object' const isObject = typeof ext === "object";
const spec = isObject ? [ext.specifier, ext] : [ext] const spec = isObject ? [ext.specifier, ext] : [ext];
const activate = isObject ? ext.activate !== false : true const activate = isObject ? ext.activate !== false : true;
// Install and possibly activate extension // Install and possibly activate extension
const extension = new Extension(...spec) const extension = new Extension(...spec);
await extension._install() await extension._install();
if (activate) extension.setActive(true) if (activate) extension.setActive(true);
// Add extension to store if needed // Add extension to store if needed
if (store) addExtension(extension) if (store) addExtension(extension);
installed.push(extension) installed.push(extension);
} }
// Return list of all installed extensions // Return list of all installed extensions
return installed return installed;
} }
/** /**

7
core/src/node/index.ts Normal file
View File

@ -0,0 +1,7 @@
export * from './extension/index'
export * from './extension/extension'
export * from './extension/manager'
export * from './extension/store'
export * from './download'
export * from './module'
export * from './api'

View File

@ -1,16 +1,14 @@
import { dispose } from "./../utils/disposable";
/** /**
* Manages imported modules. * Manages imported modules.
*/ */
export class ModuleManager { export class ModuleManager {
public requiredModules: Record<string, any> = {}; public requiredModules: Record<string, any> = {}
public static instance: ModuleManager = new ModuleManager(); public static instance: ModuleManager = new ModuleManager()
constructor() { constructor() {
if (ModuleManager.instance) { if (ModuleManager.instance) {
return ModuleManager.instance; return ModuleManager.instance
} }
} }
@ -20,14 +18,13 @@ export class ModuleManager {
* @param {any | undefined} nodule - The module to set, or undefined to clear the module. * @param {any | undefined} nodule - The module to set, or undefined to clear the module.
*/ */
setModule(moduleName: string, nodule: any | undefined) { setModule(moduleName: string, nodule: any | undefined) {
this.requiredModules[moduleName] = nodule; this.requiredModules[moduleName] = nodule
} }
/** /**
* Clears all imported modules. * Clears all imported modules.
*/ */
clearImportedModules() { clearImportedModules() {
dispose(this.requiredModules); this.requiredModules = {}
this.requiredModules = {};
} }
} }

View File

@ -67,13 +67,6 @@ export type Model = {
*/ */
description: string description: string
/**
* The model state.
* Default: "to_download"
* Enum: "to_download" "downloading" "ready" "running"
*/
state?: ModelState
/** /**
* The model settings. * The model settings.
*/ */
@ -101,15 +94,6 @@ export type ModelMetadata = {
cover?: string cover?: string
} }
/**
* The Model transition states.
*/
export enum ModelState {
Downloading = 'downloading',
Ready = 'ready',
Running = 'running',
}
/** /**
* The available model settings. * The available model settings.
*/ */

View File

@ -2,7 +2,7 @@
"compilerOptions": { "compilerOptions": {
"moduleResolution": "node", "moduleResolution": "node",
"target": "es5", "target": "es5",
"module": "es2015", "module": "ES2020",
"lib": ["es2015", "es2016", "es2017", "dom"], "lib": ["es2015", "es2016", "es2017", "dom"],
"strict": true, "strict": true,
"sourceMap": true, "sourceMap": true,

View File

@ -1,11 +1,13 @@
import { app, ipcMain, shell, nativeTheme } from 'electron' import { app, ipcMain, shell, nativeTheme } from 'electron'
import { ModuleManager } from './../managers/module'
import { join } from 'path' import { join } from 'path'
import { ExtensionManager } from './../managers/extension'
import { WindowManager } from './../managers/window' import { WindowManager } from './../managers/window'
import { userSpacePath } from './../utils/path' import { userSpacePath } from './../utils/path'
import { AppRoute } from '@janhq/core' import { AppRoute } from '@janhq/core'
import { getResourcePath } from './../utils/path' import { getResourcePath } from './../utils/path'
import {
ExtensionManager,
ModuleManager,
} from '@janhq/core/node'
export function handleAppIPCs() { export function handleAppIPCs() {
/** /**
@ -26,10 +28,6 @@ export function handleAppIPCs() {
shell.openPath(userSpacePath) shell.openPath(userSpacePath)
}) })
ipcMain.handle(AppRoute.getResourcePath, async (_event) => {
return getResourcePath()
})
/** /**
* Opens a URL in the user's default browser. * Opens a URL in the user's default browser.
* @param _event - The IPC event object. * @param _event - The IPC event object.
@ -48,6 +46,13 @@ export function handleAppIPCs() {
shell.openPath(url) shell.openPath(url)
}) })
/**
* Joins multiple paths together, respect to the current OS.
*/
ipcMain.handle(AppRoute.joinPath, async (_event, paths: string[]) =>
join(...paths)
)
/** /**
* Relaunches the app in production - reload window in development. * Relaunches the app in production - reload window in development.
* @param _event - The IPC event object. * @param _event - The IPC event object.

View File

@ -1,11 +1,11 @@
import { app, ipcMain } from 'electron' import { app, ipcMain } from 'electron'
import { DownloadManager } from './../managers/download'
import { resolve, join } from 'path' import { resolve, join } from 'path'
import { WindowManager } from './../managers/window' import { WindowManager } from './../managers/window'
import request from 'request' import request from 'request'
import { createWriteStream } from 'fs' import { createWriteStream, renameSync } from 'fs'
import { DownloadEvent, DownloadRoute } from '@janhq/core' import { DownloadEvent, DownloadRoute } from '@janhq/core'
const progress = require('request-progress') const progress = require('request-progress')
import { DownloadManager } from '@janhq/core/node'
export function handleDownloaderIPCs() { export function handleDownloaderIPCs() {
/** /**
@ -46,8 +46,13 @@ export function handleDownloaderIPCs() {
*/ */
ipcMain.handle(DownloadRoute.downloadFile, async (_event, url, fileName) => { ipcMain.handle(DownloadRoute.downloadFile, async (_event, url, fileName) => {
const userDataPath = join(app.getPath('home'), 'jan') const userDataPath = join(app.getPath('home'), 'jan')
if (typeof fileName === 'string' && fileName.includes('file:/')) {
fileName = fileName.replace('file:/', '')
}
const destination = resolve(userDataPath, fileName) const destination = resolve(userDataPath, fileName)
const rq = request(url) const rq = request(url)
// downloading file to a temp file first
const downloadingTempFile = `${destination}.download`
progress(rq, {}) progress(rq, {})
.on('progress', function (state: any) { .on('progress', function (state: any) {
@ -70,6 +75,9 @@ export function handleDownloaderIPCs() {
}) })
.on('end', function () { .on('end', function () {
if (DownloadManager.instance.networkRequests[fileName]) { if (DownloadManager.instance.networkRequests[fileName]) {
// Finished downloading, rename temp file to actual file
renameSync(downloadingTempFile, destination)
WindowManager?.instance.currentWindow?.webContents.send( WindowManager?.instance.currentWindow?.webContents.send(
DownloadEvent.onFileDownloadSuccess, DownloadEvent.onFileDownloadSuccess,
{ {
@ -87,7 +95,7 @@ export function handleDownloaderIPCs() {
) )
} }
}) })
.pipe(createWriteStream(destination)) .pipe(createWriteStream(downloadingTempFile))
DownloadManager.instance.setRequest(fileName, rq) DownloadManager.instance.setRequest(fileName, rq)
}) })

View File

@ -1,15 +1,15 @@
import { ipcMain, webContents } from 'electron' import { ipcMain, webContents } from 'electron'
import { readdirSync } from 'fs' import { readdirSync } from 'fs'
import { ModuleManager } from './../managers/module'
import { join, extname } from 'path' import { join, extname } from 'path'
import { import {
getActiveExtensions,
getAllExtensions,
installExtensions, installExtensions,
} from './../extension/store' getExtension,
import { getExtension } from './../extension/store' removeExtension,
import { removeExtension } from './../extension/store' getActiveExtensions,
import Extension from './../extension/extension' ModuleManager
} from '@janhq/core/node'
import { getResourcePath, userSpacePath } from './../utils/path' import { getResourcePath, userSpacePath } from './../utils/path'
import { ExtensionRoute } from '@janhq/core' import { ExtensionRoute } from '@janhq/core'
@ -81,7 +81,7 @@ export function handleExtensionIPCs() {
ExtensionRoute.updateExtension, ExtensionRoute.updateExtension,
async (e, extensions, reload) => { async (e, extensions, reload) => {
// Update all provided extensions // Update all provided extensions
const updated: Extension[] = [] const updated: any[] = []
for (const ext of extensions) { for (const ext of extensions) {
const extension = getExtension(ext) const extension = getExtension(ext)
const res = await extension.update() const res = await extension.update()

View File

@ -0,0 +1,37 @@
import { ipcMain } from 'electron'
// @ts-ignore
import reflect from '@alumna/reflect'
import { FileManagerRoute, getResourcePath } from '@janhq/core'
import { userSpacePath } from './../utils/path'
/**
* Handles file system extensions operations.
*/
export function handleFileMangerIPCs() {
// Handles the 'synceFile' IPC event. This event is triggered to synchronize a file from a source path to a destination path.
ipcMain.handle(
FileManagerRoute.synceFile,
async (_event, src: string, dest: string) => {
return reflect({
src,
dest,
recursive: true,
delete: false,
overwrite: true,
errorOnExist: false,
})
}
)
// Handles the 'getUserSpace' IPC event. This event is triggered to get the user space path.
ipcMain.handle(
FileManagerRoute.getUserSpace,
(): Promise<string> => Promise.resolve(userSpacePath)
)
// Handles the 'getResourcePath' IPC event. This event is triggered to get the resource path.
ipcMain.handle(FileManagerRoute.getResourcePath, async (_event) => {
return getResourcePath()
})
}

View File

@ -1,238 +1,24 @@
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
import * as fs from 'fs'
import fse from 'fs-extra'
import { join } from 'path'
import readline from 'readline'
import { userSpacePath } from './../utils/path'
import { FileSystemRoute } from '@janhq/core'
const reflect = require('@alumna/reflect')
import { FileSystemRoute } from '@janhq/core'
import { userSpacePath } from '../utils/path'
import { join } from 'path'
/** /**
* Handles file system operations. * Handles file system operations.
*/ */
export function handleFsIPCs() { export function handleFsIPCs() {
/** const moduleName = 'fs'
* Gets the path to the user data directory. Object.values(FileSystemRoute).forEach((route) => {
* @param event - The event object. ipcMain.handle(route, async (event, ...args) => {
* @returns A promise that resolves with the path to the user data directory. return import(moduleName).then((mdl) =>
*/ mdl[route](
ipcMain.handle( ...args.map((arg) =>
FileSystemRoute.getUserSpace, typeof arg === 'string' && arg.includes('file:/')
(): Promise<string> => Promise.resolve(userSpacePath) ? join(userSpacePath, arg.replace('file:/', ''))
: arg
) )
/**
* Checks whether the path is a directory.
* @param event - The event object.
* @param path - The path to check.
* @returns A promise that resolves with a boolean indicating whether the path is a directory.
*/
ipcMain.handle(
FileSystemRoute.isDirectory,
(_event, path: string): Promise<boolean> => {
const fullPath = join(userSpacePath, path)
return Promise.resolve(
fs.existsSync(fullPath) && fs.lstatSync(fullPath).isDirectory()
) )
}
) )
/**
* Reads a file from the user data directory.
* @param event - The event object.
* @param path - The path of the file to read.
* @returns A promise that resolves with the contents of the file.
*/
ipcMain.handle(
FileSystemRoute.readFile,
async (event, path: string): Promise<string> => {
return new Promise((resolve, reject) => {
fs.readFile(join(userSpacePath, path), 'utf8', (err, data) => {
if (err) {
reject(err)
} else {
resolve(data)
}
}) })
}) })
} }
)
/**
* Checks whether a file exists in the user data directory.
* @param event - The event object.
* @param path - The path of the file to check.
* @returns A promise that resolves with a boolean indicating whether the file exists.
*/
ipcMain.handle(FileSystemRoute.exists, async (_event, path: string) => {
return new Promise((resolve, reject) => {
const fullPath = join(userSpacePath, path)
fs.existsSync(fullPath) ? resolve(true) : resolve(false)
})
})
/**
* Writes data to a file in the user data directory.
* @param event - The event object.
* @param path - The path of the file to write to.
* @param data - The data to write to the file.
* @returns A promise that resolves when the file has been written.
*/
ipcMain.handle(
FileSystemRoute.writeFile,
async (event, path: string, data: string): Promise<void> => {
try {
await fs.writeFileSync(join(userSpacePath, path), data, 'utf8')
} catch (err) {
console.error(`writeFile ${path} result: ${err}`)
}
}
)
/**
* Creates a directory in the user data directory.
* @param event - The event object.
* @param path - The path of the directory to create.
* @returns A promise that resolves when the directory has been created.
*/
ipcMain.handle(
FileSystemRoute.mkdir,
async (event, path: string): Promise<void> => {
try {
fs.mkdirSync(join(userSpacePath, path), { recursive: true })
} catch (err) {
console.error(`mkdir ${path} result: ${err}`)
}
}
)
/**
* Removes a directory in the user data directory.
* @param event - The event object.
* @param path - The path of the directory to remove.
* @returns A promise that resolves when the directory is removed successfully.
*/
ipcMain.handle(
FileSystemRoute.rmdir,
async (event, path: string): Promise<void> => {
try {
await fs.rmSync(join(userSpacePath, path), { recursive: true })
} catch (err) {
console.error(`rmdir ${path} result: ${err}`)
}
}
)
/**
* Lists the files in a directory in the user data directory.
* @param event - The event object.
* @param path - The path of the directory to list files from.
* @returns A promise that resolves with an array of file names.
*/
ipcMain.handle(
FileSystemRoute.listFiles,
async (event, path: string): Promise<string[]> => {
return new Promise((resolve, reject) => {
fs.readdir(join(userSpacePath, path), (err, files) => {
if (err) {
reject(err)
} else {
resolve(files)
}
})
})
}
)
/**
* Deletes a file from the user data folder.
* @param _event - The IPC event object.
* @param filePath - The path to the file to delete.
* @returns A string indicating the result of the operation.
*/
ipcMain.handle(FileSystemRoute.deleteFile, async (_event, filePath) => {
try {
await fs.unlinkSync(join(userSpacePath, filePath))
} catch (err) {
console.error(`unlink ${filePath} result: ${err}`)
}
})
/**
* Appends data to a file in the user data directory.
* @param event - The event object.
* @param path - The path of the file to append to.
* @param data - The data to append to the file.
* @returns A promise that resolves when the file has been written.
*/
ipcMain.handle(
FileSystemRoute.appendFile,
async (_event, path: string, data: string) => {
try {
await fs.appendFileSync(join(userSpacePath, path), data, 'utf8')
} catch (err) {
console.error(`appendFile ${path} result: ${err}`)
}
}
)
ipcMain.handle(
FileSystemRoute.syncFile,
async (_event, src: string, dest: string) => {
console.debug(`Copying file from ${src} to ${dest}`)
return reflect({
src,
dest,
recursive: true,
delete: false,
overwrite: true,
errorOnExist: false,
})
}
)
ipcMain.handle(
FileSystemRoute.copyFile,
async (_event, src: string, dest: string) => {
console.debug(`Copying file from ${src} to ${dest}`)
return fse.copySync(src, dest, {
overwrite: false,
recursive: true,
errorOnExist: false,
})
}
)
/**
* Reads a file line by line.
* @param event - The event object.
* @param path - The path of the file to read.
* @returns A promise that resolves with the contents of the file.
*/
ipcMain.handle(
FileSystemRoute.readLineByLine,
async (_event, path: string) => {
const fullPath = join(userSpacePath, path)
return new Promise((res, rej) => {
try {
const readInterface = readline.createInterface({
input: fs.createReadStream(fullPath),
})
const lines: any = []
readInterface
.on('line', function (line) {
lines.push(line)
})
.on('close', function () {
res(lines)
})
} catch (err) {
rej(err)
}
})
}
)
}

View File

@ -7,27 +7,34 @@ import { createUserSpace } from './utils/path'
* Managers * Managers
**/ **/
import { WindowManager } from './managers/window' import { WindowManager } from './managers/window'
import { ModuleManager } from './managers/module' import { ExtensionManager, ModuleManager } from '@janhq/core/node'
import { ExtensionManager } from './managers/extension'
/** /**
* IPC Handlers * IPC Handlers
**/ **/
import { handleDownloaderIPCs } from './handlers/download' import { handleDownloaderIPCs } from './handlers/download'
import { handleExtensionIPCs } from './handlers/extension' import { handleExtensionIPCs } from './handlers/extension'
import { handleFileMangerIPCs } from './handlers/fileManager'
import { handleAppIPCs } from './handlers/app' import { handleAppIPCs } from './handlers/app'
import { handleAppUpdates } from './handlers/update' import { handleAppUpdates } from './handlers/update'
import { handleFsIPCs } from './handlers/fs' import { handleFsIPCs } from './handlers/fs'
import { migrateExtensions } from './utils/migration'
/**
* Server
*/
import { startServer } from '@janhq/server'
app app
.whenReady() .whenReady()
.then(createUserSpace) .then(createUserSpace)
.then(ExtensionManager.instance.migrateExtensions) .then(migrateExtensions)
.then(ExtensionManager.instance.setupExtensions) .then(ExtensionManager.instance.setupExtensions)
.then(setupMenu) .then(setupMenu)
.then(handleIPCs) .then(handleIPCs)
.then(handleAppUpdates) .then(handleAppUpdates)
.then(createMainWindow) .then(createMainWindow)
.then(startServer)
.then(() => { .then(() => {
app.on('activate', () => { app.on('activate', () => {
if (!BrowserWindow.getAllWindows().length) { if (!BrowserWindow.getAllWindows().length) {
@ -80,4 +87,5 @@ function handleIPCs() {
handleDownloaderIPCs() handleDownloaderIPCs()
handleExtensionIPCs() handleExtensionIPCs()
handleAppIPCs() handleAppIPCs()
handleFileMangerIPCs()
} }

View File

@ -1,85 +0,0 @@
import { app } from 'electron'
import { init } from './../extension'
import { join, resolve } from 'path'
import { rmdir } from 'fs'
import Store from 'electron-store'
import { existsSync, mkdirSync, writeFileSync } from 'fs'
import { userSpacePath } from './../utils/path'
/**
* Manages extension installation and migration.
*/
export class ExtensionManager {
public static instance: ExtensionManager = new ExtensionManager()
extensionsPath: string | undefined = undefined
constructor() {
if (ExtensionManager.instance) {
return ExtensionManager.instance
}
}
/**
* Sets up the extensions by initializing the `extensions` module with the `confirmInstall` and `extensionsPath` options.
* The `confirmInstall` function always returns `true` to allow extension installation.
* The `extensionsPath` option specifies the path to install extensions to.
*/
setupExtensions() {
init({
// Function to check from the main process that user wants to install a extension
confirmInstall: async (_extensions: string[]) => {
return true
},
// Path to install extension to
extensionsPath: join(userSpacePath, 'extensions'),
})
}
/**
* Migrates the extensions by deleting the `extensions` directory in the user data path.
* If the `migrated_version` key in the `Store` object does not match the current app version,
* the function deletes the `extensions` directory and sets the `migrated_version` key to the current app version.
* @returns A Promise that resolves when the migration is complete.
*/
migrateExtensions() {
return new Promise((resolve) => {
const store = new Store()
if (store.get('migrated_version') !== app.getVersion()) {
console.debug('start migration:', store.get('migrated_version'))
const fullPath = join(userSpacePath, 'extensions')
rmdir(fullPath, { recursive: true }, function (err) {
if (err) console.error(err)
store.set('migrated_version', app.getVersion())
console.debug('migrate extensions done')
resolve(undefined)
})
} else {
resolve(undefined)
}
})
}
setExtensionsPath(extPath: string) {
// Create folder if it does not exist
let extDir
try {
extDir = resolve(extPath)
if (extDir.length < 2) throw new Error()
if (!existsSync(extDir)) mkdirSync(extDir)
const extensionsJson = join(extDir, 'extensions.json')
if (!existsSync(extensionsJson))
writeFileSync(extensionsJson, '{}', 'utf8')
this.extensionsPath = extDir
} catch (error) {
throw new Error('Invalid path provided to the extensions folder')
}
}
getExtensionsFile() {
return join(this.extensionsPath ?? '', 'extensions.json')
}
}

View File

@ -72,15 +72,20 @@
"dependencies": { "dependencies": {
"@alumna/reflect": "^1.1.3", "@alumna/reflect": "^1.1.3",
"@janhq/core": "link:./core", "@janhq/core": "link:./core",
"@janhq/server": "link:./server",
"@npmcli/arborist": "^7.1.0", "@npmcli/arborist": "^7.1.0",
"@types/request": "^2.48.12", "@types/request": "^2.48.12",
"@uiball/loaders": "^1.3.0", "@uiball/loaders": "^1.3.0",
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
"electron-updater": "^6.1.7", "electron-updater": "^6.1.7",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"node-fetch": "2",
"pacote": "^17.0.4", "pacote": "^17.0.4",
"request": "^2.88.2", "request": "^2.88.2",
"request-progress": "^3.0.0", "request-progress": "^3.0.0",
"rimraf": "^5.0.5",
"typescript": "^5.3.3",
"ulid": "^2.3.0",
"use-debounce": "^9.0.4" "use-debounce": "^9.0.4"
}, },
"devDependencies": { "devDependencies": {

View File

@ -15,6 +15,9 @@
"paths": { "*": ["node_modules/*"] }, "paths": { "*": ["node_modules/*"] },
"typeRoots": ["node_modules/@types"] "typeRoots": ["node_modules/@types"]
}, },
"ts-node": {
"esm": true
},
"include": ["./**/*.ts"], "include": ["./**/*.ts"],
"exclude": ["core", "build", "dist", "tests", "node_modules"] "exclude": ["core", "build", "dist", "tests", "node_modules"]
} }

View File

@ -1,5 +1,5 @@
// @ts-nocheck // @ts-nocheck
const { app, Menu, dialog } = require("electron"); import { app, Menu, dialog, shell } from "electron";
const isMac = process.platform === "darwin"; const isMac = process.platform === "darwin";
const { autoUpdater } = require("electron-updater"); const { autoUpdater } = require("electron-updater");
import { compareSemanticVersions } from "./versionDiff"; import { compareSemanticVersions } from "./versionDiff";
@ -97,7 +97,6 @@ const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [
{ {
label: "Learn More", label: "Learn More",
click: async () => { click: async () => {
const { shell } = require("electron");
await shell.openExternal("https://jan.ai/"); await shell.openExternal("https://jan.ai/");
}, },
}, },

View File

@ -0,0 +1,30 @@
import { app } from 'electron'
import { join } from 'path'
import { rmdir } from 'fs'
import Store from 'electron-store'
import { userSpacePath } from './path'
/**
* Migrates the extensions by deleting the `extensions` directory in the user data path.
* If the `migrated_version` key in the `Store` object does not match the current app version,
* the function deletes the `extensions` directory and sets the `migrated_version` key to the current app version.
* @returns A Promise that resolves when the migration is complete.
*/
export function migrateExtensions() {
return new Promise((resolve) => {
const store = new Store()
if (store.get('migrated_version') !== app.getVersion()) {
console.debug('start migration:', store.get('migrated_version'))
const fullPath = join(userSpacePath, 'extensions')
rmdir(fullPath, { recursive: true }, function (err) {
if (err) console.error(err)
store.set('migrated_version', app.getVersion())
console.debug('migrate extensions done')
resolve(undefined)
})
} else {
resolve(undefined)
}
})
}

View File

@ -3,15 +3,16 @@ import { AssistantExtension } from "@janhq/core";
import { join } from "path"; import { join } from "path";
export default class JanAssistantExtension implements AssistantExtension { export default class JanAssistantExtension implements AssistantExtension {
private static readonly _homeDir = "assistants"; private static readonly _homeDir = "file://assistants";
type(): ExtensionType { type(): ExtensionType {
return ExtensionType.Assistant; return ExtensionType.Assistant;
} }
onLoad(): void { async onLoad() {
// making the assistant directory // making the assistant directory
fs.mkdir(JanAssistantExtension._homeDir).then(() => { if (!(await fs.existsSync(JanAssistantExtension._homeDir)))
fs.mkdirSync(JanAssistantExtension._homeDir).then(() => {
this.createJanAssistant(); this.createJanAssistant();
}); });
} }
@ -23,12 +24,12 @@ export default class JanAssistantExtension implements AssistantExtension {
async createAssistant(assistant: Assistant): Promise<void> { async createAssistant(assistant: Assistant): Promise<void> {
const assistantDir = join(JanAssistantExtension._homeDir, assistant.id); const assistantDir = join(JanAssistantExtension._homeDir, assistant.id);
await fs.mkdir(assistantDir); if (!(await fs.existsSync(assistantDir))) await fs.mkdirSync(assistantDir);
// store the assistant metadata json // store the assistant metadata json
const assistantMetadataPath = join(assistantDir, "assistant.json"); const assistantMetadataPath = join(assistantDir, "assistant.json");
try { try {
await fs.writeFile( await fs.writeFileSync(
assistantMetadataPath, assistantMetadataPath,
JSON.stringify(assistant, null, 2) JSON.stringify(assistant, null, 2)
); );
@ -41,18 +42,14 @@ export default class JanAssistantExtension implements AssistantExtension {
// get all the assistant directories // get all the assistant directories
// get all the assistant metadata json // get all the assistant metadata json
const results: Assistant[] = []; const results: Assistant[] = [];
const allFileName: string[] = await fs.listFiles( const allFileName: string[] = await fs.readdirSync(
JanAssistantExtension._homeDir JanAssistantExtension._homeDir
); );
for (const fileName of allFileName) { for (const fileName of allFileName) {
const filePath = join(JanAssistantExtension._homeDir, fileName); const filePath = join(JanAssistantExtension._homeDir, fileName);
const isDirectory = await fs.isDirectory(filePath);
if (!isDirectory) {
// if not a directory, ignore
continue;
}
const jsonFiles: string[] = (await fs.listFiles(filePath)).filter( if (filePath.includes(".DS_Store")) continue;
const jsonFiles: string[] = (await fs.readdirSync(filePath)).filter(
(file: string) => file === "assistant.json" (file: string) => file === "assistant.json"
); );
@ -61,9 +58,12 @@ export default class JanAssistantExtension implements AssistantExtension {
continue; continue;
} }
const assistant: Assistant = JSON.parse( const content = await fs.readFileSync(
await fs.readFile(join(filePath, jsonFiles[0])) join(filePath, jsonFiles[0]),
"utf-8"
); );
const assistant: Assistant =
typeof content === "object" ? content : JSON.parse(content);
results.push(assistant); results.push(assistant);
} }
@ -78,7 +78,7 @@ export default class JanAssistantExtension implements AssistantExtension {
// remove the directory // remove the directory
const assistantDir = join(JanAssistantExtension._homeDir, assistant.id); const assistantDir = join(JanAssistantExtension._homeDir, assistant.id);
await fs.rmdir(assistantDir); await fs.rmdirSync(assistantDir);
return Promise.resolve(); return Promise.resolve();
} }

View File

@ -1,7 +1,6 @@
import { ExtensionType, fs } from '@janhq/core' import { ExtensionType, fs, joinPath } from '@janhq/core'
import { ConversationalExtension } from '@janhq/core' import { ConversationalExtension } from '@janhq/core'
import { Thread, ThreadMessage } from '@janhq/core' import { Thread, ThreadMessage } from '@janhq/core'
import { join } from 'path'
/** /**
* JSONConversationalExtension is a ConversationalExtension implementation that provides * JSONConversationalExtension is a ConversationalExtension implementation that provides
@ -10,7 +9,7 @@ import { join } from 'path'
export default class JSONConversationalExtension export default class JSONConversationalExtension
implements ConversationalExtension implements ConversationalExtension
{ {
private static readonly _homeDir = 'threads' private static readonly _homeDir = 'file://threads'
private static readonly _threadInfoFileName = 'thread.json' private static readonly _threadInfoFileName = 'thread.json'
private static readonly _threadMessagesFileName = 'messages.jsonl' private static readonly _threadMessagesFileName = 'messages.jsonl'
@ -24,8 +23,9 @@ export default class JSONConversationalExtension
/** /**
* Called when the extension is loaded. * Called when the extension is loaded.
*/ */
onLoad() { async onLoad() {
fs.mkdir(JSONConversationalExtension._homeDir) if (!(await fs.existsSync(JSONConversationalExtension._homeDir)))
fs.mkdirSync(JSONConversationalExtension._homeDir)
console.debug('JSONConversationalExtension loaded') console.debug('JSONConversationalExtension loaded')
} }
@ -48,7 +48,9 @@ export default class JSONConversationalExtension
const convos = promiseResults const convos = promiseResults
.map((result) => { .map((result) => {
if (result.status === 'fulfilled') { if (result.status === 'fulfilled') {
return JSON.parse(result.value) as Thread return typeof result.value === 'object'
? result.value
: JSON.parse(result.value)
} }
}) })
.filter((convo) => convo != null) .filter((convo) => convo != null)
@ -69,16 +71,19 @@ export default class JSONConversationalExtension
*/ */
async saveThread(thread: Thread): Promise<void> { async saveThread(thread: Thread): Promise<void> {
try { try {
const threadDirPath = join( const threadDirPath = await joinPath([
JSONConversationalExtension._homeDir, JSONConversationalExtension._homeDir,
thread.id thread.id,
) ])
const threadJsonPath = join( const threadJsonPath = await joinPath([
threadDirPath, threadDirPath,
JSONConversationalExtension._threadInfoFileName JSONConversationalExtension._threadInfoFileName,
) ])
await fs.mkdir(threadDirPath) if (!(await fs.existsSync(threadDirPath))) {
await fs.writeFile(threadJsonPath, JSON.stringify(thread, null, 2)) await fs.mkdirSync(threadDirPath)
}
await fs.writeFileSync(threadJsonPath, JSON.stringify(thread))
Promise.resolve() Promise.resolve()
} catch (err) { } catch (err) {
Promise.reject(err) Promise.reject(err)
@ -89,22 +94,26 @@ export default class JSONConversationalExtension
* Delete a thread with the specified ID. * Delete a thread with the specified ID.
* @param threadId The ID of the thread to delete. * @param threadId The ID of the thread to delete.
*/ */
deleteThread(threadId: string): Promise<void> { async deleteThread(threadId: string): Promise<void> {
return fs.rmdir(join(JSONConversationalExtension._homeDir, `${threadId}`)) return fs.rmdirSync(
await joinPath([JSONConversationalExtension._homeDir, `${threadId}`]),
{ recursive: true }
)
} }
async addNewMessage(message: ThreadMessage): Promise<void> { async addNewMessage(message: ThreadMessage): Promise<void> {
try { try {
const threadDirPath = join( const threadDirPath = await joinPath([
JSONConversationalExtension._homeDir, JSONConversationalExtension._homeDir,
message.thread_id message.thread_id,
) ])
const threadMessagePath = join( const threadMessagePath = await joinPath([
threadDirPath, threadDirPath,
JSONConversationalExtension._threadMessagesFileName JSONConversationalExtension._threadMessagesFileName,
) ])
await fs.mkdir(threadDirPath) if (!(await fs.existsSync(threadDirPath)))
await fs.appendFile(threadMessagePath, JSON.stringify(message) + '\n') await fs.mkdirSync(threadDirPath)
await fs.appendFileSync(threadMessagePath, JSON.stringify(message) + '\n')
Promise.resolve() Promise.resolve()
} catch (err) { } catch (err) {
Promise.reject(err) Promise.reject(err)
@ -116,13 +125,17 @@ export default class JSONConversationalExtension
messages: ThreadMessage[] messages: ThreadMessage[]
): Promise<void> { ): Promise<void> {
try { try {
const threadDirPath = join(JSONConversationalExtension._homeDir, threadId) const threadDirPath = await joinPath([
const threadMessagePath = join( JSONConversationalExtension._homeDir,
threadId,
])
const threadMessagePath = await joinPath([
threadDirPath, threadDirPath,
JSONConversationalExtension._threadMessagesFileName JSONConversationalExtension._threadMessagesFileName,
) ])
await fs.mkdir(threadDirPath) if (!(await fs.existsSync(threadDirPath)))
await fs.writeFile( await fs.mkdirSync(threadDirPath)
await fs.writeFileSync(
threadMessagePath, threadMessagePath,
messages.map((msg) => JSON.stringify(msg)).join('\n') + messages.map((msg) => JSON.stringify(msg)).join('\n') +
(messages.length ? '\n' : '') (messages.length ? '\n' : '')
@ -139,12 +152,13 @@ export default class JSONConversationalExtension
* @returns data of the thread * @returns data of the thread
*/ */
private async readThread(threadDirName: string): Promise<any> { private async readThread(threadDirName: string): Promise<any> {
return fs.readFile( return fs.readFileSync(
join( await joinPath([
JSONConversationalExtension._homeDir, JSONConversationalExtension._homeDir,
threadDirName, threadDirName,
JSONConversationalExtension._threadInfoFileName JSONConversationalExtension._threadInfoFileName,
) ]),
'utf-8'
) )
} }
@ -153,23 +167,19 @@ export default class JSONConversationalExtension
* @private * @private
*/ */
private async getValidThreadDirs(): Promise<string[]> { private async getValidThreadDirs(): Promise<string[]> {
const fileInsideThread: string[] = await fs.listFiles( const fileInsideThread: string[] = await fs.readdirSync(
JSONConversationalExtension._homeDir JSONConversationalExtension._homeDir
) )
const threadDirs: string[] = [] const threadDirs: string[] = []
for (let i = 0; i < fileInsideThread.length; i++) { for (let i = 0; i < fileInsideThread.length; i++) {
const path = join( if (fileInsideThread[i].includes('.DS_Store')) continue
const path = await joinPath([
JSONConversationalExtension._homeDir, JSONConversationalExtension._homeDir,
fileInsideThread[i] fileInsideThread[i],
) ])
const isDirectory = await fs.isDirectory(path)
if (!isDirectory) {
console.debug(`Ignore ${path} because it is not a directory`)
continue
}
const isHavingThreadInfo = (await fs.listFiles(path)).includes( const isHavingThreadInfo = (await fs.readdirSync(path)).includes(
JSONConversationalExtension._threadInfoFileName JSONConversationalExtension._threadInfoFileName
) )
if (!isHavingThreadInfo) { if (!isHavingThreadInfo) {
@ -184,25 +194,31 @@ export default class JSONConversationalExtension
async getAllMessages(threadId: string): Promise<ThreadMessage[]> { async getAllMessages(threadId: string): Promise<ThreadMessage[]> {
try { try {
const threadDirPath = join(JSONConversationalExtension._homeDir, threadId) const threadDirPath = await joinPath([
const isDir = await fs.isDirectory(threadDirPath) JSONConversationalExtension._homeDir,
if (!isDir) { threadId,
throw Error(`${threadDirPath} is not directory`) ])
}
const files: string[] = await fs.listFiles(threadDirPath) const files: string[] = await fs.readdirSync(threadDirPath)
if ( if (
!files.includes(JSONConversationalExtension._threadMessagesFileName) !files.includes(JSONConversationalExtension._threadMessagesFileName)
) { ) {
throw Error(`${threadDirPath} not contains message file`) throw Error(`${threadDirPath} not contains message file`)
} }
const messageFilePath = join( const messageFilePath = await joinPath([
threadDirPath, threadDirPath,
JSONConversationalExtension._threadMessagesFileName JSONConversationalExtension._threadMessagesFileName,
) ])
const result = await fs.readLineByLine(messageFilePath) const result = await fs
.readFileSync(messageFilePath, 'utf-8')
.then((content) =>
content
.toString()
.split('\n')
.filter((line) => line !== '')
)
const messages: ThreadMessage[] = [] const messages: ThreadMessage[] = []
result.forEach((line: string) => { result.forEach((line: string) => {

View File

@ -17,9 +17,9 @@ import {
ThreadMessage, ThreadMessage,
events, events,
executeOnMain, executeOnMain,
getUserSpace,
fs, fs,
Model, Model,
joinPath,
} from "@janhq/core"; } from "@janhq/core";
import { InferenceExtension } from "@janhq/core"; import { InferenceExtension } from "@janhq/core";
import { requestInference } from "./helpers/sse"; import { requestInference } from "./helpers/sse";
@ -58,8 +58,9 @@ export default class JanInferenceNitroExtension implements InferenceExtension {
/** /**
* Subscribes to events emitted by the @janhq/core package. * Subscribes to events emitted by the @janhq/core package.
*/ */
onLoad(): void { async onLoad() {
fs.mkdir(JanInferenceNitroExtension._homeDir); if (!(await fs.existsSync(JanInferenceNitroExtension._homeDir)))
fs.mkdirSync(JanInferenceNitroExtension._homeDir);
this.writeDefaultEngineSettings(); this.writeDefaultEngineSettings();
// Events subscription // Events subscription
@ -91,12 +92,12 @@ export default class JanInferenceNitroExtension implements InferenceExtension {
JanInferenceNitroExtension._homeDir, JanInferenceNitroExtension._homeDir,
JanInferenceNitroExtension._engineMetadataFileName JanInferenceNitroExtension._engineMetadataFileName
); );
if (await fs.exists(engineFile)) { if (await fs.existsSync(engineFile)) {
JanInferenceNitroExtension._engineSettings = JSON.parse( const engine = await fs.readFileSync(engineFile, "utf-8");
await fs.readFile(engineFile) JanInferenceNitroExtension._engineSettings =
); typeof engine === "object" ? engine : JSON.parse(engine);
} else { } else {
await fs.writeFile( await fs.writeFileSync(
engineFile, engineFile,
JSON.stringify(JanInferenceNitroExtension._engineSettings, null, 2) JSON.stringify(JanInferenceNitroExtension._engineSettings, null, 2)
); );
@ -110,8 +111,7 @@ export default class JanInferenceNitroExtension implements InferenceExtension {
if (model.engine !== "nitro") { if (model.engine !== "nitro") {
return; return;
} }
const userSpacePath = await getUserSpace(); const modelFullPath = await joinPath(["models", model.id]);
const modelFullPath = join(userSpacePath, "models", model.id, model.id);
const nitroInitResult = await executeOnMain(MODULE, "initModel", { const nitroInitResult = await executeOnMain(MODULE, "initModel", {
modelFullPath: modelFullPath, modelFullPath: modelFullPath,

View File

@ -13,10 +13,11 @@ const NITRO_HTTP_LOAD_MODEL_URL = `${NITRO_HTTP_SERVER_URL}/inferences/llamacpp/
const NITRO_HTTP_UNLOAD_MODEL_URL = `${NITRO_HTTP_SERVER_URL}/inferences/llamacpp/unloadModel`; const NITRO_HTTP_UNLOAD_MODEL_URL = `${NITRO_HTTP_SERVER_URL}/inferences/llamacpp/unloadModel`;
const NITRO_HTTP_VALIDATE_MODEL_URL = `${NITRO_HTTP_SERVER_URL}/inferences/llamacpp/modelstatus`; const NITRO_HTTP_VALIDATE_MODEL_URL = `${NITRO_HTTP_SERVER_URL}/inferences/llamacpp/modelstatus`;
const NITRO_HTTP_KILL_URL = `${NITRO_HTTP_SERVER_URL}/processmanager/destroy`; const NITRO_HTTP_KILL_URL = `${NITRO_HTTP_SERVER_URL}/processmanager/destroy`;
const SUPPORTED_MODEL_FORMAT = ".gguf";
// The subprocess instance for Nitro // The subprocess instance for Nitro
let subprocess = undefined; let subprocess = undefined;
let currentModelFile = undefined; let currentModelFile: string = undefined;
let currentSettings = undefined; let currentSettings = undefined;
/** /**
@ -37,6 +38,21 @@ function stopModel(): Promise<void> {
*/ */
async function initModel(wrapper: any): Promise<ModelOperationResponse> { async function initModel(wrapper: any): Promise<ModelOperationResponse> {
currentModelFile = wrapper.modelFullPath; currentModelFile = wrapper.modelFullPath;
const janRoot = path.join(require("os").homedir(), "jan");
if (!currentModelFile.includes(janRoot)) {
currentModelFile = path.join(janRoot, currentModelFile);
}
const files: string[] = fs.readdirSync(currentModelFile);
// Look for GGUF model file
const ggufBinFile = files.find(
(file) =>
file === path.basename(currentModelFile) ||
file.toLowerCase().includes(SUPPORTED_MODEL_FORMAT)
);
currentModelFile = path.join(currentModelFile, ggufBinFile);
if (wrapper.model.engine !== "nitro") { if (wrapper.model.engine !== "nitro") {
return Promise.resolve({ error: "Not a nitro model" }); return Promise.resolve({ error: "Not a nitro model" });
} else { } else {
@ -66,14 +82,14 @@ async function initModel(wrapper: any): Promise<ModelOperationResponse> {
async function loadModel(nitroResourceProbe: any | undefined) { async function loadModel(nitroResourceProbe: any | undefined) {
// Gather system information for CPU physical cores and memory // Gather system information for CPU physical cores and memory
if (!nitroResourceProbe) nitroResourceProbe = await getResourcesInfo(); if (!nitroResourceProbe) nitroResourceProbe = await getResourcesInfo();
return killSubprocess() return (
killSubprocess()
.then(() => tcpPortUsed.waitUntilFree(PORT, 300, 5000)) .then(() => tcpPortUsed.waitUntilFree(PORT, 300, 5000))
// wait for 500ms to make sure the port is free for windows platform // wait for 500ms to make sure the port is free for windows platform
.then(() => { .then(() => {
if (process.platform === "win32") { if (process.platform === "win32") {
return sleep(500); return sleep(500);
} } else {
else {
return sleep(0); return sleep(0);
} }
}) })
@ -84,7 +100,8 @@ async function loadModel(nitroResourceProbe: any | undefined) {
console.error("error: ", err); console.error("error: ", err);
// TODO: Broadcast error so app could display proper error message // TODO: Broadcast error so app could display proper error message
return { error: err, currentModelFile }; return { error: err, currentModelFile };
}); })
);
} }
// Add function sleep // Add function sleep
@ -259,11 +276,11 @@ function spawnNitroProcess(nitroResourceProbe: any): Promise<any> {
function getResourcesInfo(): Promise<ResourcesInfo> { function getResourcesInfo(): Promise<ResourcesInfo> {
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
const cpu = await si.cpu(); const cpu = await si.cpu();
const mem = await si.mem(); // const mem = await si.mem();
const response = { const response: ResourcesInfo = {
numCpuPhysicalCore: cpu.physicalCores, numCpuPhysicalCore: cpu.physicalCores,
memAvailable: mem.available, memAvailable: 0,
}; };
resolve(response); resolve(response);
}); });

View File

@ -29,7 +29,7 @@ import { join } from "path";
* It also subscribes to events emitted by the @janhq/core package and handles new message requests. * It also subscribes to events emitted by the @janhq/core package and handles new message requests.
*/ */
export default class JanInferenceOpenAIExtension implements InferenceExtension { export default class JanInferenceOpenAIExtension implements InferenceExtension {
private static readonly _homeDir = "engines"; private static readonly _homeDir = "file://engines";
private static readonly _engineMetadataFileName = "openai.json"; private static readonly _engineMetadataFileName = "openai.json";
private static _currentModel: OpenAIModel; private static _currentModel: OpenAIModel;
@ -53,8 +53,9 @@ export default class JanInferenceOpenAIExtension implements InferenceExtension {
/** /**
* Subscribes to events emitted by the @janhq/core package. * Subscribes to events emitted by the @janhq/core package.
*/ */
onLoad(): void { async onLoad() {
fs.mkdir(JanInferenceOpenAIExtension._homeDir); if (!(await fs.existsSync(JanInferenceOpenAIExtension._homeDir)))
fs.mkdirSync(JanInferenceOpenAIExtension._homeDir);
JanInferenceOpenAIExtension.writeDefaultEngineSettings(); JanInferenceOpenAIExtension.writeDefaultEngineSettings();
// Events subscription // Events subscription
@ -85,12 +86,12 @@ export default class JanInferenceOpenAIExtension implements InferenceExtension {
JanInferenceOpenAIExtension._homeDir, JanInferenceOpenAIExtension._homeDir,
JanInferenceOpenAIExtension._engineMetadataFileName JanInferenceOpenAIExtension._engineMetadataFileName
); );
if (await fs.exists(engineFile)) { if (await fs.existsSync(engineFile)) {
JanInferenceOpenAIExtension._engineSettings = JSON.parse( const engine = await fs.readFileSync(engineFile, 'utf-8');
await fs.readFile(engineFile) JanInferenceOpenAIExtension._engineSettings =
); typeof engine === "object" ? engine : JSON.parse(engine);
} else { } else {
await fs.writeFile( await fs.writeFileSync(
engineFile, engineFile,
JSON.stringify(JanInferenceOpenAIExtension._engineSettings, null, 2) JSON.stringify(JanInferenceOpenAIExtension._engineSettings, null, 2)
); );

View File

@ -57,8 +57,8 @@ export default class JanInferenceTritonTrtLLMExtension
/** /**
* Subscribes to events emitted by the @janhq/core package. * Subscribes to events emitted by the @janhq/core package.
*/ */
onLoad(): void { async onLoad() {
fs.mkdir(JanInferenceTritonTrtLLMExtension._homeDir); if (!(await fs.existsSync(JanInferenceTritonTrtLLMExtension._homeDir)))
JanInferenceTritonTrtLLMExtension.writeDefaultEngineSettings(); JanInferenceTritonTrtLLMExtension.writeDefaultEngineSettings();
// Events subscription // Events subscription
@ -98,12 +98,12 @@ export default class JanInferenceTritonTrtLLMExtension
JanInferenceTritonTrtLLMExtension._homeDir, JanInferenceTritonTrtLLMExtension._homeDir,
JanInferenceTritonTrtLLMExtension._engineMetadataFileName JanInferenceTritonTrtLLMExtension._engineMetadataFileName
); );
if (await fs.exists(engine_json)) { if (await fs.existsSync(engine_json)) {
JanInferenceTritonTrtLLMExtension._engineSettings = JSON.parse( const engine = await fs.readFileSync(engine_json, "utf-8");
await fs.readFile(engine_json) JanInferenceTritonTrtLLMExtension._engineSettings =
); typeof engine === "object" ? engine : JSON.parse(engine);
} else { } else {
await fs.writeFile( await fs.writeFileSync(
engine_json, engine_json,
JSON.stringify( JSON.stringify(
JanInferenceTritonTrtLLMExtension._engineSettings, JanInferenceTritonTrtLLMExtension._engineSettings,

View File

@ -5,16 +5,21 @@ import {
abortDownload, abortDownload,
getResourcePath, getResourcePath,
getUserSpace, getUserSpace,
InferenceEngine,
joinPath,
} from '@janhq/core' } from '@janhq/core'
import { ModelExtension, Model, ModelState } from '@janhq/core' import { basename } from 'path'
import { join } from 'path' import { ModelExtension, Model } from '@janhq/core'
/** /**
* A extension for models * A extension for models
*/ */
export default class JanModelExtension implements ModelExtension { export default class JanModelExtension implements ModelExtension {
private static readonly _homeDir = 'models' private static readonly _homeDir = 'file://models'
private static readonly _modelMetadataFileName = 'model.json' private static readonly _modelMetadataFileName = 'model.json'
private static readonly _supportedModelFormat = '.gguf'
private static readonly _incompletedModelFileName = '.download'
private static readonly _offlineInferenceEngine = InferenceEngine.nitro
/** /**
* Implements type from JanExtension. * Implements type from JanExtension.
@ -41,11 +46,11 @@ export default class JanModelExtension implements ModelExtension {
private async copyModelsToHomeDir() { private async copyModelsToHomeDir() {
try { try {
if ( // list all of the files under the home directory
localStorage.getItem(`${EXTENSION_NAME}-version`) === VERSION &&
(await fs.exists(JanModelExtension._homeDir)) if (fs.existsSync(JanModelExtension._homeDir)) {
) { // ignore if the model is already downloaded
console.debug('Model already migrated') console.debug('Models already persisted.')
return return
} }
@ -54,10 +59,10 @@ export default class JanModelExtension implements ModelExtension {
// copy models folder from resources to home directory // copy models folder from resources to home directory
const resourePath = await getResourcePath() const resourePath = await getResourcePath()
const srcPath = join(resourePath, 'models') const srcPath = await joinPath([resourePath, 'models'])
const userSpace = await getUserSpace() const userSpace = await getUserSpace()
const destPath = join(userSpace, JanModelExtension._homeDir) const destPath = await joinPath([userSpace, JanModelExtension._homeDir])
await fs.syncFile(srcPath, destPath) await fs.syncFile(srcPath, destPath)
@ -88,11 +93,18 @@ export default class JanModelExtension implements ModelExtension {
*/ */
async downloadModel(model: Model): Promise<void> { async downloadModel(model: Model): Promise<void> {
// create corresponding directory // create corresponding directory
const directoryPath = join(JanModelExtension._homeDir, model.id) const modelDirPath = await joinPath([JanModelExtension._homeDir, model.id])
await fs.mkdir(directoryPath) if (!(await fs.existsSync(modelDirPath))) await fs.mkdirSync(modelDirPath)
// path to model binary // try to retrieve the download file name from the source url
const path = join(directoryPath, model.id) // if it fails, use the model ID as the file name
const extractedFileName = basename(model.source_url)
const fileName = extractedFileName
.toLowerCase()
.endsWith(JanModelExtension._supportedModelFormat)
? extractedFileName
: model.id
const path = await joinPath([modelDirPath, fileName])
downloadFile(model.source_url, path) downloadFile(model.source_url, path)
} }
@ -103,9 +115,11 @@ export default class JanModelExtension implements ModelExtension {
*/ */
async cancelModelDownload(modelId: string): Promise<void> { async cancelModelDownload(modelId: string): Promise<void> {
return abortDownload( return abortDownload(
join(JanModelExtension._homeDir, modelId, modelId) await joinPath([JanModelExtension._homeDir, modelId, modelId])
).then(() => { ).then(async () => {
fs.deleteFile(join(JanModelExtension._homeDir, modelId, modelId)) fs.unlinkSync(
await joinPath([JanModelExtension._homeDir, modelId, modelId])
)
}) })
} }
@ -116,27 +130,16 @@ export default class JanModelExtension implements ModelExtension {
*/ */
async deleteModel(modelId: string): Promise<void> { async deleteModel(modelId: string): Promise<void> {
try { try {
const dirPath = join(JanModelExtension._homeDir, modelId) const dirPath = await joinPath([JanModelExtension._homeDir, modelId])
// remove all files under dirPath except model.json // remove all files under dirPath except model.json
const files = await fs.listFiles(dirPath) const files = await fs.readdirSync(dirPath)
const deletePromises = files.map((fileName: string) => { const deletePromises = files.map(async (fileName: string) => {
if (fileName !== JanModelExtension._modelMetadataFileName) { if (fileName !== JanModelExtension._modelMetadataFileName) {
return fs.deleteFile(join(dirPath, fileName)) return fs.unlinkSync(await joinPath([dirPath, fileName]))
} }
}) })
await Promise.allSettled(deletePromises) await Promise.allSettled(deletePromises)
// update the state as default
const jsonFilePath = join(
dirPath,
JanModelExtension._modelMetadataFileName
)
const json = await fs.readFile(jsonFilePath)
const model = JSON.parse(json) as Model
delete model.state
await fs.writeFile(jsonFilePath, JSON.stringify(model, null, 2))
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} }
@ -148,24 +151,14 @@ export default class JanModelExtension implements ModelExtension {
* @returns A Promise that resolves when the model is saved. * @returns A Promise that resolves when the model is saved.
*/ */
async saveModel(model: Model): Promise<void> { async saveModel(model: Model): Promise<void> {
const jsonFilePath = join( const jsonFilePath = await joinPath([
JanModelExtension._homeDir, JanModelExtension._homeDir,
model.id, model.id,
JanModelExtension._modelMetadataFileName JanModelExtension._modelMetadataFileName,
) ])
try { try {
await fs.writeFile( await fs.writeFileSync(jsonFilePath, JSON.stringify(model, null, 2))
jsonFilePath,
JSON.stringify(
{
...model,
state: ModelState.Ready,
},
null,
2
)
)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} }
@ -176,43 +169,70 @@ export default class JanModelExtension implements ModelExtension {
* @returns A Promise that resolves with an array of all models. * @returns A Promise that resolves with an array of all models.
*/ */
async getDownloadedModels(): Promise<Model[]> { async getDownloadedModels(): Promise<Model[]> {
const models = await this.getModelsMetadata() return await this.getModelsMetadata(
return models.filter((model) => model.state === ModelState.Ready) async (modelDir: string, model: Model) => {
if (model.engine !== JanModelExtension._offlineInferenceEngine) {
return true
}
return await fs
.readdirSync(await joinPath([JanModelExtension._homeDir, modelDir]))
.then((files: string[]) => {
// or model binary exists in the directory
// model binary name can match model ID or be a .gguf file and not be an incompleted model file
return (
files.includes(modelDir) ||
files.some(
(file) =>
file
.toLowerCase()
.includes(JanModelExtension._supportedModelFormat) &&
!file.endsWith(JanModelExtension._incompletedModelFileName)
)
)
})
}
)
} }
private async getModelsMetadata(): Promise<Model[]> { private async getModelsMetadata(
selector?: (path: string, model: Model) => Promise<boolean>
): Promise<Model[]> {
try { try {
const filesUnderJanRoot = await fs.listFiles('') if (!(await fs.existsSync(JanModelExtension._homeDir))) {
if (!filesUnderJanRoot.includes(JanModelExtension._homeDir)) {
console.debug('model folder not found') console.debug('model folder not found')
return [] return []
} }
const files: string[] = await fs.listFiles(JanModelExtension._homeDir) const files: string[] = await fs.readdirSync(JanModelExtension._homeDir)
const allDirectories: string[] = [] const allDirectories: string[] = []
for (const file of files) { for (const file of files) {
const isDirectory = await fs.isDirectory( if (file === '.DS_Store') continue
join(JanModelExtension._homeDir, file)
)
if (isDirectory) {
allDirectories.push(file) allDirectories.push(file)
} }
}
const readJsonPromises = allDirectories.map((dirName) => { const readJsonPromises = allDirectories.map(async (dirName) => {
const jsonPath = join( // filter out directories that don't match the selector
// read model.json
const jsonPath = await joinPath([
JanModelExtension._homeDir, JanModelExtension._homeDir,
dirName, dirName,
JanModelExtension._modelMetadataFileName JanModelExtension._modelMetadataFileName,
) ])
return this.readModelMetadata(jsonPath) let model = await this.readModelMetadata(jsonPath)
model = typeof model === 'object' ? model : JSON.parse(model)
if (selector && !(await selector?.(dirName, model))) {
return
}
return model
}) })
const results = await Promise.allSettled(readJsonPromises) const results = await Promise.allSettled(readJsonPromises)
const modelData = results.map((result) => { const modelData = results.map((result) => {
if (result.status === 'fulfilled') { if (result.status === 'fulfilled') {
try { try {
return JSON.parse(result.value) as Model return result.value as Model
} catch { } catch {
console.debug(`Unable to parse model metadata: ${result.value}`) console.debug(`Unable to parse model metadata: ${result.value}`)
return undefined return undefined
@ -222,6 +242,7 @@ export default class JanModelExtension implements ModelExtension {
return undefined return undefined
} }
}) })
return modelData.filter((e) => !!e) return modelData.filter((e) => !!e)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@ -230,7 +251,7 @@ export default class JanModelExtension implements ModelExtension {
} }
private readModelMetadata(path: string) { private readModelMetadata(path: string) {
return fs.readFile(join(path)) return fs.readFileSync(path, 'utf-8')
} }
/** /**

View File

@ -4,11 +4,11 @@ const getResourcesInfo = async () =>
new Promise(async (resolve) => { new Promise(async (resolve) => {
const cpu = await si.cpu(); const cpu = await si.cpu();
const mem = await si.mem(); const mem = await si.mem();
const gpu = await si.graphics(); // const gpu = await si.graphics();
const response = { const response = {
cpu, cpu,
mem, mem,
gpu, // gpu,
}; };
resolve(response); resolve(response);
}); });

View File

@ -6,7 +6,8 @@
"uikit", "uikit",
"core", "core",
"electron", "electron",
"web" "web",
"server"
], ],
"nohoist": [ "nohoist": [
"uikit", "uikit",
@ -16,7 +17,9 @@
"electron", "electron",
"electron/**", "electron/**",
"web", "web",
"web/**" "web/**",
"server",
"server/**"
] ]
}, },
"scripts": { "scripts": {
@ -28,6 +31,7 @@
"test-local": "yarn lint && yarn build:test && yarn test", "test-local": "yarn lint && yarn build:test && yarn test",
"dev:uikit": "yarn workspace @janhq/uikit install && yarn workspace @janhq/uikit dev", "dev:uikit": "yarn workspace @janhq/uikit install && yarn workspace @janhq/uikit dev",
"build:uikit": "yarn workspace @janhq/uikit install && yarn workspace @janhq/uikit build", "build:uikit": "yarn workspace @janhq/uikit install && yarn workspace @janhq/uikit build",
"build:server": "cd server && yarn install && yarn run build",
"build:core": "cd core && yarn install && yarn run build", "build:core": "cd core && yarn install && yarn run build",
"build:web": "yarn workspace jan-web build && cpx \"web/out/**\" \"electron/renderer/\"", "build:web": "yarn workspace jan-web build && cpx \"web/out/**\" \"electron/renderer/\"",
"build:electron": "cpx \"models/**\" \"electron/models/\" && yarn workspace jan build", "build:electron": "cpx \"models/**\" \"electron/models/\" && yarn workspace jan build",

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

43
server/index.ts Normal file
View File

@ -0,0 +1,43 @@
import fastify from "fastify";
import dotenv from "dotenv";
import { v1Router } from "@janhq/core/node";
import path from "path";
dotenv.config();
const JAN_API_HOST = process.env.JAN_API_HOST || "0.0.0.0";
const JAN_API_PORT = Number.parseInt(process.env.JAN_API_PORT || "1337");
const server = fastify();
server.register(require("@fastify/cors"), {});
server.register(
(childContext, _, done) => {
childContext.register(require("@fastify/static"), {
root:
process.env.EXTENSION_ROOT ||
path.join(require("os").homedir(), "jan", "extensions"),
wildcard: false,
});
done();
},
{ prefix: "extensions" }
);
server.register(v1Router, { prefix: "/v1" });
export const startServer = () => {
server
.listen({
port: JAN_API_PORT,
host: JAN_API_HOST,
})
.then(() => {
console.log(
`JAN API listening at: http://${JAN_API_HOST}:${JAN_API_PORT}`
);
});
};
export const stopServer = () => {
server.close();
};

View File

View File

@ -1,19 +1,3 @@
import fastify from 'fastify' import { startServer } from "./index";
import dotenv from 'dotenv'
import v1API from './v1'
const server = fastify()
dotenv.config()
server.register(v1API, {prefix: "/api/v1"})
const JAN_API_PORT = Number.parseInt(process.env.JAN_API_PORT || '1337')
const JAN_API_HOST = process.env.JAN_API_HOST || "0.0.0.0"
server.listen({
port: JAN_API_PORT,
host: JAN_API_HOST
}).then(() => {
console.log(`JAN API listening at: http://${JAN_API_HOST}:${JAN_API_PORT}`);
})
startServer();

View File

@ -1,32 +1,37 @@
{ {
"name": "jan-server", "name": "@janhq/server",
"version": "0.1.3", "version": "0.1.3",
"main": "./build/main.js", "main": "build/index.js",
"types": "build/index.d.ts",
"author": "Jan <service@jan.ai>", "author": "Jan <service@jan.ai>",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"homepage": "https://jan.ai", "homepage": "https://jan.ai",
"description": "Use offline LLMs with your own data. Run open source models like Llama2 or Falcon on your internal computers/servers.", "description": "Use offline LLMs with your own data. Run open source models like Llama2 or Falcon on your internal computers/servers.",
"build": "", "files": [
"build/**"
],
"scripts": { "scripts": {
"lint": "eslint . --ext \".js,.jsx,.ts,.tsx\"", "lint": "eslint . --ext \".js,.jsx,.ts,.tsx\"",
"test:e2e": "playwright test --workers=1", "test:e2e": "playwright test --workers=1",
"dev": "nodemon .", "dev": "tsc --watch & node --watch build/main.js",
"build": "tsc" "build": "tsc"
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^8.4.2",
"@fastify/static": "^6.12.0",
"@janhq/core": "link:./core",
"dotenv": "^16.3.1",
"fastify": "^4.24.3",
"request": "^2.88.2",
"request-progress": "^3.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/body-parser": "^1.19.5", "@types/body-parser": "^1.19.5",
"@types/npmcli__arborist": "^5.6.4", "@types/npmcli__arborist": "^5.6.4",
"@typescript-eslint/eslint-plugin": "^6.7.3", "@typescript-eslint/eslint-plugin": "^6.7.3",
"@typescript-eslint/parser": "^6.7.3", "@typescript-eslint/parser": "^6.7.3",
"dotenv": "^16.3.1",
"eslint-plugin-react": "^7.33.2", "eslint-plugin-react": "^7.33.2",
"fastify": "^4.24.3", "run-script-os": "^1.1.6",
"nodemon": "^3.0.1", "typescript": "^5.2.2"
"run-script-os": "^1.1.6"
},
"installConfig": {
"hoistingLimits": "workspaces"
} }
} }

View File

@ -13,7 +13,9 @@
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"paths": { "*": ["node_modules/*"] }, "paths": { "*": ["node_modules/*"] },
"typeRoots": ["node_modules/@types"] "typeRoots": ["node_modules/@types"],
"ignoreDeprecations": "5.0",
"declaration": true
}, },
// "sourceMap": true, // "sourceMap": true,

View File

@ -1,8 +0,0 @@
import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify'
const router: FastifyPluginAsync = async (app: FastifyInstance, opts: FastifyPluginOptions) => {
//TODO: Add controllers for assistants here
// app.get("/", controller)
// app.post("/", controller)
}
export default router;

View File

@ -1,11 +0,0 @@
import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify'
const router: FastifyPluginAsync = async (app: FastifyInstance, opts: FastifyPluginOptions) => {
//TODO: Add controllers for here
// app.get("/", controller)
app.post("/", (req, res) => {
req.body
})
}
export default router;

View File

@ -1,37 +0,0 @@
import assistantsAPI from './assistants'
import chatCompletionAPI from './chat'
import modelsAPI from './models'
import threadsAPI from './threads'
import { FastifyInstance, FastifyPluginAsync } from 'fastify'
const router: FastifyPluginAsync = async (app: FastifyInstance, opts) => {
app.register(
assistantsAPI,
{
prefix: "/assistants"
}
)
app.register(
chatCompletionAPI,
{
prefix: "/chat/completion"
}
)
app.register(
modelsAPI,
{
prefix: "/models"
}
)
app.register(
threadsAPI,
{
prefix: "/threads"
}
)
}
export default router;

View File

@ -1,23 +0,0 @@
import { RouteHandlerMethod, FastifyRequest, FastifyReply } from 'fastify'
import { MODEL_FOLDER_PATH } from "./index"
import fs from 'fs/promises'
const controller: RouteHandlerMethod = async (req: FastifyRequest, res: FastifyReply) => {
//TODO: download models impl
//Mirror logic from JanModelExtension.downloadModel?
let model = req.body.model;
// Fetching logic
// const directoryPath = join(MODEL_FOLDER_PATH, model.id)
// await fs.mkdir(directoryPath)
// const path = join(directoryPath, model.id)
// downloadFile(model.source_url, path)
// TODO: Different model downloader from different model vendor
res.status(200).send({
status: "Ok"
})
}
export default controller;

View File

@ -1,61 +0,0 @@
export const MODEL_FOLDER_PATH = "./data/models"
export const _modelMetadataFileName = 'model.json'
import fs from 'fs/promises'
import { Model } from '@janhq/core'
import { join } from 'path'
// map string => model object
let modelIndex = new Map<String, Model>();
async function buildModelIndex(){
let modelIds = await fs.readdir(MODEL_FOLDER_PATH);
// TODO: read modelFolders to get model info, mirror JanModelExtension?
try{
for(let modelId in modelIds){
let path = join(MODEL_FOLDER_PATH, modelId)
let fileData = await fs.readFile(join(path, _modelMetadataFileName))
modelIndex.set(modelId, JSON.parse(fileData.toString("utf-8")) as Model)
}
}
catch(err){
console.error("build model index failed. ", err);
}
}
buildModelIndex()
import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify'
import downloadModelController from './downloadModel'
import { startModel, stopModel } from './modelOp'
const router: FastifyPluginAsync = async (app: FastifyInstance, opts: FastifyPluginOptions) => {
//TODO: Add controllers declaration here
///////////// CRUD ////////////////
// Model listing
app.get("/", async (req, res) => {
res.status(200).send(
modelIndex.values()
)
})
// Retrieve model info
app.get("/:id", (req, res) => {
res.status(200).send(
modelIndex.get(req.params.id)
)
})
// Delete model
app.delete("/:id", (req, res) => {
modelIndex.delete(req.params)
// TODO: delete on disk
})
///////////// Other ops ////////////////
app.post("/", downloadModelController)
app.put("/start", startModel)
app.put("/stop", stopModel)
}
export default router;

View File

@ -1,11 +0,0 @@
import {FastifyRequest, FastifyReply} from 'fastify'
export async function startModel(req: FastifyRequest, res: FastifyReply): Promise<void> {
}
export async function stopModel(req: FastifyRequest, res: FastifyReply): Promise<void> {
}

View File

@ -1,8 +0,0 @@
import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify'
const router: FastifyPluginAsync = async (app: FastifyInstance, opts: FastifyPluginOptions) => {
//TODO: Add controllers declaration here
// app.get()
}
export default router;

View File

@ -29,6 +29,13 @@ export default function CardSidebar({
useClickOutside(() => setMore(false), null, [menu, toggle]) useClickOutside(() => setMore(false), null, [menu, toggle])
let openFolderTitle: string = 'Open Containing Folder'
if (isMac) {
openFolderTitle = 'Reveal in Finder'
} else if (isWindows) {
openFolderTitle = 'Reveal in File Explorer'
}
return ( return (
<div <div
className={twMerge( className={twMerge(
@ -74,7 +81,7 @@ export default function CardSidebar({
> >
<FolderOpenIcon size={16} className="text-muted-foreground" /> <FolderOpenIcon size={16} className="text-muted-foreground" />
<span className="text-bold text-black dark:text-muted-foreground"> <span className="text-bold text-black dark:text-muted-foreground">
Reveal in Finder {openFolderTitle}
</span> </span>
</div> </div>
<div <div

View File

@ -1,7 +1,5 @@
import { Fragment } from 'react' import { Fragment } from 'react'
import { ExtensionType } from '@janhq/core'
import { ModelExtension } from '@janhq/core'
import { import {
Progress, Progress,
Modal, Modal,
@ -12,14 +10,19 @@ import {
ModalTrigger, ModalTrigger,
} from '@janhq/uikit' } from '@janhq/uikit'
import { useAtomValue } from 'jotai'
import useDownloadModel from '@/hooks/useDownloadModel'
import { useDownloadState } from '@/hooks/useDownloadState' import { useDownloadState } from '@/hooks/useDownloadState'
import { formatDownloadPercentage } from '@/utils/converter' import { formatDownloadPercentage } from '@/utils/converter'
import { extensionManager } from '@/extension' import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom'
export default function DownloadingState() { export default function DownloadingState() {
const { downloadStates } = useDownloadState() const { downloadStates } = useDownloadState()
const downloadingModels = useAtomValue(downloadingModelsAtom)
const { abortModelDownload } = useDownloadModel()
const totalCurrentProgress = downloadStates const totalCurrentProgress = downloadStates
.map((a) => a.size.transferred + a.size.transferred) .map((a) => a.size.transferred + a.size.transferred)
@ -73,9 +76,10 @@ export default function DownloadingState() {
size="sm" size="sm"
onClick={() => { onClick={() => {
if (item?.modelId) { if (item?.modelId) {
extensionManager const model = downloadingModels.find(
.get<ModelExtension>(ExtensionType.Model) (model) => model.id === item.modelId
?.cancelModelDownload(item.modelId) )
if (model) abortModelDownload(model)
} }
}} }}
> >

View File

@ -1,6 +1,5 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { ModelExtension, ExtensionType } from '@janhq/core'
import { Model } from '@janhq/core' import { Model } from '@janhq/core'
import { import {
@ -17,11 +16,12 @@ import {
import { atom, useAtomValue } from 'jotai' import { atom, useAtomValue } from 'jotai'
import useDownloadModel from '@/hooks/useDownloadModel'
import { useDownloadState } from '@/hooks/useDownloadState' import { useDownloadState } from '@/hooks/useDownloadState'
import { formatDownloadPercentage } from '@/utils/converter' import { formatDownloadPercentage } from '@/utils/converter'
import { extensionManager } from '@/extension' import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom'
type Props = { type Props = {
model: Model model: Model
@ -30,6 +30,7 @@ type Props = {
export default function ModalCancelDownload({ model, isFromList }: Props) { export default function ModalCancelDownload({ model, isFromList }: Props) {
const { modelDownloadStateAtom } = useDownloadState() const { modelDownloadStateAtom } = useDownloadState()
const downloadingModels = useAtomValue(downloadingModelsAtom)
const downloadAtom = useMemo( const downloadAtom = useMemo(
() => atom((get) => get(modelDownloadStateAtom)[model.id]), () => atom((get) => get(modelDownloadStateAtom)[model.id]),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -37,6 +38,7 @@ export default function ModalCancelDownload({ model, isFromList }: Props) {
) )
const downloadState = useAtomValue(downloadAtom) const downloadState = useAtomValue(downloadAtom)
const cancelText = `Cancel ${formatDownloadPercentage(downloadState.percent)}` const cancelText = `Cancel ${formatDownloadPercentage(downloadState.percent)}`
const { abortModelDownload } = useDownloadModel()
return ( return (
<Modal> <Modal>
@ -80,9 +82,10 @@ export default function ModalCancelDownload({ model, isFromList }: Props) {
themes="danger" themes="danger"
onClick={() => { onClick={() => {
if (downloadState?.modelId) { if (downloadState?.modelId) {
extensionManager const model = downloadingModels.find(
.get<ModelExtension>(ExtensionType.Model) (model) => model.id === downloadState.modelId
?.cancelModelDownload(downloadState.modelId) )
if (model) abortModelDownload(model)
} }
}} }}
> >

View File

@ -1,34 +1,35 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { PropsWithChildren, useEffect, useRef } from 'react' import { basename } from 'path'
import { ExtensionType } from '@janhq/core' import { PropsWithChildren, useEffect, useRef } from 'react'
import { ModelExtension } from '@janhq/core'
import { useAtomValue, useSetAtom } from 'jotai' import { useAtomValue, useSetAtom } from 'jotai'
import { useDownloadState } from '@/hooks/useDownloadState' import { useDownloadState } from '@/hooks/useDownloadState'
import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels' import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels'
import { modelBinFileName } from '@/utils/model'
import EventHandler from './EventHandler' import EventHandler from './EventHandler'
import { appDownloadProgress } from './Jotai' import { appDownloadProgress } from './Jotai'
import { extensionManager } from '@/extension/ExtensionManager'
import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom' import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom'
export default function EventListenerWrapper({ children }: PropsWithChildren) { export default function EventListenerWrapper({ children }: PropsWithChildren) {
const setProgress = useSetAtom(appDownloadProgress) const setProgress = useSetAtom(appDownloadProgress)
const models = useAtomValue(downloadingModelsAtom) const models = useAtomValue(downloadingModelsAtom)
const modelsRef = useRef(models) const modelsRef = useRef(models)
useEffect(() => {
modelsRef.current = models
}, [models])
const { setDownloadedModels, downloadedModels } = useGetDownloadedModels() const { setDownloadedModels, downloadedModels } = useGetDownloadedModels()
const { setDownloadState, setDownloadStateSuccess, setDownloadStateFailed } = const { setDownloadState, setDownloadStateSuccess, setDownloadStateFailed } =
useDownloadState() useDownloadState()
const downloadedModelRef = useRef(downloadedModels) const downloadedModelRef = useRef(downloadedModels)
useEffect(() => {
modelsRef.current = models
}, [models])
useEffect(() => { useEffect(() => {
downloadedModelRef.current = downloadedModels downloadedModelRef.current = downloadedModels
}, [downloadedModels]) }, [downloadedModels])
@ -38,40 +39,36 @@ export default function EventListenerWrapper({ children }: PropsWithChildren) {
window.electronAPI.onFileDownloadUpdate( window.electronAPI.onFileDownloadUpdate(
(_event: string, state: any | undefined) => { (_event: string, state: any | undefined) => {
if (!state) return if (!state) return
const model = modelsRef.current.find(
(model) => modelBinFileName(model) === basename(state.fileName)
)
if (model)
setDownloadState({ setDownloadState({
...state, ...state,
modelId: state.fileName.split('/').pop() ?? '', modelId: model.id,
}) })
} }
) )
window.electronAPI.onFileDownloadError( window.electronAPI.onFileDownloadError((_event: string, state: any) => {
(_event: string, callback: any) => { console.error('Download error', state)
console.error('Download error', callback) const model = modelsRef.current.find(
const modelId = callback.fileName.split('/').pop() ?? '' (model) => modelBinFileName(model) === basename(state.fileName)
setDownloadStateFailed(modelId)
}
) )
if (model) setDownloadStateFailed(model.id)
})
window.electronAPI.onFileDownloadSuccess( window.electronAPI.onFileDownloadSuccess((_event: string, state: any) => {
(_event: string, callback: any) => { if (state && state.fileName) {
if (callback && callback.fileName) { const model = modelsRef.current.find(
const modelId = callback.fileName.split('/').pop() ?? '' (model) => modelBinFileName(model) === basename(state.fileName)
)
const model = modelsRef.current.find((e) => e.id === modelId) if (model) {
setDownloadStateSuccess(model.id)
setDownloadStateSuccess(modelId)
if (model)
extensionManager
.get<ModelExtension>(ExtensionType.Model)
?.saveModel(model)
.then(() => {
setDownloadedModels([...downloadedModelRef.current, model]) setDownloadedModels([...downloadedModelRef.current, model])
}
}
}) })
}
}
)
window.electronAPI.onAppUpdateDownloadUpdate( window.electronAPI.onAppUpdateDownloadUpdate(
(_event: string, progress: any) => { (_event: string, progress: any) => {

View File

@ -81,7 +81,10 @@ export class ExtensionManager {
*/ */
async activateExtension(extension: Extension) { async activateExtension(extension: Extension) {
// Import class // Import class
await import(/* webpackIgnore: true */ extension.url).then( const extensionUrl = window.electronAPI
? extension.url
: extension.url.replace('extension://', `${API_BASE_URL}/extensions/`)
await import(/* webpackIgnore: true */ extensionUrl).then(
(extensionClass) => { (extensionClass) => {
// Register class if it has a default export // Register class if it has a default export
if ( if (

View File

@ -1,7 +1,15 @@
import { Model, ExtensionType, ModelExtension } from '@janhq/core' import {
Model,
ExtensionType,
ModelExtension,
abortDownload,
joinPath,
} from '@janhq/core'
import { useSetAtom } from 'jotai' import { useSetAtom } from 'jotai'
import { modelBinFileName } from '@/utils/model'
import { useDownloadState } from './useDownloadState' import { useDownloadState } from './useDownloadState'
import { extensionManager } from '@/extension/ExtensionManager' import { extensionManager } from '@/extension/ExtensionManager'
@ -33,8 +41,14 @@ export default function useDownloadModel() {
.get<ModelExtension>(ExtensionType.Model) .get<ModelExtension>(ExtensionType.Model)
?.downloadModel(model) ?.downloadModel(model)
} }
const abortModelDownload = async (model: Model) => {
await abortDownload(
await joinPath(['models', model.id, modelBinFileName(model)])
)
}
return { return {
downloadModel, downloadModel,
abortModelDownload,
} }
} }

View File

@ -1,12 +1,15 @@
import { join } from 'path' import { fs, joinPath } from '@janhq/core'
import { fs } from '@janhq/core'
export const useEngineSettings = () => { export const useEngineSettings = () => {
const readOpenAISettings = async () => { const readOpenAISettings = async () => {
const settings = await fs.readFile(join('engines', 'openai.json')) if (!fs.existsSync(await joinPath(['file://engines', 'openai.json'])))
return {}
const settings = await fs.readFileSync(
await joinPath(['file://engines', 'openai.json']),
'utf-8'
)
if (settings) { if (settings) {
return JSON.parse(settings) return typeof settings === 'object' ? settings : JSON.parse(settings)
} }
return {} return {}
} }
@ -17,7 +20,10 @@ export const useEngineSettings = () => {
}) => { }) => {
const settings = await readOpenAISettings() const settings = await readOpenAISettings()
settings.api_key = apiKey settings.api_key = apiKey
await fs.writeFile(join('engines', 'openai.json'), JSON.stringify(settings)) await fs.writeFileSync(
await joinPath(['file://engines', 'openai.json']),
JSON.stringify(settings)
)
} }
return { readOpenAISettings, saveOpenAISettings } return { readOpenAISettings, saveOpenAISettings }
} }

View File

@ -13,7 +13,6 @@ import {
events, events,
Model, Model,
ConversationalExtension, ConversationalExtension,
ModelRuntimeParams,
} from '@janhq/core' } from '@janhq/core'
import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { useAtom, useAtomValue, useSetAtom } from 'jotai'
@ -173,7 +172,7 @@ export default function useSendChatMessage() {
updateThreadInitSuccess(activeThread.id) updateThreadInitSuccess(activeThread.id)
updateThread(updatedThread) updateThread(updatedThread)
extensionManager await extensionManager
.get<ConversationalExtension>(ExtensionType.Conversational) .get<ConversationalExtension>(ExtensionType.Conversational)
?.saveThread(updatedThread) ?.saveThread(updatedThread)
} }

View File

@ -32,6 +32,10 @@ const nextConfig = {
JSON.stringify(process.env.ANALYTICS_ID) ?? JSON.stringify('xxx'), JSON.stringify(process.env.ANALYTICS_ID) ?? JSON.stringify('xxx'),
ANALYTICS_HOST: ANALYTICS_HOST:
JSON.stringify(process.env.ANALYTICS_HOST) ?? JSON.stringify('xxx'), JSON.stringify(process.env.ANALYTICS_HOST) ?? JSON.stringify('xxx'),
API_BASE_URL: JSON.stringify('http://localhost:1337'),
isMac: process.platform === 'darwin',
isWindows: process.platform === 'win32',
isLinux: process.platform === 'linux',
}), }),
] ]
return config return config

View File

@ -116,7 +116,7 @@ const ChatBody: React.FC = () => {
) : ( ) : (
<ScrollToBottom className="flex h-full w-full flex-col"> <ScrollToBottom className="flex h-full w-full flex-col">
{messages.map((message, index) => ( {messages.map((message, index) => (
<> <div key={message.id}>
<ChatItem {...message} key={message.id} /> <ChatItem {...message} key={message.id} />
{message.status === MessageStatus.Error && {message.status === MessageStatus.Error &&
@ -126,8 +126,8 @@ const ChatBody: React.FC = () => {
className="mt-10 flex flex-col items-center" className="mt-10 flex flex-col items-center"
> >
<span className="mb-3 text-center text-sm font-medium text-gray-500"> <span className="mb-3 text-center text-sm font-medium text-gray-500">
Oops! The generation was interrupted. Let&apos;s Oops! The generation was interrupted. Let&apos;s give it
give it another go! another go!
</span> </span>
<Button <Button
className="w-min" className="w-min"
@ -140,7 +140,7 @@ const ChatBody: React.FC = () => {
</Button> </Button>
</div> </div>
)} )}
</> </div>
))} ))}
</ScrollToBottom> </ScrollToBottom>
)} )}

View File

@ -1,8 +1,6 @@
import { join } from 'path'
import React from 'react' import React from 'react'
import { getUserSpace, openFileExplorer } from '@janhq/core' import { getUserSpace, openFileExplorer, joinPath } from '@janhq/core'
import { Input, Textarea } from '@janhq/uikit' import { Input, Textarea } from '@janhq/uikit'
@ -53,24 +51,24 @@ const Sidebar: React.FC = () => {
let filePath = undefined let filePath = undefined
const assistantId = activeThread.assistants[0]?.assistant_id const assistantId = activeThread.assistants[0]?.assistant_id
switch (type) { switch (type) {
case 'Engine':
case 'Thread': case 'Thread':
filePath = join('threads', activeThread.id) filePath = await joinPath(['threads', activeThread.id])
break break
case 'Model': case 'Model':
if (!selectedModel) return if (!selectedModel) return
filePath = join('models', selectedModel.id) filePath = await joinPath(['models', selectedModel.id])
break break
case 'Assistant': case 'Assistant':
if (!assistantId) return if (!assistantId) return
filePath = join('assistants', assistantId) filePath = await joinPath(['assistants', assistantId])
break break
default: default:
break break
} }
if (!filePath) return if (!filePath) return
const fullPath = await joinPath([userSpace, filePath])
const fullPath = join(userSpace, filePath)
openFileExplorer(fullPath) openFileExplorer(fullPath)
} }
@ -86,24 +84,24 @@ const Sidebar: React.FC = () => {
let filePath = undefined let filePath = undefined
const assistantId = activeThread.assistants[0]?.assistant_id const assistantId = activeThread.assistants[0]?.assistant_id
switch (type) { switch (type) {
case 'Engine':
case 'Thread': case 'Thread':
filePath = join('threads', activeThread.id, 'thread.json') filePath = await joinPath(['threads', activeThread.id, 'thread.json'])
break break
case 'Model': case 'Model':
if (!selectedModel) return if (!selectedModel) return
filePath = join('models', selectedModel.id, 'model.json') filePath = await joinPath(['models', selectedModel.id, 'model.json'])
break break
case 'Assistant': case 'Assistant':
if (!assistantId) return if (!assistantId) return
filePath = join('assistants', assistantId, 'assistant.json') filePath = await joinPath(['assistants', assistantId, 'assistant.json'])
break break
default: default:
break break
} }
if (!filePath) return if (!filePath) return
const fullPath = await joinPath([userSpace, filePath])
const fullPath = join(userSpace, filePath)
openFileExplorer(fullPath) openFileExplorer(fullPath)
} }

View File

@ -5,8 +5,6 @@ import React, { useState, useEffect, useRef, useContext } from 'react'
import { Button } from '@janhq/uikit' import { Button } from '@janhq/uikit'
import Loader from '@/containers/Loader'
import { FeatureToggleContext } from '@/context/FeatureToggle' import { FeatureToggleContext } from '@/context/FeatureToggle'
import { useGetAppVersion } from '@/hooks/useGetAppVersion' import { useGetAppVersion } from '@/hooks/useGetAppVersion'
@ -18,7 +16,6 @@ import { extensionManager } from '@/extension'
const ExtensionCatalog = () => { const ExtensionCatalog = () => {
const [activeExtensions, setActiveExtensions] = useState<any[]>([]) const [activeExtensions, setActiveExtensions] = useState<any[]>([])
const [extensionCatalog, setExtensionCatalog] = useState<any[]>([]) const [extensionCatalog, setExtensionCatalog] = useState<any[]>([])
const [isLoading, setIsLoading] = useState<boolean>(false)
const fileInputRef = useRef<HTMLInputElement | null>(null) const fileInputRef = useRef<HTMLInputElement | null>(null)
const { version } = useGetAppVersion() const { version } = useGetAppVersion()
const { experimentalFeatureEnabed } = useContext(FeatureToggleContext) const { experimentalFeatureEnabed } = useContext(FeatureToggleContext)
@ -95,8 +92,6 @@ const ExtensionCatalog = () => {
} }
} }
if (isLoading) return <Loader description="Installing ..." />
return ( return (
<div className="block w-full"> <div className="block w-full">
{extensionCatalog {extensionCatalog

View File

@ -15,7 +15,7 @@ export default function Models() {
const [searchValue, setsearchValue] = useState('') const [searchValue, setsearchValue] = useState('')
const filteredDownloadedModels = downloadedModels.filter((x) => { const filteredDownloadedModels = downloadedModels.filter((x) => {
return x.name.toLowerCase().includes(searchValue.toLowerCase()) return x.name?.toLowerCase().includes(searchValue.toLowerCase())
}) })
return ( return (

View File

@ -1,76 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { toast } from 'react-toastify'
const API_BASE_PATH: string = '/api/v1'
export function openExternalUrl(url: string) {
window?.open(url, '_blank')
}
export async function appVersion() {
return Promise.resolve(VERSION)
}
export function invokeExtensionFunc(
modulePath: string,
extensionFunc: string,
...args: any
): Promise<any> {
return fetchApi(modulePath, extensionFunc, args).catch((err: Error) => {
throw err
})
}
export async function downloadFile(downloadUrl: string, fileName: string) {
return fetchApi('', 'downloadFile', {
downloadUrl: downloadUrl,
fileName: fileName,
}).catch((err: Error) => {
throw err
})
}
export async function deleteFile(fileName: string) {
return fetchApi('', 'deleteFile', fileName).catch((err: Error) => {
throw err
})
}
export async function fetchApi(
modulePath: string,
extensionFunc: string,
args: any
): Promise<any> {
const response = await fetch(API_BASE_PATH + '/invokeFunction', {
method: 'POST',
body: JSON.stringify({
modulePath: modulePath,
method: extensionFunc,
args: args,
}),
headers: { contentType: 'application/json', Authorization: '' },
})
if (!response.ok) {
const json = await response.json()
if (json && json.error) {
toast.error(json.error, {
position: 'bottom-left',
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: 'light',
})
}
return null
}
const text = await response.text()
try {
const json = JSON.parse(text)
return Promise.resolve(json)
} catch (err) {
return Promise.resolve(text)
}
}

View File

@ -1,5 +1,5 @@
import * as restAPI from './cloudNativeService'
import { EventEmitter } from './eventsService' import { EventEmitter } from './eventsService'
import { restAPI } from './restService'
export const setupCoreServices = () => { export const setupCoreServices = () => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
console.debug('undefine', window) console.debug('undefine', window)
@ -10,9 +10,7 @@ export const setupCoreServices = () => {
if (!window.core) { if (!window.core) {
window.core = { window.core = {
events: new EventEmitter(), events: new EventEmitter(),
api: window.electronAPI ?? { api: window.electronAPI ?? restAPI,
...restAPI,
},
} }
} }
} }

View File

@ -15,13 +15,10 @@ export const isCoreExtensionInstalled = () => {
return true return true
} }
export const setupBaseExtensions = async () => { export const setupBaseExtensions = async () => {
if ( if (typeof window === 'undefined') {
typeof window === 'undefined' ||
typeof window.electronAPI === 'undefined'
) {
return return
} }
const baseExtensions = await window.electronAPI.baseExtensions() const baseExtensions = await window.core?.api.baseExtensions()
if ( if (
!extensionManager.get(ExtensionType.Conversational) || !extensionManager.get(ExtensionType.Conversational) ||

View File

@ -0,0 +1,59 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
AppRoute,
DownloadRoute,
ExtensionRoute,
FileSystemRoute,
} from '@janhq/core'
import { safeJsonParse } from '@/utils/json'
// Function to open an external URL in a new browser window
export function openExternalUrl(url: string) {
window?.open(url, '_blank')
}
// Async function to get the application version
export async function appVersion() {
return Promise.resolve(VERSION)
}
// Define API routes based on different route types
export const APIRoutes = [
...Object.values(AppRoute).map((r) => ({ path: 'app', route: r })),
...Object.values(DownloadRoute).map((r) => ({ path: `download`, route: r })),
...Object.values(ExtensionRoute).map((r) => ({
path: `extension`,
route: r,
})),
...Object.values(FileSystemRoute).map((r) => ({ path: `fs`, route: r })),
]
// Define the restAPI object with methods for each API route
export const restAPI = {
...Object.values(APIRoutes).reduce((acc, proxy) => {
return {
...acc,
[proxy.route]: (...args: any) => {
// For each route, define a function that sends a request to the API
return fetch(`${API_BASE_URL}/v1/${proxy.path}/${proxy.route}`, {
method: 'POST',
body: JSON.stringify(args),
headers: { contentType: 'application/json' },
}).then(async (res) => {
try {
if (proxy.path === 'fs') {
const text = await res.text()
return safeJsonParse(text) ?? text
}
return await res.json()
} catch (err) {
console.debug('Op: ', proxy, args, err)
}
})
},
}
}, {}),
openExternalUrl,
appVersion,
}

View File

@ -8,6 +8,10 @@ declare global {
declare const VERSION: string declare const VERSION: string
declare const ANALYTICS_ID: string declare const ANALYTICS_ID: string
declare const ANALYTICS_HOST: string declare const ANALYTICS_HOST: string
declare const API_BASE_URL: string
declare const isMac: boolean
declare const isWindows: boolean
declare const isLinux: boolean
interface Core { interface Core {
api: APIFunctions api: APIFunctions
events: EventEmitter events: EventEmitter

9
web/utils/json.ts Normal file
View File

@ -0,0 +1,9 @@
export const safeJsonParse = <T>(str: string) => {
try {
const jsonValue: T = JSON.parse(str)
return jsonValue
} catch {
return undefined
}
}

12
web/utils/model.ts Normal file
View File

@ -0,0 +1,12 @@
import { basename } from 'path'
import { Model } from '@janhq/core'
export const modelBinFileName = (model: Model) => {
const modelFormatExt = '.gguf'
const extractedFileName = basename(model.source_url) ?? model.id
const fileName = extractedFileName.toLowerCase().endsWith(modelFormatExt)
? extractedFileName
: model.id
return fileName
}

View File

@ -22,8 +22,7 @@ export const toRuntimeParams = (
for (const [key, value] of Object.entries(modelParams)) { for (const [key, value] of Object.entries(modelParams)) {
if (key in defaultModelParams) { if (key in defaultModelParams) {
// @ts-ignore runtimeParams[key as keyof ModelRuntimeParams] = value
runtimeParams[key] = value
} }
} }
@ -46,8 +45,7 @@ export const toSettingParams = (
for (const [key, value] of Object.entries(modelParams)) { for (const [key, value] of Object.entries(modelParams)) {
if (key in defaultSettingParams) { if (key in defaultSettingParams) {
// @ts-ignore settingParams[key as keyof ModelSettingParams] = value
settingParams[key] = value
} }
} }