diff --git a/electron/api/index.ts b/electron/api/index.ts new file mode 100644 index 0000000000..88e5ffb61e --- /dev/null +++ b/electron/api/index.ts @@ -0,0 +1,31 @@ +export const JanApiRouteConfiguration: Record = { + models: { + dirName: 'models', + metadataFileName: 'model.json', + delete: { + object: 'model', + }, + }, + assistants: { + dirName: 'assistants', + metadataFileName: 'assistant.json', + delete: { + object: 'assistant', + }, + }, + threads: { + dirName: 'threads', + metadataFileName: 'thread.json', + delete: { + object: 'thread', + }, + }, +} + +export type RouteConfiguration = { + dirName: string + metadataFileName: string + delete: { + object: string + } +} diff --git a/electron/api/models/index.ts b/electron/api/models/index.ts new file mode 100644 index 0000000000..ecf43887a6 --- /dev/null +++ b/electron/api/models/index.ts @@ -0,0 +1,288 @@ +const fs = require('fs') +import { rimraf } from 'rimraf' +import { JanApiRouteConfiguration, RouteConfiguration } from '../index' +import { join } from 'path' +import { ThreadMessage } from '@janhq/core/dist/types/types' +import { ulid } from 'ulid' +const progress = require('request-progress') +import request from 'request' +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.includes('.')) allDirectories.push(file) + } + + const readJsonPromises = allDirectories.map((dirName) => { + const jsonPath = join( + directoryPath, + dirName, + configuration.metadataFileName + ) + return readModelMetadata(jsonPath) + }) + + const results = await Promise.allSettled(readJsonPromises) + const modelData = results + .map((result) => { + if (result.status === 'fulfilled') { + try { + return JSON.parse(result.value) + } catch { + return result.value + } + } else { + console.error(result.reason) + return undefined + } + }) + .filter((e) => !!e) + + return modelData + } catch (err) { + console.error(err) + return [] + } +} + +const readModelMetadata = (path: string) => { + return fs.readFileSync(join(path)) +} + +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) + rimraf.sync(myPath) + 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) + .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}`, + } +} diff --git a/electron/main.ts b/electron/main.ts index 6e1a60d489..2ebfe531a7 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2,6 +2,7 @@ import { app, BrowserWindow } from 'electron' import { join } from 'path' import { setupMenu } from './utils/menu' import { createUserSpace } from './utils/path' +import Fastify from 'fastify' /** * Managers @@ -20,6 +21,77 @@ import { handleExtensionIPCs } from './handlers/extension' import { handleAppIPCs } from './handlers/app' import { handleAppUpdates } from './handlers/update' import { handleFsIPCs } from './handlers/fs' +import { + createMessage, + createThread, + deleteBuilder, + downloadModel, + getBuilder, + getMessages, + retrieveBuilder, + retrieveMesasge, + updateThread, +} from './api/models' +import { JanApiRouteConfiguration } from './api' + +// TODO: refactor this, this API piece of code should not belong here +const version = 'v1' + +const fastify = Fastify({ + logger: true, +}) + +fastify.listen({ port: 1337 }, function (err, address) { + if (err) { + fastify.log.error(err) + process.exit(1) + } +}) + +Object.keys(JanApiRouteConfiguration).forEach((key) => { + fastify.get(`/${version}/${key}`, async (_request) => + getBuilder(JanApiRouteConfiguration[key]) + ) + + fastify.get(`/${version}/${key}/:id`, async (request: any) => + retrieveBuilder(JanApiRouteConfiguration[key], request.params.id) + ) + + fastify.delete(`/${version}/${key}/:id`, async (request: any) => + deleteBuilder(JanApiRouteConfiguration[key], request.params.id) + ) +}) + +// get messages of thread id +fastify.get(`/${version}/threads/:threadId/messages`, async (request: any) => + getMessages(request.params.threadId) +) + +// retrieve message +fastify.get( + `/${version}/threads/:threadId/messages/:messageId`, + async (request: any) => + retrieveMesasge(request.params.threadId, request.params.messageId) +) + +// create thread +fastify.post(`/${version}/threads`, async (request: any) => + createThread(request.body) +) + +// create message +fastify.post(`/${version}/threads/:threadId/messages`, async (request: any) => + createMessage(request.params.threadId, request.body) +) + +// modify thread +fastify.patch(`/${version}/threads/:threadId`, async (request: any) => + updateThread(request.params.threadId, request.body) +) + +fastify.get(`/${version}/models/download/:modelId`, async (request: any) => + downloadModel(request.params.modelId) +) app .whenReady() diff --git a/electron/package.json b/electron/package.json index 623071e38d..daaa27019d 100644 --- a/electron/package.json +++ b/electron/package.json @@ -73,10 +73,14 @@ "@uiball/loaders": "^1.3.0", "electron-store": "^8.1.0", "electron-updater": "^6.1.4", + "fastify": "^4.25.0", "fs-extra": "^11.2.0", "pacote": "^17.0.4", "request": "^2.88.2", "request-progress": "^3.0.0", + "rimraf": "^5.0.5", + "typescript": "^5.3.3", + "ulid": "^2.3.0", "use-debounce": "^9.0.4" }, "devDependencies": {