chore: server download progress + S3 (#1925)

* fix: reduce the number of api call

Signed-off-by: James <james@jan.ai>

* fix: download progress

Signed-off-by: James <james@jan.ai>

* chore: save blob

* fix: server boot up

* fix: download state not updating

Signed-off-by: James <james@jan.ai>

* fix: copy assets

* Add Dockerfile CPU for Jan Server and Jan Web

* Add Dockerfile GPU for Jan Server and Jan Web

* feat: S3 adapter

* Update check find count from ./pre-install and correct copy:asserts command

* server add bundleDependencies @janhq/core

* server add bundleDependencies @janhq/core

* fix: update success/failed download state (#1945)

* fix: update success/failed download state

Signed-off-by: James <james@jan.ai>

* fix: download model progress and state handling for both Desktop and Web

---------

Signed-off-by: James <james@jan.ai>
Co-authored-by: James <james@jan.ai>
Co-authored-by: Louis <louis@jan.ai>

* chore: refactor

* fix: load models empty first time open

* Add Docker compose

* fix: assistants onUpdate

---------

Signed-off-by: James <james@jan.ai>
Co-authored-by: James <james@jan.ai>
Co-authored-by: Hien To <tominhhien97@gmail.com>
Co-authored-by: NamH <NamNh0122@gmail.com>
This commit is contained in:
Louis 2024-02-07 17:54:35 +07:00 committed by GitHub
parent 1442479666
commit 5890ade451
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 957 additions and 518 deletions

View File

@ -1,39 +1,58 @@
FROM node:20-bullseye AS base FROM node:20-bookworm AS base
# 1. Install dependencies only when needed # 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 WORKDIR /app
# Install dependencies based on the preferred package manager # Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ COPY . ./
RUN yarn install
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 # # 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 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 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 the package.json, yarn.lock, and build output of server yarn space to leverage Docker cache
COPY --from=builder /app/server/build ./ COPY --from=builder /app/server ./server/
COPY --from=builder /app/docs/openapi ./docs/openapi/
# Automatically leverage output traces to reduce image size # Copy pre-install dependencies
# https://nextjs.org/docs/advanced-features/output-file-tracing COPY --from=builder /app/pre-install ./pre-install/
COPY --from=builder /app/server/node_modules ./node_modules
COPY --from=builder /app/server/package.json ./package.json
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 RUN npm install -g serve@latest
ENV APPDATA /app/data
CMD ["node", "main.js"] 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

65
Dockerfile.gpu Normal file
View File

@ -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

View File

@ -24,9 +24,9 @@ endif
check-file-counts: install-and-build check-file-counts: install-and-build
ifeq ($(OS),Windows_NT) 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 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 endif
dev: check-file-counts dev: check-file-counts

View File

@ -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. 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 ## Acknowledgements
Jan builds on top of other open-source projects: Jan builds on top of other open-source projects:

View File

@ -57,6 +57,7 @@
"rollup-plugin-typescript2": "^0.36.0", "rollup-plugin-typescript2": "^0.36.0",
"ts-jest": "^26.1.1", "ts-jest": "^26.1.1",
"tslib": "^2.6.2", "tslib": "^2.6.2",
"typescript": "^5.2.2" "typescript": "^5.2.2",
"rimraf": "^3.0.2"
} }
} }

View File

@ -30,6 +30,7 @@ export enum DownloadRoute {
downloadFile = 'downloadFile', downloadFile = 'downloadFile',
pauseDownload = 'pauseDownload', pauseDownload = 'pauseDownload',
resumeDownload = 'resumeDownload', resumeDownload = 'resumeDownload',
getDownloadProgress = 'getDownloadProgress',
} }
export enum DownloadEvent { export enum DownloadEvent {

View File

@ -5,8 +5,27 @@ import { HttpServer } from '../HttpServer'
import { createWriteStream } from 'fs' import { createWriteStream } from 'fs'
import { getJanDataFolderPath } from '../../utils' import { getJanDataFolderPath } from '../../utils'
import { normalizeFilePath } from '../../path' import { normalizeFilePath } from '../../path'
import { DownloadState } from '../../../types'
export const downloadRouter = async (app: HttpServer) => { 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) => { app.post(`/${DownloadRoute.downloadFile}`, async (req, res) => {
const strictSSL = !(req.query.ignoreSSL === 'true') const strictSSL = !(req.query.ignoreSSL === 'true')
const proxy = req.query.proxy?.startsWith('http') ? req.query.proxy : undefined 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 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 request = require('request')
const progress = require('request-progress') const progress = require('request-progress')
@ -27,17 +49,44 @@ export const downloadRouter = async (app: HttpServer) => {
const rq = request({ url: normalizedArgs[0], strictSSL, proxy }) const rq = request({ url: normalizedArgs[0], strictSSL, proxy })
progress(rq, {}) progress(rq, {})
.on('progress', function (state: any) { .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) { .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 () { .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])) .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) => { app.post(`/${DownloadRoute.abortDownload}`, async (req, res) => {
@ -54,5 +103,10 @@ export const downloadRouter = async (app: HttpServer) => {
const rq = DownloadManager.instance.networkRequests[fileName] const rq = DownloadManager.instance.networkRequests[fileName]
DownloadManager.instance.networkRequests[fileName] = undefined DownloadManager.instance.networkRequests[fileName] = undefined
rq?.abort() rq?.abort()
if (rq) {
res.status(200).send({ message: 'Download aborted' })
} else {
res.status(404).send({ message: 'Download not found' })
}
}) })
} }

View File

@ -1,14 +1,29 @@
import { FileManagerRoute } from '../../../api' import { FileManagerRoute } from '../../../api'
import { HttpServer } from '../../index' import { HttpServer } from '../../index'
import { join } from 'path'
export const fsRouter = async (app: HttpServer) => { export const fileManagerRouter = async (app: HttpServer) => {
app.post(`/app/${FileManagerRoute.syncFile}`, async (request: any, reply: any) => {}) 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.getUserHomePath}`, async (request: any, reply: any) => {})
app.post(`/fs/${FileManagerRoute.fileStat}`, async (request: any, reply: any) => {})
app.post(`/app/${FileManagerRoute.fileStat}`, async (request: any, reply: any) => {})
} }

View File

@ -1,8 +1,9 @@
import { FileSystemRoute } from '../../../api' import { FileManagerRoute, FileSystemRoute } from '../../../api'
import { join } from 'path' import { join } from 'path'
import { HttpServer } from '../HttpServer' import { HttpServer } from '../HttpServer'
import { getJanDataFolderPath } from '../../utils' import { getJanDataFolderPath } from '../../utils'
import { normalizeFilePath } from '../../path' import { normalizeFilePath } from '../../path'
import { writeFileSync } from 'fs'
export const fsRouter = async (app: HttpServer) => { export const fsRouter = async (app: HttpServer) => {
const moduleName = 'fs' 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}`)
}
})
} }

View File

@ -4,6 +4,7 @@ import { threadRouter } from './thread'
import { fsRouter } from './fs' import { fsRouter } from './fs'
import { extensionRouter } from './extension' import { extensionRouter } from './extension'
import { downloadRouter } from './download' import { downloadRouter } from './download'
import { fileManagerRouter } from './fileManager'
export const v1Router = async (app: HttpServer) => { export const v1Router = async (app: HttpServer) => {
// MARK: External Routes // MARK: External Routes
@ -16,6 +17,8 @@ export const v1Router = async (app: HttpServer) => {
app.register(fsRouter, { app.register(fsRouter, {
prefix: '/fs', prefix: '/fs',
}) })
app.register(fileManagerRouter)
app.register(extensionRouter, { app.register(extensionRouter, {
prefix: '/extension', prefix: '/extension',
}) })

View File

@ -1,15 +1,18 @@
import { DownloadState } from '../types'
/** /**
* Manages file downloads and network requests. * Manages file downloads and network requests.
*/ */
export class DownloadManager { export class DownloadManager {
public networkRequests: Record<string, any> = {}; public networkRequests: Record<string, any> = {}
public static instance: DownloadManager = new DownloadManager(); public static instance: DownloadManager = new DownloadManager()
public downloadProgressMap: Record<string, DownloadState> = {}
constructor() { constructor() {
if (DownloadManager.instance) { 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. * @param {Request | undefined} request - The network request to set, or undefined to clear the request.
*/ */
setRequest(fileName: string, request: any | undefined) { setRequest(fileName: string, request: any | undefined) {
this.networkRequests[fileName] = request; this.networkRequests[fileName] = request
} }
} }

View File

@ -41,8 +41,8 @@ async function registerExtensionProtocol() {
console.error('Electron is not available') console.error('Electron is not available')
} }
const extensionPath = ExtensionManager.instance.getExtensionsPath() const extensionPath = ExtensionManager.instance.getExtensionsPath()
if (electron) { if (electron && electron.protocol) {
return electron.protocol.registerFileProtocol('extension', (request: any, callback: any) => { 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(extensionPath + entry) const url = normalize(extensionPath + entry)
@ -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.'
) )
} }
@ -122,7 +122,7 @@ function loadExtension(ext: any) {
export function getStore() { export function getStore() {
if (!ExtensionManager.instance.getExtensionsFile()) { if (!ExtensionManager.instance.getExtensionsFile()) {
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,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',
}

View File

@ -1,2 +1,3 @@
export * from './assistantEntity' export * from './assistantEntity'
export * from './assistantEvent'
export * from './assistantInterface' export * from './assistantInterface'

View File

@ -2,3 +2,26 @@ export type FileStat = {
isDirectory: boolean isDirectory: boolean
size: number 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
}

View File

@ -12,4 +12,6 @@ export enum ModelEvent {
OnModelStop = 'OnModelStop', OnModelStop = 'OnModelStop',
/** The `OnModelStopped` event is emitted when a model stopped ok. */ /** The `OnModelStopped` event is emitted when a model stopped ok. */
OnModelStopped = 'OnModelStopped', OnModelStopped = 'OnModelStopped',
/** The `OnModelUpdate` event is emitted when the model list is updated. */
OnModelsUpdate = 'OnModelsUpdate',
} }

110
docker-compose.yml Normal file
View File

@ -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

View File

@ -5,7 +5,11 @@ import request from 'request'
import { createWriteStream, renameSync } 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, getJanDataFolderPath, normalizeFilePath } from '@janhq/core/node' import {
DownloadManager,
getJanDataFolderPath,
normalizeFilePath,
} from '@janhq/core/node'
export function handleDownloaderIPCs() { export function handleDownloaderIPCs() {
/** /**
@ -56,20 +60,23 @@ export function handleDownloaderIPCs() {
*/ */
ipcMain.handle( ipcMain.handle(
DownloadRoute.downloadFile, DownloadRoute.downloadFile,
async (_event, url, fileName, network) => { async (_event, url, localPath, network) => {
const strictSSL = !network?.ignoreSSL const strictSSL = !network?.ignoreSSL
const proxy = network?.proxy?.startsWith('http') const proxy = network?.proxy?.startsWith('http')
? network.proxy ? network.proxy
: undefined : undefined
if (typeof localPath === 'string') {
if (typeof fileName === 'string') { localPath = normalizeFilePath(localPath)
fileName = normalizeFilePath(fileName)
} }
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 }) const rq = request({ url, strictSSL, proxy })
// Put request to download manager instance // Put request to download manager instance
DownloadManager.instance.setRequest(fileName, rq) DownloadManager.instance.setRequest(localPath, rq)
// Downloading file to a temp file first // Downloading file to a temp file first
const downloadingTempFile = `${destination}.download` const downloadingTempFile = `${destination}.download`
@ -81,6 +88,7 @@ export function handleDownloaderIPCs() {
{ {
...state, ...state,
fileName, fileName,
modelId,
} }
) )
}) })
@ -90,11 +98,12 @@ export function handleDownloaderIPCs() {
{ {
fileName, fileName,
err, err,
modelId,
} }
) )
}) })
.on('end', function () { .on('end', function () {
if (DownloadManager.instance.networkRequests[fileName]) { if (DownloadManager.instance.networkRequests[localPath]) {
// Finished downloading, rename temp file to actual file // Finished downloading, rename temp file to actual file
renameSync(downloadingTempFile, destination) renameSync(downloadingTempFile, destination)
@ -102,14 +111,16 @@ export function handleDownloaderIPCs() {
DownloadEvent.onFileDownloadSuccess, DownloadEvent.onFileDownloadSuccess,
{ {
fileName, fileName,
modelId,
} }
) )
DownloadManager.instance.setRequest(fileName, undefined) DownloadManager.instance.setRequest(localPath, undefined)
} else { } else {
WindowManager?.instance.currentWindow?.webContents.send( WindowManager?.instance.currentWindow?.webContents.send(
DownloadEvent.onFileDownloadError, DownloadEvent.onFileDownloadError,
{ {
fileName, fileName,
modelId,
err: { message: 'aborted' }, err: { message: 'aborted' },
} }
) )

View File

@ -38,6 +38,7 @@ export function handleFileMangerIPCs() {
getResourcePath() getResourcePath()
) )
// Handles the 'getUserHomePath' IPC event. This event is triggered to get the user home path.
ipcMain.handle(FileManagerRoute.getUserHomePath, async (_event) => ipcMain.handle(FileManagerRoute.getUserHomePath, async (_event) =>
app.getPath('home') app.getPath('home')
) )

View File

@ -1,8 +1,7 @@
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
import { getJanDataFolderPath, normalizeFilePath } from '@janhq/core/node' import { getJanDataFolderPath, normalizeFilePath } from '@janhq/core/node'
import fs from 'fs' import { FileSystemRoute } from '@janhq/core'
import { FileManagerRoute, FileSystemRoute } from '@janhq/core'
import { join } from 'path' import { join } from 'path'
/** /**
* Handles file system operations. * Handles file system operations.

View File

@ -57,17 +57,18 @@
"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": "tsc -p . && electron .", "copy:assets": "rimraf --glob \"./pre-install/*.tgz\" && cpx \"../pre-install/*.tgz\" \"./pre-install\"",
"build": "run-script-os", "dev": "yarn copy:assets && tsc -p . && electron .",
"build:test": "run-script-os", "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:darwin": "tsc -p . && electron-builder -p never -m --dir",
"build:test:win32": "tsc -p . && electron-builder -p never -w --dir", "build:test:win32": "tsc -p . && electron-builder -p never -w --dir",
"build:test:linux": "tsc -p . && electron-builder -p never -l --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:win32": "tsc -p . && electron-builder -p never -w",
"build:linux": "tsc -p . && electron-builder -p never -l deb -l AppImage", "build:linux": "tsc -p . && electron-builder -p never -l deb -l AppImage",
"build:publish": "run-script-os", "build:publish": "yarn copy:assets && run-script-os",
"build:publish:darwin": "tsc -p . && electron-builder -p always -m", "build:publish:darwin": "tsc -p . && electron-builder -p always -m --x64 --arm64",
"build:publish:win32": "tsc -p . && electron-builder -p always -w", "build:publish:win32": "tsc -p . && electron-builder -p always -w",
"build:publish:linux": "tsc -p . && electron-builder -p always -l deb -l AppImage" "build:publish:linux": "tsc -p . && electron-builder -p always -l deb -l AppImage"
}, },

View File

@ -8,9 +8,9 @@
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
"build": "tsc --module commonjs && rollup -c rollup.config.ts", "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: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 ../../electron/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 ../../electron/pre-install", "build:publish:win32": "rimraf *.tgz --glob && npm run build && npm pack && cpx *.tgz ../../pre-install",
"build:publish": "run-script-os" "build:publish": "run-script-os"
}, },
"devDependencies": { "devDependencies": {

View File

@ -9,6 +9,7 @@ import {
joinPath, joinPath,
executeOnMain, executeOnMain,
AssistantExtension, AssistantExtension,
AssistantEvent,
} from "@janhq/core"; } from "@janhq/core";
export default class JanAssistantExtension extends AssistantExtension { export default class JanAssistantExtension extends AssistantExtension {
@ -21,7 +22,7 @@ export default class JanAssistantExtension extends AssistantExtension {
async onLoad() { async onLoad() {
// making the assistant directory // making the assistant directory
const assistantDirExist = await fs.existsSync( const assistantDirExist = await fs.existsSync(
JanAssistantExtension._homeDir, JanAssistantExtension._homeDir
); );
if ( if (
localStorage.getItem(`${EXTENSION_NAME}-version`) !== VERSION || localStorage.getItem(`${EXTENSION_NAME}-version`) !== VERSION ||
@ -31,14 +32,16 @@ export default class JanAssistantExtension extends AssistantExtension {
await fs.mkdirSync(JanAssistantExtension._homeDir); await fs.mkdirSync(JanAssistantExtension._homeDir);
// Write assistant metadata // Write assistant metadata
this.createJanAssistant(); await this.createJanAssistant();
// Finished migration // Finished migration
localStorage.setItem(`${EXTENSION_NAME}-version`, VERSION); localStorage.setItem(`${EXTENSION_NAME}-version`, VERSION);
// Update the assistant list
events.emit(AssistantEvent.OnAssistantsUpdate, {});
} }
// Events subscription // Events subscription
events.on(MessageEvent.OnMessageSent, (data: MessageRequest) => events.on(MessageEvent.OnMessageSent, (data: MessageRequest) =>
JanAssistantExtension.handleMessageRequest(data, this), JanAssistantExtension.handleMessageRequest(data, this)
); );
events.on(InferenceEvent.OnInferenceStopped, () => { events.on(InferenceEvent.OnInferenceStopped, () => {
@ -53,7 +56,7 @@ export default class JanAssistantExtension extends AssistantExtension {
private static async handleMessageRequest( private static async handleMessageRequest(
data: MessageRequest, data: MessageRequest,
instance: JanAssistantExtension, instance: JanAssistantExtension
) { ) {
instance.isCancelled = false; instance.isCancelled = false;
instance.controller = new AbortController(); instance.controller = new AbortController();
@ -80,7 +83,7 @@ export default class JanAssistantExtension extends AssistantExtension {
NODE, NODE,
"toolRetrievalIngestNewDocument", "toolRetrievalIngestNewDocument",
docFile, docFile,
data.model?.proxyEngine, data.model?.proxyEngine
); );
} }
} }
@ -96,7 +99,7 @@ export default class JanAssistantExtension extends AssistantExtension {
NODE, NODE,
"toolRetrievalUpdateTextSplitter", "toolRetrievalUpdateTextSplitter",
data.thread.assistants[0].tools[0]?.settings?.chunk_size ?? 4000, 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( const retrievalResult = await executeOnMain(
NODE, NODE,
"toolRetrievalQueryResult", "toolRetrievalQueryResult",
prompt, prompt
); );
// Update the message content // Update the message content
@ -168,7 +171,7 @@ export default class JanAssistantExtension extends AssistantExtension {
try { try {
await fs.writeFileSync( await fs.writeFileSync(
assistantMetadataPath, assistantMetadataPath,
JSON.stringify(assistant, null, 2), JSON.stringify(assistant, null, 2)
); );
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -180,7 +183,7 @@ export default class JanAssistantExtension extends AssistantExtension {
// get all the assistant metadata json // get all the assistant metadata json
const results: Assistant[] = []; const results: Assistant[] = [];
const allFileName: string[] = await fs.readdirSync( const allFileName: string[] = await fs.readdirSync(
JanAssistantExtension._homeDir, JanAssistantExtension._homeDir
); );
for (const fileName of allFileName) { for (const fileName of allFileName) {
const filePath = await joinPath([ const filePath = await joinPath([
@ -190,7 +193,7 @@ export default class JanAssistantExtension extends AssistantExtension {
if (filePath.includes(".DS_Store")) continue; if (filePath.includes(".DS_Store")) continue;
const jsonFiles: string[] = (await fs.readdirSync(filePath)).filter( const jsonFiles: string[] = (await fs.readdirSync(filePath)).filter(
(file: string) => file === "assistant.json", (file: string) => file === "assistant.json"
); );
if (jsonFiles.length !== 1) { if (jsonFiles.length !== 1) {
@ -200,7 +203,7 @@ export default class JanAssistantExtension extends AssistantExtension {
const content = await fs.readFileSync( const content = await fs.readFileSync(
await joinPath([filePath, jsonFiles[0]]), await joinPath([filePath, jsonFiles[0]]),
"utf-8", "utf-8"
); );
const assistant: Assistant = const assistant: Assistant =
typeof content === "object" ? content : JSON.parse(content); typeof content === "object" ? content : JSON.parse(content);

View File

@ -7,7 +7,7 @@
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"build": "tsc -b . && webpack --config webpack.config.js", "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": { "exports": {
".": "./dist/index.js", ".": "./dist/index.js",

View File

@ -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: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:win32": "download.bat",
"downloadnitro": "run-script-os", "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: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 ../../electron/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 ../../electron/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" "build:publish": "run-script-os"
}, },
"exports": { "exports": {

View File

@ -8,7 +8,7 @@
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
"build": "tsc -b . && webpack --config webpack.config.js", "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": { "exports": {
".": "./dist/index.js", ".": "./dist/index.js",

View File

@ -8,7 +8,7 @@
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
"build": "tsc -b . && webpack --config webpack.config.js", "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": { "exports": {
".": "./dist/index.js", ".": "./dist/index.js",

View File

@ -8,7 +8,7 @@
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
"build": "tsc -b . && webpack --config webpack.config.js", "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": { "devDependencies": {
"cpx": "^1.5.0", "cpx": "^1.5.0",

View File

@ -1,3 +1,15 @@
declare const EXTENSION_NAME: string export {}
declare const MODULE_PATH: string declare global {
declare const VERSION: stringå 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
}
}

View File

@ -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
}

View File

@ -8,7 +8,13 @@ import {
ModelExtension, ModelExtension,
Model, Model,
getJanDataFolderPath, getJanDataFolderPath,
events,
DownloadEvent,
DownloadRoute,
ModelEvent,
} from '@janhq/core' } from '@janhq/core'
import { DownloadState } from '@janhq/core/.'
import { extractFileName } from './helpers/path'
/** /**
* A extension for models * A extension for models
@ -29,6 +35,8 @@ export default class JanModelExtension extends ModelExtension {
*/ */
async onLoad() { async onLoad() {
this.copyModelsToHomeDir() this.copyModelsToHomeDir()
// Handle Desktop Events
this.handleDesktopEvents()
} }
/** /**
@ -61,6 +69,8 @@ export default class JanModelExtension extends ModelExtension {
// Finished migration // Finished migration
localStorage.setItem(`${EXTENSION_NAME}-version`, VERSION) localStorage.setItem(`${EXTENSION_NAME}-version`, VERSION)
events.emit(ModelEvent.OnModelsUpdate, {})
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} }
@ -83,31 +93,66 @@ export default class JanModelExtension extends ModelExtension {
if (model.sources.length > 1) { if (model.sources.length > 1) {
// path to model binaries // path to model binaries
for (const source of model.sources) { for (const source of model.sources) {
let path = this.extractFileName(source.url) let path = extractFileName(
source.url,
JanModelExtension._supportedModelFormat
)
if (source.filename) { if (source.filename) {
path = await joinPath([modelDirPath, source.filename]) path = await joinPath([modelDirPath, source.filename])
} }
downloadFile(source.url, path, network) downloadFile(source.url, path, network)
} }
// TODO: handle multiple binaries for web later
} else { } else {
const fileName = this.extractFileName(model.sources[0]?.url) const fileName = extractFileName(
model.sources[0]?.url,
JanModelExtension._supportedModelFormat
)
const path = await joinPath([modelDirPath, fileName]) const path = await joinPath([modelDirPath, fileName])
downloadFile(model.sources[0]?.url, path, network) 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 { private async startPollingDownloadProgress(modelId: string): Promise<void> {
const extractedFileName = url.split('/').pop() // wait for some seconds before polling
const fileName = extractedFileName await new Promise((resolve) => setTimeout(resolve, 3000))
.toLowerCase()
.endsWith(JanModelExtension._supportedModelFormat) return new Promise((resolve) => {
? extractedFileName const interval = setInterval(async () => {
: extractedFileName + JanModelExtension._supportedModelFormat fetch(
return fileName `${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 return
} }
const defaultModel = await this.getDefaultModel() as Model const defaultModel = (await this.getDefaultModel()) as Model
if (!defaultModel) { if (!defaultModel) {
console.error('Unable to find default model') console.error('Unable to find default model')
return return
@ -382,4 +427,28 @@ export default class JanModelExtension extends ModelExtension {
async getConfiguredModels(): Promise<Model[]> { async getConfiguredModels(): Promise<Model[]> {
return this.getModelsMetadata() 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)
}
)
}
}
} }

View File

@ -8,7 +8,7 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"strict": false, "strict": false,
"skipLibCheck": true, "skipLibCheck": true,
"rootDir": "./src" "rootDir": "./src",
}, },
"include": ["./src"] "include": ["./src"],
} }

View File

@ -19,7 +19,7 @@ module.exports = {
new webpack.DefinePlugin({ new webpack.DefinePlugin({
EXTENSION_NAME: JSON.stringify(packageJson.name), EXTENSION_NAME: JSON.stringify(packageJson.name),
MODULE_PATH: JSON.stringify(`${packageJson.name}/${packageJson.module}`), MODULE_PATH: JSON.stringify(`${packageJson.name}/${packageJson.module}`),
VERSION: JSON.stringify(packageJson.version), VERSION: JSON.stringify(packageJson.version)
}), }),
], ],
output: { output: {

View File

@ -8,7 +8,7 @@
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
"build": "tsc -b . && webpack --config webpack.config.js", "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": { "devDependencies": {
"rimraf": "^3.0.2", "rimraf": "^3.0.2",

View File

@ -21,22 +21,23 @@
"lint": "yarn workspace jan lint && yarn workspace jan-web lint", "lint": "yarn workspace jan lint && yarn workspace jan-web lint",
"test:unit": "yarn workspace @janhq/core test", "test:unit": "yarn workspace @janhq/core test",
"test": "yarn workspace jan test:e2e", "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:electron": "yarn copy:assets && yarn workspace jan dev",
"dev:web": "yarn workspace jan-web 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\"", "dev": "concurrently --kill-others \"yarn dev:web\" \"wait-on http://localhost:3000 && yarn dev:electron\"",
"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:server": "yarn copy:assets && 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": "yarn copy:assets && yarn workspace jan build", "build:electron": "yarn copy:assets && yarn workspace jan build",
"build:electron:test": "yarn workspace jan build:test", "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: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 ./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: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 ./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 ./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:extensions": "run-script-os",
"build:test": "yarn copy:assets && yarn build:web && yarn workspace jan build:test", "build:test": "yarn copy:assets && yarn build:web && yarn workspace jan build:test",
"build": "yarn build:web && yarn build:electron", "build": "yarn build:web && yarn build:electron",

0
pre-install/.gitkeep Normal file
View File

47
server/helpers/setup.ts Normal file
View File

@ -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");
}

View File

@ -38,6 +38,7 @@ export interface ServerConfig {
isVerboseEnabled?: boolean; isVerboseEnabled?: boolean;
schemaPath?: string; schemaPath?: string;
baseDir?: string; baseDir?: string;
storageAdataper?: any;
} }
/** /**
@ -103,9 +104,12 @@ export const startServer = async (configs?: ServerConfig) => {
{ prefix: "extensions" } { prefix: "extensions" }
); );
// Register proxy middleware
if (configs?.storageAdataper)
server.addHook("preHandler", configs.storageAdataper);
// Register API routes // Register API routes
await server.register(v1Router, { prefix: "/v1" }); await server.register(v1Router, { prefix: "/v1" });
// Start listening for requests // Start listening for requests
await server await server
.listen({ .listen({

View File

@ -1,3 +1,7 @@
import { startServer } from "./index"; import { s3 } from "./middleware/s3";
import { setup } from "./helpers/setup";
startServer(); import { startServer as start } from "./index";
/**
* Setup extensions and start the server
*/
setup().then(() => start({ storageAdataper: s3 }));

70
server/middleware/s3.ts Normal file
View File

@ -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]);
}
};

View File

@ -1,5 +0,0 @@
{
"watch": ["main.ts", "v1"],
"ext": "ts, json",
"exec": "tsc && node ./build/main.js"
}

View File

@ -13,16 +13,18 @@
"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": "tsc --watch & node --watch build/main.js", "build:core": "cd node_modules/@janhq/core && yarn install && yarn build",
"build": "tsc" "dev": "yarn build:core && tsc --watch & node --watch build/main.js",
"build": "yarn build:core && tsc"
}, },
"dependencies": { "dependencies": {
"@alumna/reflect": "^1.1.3", "@alumna/reflect": "^1.1.3",
"@cyclic.sh/s3fs": "^1.2.9",
"@fastify/cors": "^8.4.2", "@fastify/cors": "^8.4.2",
"@fastify/static": "^6.12.0", "@fastify/static": "^6.12.0",
"@fastify/swagger": "^8.13.0", "@fastify/swagger": "^8.13.0",
"@fastify/swagger-ui": "2.0.1", "@fastify/swagger-ui": "2.0.1",
"@janhq/core": "link:./core", "@janhq/core": "file:../core",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"fastify": "^4.24.3", "fastify": "^4.24.3",
"request": "^2.88.2", "request": "^2.88.2",
@ -39,5 +41,8 @@
"run-script-os": "^1.1.6", "run-script-os": "^1.1.6",
"@types/tcp-port-used": "^1.0.4", "@types/tcp-port-used": "^1.0.4",
"typescript": "^5.2.2" "typescript": "^5.2.2"
} },
"bundleDependencies": [
"@janhq/core"
]
} }

View File

@ -20,5 +20,5 @@
// "sourceMap": true, // "sourceMap": true,
"include": ["./**/*.ts"], "include": ["./**/*.ts"],
"exclude": ["core", "build", "dist", "tests", "node_modules"] "exclude": ["core", "build", "dist", "tests", "node_modules", "extensions"]
} }

View File

@ -13,22 +13,22 @@ import {
import { useAtomValue } from 'jotai' import { useAtomValue } from 'jotai'
import useDownloadModel from '@/hooks/useDownloadModel' import useDownloadModel from '@/hooks/useDownloadModel'
import { useDownloadState } from '@/hooks/useDownloadState' import { modelDownloadStateAtom } from '@/hooks/useDownloadState'
import { formatDownloadPercentage } from '@/utils/converter' import { formatDownloadPercentage } from '@/utils/converter'
import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom' import { getDownloadingModelAtom } from '@/helpers/atoms/Model.atom'
export default function DownloadingState() { export default function DownloadingState() {
const { downloadStates } = useDownloadState() const downloadStates = useAtomValue(modelDownloadStateAtom)
const downloadingModels = useAtomValue(downloadingModelsAtom) const downloadingModels = useAtomValue(getDownloadingModelAtom)
const { abortModelDownload } = useDownloadModel() const { abortModelDownload } = useDownloadModel()
const totalCurrentProgress = downloadStates const totalCurrentProgress = Object.values(downloadStates)
.map((a) => a.size.transferred + a.size.transferred) .map((a) => a.size.transferred + a.size.transferred)
.reduce((partialSum, a) => partialSum + a, 0) .reduce((partialSum, a) => partialSum + a, 0)
const totalSize = downloadStates const totalSize = Object.values(downloadStates)
.map((a) => a.size.total + a.size.total) .map((a) => a.size.total + a.size.total)
.reduce((partialSum, a) => partialSum + a, 0) .reduce((partialSum, a) => partialSum + a, 0)
@ -36,12 +36,14 @@ export default function DownloadingState() {
return ( return (
<Fragment> <Fragment>
{downloadStates?.length > 0 && ( {Object.values(downloadStates)?.length > 0 && (
<Modal> <Modal>
<ModalTrigger asChild> <ModalTrigger asChild>
<div className="relative block"> <div className="relative block">
<Button size="sm" themes="outline"> <Button size="sm" themes="outline">
<span>{downloadStates.length} Downloading model</span> <span>
{Object.values(downloadStates).length} Downloading model
</span>
</Button> </Button>
<span <span
className="absolute left-0 h-full rounded-md rounded-l-md bg-primary/20" className="absolute left-0 h-full rounded-md rounded-l-md bg-primary/20"
@ -55,8 +57,7 @@ export default function DownloadingState() {
<ModalHeader> <ModalHeader>
<ModalTitle>Downloading model</ModalTitle> <ModalTitle>Downloading model</ModalTitle>
</ModalHeader> </ModalHeader>
{downloadStates.map((item, i) => { {Object.values(downloadStates).map((item, i) => (
return (
<div className="pt-2" key={i}> <div className="pt-2" key={i}>
<Progress <Progress
className="mb-2 h-2" className="mb-2 h-2"
@ -87,8 +88,7 @@ export default function DownloadingState() {
</Button> </Button>
</div> </div>
</div> </div>
) ))}
})}
</ModalContent> </ModalContent>
</Modal> </Modal>
)} )}

View File

@ -25,8 +25,7 @@ import { MainViewState } from '@/constants/screens'
import { useActiveModel } from '@/hooks/useActiveModel' import { useActiveModel } from '@/hooks/useActiveModel'
import { useDownloadState } from '@/hooks/useDownloadState' import { modelDownloadStateAtom } from '@/hooks/useDownloadState'
import useGetSystemResources from '@/hooks/useGetSystemResources' import useGetSystemResources from '@/hooks/useGetSystemResources'
import { useMainViewState } from '@/hooks/useMainViewState' import { useMainViewState } from '@/hooks/useMainViewState'
@ -53,7 +52,7 @@ const BottomBar = () => {
const downloadedModels = useAtomValue(downloadedModelsAtom) const downloadedModels = useAtomValue(downloadedModelsAtom)
const { setMainViewState } = useMainViewState() const { setMainViewState } = useMainViewState()
const { downloadStates } = useDownloadState() const downloadStates = useAtomValue(modelDownloadStateAtom)
const setShowSelectModelModal = useSetAtom(showSelectModelModalAtom) const setShowSelectModelModal = useSetAtom(showSelectModelModalAtom)
const [serverEnabled] = useAtom(serverEnabledAtom) const [serverEnabled] = useAtom(serverEnabledAtom)
@ -109,7 +108,7 @@ const BottomBar = () => {
)} )}
{downloadedModels.length === 0 && {downloadedModels.length === 0 &&
!stateModel.loading && !stateModel.loading &&
downloadStates.length === 0 && ( Object.values(downloadStates).length === 0 && (
<Button <Button
size="sm" size="sm"
themes="outline" themes="outline"

View File

@ -17,23 +17,22 @@ import {
import { atom, useAtomValue } from 'jotai' import { atom, useAtomValue } from 'jotai'
import useDownloadModel from '@/hooks/useDownloadModel' import useDownloadModel from '@/hooks/useDownloadModel'
import { useDownloadState } from '@/hooks/useDownloadState'
import { modelDownloadStateAtom } from '@/hooks/useDownloadState'
import { formatDownloadPercentage } from '@/utils/converter' import { formatDownloadPercentage } from '@/utils/converter'
import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom' import { getDownloadingModelAtom } from '@/helpers/atoms/Model.atom'
type Props = { type Props = {
model: Model model: Model
isFromList?: boolean isFromList?: boolean
} }
export default function ModalCancelDownload({ model, isFromList }: Props) { const ModalCancelDownload: React.FC<Props> = ({ model, isFromList }) => {
const { modelDownloadStateAtom } = useDownloadState() const downloadingModels = useAtomValue(getDownloadingModelAtom)
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
[model.id] [model.id]
) )
const downloadState = useAtomValue(downloadAtom) const downloadState = useAtomValue(downloadAtom)
@ -98,3 +97,5 @@ export default function ModalCancelDownload({ model, isFromList }: Props) {
</Modal> </Modal>
) )
} }
export default ModalCancelDownload

View File

@ -1,93 +1,62 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { PropsWithChildren, useCallback, useEffect } from 'react'
import { PropsWithChildren, useEffect, useRef } from 'react' import React from 'react'
import { baseName } from '@janhq/core' import { DownloadEvent, events } from '@janhq/core'
import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { useSetAtom } from 'jotai'
import { useDownloadState } from '@/hooks/useDownloadState' import { setDownloadStateAtom } from '@/hooks/useDownloadState'
import { modelBinFileName } from '@/utils/model'
import EventHandler from './EventHandler' import EventHandler from './EventHandler'
import { appDownloadProgress } from './Jotai' import { appDownloadProgress } from './Jotai'
import { const EventListenerWrapper = ({ children }: PropsWithChildren) => {
downloadedModelsAtom, const setDownloadState = useSetAtom(setDownloadStateAtom)
downloadingModelsAtom,
} from '@/helpers/atoms/Model.atom'
export default function EventListenerWrapper({ children }: PropsWithChildren) {
const setProgress = useSetAtom(appDownloadProgress) const setProgress = useSetAtom(appDownloadProgress)
const models = useAtomValue(downloadingModelsAtom)
const modelsRef = useRef(models)
const [downloadedModels, setDownloadedModels] = useAtom(downloadedModelsAtom) const onFileDownloadUpdate = useCallback(
const { async (state: DownloadState) => {
setDownloadState, console.debug('onFileDownloadUpdate', state)
setDownloadStateSuccess, setDownloadState(state)
setDownloadStateFailed, },
setDownloadStateCancelled, [setDownloadState]
} = useDownloadState() )
const downloadedModelRef = useRef(downloadedModels)
const onFileDownloadError = useCallback(
(state: DownloadState) => {
console.debug('onFileDownloadError', state)
setDownloadState(state)
},
[setDownloadState]
)
const onFileDownloadSuccess = useCallback(
(state: DownloadState) => {
console.debug('onFileDownloadSuccess', state)
setDownloadState(state)
},
[setDownloadState]
)
useEffect(() => { useEffect(() => {
modelsRef.current = models console.log('EventListenerWrapper: registering event listeners...')
}, [models])
useEffect(() => { events.on(DownloadEvent.onFileDownloadUpdate, onFileDownloadUpdate)
downloadedModelRef.current = downloadedModels events.on(DownloadEvent.onFileDownloadError, onFileDownloadError)
}, [downloadedModels]) events.on(DownloadEvent.onFileDownloadSuccess, onFileDownloadSuccess)
return () => {
console.log('EventListenerWrapper: unregistering event listeners...')
events.off(DownloadEvent.onFileDownloadUpdate, onFileDownloadUpdate)
events.off(DownloadEvent.onFileDownloadError, onFileDownloadError)
events.off(DownloadEvent.onFileDownloadSuccess, onFileDownloadSuccess)
}
}, [onFileDownloadUpdate, onFileDownloadError, onFileDownloadSuccess])
useEffect(() => { useEffect(() => {
if (window && window.electronAPI) { if (window && window.electronAPI) {
window.electronAPI.onFileDownloadUpdate(
async (_event: string, state: any | undefined) => {
if (!state) return
const modelName = await baseName(state.fileName)
const model = modelsRef.current.find(
(model) => modelBinFileName(model) === modelName
)
if (model)
setDownloadState({
...state,
modelId: model.id,
})
}
)
window.electronAPI.onFileDownloadError(
async (_event: string, state: any) => {
const modelName = await baseName(state.fileName)
const model = modelsRef.current.find(
(model) => modelBinFileName(model) === modelName
)
if (model) {
if (state.err?.message !== 'aborted') {
console.error('Download error', state)
setDownloadStateFailed(model.id, state.err.message)
} else {
setDownloadStateCancelled(model.id)
}
}
}
)
window.electronAPI.onFileDownloadSuccess(
async (_event: string, state: any) => {
if (state && state.fileName) {
const modelName = await baseName(state.fileName)
const model = modelsRef.current.find(
(model) => modelBinFileName(model) === modelName
)
if (model) {
setDownloadStateSuccess(model.id)
setDownloadedModels([...downloadedModelRef.current, model])
}
}
}
)
window.electronAPI.onAppUpdateDownloadUpdate( window.electronAPI.onAppUpdateDownloadUpdate(
(_event: string, progress: any) => { (_event: string, progress: any) => {
setProgress(progress.percent) setProgress(progress.percent)
@ -107,14 +76,9 @@ export default function EventListenerWrapper({ children }: PropsWithChildren) {
}) })
} }
return () => {} return () => {}
}, [ }, [setDownloadState, setProgress])
setDownloadState,
setDownloadStateCancelled,
setDownloadStateFailed,
setDownloadStateSuccess,
setDownloadedModels,
setProgress,
])
return <EventHandler>{children}</EventHandler> return <EventHandler>{children}</EventHandler>
} }
export default EventListenerWrapper

View File

@ -4,23 +4,28 @@ import { atom } from 'jotai'
export const stateModel = atom({ state: 'start', loading: false, model: '' }) export const stateModel = atom({ state: 'start', loading: false, model: '' })
export const activeAssistantModelAtom = atom<Model | undefined>(undefined) export const activeAssistantModelAtom = atom<Model | undefined>(undefined)
export const downloadingModelsAtom = atom<Model[]>([]) /**
* Stores the list of models which are being downloaded.
*/
const downloadingModelsAtom = atom<Model[]>([])
export const addNewDownloadingModelAtom = atom( export const getDownloadingModelAtom = atom((get) => get(downloadingModelsAtom))
null,
(get, set, model: Model) => { export const addDownloadingModelAtom = atom(null, (get, set, model: Model) => {
const currentModels = get(downloadingModelsAtom) const downloadingModels = get(downloadingModelsAtom)
set(downloadingModelsAtom, [...currentModels, model]) if (!downloadingModels.find((e) => e.id === model.id)) {
set(downloadingModelsAtom, [...downloadingModels, model])
} }
) })
export const removeDownloadingModelAtom = atom( export const removeDownloadingModelAtom = atom(
null, null,
(get, set, modelId: string) => { (get, set, modelId: string) => {
const currentModels = get(downloadingModelsAtom) const downloadingModels = get(downloadingModelsAtom)
set( set(
downloadingModelsAtom, downloadingModelsAtom,
currentModels.filter((e) => e.id !== modelId) downloadingModels.filter((e) => e.id !== modelId)
) )
} }
) )

View File

@ -1,6 +1,12 @@
import { useEffect } from 'react' import { useCallback, useEffect } from 'react'
import { Assistant, AssistantExtension, ExtensionTypeEnum } from '@janhq/core' import {
Assistant,
AssistantEvent,
AssistantExtension,
ExtensionTypeEnum,
events,
} from '@janhq/core'
import { useSetAtom } from 'jotai' import { useSetAtom } from 'jotai'
@ -10,14 +16,19 @@ import { assistantsAtom } from '@/helpers/atoms/Assistant.atom'
const useAssistants = () => { const useAssistants = () => {
const setAssistants = useSetAtom(assistantsAtom) const setAssistants = useSetAtom(assistantsAtom)
useEffect(() => { const getData = useCallback(async () => {
const getAssistants = async () => {
const assistants = await getLocalAssistants() const assistants = await getLocalAssistants()
setAssistants(assistants) setAssistants(assistants)
}
getAssistants()
}, [setAssistants]) }, [setAssistants])
useEffect(() => {
getData()
events.on(AssistantEvent.OnAssistantsUpdate, () => getData())
return () => {
events.off(AssistantEvent.OnAssistantsUpdate, () => getData())
}
}, [getData])
} }
const getLocalAssistants = async (): Promise<Assistant[]> => const getLocalAssistants = async (): Promise<Assistant[]> =>

View File

@ -1,4 +1,4 @@
import { useContext } from 'react' import { useCallback, useContext } from 'react'
import { import {
Model, Model,
@ -15,36 +15,21 @@ import { FeatureToggleContext } from '@/context/FeatureToggle'
import { modelBinFileName } from '@/utils/model' import { modelBinFileName } from '@/utils/model'
import { useDownloadState } from './useDownloadState' import { setDownloadStateAtom } from './useDownloadState'
import { extensionManager } from '@/extension/ExtensionManager' import { extensionManager } from '@/extension/ExtensionManager'
import { addNewDownloadingModelAtom } from '@/helpers/atoms/Model.atom' import { addDownloadingModelAtom } from '@/helpers/atoms/Model.atom'
export default function useDownloadModel() { export default function useDownloadModel() {
const { ignoreSSL, proxy } = useContext(FeatureToggleContext) const { ignoreSSL, proxy } = useContext(FeatureToggleContext)
const { setDownloadState } = useDownloadState() const setDownloadState = useSetAtom(setDownloadStateAtom)
const addNewDownloadingModel = useSetAtom(addNewDownloadingModelAtom) const addDownloadingModel = useSetAtom(addDownloadingModelAtom)
const downloadModel = async (model: Model) => { const downloadModel = useCallback(
const childrenDownloadProgress: DownloadState[] = [] async (model: Model) => {
model.sources.forEach((source: ModelArtifact) => { const childProgresses: DownloadState[] = model.sources.map(
childrenDownloadProgress.push({ (source: ModelArtifact) => ({
modelId: source.filename, filename: source.filename,
time: {
elapsed: 0,
remaining: 0,
},
speed: 0,
percent: 0,
size: {
total: 0,
transferred: 0,
},
})
})
// set an initial download state
setDownloadState({
modelId: model.id, modelId: model.id,
time: { time: {
elapsed: 0, elapsed: 0,
@ -56,15 +41,34 @@ export default function useDownloadModel() {
total: 0, total: 0,
transferred: 0, transferred: 0,
}, },
children: childrenDownloadProgress, downloadState: 'downloading',
})
)
// set an initial download state
setDownloadState({
filename: '',
modelId: model.id,
time: {
elapsed: 0,
remaining: 0,
},
speed: 0,
percent: 0,
size: {
total: 0,
transferred: 0,
},
children: childProgresses,
downloadState: 'downloading',
}) })
addNewDownloadingModel(model) addDownloadingModel(model)
await extensionManager await localDownloadModel(model, ignoreSSL, proxy)
.get<ModelExtension>(ExtensionTypeEnum.Model) },
?.downloadModel(model, { ignoreSSL, proxy }) [ignoreSSL, proxy, addDownloadingModel, setDownloadState]
} )
const abortModelDownload = async (model: Model) => { const abortModelDownload = async (model: Model) => {
await abortDownload( await abortDownload(
@ -77,3 +81,12 @@ export default function useDownloadModel() {
abortModelDownload, abortModelDownload,
} }
} }
const localDownloadModel = async (
model: Model,
ignoreSSL: boolean,
proxy: string
) =>
extensionManager
.get<ModelExtension>(ExtensionTypeEnum.Model)
?.downloadModel(model, { ignoreSSL, proxy })

View File

@ -1,96 +1,64 @@
import { atom, useSetAtom, useAtomValue } from 'jotai' import { atom } from 'jotai'
import { toaster } from '@/containers/Toast' import { toaster } from '@/containers/Toast'
import {
configuredModelsAtom,
downloadedModelsAtom,
removeDownloadingModelAtom,
} from '@/helpers/atoms/Model.atom'
// download states // download states
const modelDownloadStateAtom = atom<Record<string, DownloadState>>({}) export const modelDownloadStateAtom = atom<Record<string, DownloadState>>({})
const setDownloadStateAtom = atom(null, (get, set, state: DownloadState) => { /**
* Used to set the download state for a particular model.
*/
export const setDownloadStateAtom = atom(
null,
(get, set, state: DownloadState) => {
const currentState = { ...get(modelDownloadStateAtom) } const currentState = { ...get(modelDownloadStateAtom) }
console.debug(
`current download state for ${state.modelId} is ${JSON.stringify(state)}` if (state.downloadState === 'end') {
// download successfully
delete currentState[state.modelId]
set(removeDownloadingModelAtom, state.modelId)
const model = get(configuredModelsAtom).find(
(e) => e.id === state.modelId
) )
currentState[state.modelId] = state if (model) set(downloadedModelsAtom, (prev) => [...prev, model])
set(modelDownloadStateAtom, currentState)
})
const setDownloadStateSuccessAtom = atom(null, (get, set, modelId: string) => {
const currentState = { ...get(modelDownloadStateAtom) }
const state = currentState[modelId]
if (!state) {
console.debug(`Cannot find download state for ${modelId}`)
return
}
delete currentState[modelId]
set(modelDownloadStateAtom, currentState)
toaster({ toaster({
title: 'Download Completed', title: 'Download Completed',
description: `Download ${modelId} completed`, description: `Download ${state.modelId} completed`,
type: 'success', type: 'success',
}) })
}) } else if (state.downloadState === 'error') {
// download error
const setDownloadStateFailedAtom = atom( delete currentState[state.modelId]
null, set(removeDownloadingModelAtom, state.modelId)
(get, set, modelId: string, error: string) => { if (state.error === 'aborted') {
const currentState = { ...get(modelDownloadStateAtom) } toaster({
const state = currentState[modelId] title: 'Cancel Download',
if (!state) { description: `Model ${state.modelId} download cancelled`,
console.debug(`Cannot find download state for ${modelId}`) type: 'warning',
return })
} } else {
if (error.includes('certificate')) { let error = state.error
error += '. To fix enable "Ignore SSL Certificates" in Advanced settings.' if (state.error?.includes('certificate')) {
error +=
'. To fix enable "Ignore SSL Certificates" in Advanced settings.'
} }
toaster({ toaster({
title: 'Download Failed', title: 'Download Failed',
description: `Model ${modelId} download failed: ${error}`, description: `Model ${state.modelId} download failed: ${error}`,
type: 'error', type: 'error',
}) })
}
} else {
// download in progress
currentState[state.modelId] = state
}
delete currentState[modelId]
set(modelDownloadStateAtom, currentState) set(modelDownloadStateAtom, currentState)
} }
) )
const setDownloadStateCancelledAtom = atom(
null,
(get, set, modelId: string) => {
const currentState = { ...get(modelDownloadStateAtom) }
const state = currentState[modelId]
if (!state) {
console.debug(`Cannot find download state for ${modelId}`)
toaster({
title: 'Cancel Download',
description: `Model ${modelId} cancel download`,
type: 'warning',
})
return
}
delete currentState[modelId]
set(modelDownloadStateAtom, currentState)
}
)
export function useDownloadState() {
const modelDownloadState = useAtomValue(modelDownloadStateAtom)
const setDownloadState = useSetAtom(setDownloadStateAtom)
const setDownloadStateSuccess = useSetAtom(setDownloadStateSuccessAtom)
const setDownloadStateFailed = useSetAtom(setDownloadStateFailedAtom)
const setDownloadStateCancelled = useSetAtom(setDownloadStateCancelledAtom)
const downloadStates: DownloadState[] = []
for (const [, value] of Object.entries(modelDownloadState)) {
downloadStates.push(value)
}
return {
modelDownloadStateAtom,
modelDownloadState,
setDownloadState,
setDownloadStateSuccess,
setDownloadStateFailed,
setDownloadStateCancelled,
downloadStates,
}
}

View File

@ -1,6 +1,12 @@
import { useEffect } from 'react' import { useCallback, useEffect } from 'react'
import { ExtensionTypeEnum, Model, ModelExtension } from '@janhq/core' import {
ExtensionTypeEnum,
Model,
ModelEvent,
ModelExtension,
events,
} from '@janhq/core'
import { useSetAtom } from 'jotai' import { useSetAtom } from 'jotai'
@ -14,23 +20,30 @@ const useModels = () => {
const setDownloadedModels = useSetAtom(downloadedModelsAtom) const setDownloadedModels = useSetAtom(downloadedModelsAtom)
const setConfiguredModels = useSetAtom(configuredModelsAtom) const setConfiguredModels = useSetAtom(configuredModelsAtom)
useEffect(() => { const getData = useCallback(() => {
const getDownloadedModels = async () => { const getDownloadedModels = async () => {
const models = await getLocalDownloadedModels() const models = await getLocalDownloadedModels()
setDownloadedModels(models) setDownloadedModels(models)
} }
getDownloadedModels()
}, [setDownloadedModels])
useEffect(() => {
const getConfiguredModels = async () => { const getConfiguredModels = async () => {
const models = await getLocalConfiguredModels() const models = await getLocalConfiguredModels()
setConfiguredModels(models) setConfiguredModels(models)
} }
getDownloadedModels()
getConfiguredModels() getConfiguredModels()
}, [setConfiguredModels]) }, [setDownloadedModels, setConfiguredModels])
useEffect(() => {
// Try get data on mount
getData()
// Listen for model updates
events.on(ModelEvent.OnModelsUpdate, async () => getData())
return () => {
// Remove listener on unmount
events.off(ModelEvent.OnModelsUpdate, async () => {})
}
}, [getData])
} }
const getLocalConfiguredModels = async (): Promise<Model[]> => const getLocalConfiguredModels = async (): Promise<Model[]> =>

View File

@ -10,8 +10,6 @@ import {
import { useSetAtom } from 'jotai' import { useSetAtom } from 'jotai'
import { loadModelErrorAtom } from './useActiveModel'
import { extensionManager } from '@/extension' import { extensionManager } from '@/extension'
import { setConvoMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' import { setConvoMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
import { import {
@ -26,7 +24,6 @@ export default function useSetActiveThread() {
const setThreadMessage = useSetAtom(setConvoMessagesAtom) const setThreadMessage = useSetAtom(setConvoMessagesAtom)
const setThreadModelParams = useSetAtom(setThreadModelParamsAtom) const setThreadModelParams = useSetAtom(setThreadModelParamsAtom)
const setIsGeneratingResponse = useSetAtom(isGeneratingResponseAtom) const setIsGeneratingResponse = useSetAtom(isGeneratingResponseAtom)
const setLoadModelError = useSetAtom(loadModelErrorAtom)
const setActiveThread = useCallback( const setActiveThread = useCallback(
async (thread: Thread) => { async (thread: Thread) => {

View File

@ -27,7 +27,9 @@ const nextConfig = {
VERSION: JSON.stringify(packageJson.version), VERSION: JSON.stringify(packageJson.version),
ANALYTICS_ID: JSON.stringify(process.env.ANALYTICS_ID), ANALYTICS_ID: JSON.stringify(process.env.ANALYTICS_ID),
ANALYTICS_HOST: JSON.stringify(process.env.ANALYTICS_HOST), ANALYTICS_HOST: JSON.stringify(process.env.ANALYTICS_HOST),
API_BASE_URL: JSON.stringify('http://localhost:1337'), API_BASE_URL: JSON.stringify(
process.env.API_BASE_URL ?? 'http://localhost:1337'
),
isMac: process.platform === 'darwin', isMac: process.platform === 'darwin',
isWindows: process.platform === 'win32', isWindows: process.platform === 'win32',
isLinux: process.platform === 'linux', isLinux: process.platform === 'linux',

View File

@ -10,8 +10,6 @@ import LogoMark from '@/containers/Brand/Logo/Mark'
import { MainViewState } from '@/constants/screens' import { MainViewState } from '@/constants/screens'
import { loadModelErrorAtom } from '@/hooks/useActiveModel'
import { useMainViewState } from '@/hooks/useMainViewState' import { useMainViewState } from '@/hooks/useMainViewState'
import ChatItem from '../ChatItem' import ChatItem from '../ChatItem'

View File

@ -9,7 +9,6 @@ import { Button } from '@janhq/uikit'
import { useAtomValue, useSetAtom } from 'jotai' import { useAtomValue, useSetAtom } from 'jotai'
import { RefreshCcw } from 'lucide-react' import { RefreshCcw } from 'lucide-react'
import { useActiveModel } from '@/hooks/useActiveModel'
import useSendChatMessage from '@/hooks/useSendChatMessage' import useSendChatMessage from '@/hooks/useSendChatMessage'
import { extensionManager } from '@/extension' import { extensionManager } from '@/extension'

View File

@ -25,7 +25,7 @@ import { MainViewState } from '@/constants/screens'
import { useCreateNewThread } from '@/hooks/useCreateNewThread' import { useCreateNewThread } from '@/hooks/useCreateNewThread'
import useDownloadModel from '@/hooks/useDownloadModel' import useDownloadModel from '@/hooks/useDownloadModel'
import { useDownloadState } from '@/hooks/useDownloadState' import { modelDownloadStateAtom } from '@/hooks/useDownloadState'
import { useMainViewState } from '@/hooks/useMainViewState' import { useMainViewState } from '@/hooks/useMainViewState'
@ -49,7 +49,6 @@ type Props = {
const ExploreModelItemHeader: React.FC<Props> = ({ model, onClick, open }) => { const ExploreModelItemHeader: React.FC<Props> = ({ model, onClick, open }) => {
const { downloadModel } = useDownloadModel() const { downloadModel } = useDownloadModel()
const downloadedModels = useAtomValue(downloadedModelsAtom) const downloadedModels = useAtomValue(downloadedModelsAtom)
const { modelDownloadStateAtom } = useDownloadState()
const { requestCreateNewThread } = useCreateNewThread() const { requestCreateNewThread } = useCreateNewThread()
const totalRam = useAtomValue(totalRamAtom) const totalRam = useAtomValue(totalRamAtom)
const nvidiaTotalVram = useAtomValue(nvidiaTotalVramAtom) const nvidiaTotalVram = useAtomValue(nvidiaTotalVramAtom)

View File

@ -1,84 +0,0 @@
import React, { useMemo } from 'react'
import { Model } from '@janhq/core'
import { Button } from '@janhq/uikit'
import { atom, useAtomValue } from 'jotai'
import ModalCancelDownload from '@/containers/ModalCancelDownload'
import { MainViewState } from '@/constants/screens'
import useDownloadModel from '@/hooks/useDownloadModel'
import { useDownloadState } from '@/hooks/useDownloadState'
import { useMainViewState } from '@/hooks/useMainViewState'
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
type Props = {
model: Model
isRecommended: boolean
}
const ModelVersionItem: React.FC<Props> = ({ model }) => {
const { downloadModel } = useDownloadModel()
const downloadedModels = useAtomValue(downloadedModelsAtom)
const { setMainViewState } = useMainViewState()
const isDownloaded =
downloadedModels.find(
(downloadedModel) => downloadedModel.id === model.id
) != null
const { modelDownloadStateAtom, downloadStates } = useDownloadState()
const downloadAtom = useMemo(
() => atom((get) => get(modelDownloadStateAtom)[model.id ?? '']),
/* eslint-disable react-hooks/exhaustive-deps */
[model.id]
)
const downloadState = useAtomValue(downloadAtom)
const onDownloadClick = () => {
downloadModel(model)
}
let downloadButton = (
<Button themes="outline" size="sm" onClick={() => onDownloadClick()}>
Download
</Button>
)
if (isDownloaded) {
downloadButton = (
<Button
themes="outline"
size="sm"
onClick={() => {
setMainViewState(MainViewState.MyModels)
}}
>
Use
</Button>
)
}
if (downloadState != null && downloadStates.length > 0) {
downloadButton = <ModalCancelDownload model={model} isFromList />
}
return (
<div className="flex items-center justify-between gap-4 border-t border-border pb-3 pl-3 pr-4 pt-3 first:border-t-0">
<div className="flex items-center gap-2">
<span className="line-clamp-1 flex-1" title={model.name}>
{model.name}
</span>
</div>
<div className="flex items-center gap-4">
<div className="flex justify-end gap-2"></div>
{downloadButton}
</div>
</div>
)
}
export default ModelVersionItem

View File

@ -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 (
<div className="pt-4">
{models.map((model) => (
<ModelVersionItem
key={model.name}
model={model}
isRecommended={model.name === recommendedVersion}
/>
))}
</div>
)
}

View File

@ -3,6 +3,7 @@ import {
AppRoute, AppRoute,
DownloadRoute, DownloadRoute,
ExtensionRoute, ExtensionRoute,
FileManagerRoute,
FileSystemRoute, FileSystemRoute,
} from '@janhq/core' } from '@janhq/core'
@ -22,6 +23,7 @@ export const APIRoutes = [
route: r, route: r,
})), })),
...Object.values(FileSystemRoute).map((r) => ({ path: `fs`, 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 // Define the restAPI object with methods for each API route
@ -50,4 +52,6 @@ export const restAPI = {
} }
}, {}), }, {}),
openExternalUrl, openExternalUrl,
// Jan Server URL
baseApiUrl: API_BASE_URL,
} }

View File

@ -1,12 +1,13 @@
type DownloadState = { type DownloadState = {
modelId: string modelId: string
filename: string
time: DownloadTime time: DownloadTime
speed: number speed: number
percent: number percent: number
size: DownloadSize size: DownloadSize
isFinished?: boolean
children?: DownloadState[] children?: DownloadState[]
error?: string error?: string
downloadState: 'downloading' | 'error' | 'end'
} }
type DownloadTime = { type DownloadTime = {