diff --git a/Dockerfile b/Dockerfile index 949a92673..82c657604 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,39 +1,58 @@ -FROM node:20-bullseye AS base +FROM node:20-bookworm AS base # 1. Install dependencies only when needed -FROM base AS deps +FROM base AS builder + +# Install g++ 11 +RUN apt update && apt install -y gcc-11 g++-11 cpp-11 jq xsel && rm -rf /var/lib/apt/lists/* + WORKDIR /app # Install dependencies based on the preferred package manager -COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ -RUN yarn install +COPY . ./ + +RUN export NITRO_VERSION=$(cat extensions/inference-nitro-extension/bin/version.txt) && \ + jq --arg nitroVersion $NITRO_VERSION '(.scripts."downloadnitro:linux" | gsub("\\${NITRO_VERSION}"; $nitroVersion)) | gsub("\r"; "")' extensions/inference-nitro-extension/package.json > /tmp/newcommand.txt && export NEW_COMMAND=$(sed 's/^"//;s/"$//' /tmp/newcommand.txt) && jq --arg newCommand "$NEW_COMMAND" '.scripts."downloadnitro:linux" = $newCommand' extensions/inference-nitro-extension/package.json > /tmp/package.json && mv /tmp/package.json extensions/inference-nitro-extension/package.json +RUN make install-and-build +RUN yarn workspace jan-web install + +RUN export NODE_ENV=production && yarn workspace jan-web build # # 2. Rebuild the source code only when needed -FROM base AS builder -WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules -COPY . . -# This will do the trick, use the corresponding env file for each environment. -RUN yarn workspace server install -RUN yarn server:prod - -# 3. Production image, copy all the files and run next FROM base AS runner + +# Install g++ 11 +RUN apt update && apt install -y gcc-11 g++-11 cpp-11 jq xsel && rm -rf /var/lib/apt/lists/* + WORKDIR /app -ENV NODE_ENV=production +# Copy the package.json and yarn.lock of root yarn space to leverage Docker cache +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/node_modules ./node_modules/ +COPY --from=builder /app/yarn.lock ./yarn.lock -# RUN addgroup -g 1001 -S nodejs; -COPY --from=builder /app/server/build ./ +# Copy the package.json, yarn.lock, and build output of server yarn space to leverage Docker cache +COPY --from=builder /app/server ./server/ +COPY --from=builder /app/docs/openapi ./docs/openapi/ -# Automatically leverage output traces to reduce image size -# https://nextjs.org/docs/advanced-features/output-file-tracing -COPY --from=builder /app/server/node_modules ./node_modules -COPY --from=builder /app/server/package.json ./package.json +# Copy pre-install dependencies +COPY --from=builder /app/pre-install ./pre-install/ -EXPOSE 4000 3928 +# Copy the package.json, yarn.lock, and output of web yarn space to leverage Docker cache +COPY --from=builder /app/web/out ./web/out/ +COPY --from=builder /app/web/.next ./web/.next/ +COPY --from=builder /app/web/package.json ./web/package.json +COPY --from=builder /app/web/yarn.lock ./web/yarn.lock +COPY --from=builder /app/models ./models/ -ENV PORT 4000 -ENV APPDATA /app/data +RUN npm install -g serve@latest -CMD ["node", "main.js"] \ No newline at end of file +EXPOSE 1337 3000 3928 + +ENV JAN_API_HOST 0.0.0.0 +ENV JAN_API_PORT 1337 + +CMD ["sh", "-c", "cd server && node build/main.js & cd web && npx serve out"] + +# docker build -t jan . +# docker run -p 1337:1337 -p 3000:3000 -p 3928:3928 jan diff --git a/Dockerfile.gpu b/Dockerfile.gpu new file mode 100644 index 000000000..7b00e91d5 --- /dev/null +++ b/Dockerfile.gpu @@ -0,0 +1,65 @@ +FROM nvidia/cuda:12.0.0-devel-ubuntu22.04 AS base + +# 1. Install dependencies only when needed +FROM base AS builder + +# Install g++ 11 +RUN apt update && apt install -y gcc-11 g++-11 cpp-11 jq xsel curl gnupg && curl -sL https://deb.nodesource.com/setup_20.x | bash - && apt install nodejs -y && rm -rf /var/lib/apt/lists/* + +RUN npm install -g yarn + +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY . ./ + +RUN export NITRO_VERSION=$(cat extensions/inference-nitro-extension/bin/version.txt) && \ + jq --arg nitroVersion $NITRO_VERSION '(.scripts."downloadnitro:linux" | gsub("\\${NITRO_VERSION}"; $nitroVersion)) | gsub("\r"; "")' extensions/inference-nitro-extension/package.json > /tmp/newcommand.txt && export NEW_COMMAND=$(sed 's/^"//;s/"$//' /tmp/newcommand.txt) && jq --arg newCommand "$NEW_COMMAND" '.scripts."downloadnitro:linux" = $newCommand' extensions/inference-nitro-extension/package.json > /tmp/package.json && mv /tmp/package.json extensions/inference-nitro-extension/package.json +RUN make install-and-build +RUN yarn workspace jan-web install + +RUN export NODE_ENV=production && yarn workspace jan-web build + +# # 2. Rebuild the source code only when needed +FROM base AS runner + +# Install g++ 11 +RUN apt update && apt install -y gcc-11 g++-11 cpp-11 jq xsel curl gnupg && curl -sL https://deb.nodesource.com/setup_20.x | bash - && apt-get install nodejs -y && rm -rf /var/lib/apt/lists/* + +RUN npm install -g yarn + +WORKDIR /app + +# Copy the package.json and yarn.lock of root yarn space to leverage Docker cache +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/node_modules ./node_modules/ +COPY --from=builder /app/yarn.lock ./yarn.lock + +# Copy the package.json, yarn.lock, and build output of server yarn space to leverage Docker cache +COPY --from=builder /app/server ./server/ +COPY --from=builder /app/docs/openapi ./docs/openapi/ + +# Copy pre-install dependencies +COPY --from=builder /app/pre-install ./pre-install/ + +# Copy the package.json, yarn.lock, and output of web yarn space to leverage Docker cache +COPY --from=builder /app/web/out ./web/out/ +COPY --from=builder /app/web/.next ./web/.next/ +COPY --from=builder /app/web/package.json ./web/package.json +COPY --from=builder /app/web/yarn.lock ./web/yarn.lock +COPY --from=builder /app/models ./models/ + +RUN npm install -g serve@latest + +EXPOSE 1337 3000 3928 + +ENV LD_LIBRARY_PATH=/usr/local/cuda-12.0/targets/x86_64-linux/lib:/usr/local/cuda-12.0/compat${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}} + +ENV JAN_API_HOST 0.0.0.0 +ENV JAN_API_PORT 1337 + +CMD ["sh", "-c", "cd server && node build/main.js & cd web && npx serve out"] + +# pre-requisites: nvidia-docker +# docker build -t jan-gpu . -f Dockerfile.gpu +# docker run -p 1337:1337 -p 3000:3000 -p 3928:3928 --gpus all jan-gpu diff --git a/Makefile b/Makefile index 905a68321..ffb1abee2 100644 --- a/Makefile +++ b/Makefile @@ -24,9 +24,9 @@ endif check-file-counts: install-and-build ifeq ($(OS),Windows_NT) - powershell -Command "if ((Get-ChildItem -Path electron/pre-install -Filter *.tgz | Measure-Object | Select-Object -ExpandProperty Count) -ne (Get-ChildItem -Path extensions -Directory | Measure-Object | Select-Object -ExpandProperty Count)) { Write-Host 'Number of .tgz files in electron/pre-install does not match the number of subdirectories in extension'; exit 1 } else { Write-Host 'Extension build successful' }" + powershell -Command "if ((Get-ChildItem -Path pre-install -Filter *.tgz | Measure-Object | Select-Object -ExpandProperty Count) -ne (Get-ChildItem -Path extensions -Directory | Measure-Object | Select-Object -ExpandProperty Count)) { Write-Host 'Number of .tgz files in pre-install does not match the number of subdirectories in extension'; exit 1 } else { Write-Host 'Extension build successful' }" else - @tgz_count=$$(find electron/pre-install -type f -name "*.tgz" | wc -l); dir_count=$$(find extensions -mindepth 1 -maxdepth 1 -type d | wc -l); if [ $$tgz_count -ne $$dir_count ]; then echo "Number of .tgz files in electron/pre-install ($$tgz_count) does not match the number of subdirectories in extension ($$dir_count)"; exit 1; else echo "Extension build successful"; fi + @tgz_count=$$(find pre-install -type f -name "*.tgz" | wc -l); dir_count=$$(find extensions -mindepth 1 -maxdepth 1 -type d | wc -l); if [ $$tgz_count -ne $$dir_count ]; then echo "Number of .tgz files in pre-install ($$tgz_count) does not match the number of subdirectories in extension ($$dir_count)"; exit 1; else echo "Extension build successful"; fi endif dev: check-file-counts diff --git a/README.md b/README.md index e1f74ef23..14437498b 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,31 @@ make build This will build the app MacOS m1/m2 for production (with code signing already done) and put the result in `dist` folder. +### Docker mode + +- Supported OS: Linux, WSL2 Docker +- Pre-requisites: + - `docker` and `docker compose`, follow instruction [here](https://docs.docker.com/engine/install/ubuntu/) + + ```bash + curl -fsSL https://get.docker.com -o get-docker.sh + sudo sh ./get-docker.sh --dry-run + ``` + + - `nvidia docker`, follow instruction [here](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) (If you want to run with GPU mode) + +- Run Jan in Docker mode + + ```bash + # CPU mode + docker compose --profile cpu up + + # GPU mode + docker compose --profile gpu up + ``` + + This will start the web server and you can access Jan at `http://localhost:3000`. + ## Acknowledgements Jan builds on top of other open-source projects: diff --git a/core/package.json b/core/package.json index 437e6d0a6..c3abe2d56 100644 --- a/core/package.json +++ b/core/package.json @@ -57,6 +57,7 @@ "rollup-plugin-typescript2": "^0.36.0", "ts-jest": "^26.1.1", "tslib": "^2.6.2", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "rimraf": "^3.0.2" } } diff --git a/core/src/api/index.ts b/core/src/api/index.ts index 0d7cc51f7..f4ec3cd7e 100644 --- a/core/src/api/index.ts +++ b/core/src/api/index.ts @@ -30,6 +30,7 @@ export enum DownloadRoute { downloadFile = 'downloadFile', pauseDownload = 'pauseDownload', resumeDownload = 'resumeDownload', + getDownloadProgress = 'getDownloadProgress', } export enum DownloadEvent { diff --git a/core/src/node/api/routes/download.ts b/core/src/node/api/routes/download.ts index ab8c0bd37..7fb05daee 100644 --- a/core/src/node/api/routes/download.ts +++ b/core/src/node/api/routes/download.ts @@ -5,8 +5,27 @@ import { HttpServer } from '../HttpServer' import { createWriteStream } from 'fs' import { getJanDataFolderPath } from '../../utils' import { normalizeFilePath } from '../../path' +import { DownloadState } from '../../../types' export const downloadRouter = async (app: HttpServer) => { + app.get(`/${DownloadRoute.getDownloadProgress}/:modelId`, async (req, res) => { + const modelId = req.params.modelId + + console.debug(`Getting download progress for model ${modelId}`) + console.debug( + `All Download progress: ${JSON.stringify(DownloadManager.instance.downloadProgressMap)}` + ) + + // check if null DownloadManager.instance.downloadProgressMap + if (!DownloadManager.instance.downloadProgressMap[modelId]) { + return res.status(404).send({ + message: 'Download progress not found', + }) + } else { + return res.status(200).send(DownloadManager.instance.downloadProgressMap[modelId]) + } + }) + app.post(`/${DownloadRoute.downloadFile}`, async (req, res) => { const strictSSL = !(req.query.ignoreSSL === 'true') const proxy = req.query.proxy?.startsWith('http') ? req.query.proxy : undefined @@ -19,7 +38,10 @@ export const downloadRouter = async (app: HttpServer) => { }) const localPath = normalizedArgs[1] - const fileName = localPath.split('/').pop() ?? '' + const array = localPath.split('/') + const fileName = array.pop() ?? '' + const modelId = array.pop() ?? '' + console.debug('downloadFile', normalizedArgs, fileName, modelId) const request = require('request') const progress = require('request-progress') @@ -27,17 +49,44 @@ export const downloadRouter = async (app: HttpServer) => { const rq = request({ url: normalizedArgs[0], strictSSL, proxy }) progress(rq, {}) .on('progress', function (state: any) { - console.log('download onProgress', state) + const downloadProps: DownloadState = { + ...state, + modelId, + fileName, + downloadState: 'downloading', + } + console.debug(`Download ${modelId} onProgress`, downloadProps) + DownloadManager.instance.downloadProgressMap[modelId] = downloadProps }) .on('error', function (err: Error) { - console.log('download onError', err) + console.debug(`Download ${modelId} onError`, err.message) + + const currentDownloadState = DownloadManager.instance.downloadProgressMap[modelId] + if (currentDownloadState) { + DownloadManager.instance.downloadProgressMap[modelId] = { + ...currentDownloadState, + downloadState: 'error', + } + } }) .on('end', function () { - console.log('download onEnd') + console.debug(`Download ${modelId} onEnd`) + + const currentDownloadState = DownloadManager.instance.downloadProgressMap[modelId] + if (currentDownloadState) { + if (currentDownloadState.downloadState === 'downloading') { + // if the previous state is downloading, then set the state to end (success) + DownloadManager.instance.downloadProgressMap[modelId] = { + ...currentDownloadState, + downloadState: 'end', + } + } + } }) .pipe(createWriteStream(normalizedArgs[1])) - DownloadManager.instance.setRequest(fileName, rq) + DownloadManager.instance.setRequest(localPath, rq) + res.status(200).send({ message: 'Download started' }) }) app.post(`/${DownloadRoute.abortDownload}`, async (req, res) => { @@ -54,5 +103,10 @@ export const downloadRouter = async (app: HttpServer) => { const rq = DownloadManager.instance.networkRequests[fileName] DownloadManager.instance.networkRequests[fileName] = undefined rq?.abort() + if (rq) { + res.status(200).send({ message: 'Download aborted' }) + } else { + res.status(404).send({ message: 'Download not found' }) + } }) } diff --git a/core/src/node/api/routes/fileManager.ts b/core/src/node/api/routes/fileManager.ts index 66056444e..b4c73dda1 100644 --- a/core/src/node/api/routes/fileManager.ts +++ b/core/src/node/api/routes/fileManager.ts @@ -1,14 +1,29 @@ import { FileManagerRoute } from '../../../api' import { HttpServer } from '../../index' +import { join } from 'path' -export const fsRouter = async (app: HttpServer) => { - app.post(`/app/${FileManagerRoute.syncFile}`, async (request: any, reply: any) => {}) +export const fileManagerRouter = async (app: HttpServer) => { + app.post(`/fs/${FileManagerRoute.syncFile}`, async (request: any, reply: any) => { + const reflect = require('@alumna/reflect') + const args = JSON.parse(request.body) + return reflect({ + src: args[0], + dest: args[1], + recursive: true, + delete: false, + overwrite: true, + errorOnExist: false, + }) + }) - app.post(`/app/${FileManagerRoute.getJanDataFolderPath}`, async (request: any, reply: any) => {}) + app.post(`/fs/${FileManagerRoute.getJanDataFolderPath}`, async (request: any, reply: any) => + global.core.appPath() + ) - app.post(`/app/${FileManagerRoute.getResourcePath}`, async (request: any, reply: any) => {}) + app.post(`/fs/${FileManagerRoute.getResourcePath}`, async (request: any, reply: any) => + join(global.core.appPath(), '../../..') + ) app.post(`/app/${FileManagerRoute.getUserHomePath}`, async (request: any, reply: any) => {}) - - app.post(`/app/${FileManagerRoute.fileStat}`, async (request: any, reply: any) => {}) + app.post(`/fs/${FileManagerRoute.fileStat}`, async (request: any, reply: any) => {}) } diff --git a/core/src/node/api/routes/fs.ts b/core/src/node/api/routes/fs.ts index c5404ccce..9535418a0 100644 --- a/core/src/node/api/routes/fs.ts +++ b/core/src/node/api/routes/fs.ts @@ -1,8 +1,9 @@ -import { FileSystemRoute } from '../../../api' +import { FileManagerRoute, FileSystemRoute } from '../../../api' import { join } from 'path' import { HttpServer } from '../HttpServer' import { getJanDataFolderPath } from '../../utils' import { normalizeFilePath } from '../../path' +import { writeFileSync } from 'fs' export const fsRouter = async (app: HttpServer) => { const moduleName = 'fs' @@ -26,4 +27,14 @@ export const fsRouter = async (app: HttpServer) => { } }) }) + app.post(`/${FileManagerRoute.writeBlob}`, async (request: any, reply: any) => { + try { + const args = JSON.parse(request.body) as any[] + console.log('writeBlob:', args[0]) + const dataBuffer = Buffer.from(args[1], 'base64') + writeFileSync(args[0], dataBuffer) + } catch (err) { + console.error(`writeFile ${request.body} result: ${err}`) + } + }) } diff --git a/core/src/node/api/routes/v1.ts b/core/src/node/api/routes/v1.ts index a2a48cd8b..301c41ac0 100644 --- a/core/src/node/api/routes/v1.ts +++ b/core/src/node/api/routes/v1.ts @@ -4,6 +4,7 @@ import { threadRouter } from './thread' import { fsRouter } from './fs' import { extensionRouter } from './extension' import { downloadRouter } from './download' +import { fileManagerRouter } from './fileManager' export const v1Router = async (app: HttpServer) => { // MARK: External Routes @@ -16,6 +17,8 @@ export const v1Router = async (app: HttpServer) => { app.register(fsRouter, { prefix: '/fs', }) + app.register(fileManagerRouter) + app.register(extensionRouter, { prefix: '/extension', }) diff --git a/core/src/node/download.ts b/core/src/node/download.ts index 6d15fc344..b3f284440 100644 --- a/core/src/node/download.ts +++ b/core/src/node/download.ts @@ -1,15 +1,18 @@ +import { DownloadState } from '../types' /** * Manages file downloads and network requests. */ export class DownloadManager { - public networkRequests: Record = {}; + public networkRequests: Record = {} - public static instance: DownloadManager = new DownloadManager(); + public static instance: DownloadManager = new DownloadManager() + + public downloadProgressMap: Record = {} constructor() { if (DownloadManager.instance) { - return DownloadManager.instance; + return DownloadManager.instance } } /** @@ -18,6 +21,6 @@ export class DownloadManager { * @param {Request | undefined} request - The network request to set, or undefined to clear the request. */ setRequest(fileName: string, request: any | undefined) { - this.networkRequests[fileName] = request; + this.networkRequests[fileName] = request } } diff --git a/core/src/node/extension/index.ts b/core/src/node/extension/index.ts index ed8544773..994fc97f2 100644 --- a/core/src/node/extension/index.ts +++ b/core/src/node/extension/index.ts @@ -41,8 +41,8 @@ async function registerExtensionProtocol() { console.error('Electron is not available') } const extensionPath = ExtensionManager.instance.getExtensionsPath() - if (electron) { - return electron.protocol.registerFileProtocol('extension', (request: any, callback: any) => { + if (electron && electron.protocol) { + return electron.protocol?.registerFileProtocol('extension', (request: any, callback: any) => { const entry = request.url.substr('extension://'.length - 1) const url = normalize(extensionPath + entry) @@ -69,7 +69,7 @@ export function useExtensions(extensionsPath: string) { // Read extension list from extensions folder const extensions = JSON.parse( - readFileSync(ExtensionManager.instance.getExtensionsFile(), 'utf-8'), + readFileSync(ExtensionManager.instance.getExtensionsFile(), 'utf-8') ) try { // Create and store a Extension instance for each extension in list @@ -82,7 +82,7 @@ export function useExtensions(extensionsPath: string) { throw new Error( 'Could not successfully rebuild list of installed extensions.\n' + error + - '\nPlease check the extensions.json file in the extensions folder.', + '\nPlease check the extensions.json file in the extensions folder.' ) } @@ -122,7 +122,7 @@ function loadExtension(ext: any) { export function getStore() { if (!ExtensionManager.instance.getExtensionsFile()) { 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' ) } diff --git a/core/src/types/assistant/assistantEvent.ts b/core/src/types/assistant/assistantEvent.ts new file mode 100644 index 000000000..f8f3e6ad0 --- /dev/null +++ b/core/src/types/assistant/assistantEvent.ts @@ -0,0 +1,8 @@ +/** + * The `EventName` enumeration contains the names of all the available events in the Jan platform. + */ +export enum AssistantEvent { + /** The `OnAssistantsUpdate` event is emitted when the assistant list is updated. */ + OnAssistantsUpdate = 'OnAssistantsUpdate', + } + \ No newline at end of file diff --git a/core/src/types/assistant/index.ts b/core/src/types/assistant/index.ts index 83ea73f85..e18589551 100644 --- a/core/src/types/assistant/index.ts +++ b/core/src/types/assistant/index.ts @@ -1,2 +1,3 @@ export * from './assistantEntity' +export * from './assistantEvent' export * from './assistantInterface' diff --git a/core/src/types/file/index.ts b/core/src/types/file/index.ts index 6526cfc6d..57d687d2f 100644 --- a/core/src/types/file/index.ts +++ b/core/src/types/file/index.ts @@ -2,3 +2,26 @@ export type FileStat = { isDirectory: boolean size: number } + +export type DownloadState = { + modelId: string + filename: string + time: DownloadTime + speed: number + percent: number + + size: DownloadSize + children?: DownloadState[] + error?: string + downloadState: 'downloading' | 'error' | 'end' +} + +type DownloadTime = { + elapsed: number + remaining: number +} + +type DownloadSize = { + total: number + transferred: number +} diff --git a/core/src/types/model/modelEvent.ts b/core/src/types/model/modelEvent.ts index 978a48724..443f3a34f 100644 --- a/core/src/types/model/modelEvent.ts +++ b/core/src/types/model/modelEvent.ts @@ -12,4 +12,6 @@ export enum ModelEvent { OnModelStop = 'OnModelStop', /** The `OnModelStopped` event is emitted when a model stopped ok. */ OnModelStopped = 'OnModelStopped', + /** The `OnModelUpdate` event is emitted when the model list is updated. */ + OnModelsUpdate = 'OnModelsUpdate', } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..fd0f44096 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,110 @@ +version: '3.7' + +services: + minio: + image: minio/minio + volumes: + - minio_data:/data + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: minioadmin # This acts as AWS_ACCESS_KEY + MINIO_ROOT_PASSWORD: minioadmin # This acts as AWS_SECRET_ACCESS_KEY + command: server --console-address ":9001" /data + restart: always + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 + networks: + vpcbr: + ipv4_address: 10.5.0.2 + + createbuckets: + image: minio/mc + depends_on: + - minio + entrypoint: > + /bin/sh -c " + /usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin; + /usr/bin/mc mb myminio/mybucket; + /usr/bin/mc policy set public myminio/mybucket; + exit 0; + " + networks: + vpcbr: + + + app_cpu: + image: jan:latest + volumes: + - app_data:/app/server/build/jan + build: + context: . + dockerfile: Dockerfile + environment: + AWS_ACCESS_KEY_ID: minioadmin + AWS_SECRET_ACCESS_KEY: minioadmin + S3_BUCKET_NAME: mybucket + AWS_ENDPOINT: http://10.5.0.2:9000 + AWS_REGION: us-east-1 + restart: always + profiles: + - cpu + ports: + - "3000:3000" + - "1337:1337" + - "3928:3928" + networks: + vpcbr: + ipv4_address: 10.5.0.3 + + + app_gpu: + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + image: jan-gpu:latest + volumes: + - app_data:/app/server/build/jan + build: + context: . + dockerfile: Dockerfile.gpu + restart: always + environment: + AWS_ACCESS_KEY_ID: minioadmin + AWS_SECRET_ACCESS_KEY: minioadmin + S3_BUCKET_NAME: mybucket + AWS_ENDPOINT: http://10.5.0.2:9000 + AWS_REGION: us-east-1 + + profiles: + - gpu + ports: + - "3000:3000" + - "1337:1337" + - "3928:3928" + networks: + vpcbr: + ipv4_address: 10.5.0.4 + +volumes: + minio_data: + app_data: + +networks: + vpcbr: + driver: bridge + ipam: + config: + - subnet: 10.5.0.0/16 + gateway: 10.5.0.1 + +# docker compose --profile cpu up +# docker compose --profile gpu up diff --git a/electron/handlers/download.ts b/electron/handlers/download.ts index f63e56f6b..5f1d8371e 100644 --- a/electron/handlers/download.ts +++ b/electron/handlers/download.ts @@ -5,7 +5,11 @@ import request from 'request' import { createWriteStream, renameSync } from 'fs' import { DownloadEvent, DownloadRoute } from '@janhq/core' const progress = require('request-progress') -import { DownloadManager, getJanDataFolderPath, normalizeFilePath } from '@janhq/core/node' +import { + DownloadManager, + getJanDataFolderPath, + normalizeFilePath, +} from '@janhq/core/node' export function handleDownloaderIPCs() { /** @@ -56,20 +60,23 @@ export function handleDownloaderIPCs() { */ ipcMain.handle( DownloadRoute.downloadFile, - async (_event, url, fileName, network) => { + async (_event, url, localPath, network) => { const strictSSL = !network?.ignoreSSL const proxy = network?.proxy?.startsWith('http') ? network.proxy : undefined - - if (typeof fileName === 'string') { - fileName = normalizeFilePath(fileName) + if (typeof localPath === 'string') { + localPath = normalizeFilePath(localPath) } - const destination = resolve(getJanDataFolderPath(), fileName) + const array = localPath.split('/') + const fileName = array.pop() ?? '' + const modelId = array.pop() ?? '' + + const destination = resolve(getJanDataFolderPath(), localPath) const rq = request({ url, strictSSL, proxy }) // Put request to download manager instance - DownloadManager.instance.setRequest(fileName, rq) + DownloadManager.instance.setRequest(localPath, rq) // Downloading file to a temp file first const downloadingTempFile = `${destination}.download` @@ -81,6 +88,7 @@ export function handleDownloaderIPCs() { { ...state, fileName, + modelId, } ) }) @@ -90,11 +98,12 @@ export function handleDownloaderIPCs() { { fileName, err, + modelId, } ) }) .on('end', function () { - if (DownloadManager.instance.networkRequests[fileName]) { + if (DownloadManager.instance.networkRequests[localPath]) { // Finished downloading, rename temp file to actual file renameSync(downloadingTempFile, destination) @@ -102,14 +111,16 @@ export function handleDownloaderIPCs() { DownloadEvent.onFileDownloadSuccess, { fileName, + modelId, } ) - DownloadManager.instance.setRequest(fileName, undefined) + DownloadManager.instance.setRequest(localPath, undefined) } else { WindowManager?.instance.currentWindow?.webContents.send( DownloadEvent.onFileDownloadError, { fileName, + modelId, err: { message: 'aborted' }, } ) diff --git a/electron/handlers/fileManager.ts b/electron/handlers/fileManager.ts index e328cb53b..15c371d34 100644 --- a/electron/handlers/fileManager.ts +++ b/electron/handlers/fileManager.ts @@ -38,6 +38,7 @@ export function handleFileMangerIPCs() { getResourcePath() ) + // Handles the 'getUserHomePath' IPC event. This event is triggered to get the user home path. ipcMain.handle(FileManagerRoute.getUserHomePath, async (_event) => app.getPath('home') ) diff --git a/electron/handlers/fs.ts b/electron/handlers/fs.ts index 34026b940..8ac575cb2 100644 --- a/electron/handlers/fs.ts +++ b/electron/handlers/fs.ts @@ -1,8 +1,7 @@ import { ipcMain } from 'electron' import { getJanDataFolderPath, normalizeFilePath } from '@janhq/core/node' -import fs from 'fs' -import { FileManagerRoute, FileSystemRoute } from '@janhq/core' +import { FileSystemRoute } from '@janhq/core' import { join } from 'path' /** * Handles file system operations. diff --git a/electron/package.json b/electron/package.json index 08f15b262..229979b41 100644 --- a/electron/package.json +++ b/electron/package.json @@ -57,17 +57,18 @@ "scripts": { "lint": "eslint . --ext \".js,.jsx,.ts,.tsx\"", "test:e2e": "playwright test --workers=1", - "dev": "tsc -p . && electron .", - "build": "run-script-os", - "build:test": "run-script-os", + "copy:assets": "rimraf --glob \"./pre-install/*.tgz\" && cpx \"../pre-install/*.tgz\" \"./pre-install\"", + "dev": "yarn copy:assets && tsc -p . && electron .", + "build": "yarn copy:assets && run-script-os", + "build:test": "yarn copy:assets && run-script-os", "build:test:darwin": "tsc -p . && electron-builder -p never -m --dir", "build:test:win32": "tsc -p . && electron-builder -p never -w --dir", "build:test:linux": "tsc -p . && electron-builder -p never -l --dir", - "build:darwin": "tsc -p . && electron-builder -p never -m", + "build:darwin": "tsc -p . && electron-builder -p never -m --x64 --arm64", "build:win32": "tsc -p . && electron-builder -p never -w", "build:linux": "tsc -p . && electron-builder -p never -l deb -l AppImage", - "build:publish": "run-script-os", - "build:publish:darwin": "tsc -p . && electron-builder -p always -m", + "build:publish": "yarn copy:assets && run-script-os", + "build:publish:darwin": "tsc -p . && electron-builder -p always -m --x64 --arm64", "build:publish:win32": "tsc -p . && electron-builder -p always -w", "build:publish:linux": "tsc -p . && electron-builder -p always -l deb -l AppImage" }, diff --git a/extensions/assistant-extension/package.json b/extensions/assistant-extension/package.json index 84bcdf47e..5f45ecabe 100644 --- a/extensions/assistant-extension/package.json +++ b/extensions/assistant-extension/package.json @@ -8,9 +8,9 @@ "license": "AGPL-3.0", "scripts": { "build": "tsc --module commonjs && rollup -c rollup.config.ts", - "build:publish:linux": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../electron/pre-install", - "build:publish:darwin": "rimraf *.tgz --glob && npm run build && ../../.github/scripts/auto-sign.sh && npm pack && cpx *.tgz ../../electron/pre-install", - "build:publish:win32": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../electron/pre-install", + "build:publish:linux": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../pre-install", + "build:publish:darwin": "rimraf *.tgz --glob && npm run build && ../../.github/scripts/auto-sign.sh && npm pack && cpx *.tgz ../../pre-install", + "build:publish:win32": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../pre-install", "build:publish": "run-script-os" }, "devDependencies": { diff --git a/extensions/assistant-extension/src/index.ts b/extensions/assistant-extension/src/index.ts index 6495ea786..8bc8cafdc 100644 --- a/extensions/assistant-extension/src/index.ts +++ b/extensions/assistant-extension/src/index.ts @@ -9,6 +9,7 @@ import { joinPath, executeOnMain, AssistantExtension, + AssistantEvent, } from "@janhq/core"; export default class JanAssistantExtension extends AssistantExtension { @@ -21,7 +22,7 @@ export default class JanAssistantExtension extends AssistantExtension { async onLoad() { // making the assistant directory const assistantDirExist = await fs.existsSync( - JanAssistantExtension._homeDir, + JanAssistantExtension._homeDir ); if ( localStorage.getItem(`${EXTENSION_NAME}-version`) !== VERSION || @@ -31,14 +32,16 @@ export default class JanAssistantExtension extends AssistantExtension { await fs.mkdirSync(JanAssistantExtension._homeDir); // Write assistant metadata - this.createJanAssistant(); + await this.createJanAssistant(); // Finished migration localStorage.setItem(`${EXTENSION_NAME}-version`, VERSION); + // Update the assistant list + events.emit(AssistantEvent.OnAssistantsUpdate, {}); } // Events subscription events.on(MessageEvent.OnMessageSent, (data: MessageRequest) => - JanAssistantExtension.handleMessageRequest(data, this), + JanAssistantExtension.handleMessageRequest(data, this) ); events.on(InferenceEvent.OnInferenceStopped, () => { @@ -53,7 +56,7 @@ export default class JanAssistantExtension extends AssistantExtension { private static async handleMessageRequest( data: MessageRequest, - instance: JanAssistantExtension, + instance: JanAssistantExtension ) { instance.isCancelled = false; instance.controller = new AbortController(); @@ -80,7 +83,7 @@ export default class JanAssistantExtension extends AssistantExtension { NODE, "toolRetrievalIngestNewDocument", docFile, - data.model?.proxyEngine, + data.model?.proxyEngine ); } } @@ -96,7 +99,7 @@ export default class JanAssistantExtension extends AssistantExtension { NODE, "toolRetrievalUpdateTextSplitter", data.thread.assistants[0].tools[0]?.settings?.chunk_size ?? 4000, - data.thread.assistants[0].tools[0]?.settings?.chunk_overlap ?? 200, + data.thread.assistants[0].tools[0]?.settings?.chunk_overlap ?? 200 ); } @@ -110,7 +113,7 @@ export default class JanAssistantExtension extends AssistantExtension { const retrievalResult = await executeOnMain( NODE, "toolRetrievalQueryResult", - prompt, + prompt ); // Update the message content @@ -168,7 +171,7 @@ export default class JanAssistantExtension extends AssistantExtension { try { await fs.writeFileSync( assistantMetadataPath, - JSON.stringify(assistant, null, 2), + JSON.stringify(assistant, null, 2) ); } catch (err) { console.error(err); @@ -180,7 +183,7 @@ export default class JanAssistantExtension extends AssistantExtension { // get all the assistant metadata json const results: Assistant[] = []; const allFileName: string[] = await fs.readdirSync( - JanAssistantExtension._homeDir, + JanAssistantExtension._homeDir ); for (const fileName of allFileName) { const filePath = await joinPath([ @@ -190,7 +193,7 @@ export default class JanAssistantExtension extends AssistantExtension { if (filePath.includes(".DS_Store")) continue; const jsonFiles: string[] = (await fs.readdirSync(filePath)).filter( - (file: string) => file === "assistant.json", + (file: string) => file === "assistant.json" ); if (jsonFiles.length !== 1) { @@ -200,7 +203,7 @@ export default class JanAssistantExtension extends AssistantExtension { const content = await fs.readFileSync( await joinPath([filePath, jsonFiles[0]]), - "utf-8", + "utf-8" ); const assistant: Assistant = typeof content === "object" ? content : JSON.parse(content); diff --git a/extensions/conversational-extension/package.json b/extensions/conversational-extension/package.json index a60c12339..b84c75d3d 100644 --- a/extensions/conversational-extension/package.json +++ b/extensions/conversational-extension/package.json @@ -7,7 +7,7 @@ "license": "MIT", "scripts": { "build": "tsc -b . && webpack --config webpack.config.js", - "build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../electron/pre-install" + "build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../pre-install" }, "exports": { ".": "./dist/index.js", diff --git a/extensions/inference-nitro-extension/package.json b/extensions/inference-nitro-extension/package.json index 8ad516ad9..cccfbefd0 100644 --- a/extensions/inference-nitro-extension/package.json +++ b/extensions/inference-nitro-extension/package.json @@ -12,9 +12,9 @@ "downloadnitro:darwin": "NITRO_VERSION=$(cat ./bin/version.txt) && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-mac-arm64.tar.gz -e --strip 1 -o ./bin/mac-arm64 && chmod +x ./bin/mac-arm64/nitro && download https://github.com/janhq/nitro/releases/download/v${NITRO_VERSION}/nitro-${NITRO_VERSION}-mac-amd64.tar.gz -e --strip 1 -o ./bin/mac-x64 && chmod +x ./bin/mac-x64/nitro", "downloadnitro:win32": "download.bat", "downloadnitro": "run-script-os", - "build:publish:darwin": "rimraf *.tgz --glob && npm run build && npm run downloadnitro && ../../.github/scripts/auto-sign.sh && cpx \"bin/**\" \"dist/bin\" && npm pack && cpx *.tgz ../../electron/pre-install", - "build:publish:win32": "rimraf *.tgz --glob && npm run build && npm run downloadnitro && cpx \"bin/**\" \"dist/bin\" && npm pack && cpx *.tgz ../../electron/pre-install", - "build:publish:linux": "rimraf *.tgz --glob && npm run build && npm run downloadnitro && cpx \"bin/**\" \"dist/bin\" && npm pack && cpx *.tgz ../../electron/pre-install", + "build:publish:darwin": "rimraf *.tgz --glob && npm run build && npm run downloadnitro && ../../.github/scripts/auto-sign.sh && cpx \"bin/**\" \"dist/bin\" && npm pack && cpx *.tgz ../../pre-install", + "build:publish:win32": "rimraf *.tgz --glob && npm run build && npm run downloadnitro && cpx \"bin/**\" \"dist/bin\" && npm pack && cpx *.tgz ../../pre-install", + "build:publish:linux": "rimraf *.tgz --glob && npm run build && npm run downloadnitro && cpx \"bin/**\" \"dist/bin\" && npm pack && cpx *.tgz ../../pre-install", "build:publish": "run-script-os" }, "exports": { diff --git a/extensions/inference-openai-extension/package.json b/extensions/inference-openai-extension/package.json index 5fa0ce974..0ba6f18db 100644 --- a/extensions/inference-openai-extension/package.json +++ b/extensions/inference-openai-extension/package.json @@ -8,7 +8,7 @@ "license": "AGPL-3.0", "scripts": { "build": "tsc -b . && webpack --config webpack.config.js", - "build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../electron/pre-install" + "build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../pre-install" }, "exports": { ".": "./dist/index.js", diff --git a/extensions/inference-triton-trtllm-extension/package.json b/extensions/inference-triton-trtllm-extension/package.json index 1d27f9f18..0f4c2de23 100644 --- a/extensions/inference-triton-trtllm-extension/package.json +++ b/extensions/inference-triton-trtllm-extension/package.json @@ -8,7 +8,7 @@ "license": "AGPL-3.0", "scripts": { "build": "tsc -b . && webpack --config webpack.config.js", - "build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../electron/pre-install" + "build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../pre-install" }, "exports": { ".": "./dist/index.js", diff --git a/extensions/model-extension/package.json b/extensions/model-extension/package.json index 86f177d14..1af5d38cb 100644 --- a/extensions/model-extension/package.json +++ b/extensions/model-extension/package.json @@ -8,7 +8,7 @@ "license": "AGPL-3.0", "scripts": { "build": "tsc -b . && webpack --config webpack.config.js", - "build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../electron/pre-install" + "build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../pre-install" }, "devDependencies": { "cpx": "^1.5.0", diff --git a/extensions/model-extension/src/@types/global.d.ts b/extensions/model-extension/src/@types/global.d.ts index e998455f2..7a9202a62 100644 --- a/extensions/model-extension/src/@types/global.d.ts +++ b/extensions/model-extension/src/@types/global.d.ts @@ -1,3 +1,15 @@ -declare const EXTENSION_NAME: string -declare const MODULE_PATH: string -declare const VERSION: stringÄ +export {} +declare global { + declare const EXTENSION_NAME: string + declare const MODULE_PATH: string + declare const VERSION: string + + interface Core { + api: APIFunctions + events: EventEmitter + } + interface Window { + core?: Core | undefined + electronAPI?: any | undefined + } +} diff --git a/extensions/model-extension/src/helpers/path.ts b/extensions/model-extension/src/helpers/path.ts new file mode 100644 index 000000000..cbb151aa6 --- /dev/null +++ b/extensions/model-extension/src/helpers/path.ts @@ -0,0 +1,11 @@ +/** + * try to retrieve the download file name from the source url + */ + +export function extractFileName(url: string, fileExtension: string): string { + const extractedFileName = url.split('/').pop() + const fileName = extractedFileName.toLowerCase().endsWith(fileExtension) + ? extractedFileName + : extractedFileName + fileExtension + return fileName +} diff --git a/extensions/model-extension/src/index.ts b/extensions/model-extension/src/index.ts index b9fa7731e..e26fd4929 100644 --- a/extensions/model-extension/src/index.ts +++ b/extensions/model-extension/src/index.ts @@ -8,7 +8,13 @@ import { ModelExtension, Model, getJanDataFolderPath, + events, + DownloadEvent, + DownloadRoute, + ModelEvent, } from '@janhq/core' +import { DownloadState } from '@janhq/core/.' +import { extractFileName } from './helpers/path' /** * A extension for models @@ -29,6 +35,8 @@ export default class JanModelExtension extends ModelExtension { */ async onLoad() { this.copyModelsToHomeDir() + // Handle Desktop Events + this.handleDesktopEvents() } /** @@ -61,6 +69,8 @@ export default class JanModelExtension extends ModelExtension { // Finished migration localStorage.setItem(`${EXTENSION_NAME}-version`, VERSION) + + events.emit(ModelEvent.OnModelsUpdate, {}) } catch (err) { console.error(err) } @@ -83,31 +93,66 @@ export default class JanModelExtension extends ModelExtension { if (model.sources.length > 1) { // path to model binaries for (const source of model.sources) { - let path = this.extractFileName(source.url) + let path = extractFileName( + source.url, + JanModelExtension._supportedModelFormat + ) if (source.filename) { path = await joinPath([modelDirPath, source.filename]) } downloadFile(source.url, path, network) } + // TODO: handle multiple binaries for web later } else { - const fileName = this.extractFileName(model.sources[0]?.url) + const fileName = extractFileName( + model.sources[0]?.url, + JanModelExtension._supportedModelFormat + ) const path = await joinPath([modelDirPath, fileName]) downloadFile(model.sources[0]?.url, path, network) + + if (window && window.core?.api && window.core.api.baseApiUrl) { + this.startPollingDownloadProgress(model.id) + } } } /** - * try to retrieve the download file name from the source url + * Specifically for Jan server. */ - private extractFileName(url: string): string { - const extractedFileName = url.split('/').pop() - const fileName = extractedFileName - .toLowerCase() - .endsWith(JanModelExtension._supportedModelFormat) - ? extractedFileName - : extractedFileName + JanModelExtension._supportedModelFormat - return fileName + private async startPollingDownloadProgress(modelId: string): Promise { + // wait for some seconds before polling + await new Promise((resolve) => setTimeout(resolve, 3000)) + + return new Promise((resolve) => { + const interval = setInterval(async () => { + fetch( + `${window.core.api.baseApiUrl}/v1/download/${DownloadRoute.getDownloadProgress}/${modelId}`, + { + method: 'GET', + headers: { contentType: 'application/json' }, + } + ).then(async (res) => { + const state: DownloadState = await res.json() + if (state.downloadState === 'end') { + events.emit(DownloadEvent.onFileDownloadSuccess, state) + clearInterval(interval) + resolve() + return + } + + if (state.downloadState === 'error') { + events.emit(DownloadEvent.onFileDownloadError, state) + clearInterval(interval) + resolve() + return + } + + events.emit(DownloadEvent.onFileDownloadUpdate, state) + }) + }, 1000) + }) } /** @@ -318,7 +363,7 @@ export default class JanModelExtension extends ModelExtension { return } - const defaultModel = await this.getDefaultModel() as Model + const defaultModel = (await this.getDefaultModel()) as Model if (!defaultModel) { console.error('Unable to find default model') return @@ -382,4 +427,28 @@ export default class JanModelExtension extends ModelExtension { async getConfiguredModels(): Promise { return this.getModelsMetadata() } + + handleDesktopEvents() { + if (window && window.electronAPI) { + window.electronAPI.onFileDownloadUpdate( + async (_event: string, state: any | undefined) => { + if (!state) return + state.downloadState = 'update' + events.emit(DownloadEvent.onFileDownloadUpdate, state) + } + ) + window.electronAPI.onFileDownloadError( + async (_event: string, state: any) => { + state.downloadState = 'error' + events.emit(DownloadEvent.onFileDownloadError, state) + } + ) + window.electronAPI.onFileDownloadSuccess( + async (_event: string, state: any) => { + state.downloadState = 'end' + events.emit(DownloadEvent.onFileDownloadSuccess, state) + } + ) + } + } } diff --git a/extensions/model-extension/tsconfig.json b/extensions/model-extension/tsconfig.json index addd8e127..c175d9437 100644 --- a/extensions/model-extension/tsconfig.json +++ b/extensions/model-extension/tsconfig.json @@ -8,7 +8,7 @@ "forceConsistentCasingInFileNames": true, "strict": false, "skipLibCheck": true, - "rootDir": "./src" + "rootDir": "./src", }, - "include": ["./src"] + "include": ["./src"], } diff --git a/extensions/model-extension/webpack.config.js b/extensions/model-extension/webpack.config.js index 347719f91..c67bf8dc0 100644 --- a/extensions/model-extension/webpack.config.js +++ b/extensions/model-extension/webpack.config.js @@ -19,7 +19,7 @@ module.exports = { new webpack.DefinePlugin({ EXTENSION_NAME: JSON.stringify(packageJson.name), MODULE_PATH: JSON.stringify(`${packageJson.name}/${packageJson.module}`), - VERSION: JSON.stringify(packageJson.version), + VERSION: JSON.stringify(packageJson.version) }), ], output: { diff --git a/extensions/monitoring-extension/package.json b/extensions/monitoring-extension/package.json index 20d3c485f..538f6bdee 100644 --- a/extensions/monitoring-extension/package.json +++ b/extensions/monitoring-extension/package.json @@ -8,7 +8,7 @@ "license": "AGPL-3.0", "scripts": { "build": "tsc -b . && webpack --config webpack.config.js", - "build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../electron/pre-install" + "build:publish": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../pre-install" }, "devDependencies": { "rimraf": "^3.0.2", diff --git a/package.json b/package.json index 4b8bc4af0..957934fda 100644 --- a/package.json +++ b/package.json @@ -21,22 +21,23 @@ "lint": "yarn workspace jan lint && yarn workspace jan-web lint", "test:unit": "yarn workspace @janhq/core test", "test": "yarn workspace jan test:e2e", - "copy:assets": "cpx \"models/**\" \"electron/models/\" && cpx \"docs/openapi/**\" \"electron/docs/openapi\"", + "copy:assets": "cpx \"models/**\" \"electron/models/\" && cpx \"pre-install/*.tgz\" \"electron/pre-install/\" && cpx \"docs/openapi/**\" \"electron/docs/openapi\"", "dev:electron": "yarn copy:assets && yarn workspace jan dev", "dev:web": "yarn workspace jan-web dev", - "dev:server": "yarn workspace @janhq/server dev", + "dev:server": "yarn copy:assets && yarn workspace @janhq/server dev", "dev": "concurrently --kill-others \"yarn dev:web\" \"wait-on http://localhost:3000 && yarn dev:electron\"", "test-local": "yarn lint && yarn build:test && yarn test", "dev:uikit": "yarn workspace @janhq/uikit install && yarn workspace @janhq/uikit dev", "build:uikit": "yarn workspace @janhq/uikit install && yarn workspace @janhq/uikit build", - "build:server": "cd server && yarn install && yarn run build", + "build:server": "yarn copy:assets && cd server && yarn install && yarn run build", "build:core": "cd core && yarn install && yarn run build", "build:web": "yarn workspace jan-web build && cpx \"web/out/**\" \"electron/renderer/\"", "build:electron": "yarn copy:assets && yarn workspace jan build", "build:electron:test": "yarn workspace jan build:test", - "build:extensions:windows": "rimraf ./electron/pre-install/*.tgz && powershell -command \"$jobs = Get-ChildItem -Path './extensions' -Directory | ForEach-Object { Start-Job -Name ($_.Name) -ScriptBlock { param($_dir); try { Set-Location $_dir; npm install; npm run build:publish; Write-Output 'Build successful in ' + $_dir } catch { Write-Error 'Error in ' + $_dir; throw } } -ArgumentList $_.FullName }; $jobs | Wait-Job; $jobs | ForEach-Object { Receive-Job -Job $_ -Keep } | ForEach-Object { Write-Host $_ }; $failed = $jobs | Where-Object { $_.State -ne 'Completed' -or $_.ChildJobs[0].JobStateInfo.State -ne 'Completed' }; if ($failed) { Exit 1 }\"", - "build:extensions:linux": "rimraf ./electron/pre-install/*.tgz && find ./extensions -mindepth 1 -maxdepth 1 -type d -print0 | xargs -0 -n 1 -P 4 -I {} sh -c 'cd {} && npm install && npm run build:publish'", - "build:extensions:darwin": "rimraf ./electron/pre-install/*.tgz && find ./extensions -mindepth 1 -maxdepth 1 -type d -print0 | xargs -0 -n 1 -P 4 -I {} sh -c 'cd {} && npm install && npm run build:publish'", + "build:extensions:windows": "rimraf ./pre-install/*.tgz && powershell -command \"$jobs = Get-ChildItem -Path './extensions' -Directory | ForEach-Object { Start-Job -Name ($_.Name) -ScriptBlock { param($_dir); try { Set-Location $_dir; npm install; npm run build:publish; Write-Output 'Build successful in ' + $_dir } catch { Write-Error 'Error in ' + $_dir; throw } } -ArgumentList $_.FullName }; $jobs | Wait-Job; $jobs | ForEach-Object { Receive-Job -Job $_ -Keep } | ForEach-Object { Write-Host $_ }; $failed = $jobs | Where-Object { $_.State -ne 'Completed' -or $_.ChildJobs[0].JobStateInfo.State -ne 'Completed' }; if ($failed) { Exit 1 }\"", + "build:extensions:linux": "rimraf ./pre-install/*.tgz && find ./extensions -mindepth 1 -maxdepth 1 -type d -print0 | xargs -0 -n 1 -P 4 -I {} sh -c 'cd {} && npm install && npm run build:publish'", + "build:extensions:darwin": "rimraf ./pre-install/*.tgz && find ./extensions -mindepth 1 -maxdepth 1 -type d -print0 | xargs -0 -n 1 -P 4 -I {} sh -c 'cd {} && npm install && npm run build:publish'", + "build:extensions:server": "yarn workspace build:extensions ", "build:extensions": "run-script-os", "build:test": "yarn copy:assets && yarn build:web && yarn workspace jan build:test", "build": "yarn build:web && yarn build:electron", diff --git a/pre-install/.gitkeep b/pre-install/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/server/helpers/setup.ts b/server/helpers/setup.ts new file mode 100644 index 000000000..51d8eebe5 --- /dev/null +++ b/server/helpers/setup.ts @@ -0,0 +1,47 @@ +import { join, extname } from "path"; +import { existsSync, readdirSync, writeFileSync, mkdirSync } from "fs"; +import { init, installExtensions } from "@janhq/core/node"; + +export async function setup() { + /** + * Setup Jan Data Directory + */ + const appDir = process.env.JAN_DATA_DIRECTORY ?? join(__dirname, "..", "jan"); + + console.debug(`Create app data directory at ${appDir}...`); + if (!existsSync(appDir)) mkdirSync(appDir); + //@ts-ignore + global.core = { + // Define appPath function for app to retrieve app path globaly + appPath: () => appDir, + }; + init({ + extensionsPath: join(appDir, "extensions"), + }); + + /** + * Write app configurations. See #1619 + */ + console.debug("Writing config file..."); + writeFileSync( + join(appDir, "settings.json"), + JSON.stringify({ + data_folder: appDir, + }), + "utf-8" + ); + + /** + * Install extensions + */ + + console.debug("Installing extensions..."); + + const baseExtensionPath = join(__dirname, "../../..", "pre-install"); + const extensions = readdirSync(baseExtensionPath) + .filter((file) => extname(file) === ".tgz") + .map((file) => join(baseExtensionPath, file)); + + await installExtensions(extensions); + console.debug("Extensions installed"); +} diff --git a/server/index.ts b/server/index.ts index 05bfdca96..91349a81f 100644 --- a/server/index.ts +++ b/server/index.ts @@ -38,6 +38,7 @@ export interface ServerConfig { isVerboseEnabled?: boolean; schemaPath?: string; baseDir?: string; + storageAdataper?: any; } /** @@ -103,9 +104,12 @@ export const startServer = async (configs?: ServerConfig) => { { prefix: "extensions" } ); + // Register proxy middleware + if (configs?.storageAdataper) + server.addHook("preHandler", configs.storageAdataper); + // Register API routes await server.register(v1Router, { prefix: "/v1" }); - // Start listening for requests await server .listen({ diff --git a/server/main.ts b/server/main.ts index c3eb69135..3be397e6f 100644 --- a/server/main.ts +++ b/server/main.ts @@ -1,3 +1,7 @@ -import { startServer } from "./index"; - -startServer(); +import { s3 } from "./middleware/s3"; +import { setup } from "./helpers/setup"; +import { startServer as start } from "./index"; +/** + * Setup extensions and start the server + */ +setup().then(() => start({ storageAdataper: s3 })); diff --git a/server/middleware/s3.ts b/server/middleware/s3.ts new file mode 100644 index 000000000..624865222 --- /dev/null +++ b/server/middleware/s3.ts @@ -0,0 +1,70 @@ +import { join } from "path"; + +// Middleware to intercept requests and proxy if certain conditions are met +const config = { + endpoint: process.env.AWS_ENDPOINT, + region: process.env.AWS_REGION, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, +}; + +const S3_BUCKET_NAME = process.env.S3_BUCKET_NAME; + +const fs = require("@cyclic.sh/s3fs")(S3_BUCKET_NAME, config); +const PROXY_PREFIX = "/v1/fs"; +const PROXY_ROUTES = ["/threads", "/messages"]; + +export const s3 = (req: any, reply: any, done: any) => { + // Proxy FS requests to S3 using S3FS + if (req.url.startsWith(PROXY_PREFIX)) { + const route = req.url.split("/").pop(); + const args = parseRequestArgs(req); + + // Proxy matched requests to the s3fs module + if (args.length && PROXY_ROUTES.some((route) => args[0].includes(route))) { + try { + // Handle customized route + // S3FS does not handle appendFileSync + if (route === "appendFileSync") { + let result = handAppendFileSync(args); + + reply.status(200).send(result); + return; + } + // Reroute the other requests to the s3fs module + const result = fs[route](...args); + reply.status(200).send(result); + return; + } catch (ex) { + console.log(ex); + } + } + } + // Let other requests go through + done(); +}; + +const parseRequestArgs = (req: Request) => { + const { + getJanDataFolderPath, + normalizeFilePath, + } = require("@janhq/core/node"); + + return JSON.parse(req.body as any).map((arg: any) => + typeof arg === "string" && + (arg.startsWith(`file:/`) || arg.startsWith(`file:\\`)) + ? join(getJanDataFolderPath(), normalizeFilePath(arg)) + : arg + ); +}; + +const handAppendFileSync = (args: any[]) => { + if (fs.existsSync(args[0])) { + const data = fs.readFileSync(args[0], "utf-8"); + return fs.writeFileSync(args[0], data + args[1]); + } else { + return fs.writeFileSync(args[0], args[1]); + } +}; diff --git a/server/nodemon.json b/server/nodemon.json deleted file mode 100644 index 0ea41ca96..000000000 --- a/server/nodemon.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "watch": ["main.ts", "v1"], - "ext": "ts, json", - "exec": "tsc && node ./build/main.js" -} \ No newline at end of file diff --git a/server/package.json b/server/package.json index f61730da4..c1a104506 100644 --- a/server/package.json +++ b/server/package.json @@ -13,16 +13,18 @@ "scripts": { "lint": "eslint . --ext \".js,.jsx,.ts,.tsx\"", "test:e2e": "playwright test --workers=1", - "dev": "tsc --watch & node --watch build/main.js", - "build": "tsc" + "build:core": "cd node_modules/@janhq/core && yarn install && yarn build", + "dev": "yarn build:core && tsc --watch & node --watch build/main.js", + "build": "yarn build:core && tsc" }, "dependencies": { "@alumna/reflect": "^1.1.3", + "@cyclic.sh/s3fs": "^1.2.9", "@fastify/cors": "^8.4.2", "@fastify/static": "^6.12.0", "@fastify/swagger": "^8.13.0", "@fastify/swagger-ui": "2.0.1", - "@janhq/core": "link:./core", + "@janhq/core": "file:../core", "dotenv": "^16.3.1", "fastify": "^4.24.3", "request": "^2.88.2", @@ -39,5 +41,8 @@ "run-script-os": "^1.1.6", "@types/tcp-port-used": "^1.0.4", "typescript": "^5.2.2" - } + }, + "bundleDependencies": [ + "@janhq/core" + ] } diff --git a/server/tsconfig.json b/server/tsconfig.json index 2c4fc4a64..dd27b8932 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -20,5 +20,5 @@ // "sourceMap": true, "include": ["./**/*.ts"], - "exclude": ["core", "build", "dist", "tests", "node_modules"] + "exclude": ["core", "build", "dist", "tests", "node_modules", "extensions"] } diff --git a/web/containers/Layout/BottomBar/DownloadingState/index.tsx b/web/containers/Layout/BottomBar/DownloadingState/index.tsx index 7aef36caf..c7191d0b9 100644 --- a/web/containers/Layout/BottomBar/DownloadingState/index.tsx +++ b/web/containers/Layout/BottomBar/DownloadingState/index.tsx @@ -13,22 +13,22 @@ import { import { useAtomValue } from 'jotai' import useDownloadModel from '@/hooks/useDownloadModel' -import { useDownloadState } from '@/hooks/useDownloadState' +import { modelDownloadStateAtom } from '@/hooks/useDownloadState' import { formatDownloadPercentage } from '@/utils/converter' -import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom' +import { getDownloadingModelAtom } from '@/helpers/atoms/Model.atom' export default function DownloadingState() { - const { downloadStates } = useDownloadState() - const downloadingModels = useAtomValue(downloadingModelsAtom) + const downloadStates = useAtomValue(modelDownloadStateAtom) + const downloadingModels = useAtomValue(getDownloadingModelAtom) const { abortModelDownload } = useDownloadModel() - const totalCurrentProgress = downloadStates + const totalCurrentProgress = Object.values(downloadStates) .map((a) => a.size.transferred + a.size.transferred) .reduce((partialSum, a) => partialSum + a, 0) - const totalSize = downloadStates + const totalSize = Object.values(downloadStates) .map((a) => a.size.total + a.size.total) .reduce((partialSum, a) => partialSum + a, 0) @@ -36,12 +36,14 @@ export default function DownloadingState() { return ( - {downloadStates?.length > 0 && ( + {Object.values(downloadStates)?.length > 0 && (
Downloading model - {downloadStates.map((item, i) => { - return ( -
- -
-
-

{item?.modelId}

- {formatDownloadPercentage(item?.percent)} -
- + {Object.values(downloadStates).map((item, i) => ( +
+ +
+
+

{item?.modelId}

+ {formatDownloadPercentage(item?.percent)}
+
- ) - })} +
+ ))} )} diff --git a/web/containers/Layout/BottomBar/index.tsx b/web/containers/Layout/BottomBar/index.tsx index 32dc70c70..5021b821c 100644 --- a/web/containers/Layout/BottomBar/index.tsx +++ b/web/containers/Layout/BottomBar/index.tsx @@ -25,8 +25,7 @@ import { MainViewState } from '@/constants/screens' import { useActiveModel } from '@/hooks/useActiveModel' -import { useDownloadState } from '@/hooks/useDownloadState' - +import { modelDownloadStateAtom } from '@/hooks/useDownloadState' import useGetSystemResources from '@/hooks/useGetSystemResources' import { useMainViewState } from '@/hooks/useMainViewState' @@ -53,7 +52,7 @@ const BottomBar = () => { const downloadedModels = useAtomValue(downloadedModelsAtom) const { setMainViewState } = useMainViewState() - const { downloadStates } = useDownloadState() + const downloadStates = useAtomValue(modelDownloadStateAtom) const setShowSelectModelModal = useSetAtom(showSelectModelModalAtom) const [serverEnabled] = useAtom(serverEnabledAtom) @@ -109,7 +108,7 @@ const BottomBar = () => { )} {downloadedModels.length === 0 && !stateModel.loading && - downloadStates.length === 0 && ( + Object.values(downloadStates).length === 0 && ( - ) - - if (isDownloaded) { - downloadButton = ( - - ) - } - - if (downloadState != null && downloadStates.length > 0) { - downloadButton = - } - - return ( -
-
- - {model.name} - -
-
-
- {downloadButton} -
-
- ) -} - -export default ModelVersionItem diff --git a/web/screens/ExploreModels/ModelVersionList/index.tsx b/web/screens/ExploreModels/ModelVersionList/index.tsx deleted file mode 100644 index 7992b7a51..000000000 --- a/web/screens/ExploreModels/ModelVersionList/index.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Model } from '@janhq/core' - -import ModelVersionItem from '../ModelVersionItem' - -type Props = { - models: Model[] - recommendedVersion: string -} - -export default function ModelVersionList({ - models, - recommendedVersion, -}: Props) { - return ( -
- {models.map((model) => ( - - ))} -
- ) -} diff --git a/web/services/restService.ts b/web/services/restService.ts index 25488ae15..6b749fd71 100644 --- a/web/services/restService.ts +++ b/web/services/restService.ts @@ -3,6 +3,7 @@ import { AppRoute, DownloadRoute, ExtensionRoute, + FileManagerRoute, FileSystemRoute, } from '@janhq/core' @@ -22,6 +23,7 @@ export const APIRoutes = [ route: r, })), ...Object.values(FileSystemRoute).map((r) => ({ path: `fs`, route: r })), + ...Object.values(FileManagerRoute).map((r) => ({ path: `fs`, route: r })), ] // Define the restAPI object with methods for each API route @@ -50,4 +52,6 @@ export const restAPI = { } }, {}), openExternalUrl, + // Jan Server URL + baseApiUrl: API_BASE_URL, } diff --git a/web/types/downloadState.d.ts b/web/types/downloadState.d.ts index cca526bf1..766a0bde5 100644 --- a/web/types/downloadState.d.ts +++ b/web/types/downloadState.d.ts @@ -1,12 +1,13 @@ type DownloadState = { modelId: string + filename: string time: DownloadTime speed: number percent: number size: DownloadSize - isFinished?: boolean children?: DownloadState[] error?: string + downloadState: 'downloading' | 'error' | 'end' } type DownloadTime = {