Merge pull request #1948 from janhq/dev

Sync dev branch to docs branch
This commit is contained in:
Hieu 2024-02-07 11:03:33 +07:00 committed by GitHub
commit 3060679c93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
183 changed files with 5169 additions and 2366 deletions

View File

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

View File

@ -8,3 +8,5 @@ fi
# If both variables are set, execute the following commands # If both variables are set, execute the following commands
find "$APP_PATH" \( -type f -perm +111 -o -name "*.node" \) -exec codesign -s "$DEVELOPER_ID" --options=runtime {} \; find "$APP_PATH" \( -type f -perm +111 -o -name "*.node" \) -exec codesign -s "$DEVELOPER_ID" --options=runtime {} \;
find "$APP_PATH" -type f -name "*.o" -exec codesign -s "$DEVELOPER_ID" --options=runtime {} \;

View File

@ -55,10 +55,10 @@ jobs:
steps: steps:
- name: install-aws-cli-action - name: install-aws-cli-action
uses: unfor19/install-aws-cli-action@v1 uses: unfor19/install-aws-cli-action@v1
- name: Delete object older than 7 days - name: Delete object older than 10 days
run: | run: |
# Get the list of objects in the 'latest' folder # Get the list of objects in the 'latest' folder
OBJECTS=$(aws s3api list-objects --bucket ${{ secrets.CLOUDFLARE_R2_BUCKET_NAME }} --query 'Contents[?LastModified<`'$(date -d "$current_date -30 days" -u +"%Y-%m-%dT%H:%M:%SZ")'`].{Key: Key}' --endpoint-url https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com | jq -c .) OBJECTS=$(aws s3api list-objects --bucket ${{ secrets.CLOUDFLARE_R2_BUCKET_NAME }} --query 'Contents[?LastModified<`'$(date -d "$current_date -10 days" -u +"%Y-%m-%dT%H:%M:%SZ")'`].{Key: Key}' --endpoint-url https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com | jq -c .)
# Create a JSON file for the delete operation # Create a JSON file for the delete operation
echo "{\"Objects\": $OBJECTS, \"Quiet\": false}" > delete.json echo "{\"Objects\": $OBJECTS, \"Quiet\": false}" > delete.json

View File

@ -1,8 +1,14 @@
name: Jan Build Electron App Nightly or Manual name: Jan Build Electron App Nightly or Manual
on: on:
push:
branches:
- main
paths-ignore:
- 'README.md'
- 'docs/**'
schedule: schedule:
- cron: '0 20 * * 2,3,4' # At 8 PM UTC on Tuesday, Wednesday, and Thursday, which is 3 AM UTC+7 - cron: '0 20 * * 1,2,3' # At 8 PM UTC on Monday, Tuesday, and Wednesday which is 3 AM UTC+7 Tuesday, Wednesday, and Thursday
workflow_dispatch: workflow_dispatch:
inputs: inputs:
public_provider: public_provider:
@ -23,19 +29,36 @@ jobs:
- name: Set public provider - name: Set public provider
id: set-public-provider id: set-public-provider
run: | run: |
if [ ${{ github.event == 'workflow_dispatch' }} ]; then if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "::set-output name=public_provider::${{ github.event.inputs.public_provider }}" echo "::set-output name=public_provider::${{ github.event.inputs.public_provider }}"
echo "::set-output name=ref::${{ github.ref }}" echo "::set-output name=ref::${{ github.ref }}"
else else
if [ "${{ github.event_name }}" == "schedule" ]; then
echo "::set-output name=public_provider::cloudflare-r2" echo "::set-output name=public_provider::cloudflare-r2"
echo "::set-output name=ref::refs/heads/dev" echo "::set-output name=ref::refs/heads/dev"
elif [ "${{ github.event_name }}" == "push" ]; then
echo "::set-output name=public_provider::cloudflare-r2"
echo "::set-output name=ref::${{ github.ref }}"
else
echo "::set-output name=public_provider::none"
echo "::set-output name=ref::${{ github.ref }}"
fi
fi fi
# Job create Update app version based on latest release tag with build number and save to output # Job create Update app version based on latest release tag with build number and save to output
get-update-version: get-update-version:
uses: ./.github/workflows/template-get-update-version.yml uses: ./.github/workflows/template-get-update-version.yml
build-macos: build-macos-x64:
uses: ./.github/workflows/template-build-macos.yml uses: ./.github/workflows/template-build-macos-x64.yml
needs: [get-update-version, set-public-provider]
secrets: inherit
with:
ref: ${{ needs.set-public-provider.outputs.ref }}
public_provider: ${{ needs.set-public-provider.outputs.public_provider }}
new_version: ${{ needs.get-update-version.outputs.new_version }}
build-macos-arm64:
uses: ./.github/workflows/template-build-macos-arm64.yml
needs: [get-update-version, set-public-provider] needs: [get-update-version, set-public-provider]
secrets: inherit secrets: inherit
with: with:
@ -62,8 +85,51 @@ jobs:
public_provider: ${{ needs.set-public-provider.outputs.public_provider }} public_provider: ${{ needs.set-public-provider.outputs.public_provider }}
new_version: ${{ needs.get-update-version.outputs.new_version }} new_version: ${{ needs.get-update-version.outputs.new_version }}
combine-latest-mac-yml:
needs: [set-public-provider, build-macos-x64, build-macos-arm64]
runs-on: ubuntu-latest
steps:
- name: Getting the repo
uses: actions/checkout@v3
with:
ref: ${{ needs.set-public-provider.outputs.ref }}
- name: Download mac-x64 artifacts
uses: actions/download-artifact@v3
with:
name: latest-mac-x64
path: ./latest-mac-x64
- name: Download mac-arm artifacts
uses: actions/download-artifact@v3
with:
name: latest-mac-arm64
path: ./latest-mac-arm64
- name: 'Merge latest-mac.yml'
# unfortunately electron-builder doesn't understand that we have two different releases for mac-x64 and mac-arm, so we need to manually merge the latest files
# see https://github.com/electron-userland/electron-builder/issues/5592
run: |
ls -la .
ls -la ./latest-mac-x64
ls -la ./latest-mac-arm64
ls -la ./electron
cp ./electron/merge-latest-ymls.js /tmp/merge-latest-ymls.js
npm install js-yaml --prefix /tmp
node /tmp/merge-latest-ymls.js ./latest-mac-x64/latest-mac.yml ./latest-mac-arm64/latest-mac.yml ./latest-mac.yml
cat ./latest-mac.yml
- name: Upload latest-mac.yml
if: ${{ needs.set-public-provider.outputs.public_provider == 'cloudflare-r2' }}
run: |
aws s3api put-object --endpoint-url https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com --bucket ${{ secrets.CLOUDFLARE_R2_BUCKET_NAME }} --key "latest/latest-mac.yml" --body "./latest-mac.yml"
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: "true"
noti-discord-nightly-and-update-url-readme: noti-discord-nightly-and-update-url-readme:
needs: [build-macos, build-windows-x64, build-linux-x64, get-update-version, set-public-provider] needs: [build-macos-x64, build-macos-arm64, build-windows-x64, build-linux-x64, get-update-version, set-public-provider, combine-latest-mac-yml]
secrets: inherit secrets: inherit
if: github.event_name == 'schedule' if: github.event_name == 'schedule'
uses: ./.github/workflows/template-noti-discord-and-update-url-readme.yml uses: ./.github/workflows/template-noti-discord-and-update-url-readme.yml
@ -73,8 +139,19 @@ jobs:
push_to_branch: dev push_to_branch: dev
new_version: ${{ needs.get-update-version.outputs.new_version }} new_version: ${{ needs.get-update-version.outputs.new_version }}
noti-discord-pre-release-and-update-url-readme:
needs: [build-macos-x64, build-macos-arm64, build-windows-x64, build-linux-x64, get-update-version, set-public-provider, combine-latest-mac-yml]
secrets: inherit
if: github.event_name == 'push'
uses: ./.github/workflows/template-noti-discord-and-update-url-readme.yml
with:
ref: refs/heads/dev
build_reason: Pre-release
push_to_branch: dev
new_version: ${{ needs.get-update-version.outputs.new_version }}
noti-discord-manual-and-update-url-readme: noti-discord-manual-and-update-url-readme:
needs: [build-macos, build-windows-x64, build-linux-x64, get-update-version, set-public-provider] needs: [build-macos-x64, build-macos-arm64, build-windows-x64, build-linux-x64, get-update-version, set-public-provider, combine-latest-mac-yml]
secrets: inherit secrets: inherit
if: github.event_name == 'workflow_dispatch' && github.event.inputs.public_provider == 'cloudflare-r2' if: github.event_name == 'workflow_dispatch' && github.event.inputs.public_provider == 'cloudflare-r2'
uses: ./.github/workflows/template-noti-discord-and-update-url-readme.yml uses: ./.github/workflows/template-noti-discord-and-update-url-readme.yml

View File

@ -1,52 +0,0 @@
name: Jan Build Electron Pre Release
on:
push:
branches:
- main
paths:
- "!README.md"
jobs:
# Job create Update app version based on latest release tag with build number and save to output
get-update-version:
uses: ./.github/workflows/template-get-update-version.yml
build-macos:
uses: ./.github/workflows/template-build-macos.yml
secrets: inherit
needs: [get-update-version]
with:
ref: ${{ github.ref }}
public_provider: cloudflare-r2
new_version: ${{ needs.get-update-version.outputs.new_version }}
build-windows-x64:
uses: ./.github/workflows/template-build-windows-x64.yml
secrets: inherit
needs: [get-update-version]
with:
ref: ${{ github.ref }}
public_provider: cloudflare-r2
new_version: ${{ needs.get-update-version.outputs.new_version }}
build-linux-x64:
uses: ./.github/workflows/template-build-linux-x64.yml
secrets: inherit
needs: [get-update-version]
with:
ref: ${{ github.ref }}
public_provider: cloudflare-r2
new_version: ${{ needs.get-update-version.outputs.new_version }}
noti-discord-nightly-and-update-url-readme:
needs: [build-macos, build-windows-x64, build-linux-x64, get-update-version]
secrets: inherit
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: ./.github/workflows/template-noti-discord-and-update-url-readme.yml
with:
ref: refs/heads/dev
build_reason: Nightly
push_to_branch: dev
new_version: ${{ needs.get-update-version.outputs.new_version }}

View File

@ -9,8 +9,42 @@ jobs:
get-update-version: get-update-version:
uses: ./.github/workflows/template-get-update-version.yml uses: ./.github/workflows/template-get-update-version.yml
build-macos: create-draft-release:
uses: ./.github/workflows/template-build-macos.yml runs-on: ubuntu-latest
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
version: ${{ steps.get_version.outputs.version }}
permissions:
contents: write
steps:
- name: Extract tag name without v prefix
id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV && echo "::set-output name=version::${GITHUB_REF#refs/tags/v}"
env:
GITHUB_REF: ${{ github.ref }}
- name: Create Draft Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref_name }}
release_name: "${{ env.VERSION }}"
draft: true
prerelease: false
build-macos-x64:
uses: ./.github/workflows/template-build-macos-x64.yml
secrets: inherit
needs: [get-update-version]
with:
ref: ${{ github.ref }}
public_provider: github
new_version: ${{ needs.get-update-version.outputs.new_version }}
build-macos-arm64:
uses: ./.github/workflows/template-build-macos-arm64.yml
secrets: inherit secrets: inherit
needs: [get-update-version] needs: [get-update-version]
with: with:
@ -36,8 +70,52 @@ jobs:
public_provider: github public_provider: github
new_version: ${{ needs.get-update-version.outputs.new_version }} new_version: ${{ needs.get-update-version.outputs.new_version }}
combine-latest-mac-yml:
needs: [build-macos-x64, build-macos-arm64, create-draft-release]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Getting the repo
uses: actions/checkout@v3
- name: Download mac-x64 artifacts
uses: actions/download-artifact@v3
with:
name: latest-mac-x64
path: ./latest-mac-x64
- name: Download mac-arm artifacts
uses: actions/download-artifact@v3
with:
name: latest-mac-arm64
path: ./latest-mac-arm64
- name: 'Merge latest-mac.yml'
# unfortunately electron-builder doesn't understand that we have two different releases for mac-x64 and mac-arm, so we need to manually merge the latest files
# see https://github.com/electron-userland/electron-builder/issues/5592
run: |
ls -la .
ls -la ./latest-mac-x64
ls -la ./latest-mac-arm64
ls -la ./electron
cp ./electron/merge-latest-ymls.js /tmp/merge-latest-ymls.js
npm install js-yaml --prefix /tmp
node /tmp/merge-latest-ymls.js ./latest-mac-x64/latest-mac.yml ./latest-mac-arm64/latest-mac.yml ./latest-mac.yml
cat ./latest-mac.yml
- name: Yet Another Upload Release Asset Action
uses: shogo82148/actions-upload-release-asset@v1.7.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-draft-release.outputs.upload_url }}
asset_path: ./latest-mac.yml
asset_name: latest-mac.yml
asset_content_type: text/yaml
overwrite: true
update_release_draft: update_release_draft:
needs: [build-macos, build-windows-x64, build-linux-x64] needs: [build-macos-x64, build-macos-arm64, build-windows-x64, build-linux-x64, combine-latest-mac-yml]
permissions: permissions:
# write permission is required to create a github release # write permission is required to create a github release
contents: write contents: write

View File

@ -98,8 +98,8 @@ jobs:
make build-and-publish make build-and-publish
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANALYTICS_ID: ${{ secrets.JAN_APP_POSTHOG_PROJECT_API_KEY }} ANALYTICS_ID: ${{ secrets.JAN_APP_UMAMI_PROJECT_API_KEY }}
ANALYTICS_HOST: ${{ secrets.JAN_APP_POSTHOG_URL }} ANALYTICS_HOST: ${{ secrets.JAN_APP_UMAMI_URL }}
- name: Upload Artifact .deb file - name: Upload Artifact .deb file
if: inputs.public_provider != 'github' if: inputs.public_provider != 'github'

View File

@ -0,0 +1,160 @@
name: build-macos
on:
workflow_call:
inputs:
ref:
required: true
type: string
default: 'refs/heads/main'
public_provider:
required: true
type: string
default: none
description: 'none: build only, github: build and publish to github, cloudflare: build and publish to cloudflare'
new_version:
required: true
type: string
default: ''
cloudflare_r2_path:
required: false
type: string
default: '/latest/'
secrets:
CLOUDFLARE_R2_BUCKET_NAME:
required: false
CLOUDFLARE_R2_ACCESS_KEY_ID:
required: false
CLOUDFLARE_R2_SECRET_ACCESS_KEY:
required: false
CLOUDFLARE_ACCOUNT_ID:
required: false
CODE_SIGN_P12_BASE64:
required: false
CODE_SIGN_P12_PASSWORD:
required: false
APPLE_ID:
required: false
APPLE_APP_SPECIFIC_PASSWORD:
required: false
DEVELOPER_ID:
required: false
jobs:
build-macos:
runs-on: macos-silicon
environment: production
permissions:
contents: write
steps:
- name: Getting the repo
uses: actions/checkout@v3
with:
ref: ${{ inputs.ref }}
- name: Installing node
uses: actions/setup-node@v1
with:
node-version: 20
- name: Unblock keychain
run: |
security unlock-keychain -p ${{ secrets.KEYCHAIN_PASSWORD }} ~/Library/Keychains/login.keychain-db
# - uses: actions/setup-python@v5
# with:
# python-version: '3.11'
# - name: Install jq
# uses: dcarbone/install-jq-action@v2.0.1
- name: Update app version based on latest release tag with build number
if: inputs.public_provider != 'github'
run: |
echo "Version: ${{ inputs.new_version }}"
# Update the version in electron/package.json
jq --arg version "${{ inputs.new_version }}" '.version = $version' electron/package.json > /tmp/package.json
mv /tmp/package.json electron/package.json
jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json
mv /tmp/package.json web/package.json
jq '.build.publish = [{"provider": "generic", "url": "${{ secrets.CLOUDFLARE_R2_PUBLIC_URL }}", "channel": "latest"}, {"provider": "s3", "bucket": "${{ secrets.CLOUDFLARE_R2_BUCKET_NAME }}", "region": "auto", "endpoint": "https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com", "path": "${{ inputs.cloudflare_r2_path }}", "channel": "latest"}]' electron/package.json > /tmp/package.json
mv /tmp/package.json electron/package.json
cat electron/package.json
- name: Update app version base on tag
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github'
run: |
if [[ ! "${VERSION_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Tag is not valid!"
exit 1
fi
jq --arg version "${VERSION_TAG#v}" '.version = $version' electron/package.json > /tmp/package.json
mv /tmp/package.json electron/package.json
jq --arg version "${VERSION_TAG#v}" '.version = $version' web/package.json > /tmp/package.json
mv /tmp/package.json web/package.json
env:
VERSION_TAG: ${{ inputs.new_version }}
# - name: Get Cer for code signing
# run: base64 -d <<< "$CODE_SIGN_P12_BASE64" > /tmp/codesign.p12
# shell: bash
# env:
# CODE_SIGN_P12_BASE64: ${{ secrets.CODE_SIGN_P12_BASE64 }}
# - uses: apple-actions/import-codesign-certs@v2
# continue-on-error: true
# with:
# p12-file-base64: ${{ secrets.CODE_SIGN_P12_BASE64 }}
# p12-password: ${{ secrets.CODE_SIGN_P12_PASSWORD }}
- name: Build and publish app to cloudflare r2 or github artifactory
if: inputs.public_provider != 'github'
run: |
# check public_provider is true or not
echo "public_provider is ${{ inputs.public_provider }}"
if [ "${{ inputs.public_provider }}" == "none" ]; then
make build
else
make build-and-publish
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# CSC_LINK: "/tmp/codesign.p12"
# CSC_KEY_PASSWORD: ${{ secrets.CODE_SIGN_P12_PASSWORD }}
# CSC_IDENTITY_AUTO_DISCOVERY: "true"
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APP_PATH: "."
DEVELOPER_ID: ${{ secrets.DEVELOPER_ID }}
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: "true"
- name: Build and publish app to github
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github'
run: |
make build-and-publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# CSC_LINK: "/tmp/codesign.p12"
# CSC_KEY_PASSWORD: ${{ secrets.CODE_SIGN_P12_PASSWORD }}
# CSC_IDENTITY_AUTO_DISCOVERY: "true"
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APP_PATH: "."
DEVELOPER_ID: ${{ secrets.DEVELOPER_ID }}
ANALYTICS_ID: ${{ secrets.JAN_APP_UMAMI_PROJECT_API_KEY }}
ANALYTICS_HOST: ${{ secrets.JAN_APP_UMAMI_URL }}
- name: Upload Artifact
if: inputs.public_provider != 'github'
uses: actions/upload-artifact@v2
with:
name: jan-mac-arm64-${{ inputs.new_version }}
path: ./electron/dist/jan-mac-arm64-${{ inputs.new_version }}.dmg
- name: Upload Artifact
uses: actions/upload-artifact@v2
with:
name: latest-mac-arm64
path: ./electron/dist/latest-mac.yml

View File

@ -137,8 +137,8 @@ jobs:
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APP_PATH: "." APP_PATH: "."
DEVELOPER_ID: ${{ secrets.DEVELOPER_ID }} DEVELOPER_ID: ${{ secrets.DEVELOPER_ID }}
ANALYTICS_ID: ${{ secrets.JAN_APP_POSTHOG_PROJECT_API_KEY }} ANALYTICS_ID: ${{ secrets.JAN_APP_UMAMI_PROJECT_API_KEY }}
ANALYTICS_HOST: ${{ secrets.JAN_APP_POSTHOG_URL }} ANALYTICS_HOST: ${{ secrets.JAN_APP_UMAMI_URL }}
- name: Upload Artifact - name: Upload Artifact
if: inputs.public_provider != 'github' if: inputs.public_provider != 'github'
@ -148,9 +148,8 @@ jobs:
path: ./electron/dist/jan-mac-x64-${{ inputs.new_version }}.dmg path: ./electron/dist/jan-mac-x64-${{ inputs.new_version }}.dmg
- name: Upload Artifact - name: Upload Artifact
if: inputs.public_provider != 'github'
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2
with: with:
name: jan-mac-arm64-${{ inputs.new_version }} name: latest-mac-x64
path: ./electron/dist/jan-mac-arm64-${{ inputs.new_version }}.dmg path: ./electron/dist/latest-mac.yml

View File

@ -127,8 +127,8 @@ jobs:
make build-and-publish make build-and-publish
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANALYTICS_ID: ${{ secrets.JAN_APP_POSTHOG_PROJECT_API_KEY }} ANALYTICS_ID: ${{ secrets.JAN_APP_UMAMI_PROJECT_API_KEY }}
ANALYTICS_HOST: ${{ secrets.JAN_APP_POSTHOG_URL }} ANALYTICS_HOST: ${{ secrets.JAN_APP_UMAMI_URL }}
AZURE_KEY_VAULT_URI: ${{ secrets.AZURE_KEY_VAULT_URI }} AZURE_KEY_VAULT_URI: ${{ secrets.AZURE_KEY_VAULT_URI }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}

View File

@ -17,7 +17,7 @@ jobs:
with: with:
fetch-depth: "0" fetch-depth: "0"
token: ${{ secrets.PAT_SERVICE_ACCOUNT }} token: ${{ secrets.PAT_SERVICE_ACCOUNT }}
ref: main ref: dev
- name: Get Latest Release - name: Get Latest Release
uses: pozetroninc/github-action-get-latest-release@v0.7.0 uses: pozetroninc/github-action-get-latest-release@v0.7.0
@ -46,4 +46,4 @@ jobs:
git config --global user.name "Service Account" git config --global user.name "Service Account"
git add README.md git add README.md
git commit -m "Update README.md with Stable Download URLs" git commit -m "Update README.md with Stable Download URLs"
git -c http.extraheader="AUTHORIZATION: bearer ${{ secrets.PAT_SERVICE_ACCOUNT }}" push origin HEAD:main git -c http.extraheader="AUTHORIZATION: bearer ${{ secrets.PAT_SERVICE_ACCOUNT }}" push origin HEAD:dev

3
.gitignore vendored
View File

@ -12,6 +12,8 @@ build
electron/renderer electron/renderer
electron/models electron/models
electron/docs electron/docs
electron/engines
server/pre-install
package-lock.json package-lock.json
*.log *.log
@ -26,3 +28,4 @@ extensions/inference-nitro-extension/bin/*/*.exp
extensions/inference-nitro-extension/bin/*/*.lib extensions/inference-nitro-extension/bin/*/*.lib
extensions/inference-nitro-extension/bin/saved-* extensions/inference-nitro-extension/bin/saved-*
extensions/inference-nitro-extension/bin/*.tar.gz extensions/inference-nitro-extension/bin/*.tar.gz

View File

@ -43,31 +43,31 @@ Jan is an open-source ChatGPT alternative that runs 100% offline on your compute
<tr style="text-align:center"> <tr style="text-align:center">
<td style="text-align:center"><b>Stable (Recommended)</b></td> <td style="text-align:center"><b>Stable (Recommended)</b></td>
<td style="text-align:center"> <td style="text-align:center">
<a href='https://github.com/janhq/jan/releases/download/v0.4.4/jan-win-x64-0.4.4.exe'> <a href='https://github.com/janhq/jan/releases/download/v0.4.6/jan-win-x64-0.4.6.exe'>
<img src='./docs/static/img/windows.png' style="height:14px; width: 14px" /> <img src='./docs/static/img/windows.png' style="height:14px; width: 14px" />
<b>jan.exe</b> <b>jan.exe</b>
</a> </a>
</td> </td>
<td style="text-align:center"> <td style="text-align:center">
<a href='https://github.com/janhq/jan/releases/download/v0.4.4/jan-mac-x64-0.4.4.dmg'> <a href='https://github.com/janhq/jan/releases/download/v0.4.6/jan-mac-x64-0.4.6.dmg'>
<img src='./docs/static/img/mac.png' style="height:15px; width: 15px" /> <img src='./docs/static/img/mac.png' style="height:15px; width: 15px" />
<b>Intel</b> <b>Intel</b>
</a> </a>
</td> </td>
<td style="text-align:center"> <td style="text-align:center">
<a href='https://github.com/janhq/jan/releases/download/v0.4.4/jan-mac-arm64-0.4.4.dmg'> <a href='https://github.com/janhq/jan/releases/download/v0.4.6/jan-mac-arm64-0.4.6.dmg'>
<img src='./docs/static/img/mac.png' style="height:15px; width: 15px" /> <img src='./docs/static/img/mac.png' style="height:15px; width: 15px" />
<b>M1/M2</b> <b>M1/M2</b>
</a> </a>
</td> </td>
<td style="text-align:center"> <td style="text-align:center">
<a href='https://github.com/janhq/jan/releases/download/v0.4.4/jan-linux-amd64-0.4.4.deb'> <a href='https://github.com/janhq/jan/releases/download/v0.4.6/jan-linux-amd64-0.4.6.deb'>
<img src='./docs/static/img/linux.png' style="height:14px; width: 14px" /> <img src='./docs/static/img/linux.png' style="height:14px; width: 14px" />
<b>jan.deb</b> <b>jan.deb</b>
</a> </a>
</td> </td>
<td style="text-align:center"> <td style="text-align:center">
<a href='https://github.com/janhq/jan/releases/download/v0.4.4/jan-linux-x86_64-0.4.4.AppImage'> <a href='https://github.com/janhq/jan/releases/download/v0.4.6/jan-linux-x86_64-0.4.6.AppImage'>
<img src='./docs/static/img/linux.png' style="height:14px; width: 14px" /> <img src='./docs/static/img/linux.png' style="height:14px; width: 14px" />
<b>jan.AppImage</b> <b>jan.AppImage</b>
</a> </a>
@ -76,31 +76,31 @@ Jan is an open-source ChatGPT alternative that runs 100% offline on your compute
<tr style="text-align:center"> <tr style="text-align:center">
<td style="text-align:center"><b>Experimental (Nightly Build)</b></td> <td style="text-align:center"><b>Experimental (Nightly Build)</b></td>
<td style="text-align:center"> <td style="text-align:center">
<a href='https://delta.jan.ai/latest/jan-win-x64-0.4.4-180.exe'> <a href='https://delta.jan.ai/latest/jan-win-x64-0.4.6-263.exe'>
<img src='./docs/static/img/windows.png' style="height:14px; width: 14px" /> <img src='./docs/static/img/windows.png' style="height:14px; width: 14px" />
<b>jan.exe</b> <b>jan.exe</b>
</a> </a>
</td> </td>
<td style="text-align:center"> <td style="text-align:center">
<a href='https://delta.jan.ai/latest/jan-mac-x64-0.4.4-180.dmg'> <a href='https://delta.jan.ai/latest/jan-mac-x64-0.4.6-263.dmg'>
<img src='./docs/static/img/mac.png' style="height:15px; width: 15px" /> <img src='./docs/static/img/mac.png' style="height:15px; width: 15px" />
<b>Intel</b> <b>Intel</b>
</a> </a>
</td> </td>
<td style="text-align:center"> <td style="text-align:center">
<a href='https://delta.jan.ai/latest/jan-mac-arm64-0.4.4-180.dmg'> <a href='https://delta.jan.ai/latest/jan-mac-arm64-0.4.6-263.dmg'>
<img src='./docs/static/img/mac.png' style="height:15px; width: 15px" /> <img src='./docs/static/img/mac.png' style="height:15px; width: 15px" />
<b>M1/M2</b> <b>M1/M2</b>
</a> </a>
</td> </td>
<td style="text-align:center"> <td style="text-align:center">
<a href='https://delta.jan.ai/latest/jan-linux-amd64-0.4.4-180.deb'> <a href='https://delta.jan.ai/latest/jan-linux-amd64-0.4.6-263.deb'>
<img src='./docs/static/img/linux.png' style="height:14px; width: 14px" /> <img src='./docs/static/img/linux.png' style="height:14px; width: 14px" />
<b>jan.deb</b> <b>jan.deb</b>
</a> </a>
</td> </td>
<td style="text-align:center"> <td style="text-align:center">
<a href='https://delta.jan.ai/latest/jan-linux-x86_64-0.4.4-180.AppImage'> <a href='https://delta.jan.ai/latest/jan-linux-x86_64-0.4.6-263.AppImage'>
<img src='./docs/static/img/linux.png' style="height:14px; width: 14px" /> <img src='./docs/static/img/linux.png' style="height:14px; width: 14px" />
<b>jan.AppImage</b> <b>jan.AppImage</b>
</a> </a>

View File

@ -3,7 +3,6 @@
* @description Enum of all the routes exposed by the app * @description Enum of all the routes exposed by the app
*/ */
export enum AppRoute { export enum AppRoute {
appDataPath = 'appDataPath',
openExternalUrl = 'openExternalUrl', openExternalUrl = 'openExternalUrl',
openAppDirectory = 'openAppDirectory', openAppDirectory = 'openAppDirectory',
openFileExplore = 'openFileExplorer', openFileExplore = 'openFileExplorer',
@ -12,6 +11,7 @@ export enum AppRoute {
updateAppConfiguration = 'updateAppConfiguration', updateAppConfiguration = 'updateAppConfiguration',
relaunch = 'relaunch', relaunch = 'relaunch',
joinPath = 'joinPath', joinPath = 'joinPath',
isSubdirectory = 'isSubdirectory',
baseName = 'baseName', baseName = 'baseName',
startServer = 'startServer', startServer = 'startServer',
stopServer = 'stopServer', stopServer = 'stopServer',
@ -61,7 +61,9 @@ export enum FileManagerRoute {
syncFile = 'syncFile', syncFile = 'syncFile',
getJanDataFolderPath = 'getJanDataFolderPath', getJanDataFolderPath = 'getJanDataFolderPath',
getResourcePath = 'getResourcePath', getResourcePath = 'getResourcePath',
getUserHomePath = 'getUserHomePath',
fileStat = 'fileStat', fileStat = 'fileStat',
writeBlob = 'writeBlob',
} }
export type ApiFunction = (...args: any[]) => any export type ApiFunction = (...args: any[]) => any

View File

@ -22,7 +22,11 @@ const executeOnMain: (extension: string, method: string, ...args: any[]) => Prom
* @param {object} network - Optional object to specify proxy/whether to ignore SSL certificates. * @param {object} network - Optional object to specify proxy/whether to ignore SSL certificates.
* @returns {Promise<any>} A promise that resolves when the file is downloaded. * @returns {Promise<any>} A promise that resolves when the file is downloaded.
*/ */
const downloadFile: (url: string, fileName: string, network?: { proxy?: string, ignoreSSL?: boolean }) => Promise<any> = (url, fileName, network) => { const downloadFile: (
url: string,
fileName: string,
network?: { proxy?: string; ignoreSSL?: boolean }
) => Promise<any> = (url, fileName, network) => {
return global.core?.api?.downloadFile(url, fileName, network) return global.core?.api?.downloadFile(url, fileName, network)
} }
@ -79,6 +83,12 @@ const openExternalUrl: (url: string) => Promise<any> = (url) =>
*/ */
const getResourcePath: () => Promise<string> = () => global.core.api?.getResourcePath() const getResourcePath: () => Promise<string> = () => global.core.api?.getResourcePath()
/**
* Gets the user's home path.
* @returns return user's home path
*/
const getUserHomePath = (): Promise<string> => global.core.api?.getUserHomePath()
/** /**
* Log to file from browser processes. * Log to file from browser processes.
* *
@ -87,6 +97,17 @@ const getResourcePath: () => Promise<string> = () => global.core.api?.getResourc
const log: (message: string, fileName?: string) => void = (message, fileName) => const log: (message: string, fileName?: string) => void = (message, fileName) =>
global.core.api?.log(message, fileName) global.core.api?.log(message, fileName)
/**
* Check whether the path is a subdirectory of another path.
*
* @param from - The path to check.
* @param to - The path to check against.
*
* @returns {Promise<boolean>} - A promise that resolves with a boolean indicating whether the path is a subdirectory.
*/
const isSubdirectory: (from: string, to: string) => Promise<boolean> = (from: string, to: string) =>
global.core.api?.isSubdirectory(from, to)
/** /**
* Register extension point function type definition * Register extension point function type definition
*/ */
@ -94,7 +115,7 @@ export type RegisterExtensionPoint = (
extensionName: string, extensionName: string,
extensionId: string, extensionId: string,
method: Function, method: Function,
priority?: number, priority?: number
) => void ) => void
/** /**
@ -111,5 +132,7 @@ export {
openExternalUrl, openExternalUrl,
baseName, baseName,
log, log,
isSubdirectory,
getUserHomePath,
FileStat, FileStat,
} }

View File

@ -1,4 +1,4 @@
import { FileStat } from "./types" import { FileStat } from './types'
/** /**
* Writes data to a file at the specified path. * Writes data to a file at the specified path.
@ -6,6 +6,15 @@ import { FileStat } from "./types"
*/ */
const writeFileSync = (...args: any[]) => global.core.api?.writeFileSync(...args) const writeFileSync = (...args: any[]) => global.core.api?.writeFileSync(...args)
/**
* Writes blob data to a file at the specified path.
* @param path - The path to file.
* @param data - The blob data.
* @returns
*/
const writeBlob: (path: string, data: string) => Promise<any> = (path, data) =>
global.core.api?.writeBlob(path, data)
/** /**
* Reads the contents of a file at the specified path. * Reads the contents of a file at the specified path.
* @returns {Promise<any>} A Promise that resolves with the contents of the file. * @returns {Promise<any>} A Promise that resolves with the contents of the file.
@ -60,7 +69,6 @@ const syncFile: (src: string, dest: string) => Promise<any> = (src, dest) =>
*/ */
const copyFileSync = (...args: any[]) => global.core.api?.copyFileSync(...args) const copyFileSync = (...args: any[]) => global.core.api?.copyFileSync(...args)
/** /**
* Gets the file's stats. * Gets the file's stats.
* *
@ -70,7 +78,6 @@ const copyFileSync = (...args: any[]) => global.core.api?.copyFileSync(...args)
const fileStat: (path: string) => Promise<FileStat | undefined> = (path) => const fileStat: (path: string) => Promise<FileStat | undefined> = (path) =>
global.core.api?.fileStat(path) global.core.api?.fileStat(path)
// TODO: Export `dummy` fs functions automatically // TODO: Export `dummy` fs functions automatically
// Currently adding these manually // Currently adding these manually
export const fs = { export const fs = {
@ -84,5 +91,6 @@ export const fs = {
appendFileSync, appendFileSync,
copyFileSync, copyFileSync,
syncFile, syncFile,
fileStat fileStat,
writeBlob,
} }

View File

@ -2,7 +2,8 @@ import fs from 'fs'
import { JanApiRouteConfiguration, RouteConfiguration } from './configuration' import { JanApiRouteConfiguration, RouteConfiguration } from './configuration'
import { join } from 'path' import { join } from 'path'
import { ContentType, MessageStatus, Model, ThreadMessage } from './../../../index' import { ContentType, MessageStatus, Model, ThreadMessage } from './../../../index'
import { getJanDataFolderPath } from '../../utils' import { getEngineConfiguration, getJanDataFolderPath } from '../../utils'
import { DEFAULT_CHAT_COMPLETION_URL } from './consts'
export const getBuilder = async (configuration: RouteConfiguration) => { export const getBuilder = async (configuration: RouteConfiguration) => {
const directoryPath = join(getJanDataFolderPath(), configuration.dirName) const directoryPath = join(getJanDataFolderPath(), configuration.dirName)
@ -309,7 +310,7 @@ export const chatCompletions = async (request: any, reply: any) => {
const engineConfiguration = await getEngineConfiguration(requestedModel.engine) const engineConfiguration = await getEngineConfiguration(requestedModel.engine)
let apiKey: string | undefined = undefined let apiKey: string | undefined = undefined
let apiUrl: string = 'http://127.0.0.1:3928/inferences/llamacpp/chat_completion' // default nitro url let apiUrl: string = DEFAULT_CHAT_COMPLETION_URL
if (engineConfiguration) { if (engineConfiguration) {
apiKey = engineConfiguration.api_key apiKey = engineConfiguration.api_key
@ -320,7 +321,7 @@ export const chatCompletions = async (request: any, reply: any) => {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
'Connection': 'keep-alive', 'Connection': 'keep-alive',
"Access-Control-Allow-Origin": "*" 'Access-Control-Allow-Origin': '*',
}) })
const headers: Record<string, any> = { const headers: Record<string, any> = {
@ -346,13 +347,3 @@ export const chatCompletions = async (request: any, reply: any) => {
response.body.pipe(reply.raw) response.body.pipe(reply.raw)
} }
} }
const getEngineConfiguration = async (engineId: string) => {
if (engineId !== 'openai') {
return undefined
}
const directoryPath = join(getJanDataFolderPath(), 'engines')
const filePath = join(directoryPath, `${engineId}.json`)
const data = await fs.readFileSync(filePath, 'utf-8')
return JSON.parse(data)
}

View File

@ -0,0 +1,19 @@
// The PORT to use for the Nitro subprocess
export const NITRO_DEFAULT_PORT = 3928
// The HOST address to use for the Nitro subprocess
export const LOCAL_HOST = '127.0.0.1'
export const SUPPORTED_MODEL_FORMAT = '.gguf'
// The URL for the Nitro subprocess
const NITRO_HTTP_SERVER_URL = `http://${LOCAL_HOST}:${NITRO_DEFAULT_PORT}`
// The URL for the Nitro subprocess to load a model
export const NITRO_HTTP_LOAD_MODEL_URL = `${NITRO_HTTP_SERVER_URL}/inferences/llamacpp/loadmodel`
// The URL for the Nitro subprocess to validate a model
export const NITRO_HTTP_VALIDATE_MODEL_URL = `${NITRO_HTTP_SERVER_URL}/inferences/llamacpp/modelstatus`
// The URL for the Nitro subprocess to kill itself
export const NITRO_HTTP_KILL_URL = `${NITRO_HTTP_SERVER_URL}/processmanager/destroy`
export const DEFAULT_CHAT_COMPLETION_URL = `http://${LOCAL_HOST}:${NITRO_DEFAULT_PORT}/inferences/llamacpp/chat_completion` // default nitro url

View File

@ -0,0 +1,351 @@
import fs from 'fs'
import { join } from 'path'
import { getJanDataFolderPath, getJanExtensionsPath, getSystemResourceInfo } from '../../utils'
import { logServer } from '../../log'
import { ChildProcessWithoutNullStreams, spawn } from 'child_process'
import { Model, ModelSettingParams, PromptTemplate } from '../../../types'
import {
LOCAL_HOST,
NITRO_DEFAULT_PORT,
NITRO_HTTP_KILL_URL,
NITRO_HTTP_LOAD_MODEL_URL,
NITRO_HTTP_VALIDATE_MODEL_URL,
SUPPORTED_MODEL_FORMAT,
} from './consts'
// The subprocess instance for Nitro
let subprocess: ChildProcessWithoutNullStreams | undefined = undefined
// TODO: move this to core type
interface NitroModelSettings extends ModelSettingParams {
llama_model_path: string
cpu_threads: number
}
export const startModel = async (modelId: string, settingParams?: ModelSettingParams) => {
try {
await runModel(modelId, settingParams)
return {
message: `Model ${modelId} started`,
}
} catch (e) {
return {
error: e,
}
}
}
const runModel = async (modelId: string, settingParams?: ModelSettingParams): Promise<void> => {
const janDataFolderPath = getJanDataFolderPath()
const modelFolderFullPath = join(janDataFolderPath, 'models', modelId)
if (!fs.existsSync(modelFolderFullPath)) {
throw `Model not found: ${modelId}`
}
const files: string[] = fs.readdirSync(modelFolderFullPath)
// Look for GGUF model file
const ggufBinFile = files.find((file) => file.toLowerCase().includes(SUPPORTED_MODEL_FORMAT))
const modelMetadataPath = join(modelFolderFullPath, 'model.json')
const modelMetadata: Model = JSON.parse(fs.readFileSync(modelMetadataPath, 'utf-8'))
if (!ggufBinFile) {
throw 'No GGUF model file found'
}
const modelBinaryPath = join(modelFolderFullPath, ggufBinFile)
const nitroResourceProbe = await getSystemResourceInfo()
const nitroModelSettings: NitroModelSettings = {
...modelMetadata.settings,
...settingParams,
llama_model_path: modelBinaryPath,
// This is critical and requires real CPU physical core count (or performance core)
cpu_threads: Math.max(1, nitroResourceProbe.numCpuPhysicalCore),
...(modelMetadata.settings.mmproj && {
mmproj: join(modelFolderFullPath, modelMetadata.settings.mmproj),
}),
}
logServer(`[NITRO]::Debug: Nitro model settings: ${JSON.stringify(nitroModelSettings)}`)
// Convert settings.prompt_template to system_prompt, user_prompt, ai_prompt
if (modelMetadata.settings.prompt_template) {
const promptTemplate = modelMetadata.settings.prompt_template
const prompt = promptTemplateConverter(promptTemplate)
if (prompt?.error) {
return Promise.reject(prompt.error)
}
nitroModelSettings.system_prompt = prompt.system_prompt
nitroModelSettings.user_prompt = prompt.user_prompt
nitroModelSettings.ai_prompt = prompt.ai_prompt
}
await runNitroAndLoadModel(modelId, nitroModelSettings)
}
// TODO: move to util
const promptTemplateConverter = (promptTemplate: string): PromptTemplate => {
// Split the string using the markers
const systemMarker = '{system_message}'
const promptMarker = '{prompt}'
if (promptTemplate.includes(systemMarker) && promptTemplate.includes(promptMarker)) {
// Find the indices of the markers
const systemIndex = promptTemplate.indexOf(systemMarker)
const promptIndex = promptTemplate.indexOf(promptMarker)
// Extract the parts of the string
const system_prompt = promptTemplate.substring(0, systemIndex)
const user_prompt = promptTemplate.substring(systemIndex + systemMarker.length, promptIndex)
const ai_prompt = promptTemplate.substring(promptIndex + promptMarker.length)
// Return the split parts
return { system_prompt, user_prompt, ai_prompt }
} else if (promptTemplate.includes(promptMarker)) {
// Extract the parts of the string for the case where only promptMarker is present
const promptIndex = promptTemplate.indexOf(promptMarker)
const user_prompt = promptTemplate.substring(0, promptIndex)
const ai_prompt = promptTemplate.substring(promptIndex + promptMarker.length)
// Return the split parts
return { user_prompt, ai_prompt }
}
// Return an error if none of the conditions are met
return { error: 'Cannot split prompt template' }
}
const runNitroAndLoadModel = async (modelId: string, modelSettings: NitroModelSettings) => {
// Gather system information for CPU physical cores and memory
const tcpPortUsed = require('tcp-port-used')
await stopModel(modelId)
await tcpPortUsed.waitUntilFree(NITRO_DEFAULT_PORT, 300, 5000)
/**
* There is a problem with Windows process manager
* Should wait for awhile to make sure the port is free and subprocess is killed
* The tested threshold is 500ms
**/
if (process.platform === 'win32') {
await new Promise((resolve) => setTimeout(resolve, 500))
}
await spawnNitroProcess()
await loadLLMModel(modelSettings)
await validateModelStatus()
}
const spawnNitroProcess = async (): Promise<void> => {
logServer(`[NITRO]::Debug: Spawning Nitro subprocess...`)
let binaryFolder = join(
getJanExtensionsPath(),
'@janhq',
'inference-nitro-extension',
'dist',
'bin'
)
let executableOptions = executableNitroFile()
const tcpPortUsed = require('tcp-port-used')
const args: string[] = ['1', LOCAL_HOST, NITRO_DEFAULT_PORT.toString()]
// Execute the binary
logServer(
`[NITRO]::Debug: Spawn nitro at path: ${executableOptions.executablePath}, and args: ${args}`
)
subprocess = spawn(
executableOptions.executablePath,
['1', LOCAL_HOST, NITRO_DEFAULT_PORT.toString()],
{
cwd: binaryFolder,
env: {
...process.env,
CUDA_VISIBLE_DEVICES: executableOptions.cudaVisibleDevices,
},
}
)
// Handle subprocess output
subprocess.stdout.on('data', (data: any) => {
logServer(`[NITRO]::Debug: ${data}`)
})
subprocess.stderr.on('data', (data: any) => {
logServer(`[NITRO]::Error: ${data}`)
})
subprocess.on('close', (code: any) => {
logServer(`[NITRO]::Debug: Nitro exited with code: ${code}`)
subprocess = undefined
})
tcpPortUsed.waitUntilUsed(NITRO_DEFAULT_PORT, 300, 30000).then(() => {
logServer(`[NITRO]::Debug: Nitro is ready`)
})
}
type NitroExecutableOptions = {
executablePath: string
cudaVisibleDevices: string
}
const executableNitroFile = (): NitroExecutableOptions => {
const nvidiaInfoFilePath = join(getJanDataFolderPath(), 'settings', 'settings.json')
let binaryFolder = join(
getJanExtensionsPath(),
'@janhq',
'inference-nitro-extension',
'dist',
'bin'
)
let cudaVisibleDevices = ''
let binaryName = 'nitro'
/**
* The binary folder is different for each platform.
*/
if (process.platform === 'win32') {
/**
* For Windows: win-cpu, win-cuda-11-7, win-cuda-12-0
*/
let nvidiaInfo = JSON.parse(fs.readFileSync(nvidiaInfoFilePath, 'utf-8'))
if (nvidiaInfo['run_mode'] === 'cpu') {
binaryFolder = join(binaryFolder, 'win-cpu')
} else {
if (nvidiaInfo['cuda'].version === '12') {
binaryFolder = join(binaryFolder, 'win-cuda-12-0')
} else {
binaryFolder = join(binaryFolder, 'win-cuda-11-7')
}
cudaVisibleDevices = nvidiaInfo['gpu_highest_vram']
}
binaryName = 'nitro.exe'
} else if (process.platform === 'darwin') {
/**
* For MacOS: mac-arm64 (Silicon), mac-x64 (InteL)
*/
if (process.arch === 'arm64') {
binaryFolder = join(binaryFolder, 'mac-arm64')
} else {
binaryFolder = join(binaryFolder, 'mac-x64')
}
} else {
/**
* For Linux: linux-cpu, linux-cuda-11-7, linux-cuda-12-0
*/
let nvidiaInfo = JSON.parse(fs.readFileSync(nvidiaInfoFilePath, 'utf-8'))
if (nvidiaInfo['run_mode'] === 'cpu') {
binaryFolder = join(binaryFolder, 'linux-cpu')
} else {
if (nvidiaInfo['cuda'].version === '12') {
binaryFolder = join(binaryFolder, 'linux-cuda-12-0')
} else {
binaryFolder = join(binaryFolder, 'linux-cuda-11-7')
}
cudaVisibleDevices = nvidiaInfo['gpu_highest_vram']
}
}
return {
executablePath: join(binaryFolder, binaryName),
cudaVisibleDevices,
}
}
const validateModelStatus = async (): Promise<void> => {
// Send a GET request to the validation URL.
// Retry the request up to 3 times if it fails, with a delay of 500 milliseconds between retries.
const fetchRT = require('fetch-retry')
const fetchRetry = fetchRT(fetch)
return fetchRetry(NITRO_HTTP_VALIDATE_MODEL_URL, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
retries: 5,
retryDelay: 500,
}).then(async (res: Response) => {
logServer(`[NITRO]::Debug: Validate model state success with response ${JSON.stringify(res)}`)
// If the response is OK, check model_loaded status.
if (res.ok) {
const body = await res.json()
// If the model is loaded, return an empty object.
// Otherwise, return an object with an error message.
if (body.model_loaded) {
return Promise.resolve()
}
}
return Promise.reject('Validate model status failed')
})
}
const loadLLMModel = async (settings: NitroModelSettings): Promise<Response> => {
logServer(`[NITRO]::Debug: Loading model with params ${JSON.stringify(settings)}`)
const fetchRT = require('fetch-retry')
const fetchRetry = fetchRT(fetch)
return fetchRetry(NITRO_HTTP_LOAD_MODEL_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(settings),
retries: 3,
retryDelay: 500,
})
.then((res: any) => {
logServer(`[NITRO]::Debug: Load model success with response ${JSON.stringify(res)}`)
return Promise.resolve(res)
})
.catch((err: any) => {
logServer(`[NITRO]::Error: Load model failed with error ${err}`)
return Promise.reject(err)
})
}
/**
* Stop model and kill nitro process.
*/
export const stopModel = async (_modelId: string) => {
if (!subprocess) {
return {
error: "Model isn't running",
}
}
return new Promise((resolve, reject) => {
const controller = new AbortController()
setTimeout(() => {
controller.abort()
reject({
error: 'Failed to stop model: Timedout',
})
}, 5000)
const tcpPortUsed = require('tcp-port-used')
logServer(`[NITRO]::Debug: Request to kill Nitro`)
fetch(NITRO_HTTP_KILL_URL, {
method: 'DELETE',
signal: controller.signal,
})
.then(() => {
subprocess?.kill()
subprocess = undefined
})
.catch(() => {
// don't need to do anything, we still kill the subprocess
})
.then(() => tcpPortUsed.waitUntilFree(NITRO_DEFAULT_PORT, 300, 5000))
.then(() => logServer(`[NITRO]::Debug: Nitro process is terminated`))
.then(() =>
resolve({
message: 'Model stopped',
})
)
})
}

View File

@ -10,6 +10,10 @@ import {
} from '../common/builder' } from '../common/builder'
import { JanApiRouteConfiguration } from '../common/configuration' import { JanApiRouteConfiguration } from '../common/configuration'
import { startModel, stopModel } from '../common/startStopModel'
import { ModelSettingParams } from '../../../types'
import { getJanDataFolderPath } from '../../utils'
import { normalizeFilePath } from '../../path'
export const commonRouter = async (app: HttpServer) => { export const commonRouter = async (app: HttpServer) => {
// Common Routes // Common Routes
@ -17,26 +21,47 @@ export const commonRouter = async (app: HttpServer) => {
app.get(`/${key}`, async (_request) => getBuilder(JanApiRouteConfiguration[key])) app.get(`/${key}`, async (_request) => getBuilder(JanApiRouteConfiguration[key]))
app.get(`/${key}/:id`, async (request: any) => app.get(`/${key}/:id`, async (request: any) =>
retrieveBuilder(JanApiRouteConfiguration[key], request.params.id), retrieveBuilder(JanApiRouteConfiguration[key], request.params.id)
) )
app.delete(`/${key}/:id`, async (request: any) => app.delete(`/${key}/:id`, async (request: any) =>
deleteBuilder(JanApiRouteConfiguration[key], request.params.id), deleteBuilder(JanApiRouteConfiguration[key], request.params.id)
) )
}) })
// Download Model Routes // Download Model Routes
app.get(`/models/download/:modelId`, async (request: any) => app.get(`/models/download/:modelId`, async (request: any) =>
downloadModel(request.params.modelId, { ignoreSSL: request.query.ignoreSSL === 'true', proxy: request.query.proxy }), downloadModel(request.params.modelId, {
ignoreSSL: request.query.ignoreSSL === 'true',
proxy: request.query.proxy,
})
) )
app.put(`/models/:modelId/start`, async (request: any) => {
let settingParams: ModelSettingParams | undefined = undefined
if (Object.keys(request.body).length !== 0) {
settingParams = JSON.parse(request.body) as ModelSettingParams
}
return startModel(request.params.modelId, settingParams)
})
app.put(`/models/:modelId/stop`, async (request: any) => stopModel(request.params.modelId))
// Chat Completion Routes // Chat Completion Routes
app.post(`/chat/completions`, async (request: any, reply: any) => chatCompletions(request, reply)) app.post(`/chat/completions`, async (request: any, reply: any) => chatCompletions(request, reply))
// App Routes // App Routes
app.post(`/app/${AppRoute.joinPath}`, async (request: any, reply: any) => { app.post(`/app/${AppRoute.joinPath}`, async (request: any, reply: any) => {
const args = JSON.parse(request.body) as any[] const args = JSON.parse(request.body) as any[]
reply.send(JSON.stringify(join(...args[0])))
const paths = args[0].map((arg: string) =>
typeof arg === 'string' && (arg.startsWith(`file:/`) || arg.startsWith(`file:\\`))
? join(getJanDataFolderPath(), normalizeFilePath(arg))
: arg
)
reply.send(JSON.stringify(join(...paths)))
}) })
app.post(`/app/${AppRoute.baseName}`, async (request: any, reply: any) => { app.post(`/app/${AppRoute.baseName}`, async (request: any, reply: any) => {

View File

@ -4,55 +4,55 @@ import { DownloadManager } from '../../download'
import { HttpServer } from '../HttpServer' 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'
export const downloadRouter = async (app: HttpServer) => { export const downloadRouter = async (app: HttpServer) => {
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
const body = JSON.parse(req.body as any); const body = JSON.parse(req.body as any)
const normalizedArgs = body.map((arg: any) => { const normalizedArgs = body.map((arg: any) => {
if (typeof arg === "string") { if (typeof arg === 'string' && arg.startsWith('file:')) {
return join(getJanDataFolderPath(), normalizeFilePath(arg)); return join(getJanDataFolderPath(), normalizeFilePath(arg))
} }
return arg; return arg
}); })
const localPath = normalizedArgs[1]; const localPath = normalizedArgs[1]
const fileName = localPath.split("/").pop() ?? ""; const fileName = localPath.split('/').pop() ?? ''
const request = require("request"); const request = require('request')
const progress = require("request-progress"); const progress = require('request-progress')
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); console.log('download onProgress', state)
}) })
.on("error", function (err: Error) { .on('error', function (err: Error) {
console.log("download onError", err); console.log('download onError', err)
}) })
.on("end", function () { .on('end', function () {
console.log("download onEnd"); console.log('download onEnd')
}) })
.pipe(createWriteStream(normalizedArgs[1])); .pipe(createWriteStream(normalizedArgs[1]))
DownloadManager.instance.setRequest(fileName, rq); DownloadManager.instance.setRequest(fileName, rq)
}); })
app.post(`/${DownloadRoute.abortDownload}`, async (req, res) => { app.post(`/${DownloadRoute.abortDownload}`, async (req, res) => {
const body = JSON.parse(req.body as any); const body = JSON.parse(req.body as any)
const normalizedArgs = body.map((arg: any) => { const normalizedArgs = body.map((arg: any) => {
if (typeof arg === "string") { if (typeof arg === 'string' && arg.startsWith('file:')) {
return join(getJanDataFolderPath(), normalizeFilePath(arg)); return join(getJanDataFolderPath(), normalizeFilePath(arg))
} }
return arg; return arg
}); })
const localPath = normalizedArgs[0]; const localPath = normalizedArgs[0]
const fileName = localPath.split("/").pop() ?? ""; const fileName = localPath.split('/').pop() ?? ''
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()
}); })
}; }

View File

@ -8,5 +8,7 @@ export const fsRouter = async (app: HttpServer) => {
app.post(`/app/${FileManagerRoute.getResourcePath}`, async (request: any, reply: any) => {}) app.post(`/app/${FileManagerRoute.getResourcePath}`, async (request: any, reply: any) => {})
app.post(`/app/${FileManagerRoute.getUserHomePath}`, async (request: any, reply: any) => {})
app.post(`/app/${FileManagerRoute.fileStat}`, async (request: any, reply: any) => {}) app.post(`/app/${FileManagerRoute.fileStat}`, async (request: any, reply: any) => {})
} }

View File

@ -2,6 +2,7 @@ import { 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'
export const fsRouter = async (app: HttpServer) => { export const fsRouter = async (app: HttpServer) => {
const moduleName = 'fs' const moduleName = 'fs'
@ -13,10 +14,10 @@ export const fsRouter = async (app: HttpServer) => {
const result = await import(moduleName).then((mdl) => { const result = await import(moduleName).then((mdl) => {
return mdl[route]( return mdl[route](
...body.map((arg: any) => ...body.map((arg: any) =>
typeof arg === 'string' && arg.includes('file:/') typeof arg === 'string' && (arg.startsWith(`file:/`) || arg.startsWith(`file:\\`))
? join(getJanDataFolderPath(), arg.replace('file:/', '')) ? join(getJanDataFolderPath(), normalizeFilePath(arg))
: arg, : arg
), )
) )
}) })
res.status(200).send(result) res.status(200).send(result)

View File

@ -2,38 +2,36 @@ import fs from 'fs'
import util from 'util' import util from 'util'
import { getAppLogPath, getServerLogPath } from './utils' import { getAppLogPath, getServerLogPath } from './utils'
export const log = function (message: string) { export const log = (message: string) => {
const appLogPath = getAppLogPath() const path = getAppLogPath()
if (!message.startsWith('[')) { if (!message.startsWith('[')) {
message = `[APP]::${message}` message = `[APP]::${message}`
} }
message = `${new Date().toISOString()} ${message}` message = `${new Date().toISOString()} ${message}`
if (fs.existsSync(appLogPath)) { writeLog(message, path)
var log_file = fs.createWriteStream(appLogPath, {
flags: 'a',
})
log_file.write(util.format(message) + '\n')
log_file.close()
console.debug(message)
}
} }
export const logServer = function (message: string) { export const logServer = (message: string) => {
const serverLogPath = getServerLogPath() const path = getServerLogPath()
if (!message.startsWith('[')) { if (!message.startsWith('[')) {
message = `[SERVER]::${message}` message = `[SERVER]::${message}`
} }
message = `${new Date().toISOString()} ${message}` message = `${new Date().toISOString()} ${message}`
writeLog(message, path)
}
if (fs.existsSync(serverLogPath)) { const writeLog = (message: string, logPath: string) => {
var log_file = fs.createWriteStream(serverLogPath, { if (!fs.existsSync(logPath)) {
fs.writeFileSync(logPath, message)
} else {
const logFile = fs.createWriteStream(logPath, {
flags: 'a', flags: 'a',
}) })
log_file.write(util.format(message) + '\n') logFile.write(util.format(message) + '\n')
log_file.close() logFile.close()
console.debug(message) console.debug(message)
} }
} }

View File

@ -1,16 +1,18 @@
import { AppConfiguration } from "../../types"; import { AppConfiguration, SystemResourceInfo } from '../../types'
import { join } from "path"; import { join } from 'path'
import fs from "fs"; import fs from 'fs'
import os from "os"; import os from 'os'
import { log, logServer } from '../log'
import childProcess from 'child_process'
// TODO: move this to core // TODO: move this to core
const configurationFileName = "settings.json"; const configurationFileName = 'settings.json'
// TODO: do no specify app name in framework module // TODO: do no specify app name in framework module
const defaultJanDataFolder = join(os.homedir(), "jan"); const defaultJanDataFolder = join(os.homedir(), 'jan')
const defaultAppConfig: AppConfiguration = { const defaultAppConfig: AppConfiguration = {
data_folder: defaultJanDataFolder, data_folder: defaultJanDataFolder,
}; }
/** /**
* Getting App Configurations. * Getting App Configurations.
@ -20,39 +22,39 @@ const defaultAppConfig: AppConfiguration = {
export const getAppConfigurations = (): AppConfiguration => { export const getAppConfigurations = (): AppConfiguration => {
// Retrieve Application Support folder path // Retrieve Application Support folder path
// Fallback to user home directory if not found // Fallback to user home directory if not found
const configurationFile = getConfigurationFilePath(); const configurationFile = getConfigurationFilePath()
if (!fs.existsSync(configurationFile)) { if (!fs.existsSync(configurationFile)) {
// create default app config if we don't have one // create default app config if we don't have one
console.debug(`App config not found, creating default config at ${configurationFile}`); console.debug(`App config not found, creating default config at ${configurationFile}`)
fs.writeFileSync(configurationFile, JSON.stringify(defaultAppConfig)); fs.writeFileSync(configurationFile, JSON.stringify(defaultAppConfig))
return defaultAppConfig; return defaultAppConfig
} }
try { try {
const appConfigurations: AppConfiguration = JSON.parse( const appConfigurations: AppConfiguration = JSON.parse(
fs.readFileSync(configurationFile, "utf-8"), fs.readFileSync(configurationFile, 'utf-8')
); )
return appConfigurations; return appConfigurations
} catch (err) { } catch (err) {
console.error(`Failed to read app config, return default config instead! Err: ${err}`); console.error(`Failed to read app config, return default config instead! Err: ${err}`)
return defaultAppConfig; return defaultAppConfig
}
} }
};
const getConfigurationFilePath = () => const getConfigurationFilePath = () =>
join( join(
global.core?.appPath() || process.env[process.platform == "win32" ? "USERPROFILE" : "HOME"], global.core?.appPath() || process.env[process.platform == 'win32' ? 'USERPROFILE' : 'HOME'],
configurationFileName, configurationFileName
); )
export const updateAppConfiguration = (configuration: AppConfiguration): Promise<void> => { export const updateAppConfiguration = (configuration: AppConfiguration): Promise<void> => {
const configurationFile = getConfigurationFilePath(); const configurationFile = getConfigurationFilePath()
console.debug("updateAppConfiguration, configurationFile: ", configurationFile); console.debug('updateAppConfiguration, configurationFile: ', configurationFile)
fs.writeFileSync(configurationFile, JSON.stringify(configuration)); fs.writeFileSync(configurationFile, JSON.stringify(configuration))
return Promise.resolve(); return Promise.resolve()
}; }
/** /**
* Utility function to get server log path * Utility function to get server log path
@ -60,13 +62,13 @@ export const updateAppConfiguration = (configuration: AppConfiguration): Promise
* @returns {string} The log path. * @returns {string} The log path.
*/ */
export const getServerLogPath = (): string => { export const getServerLogPath = (): string => {
const appConfigurations = getAppConfigurations(); const appConfigurations = getAppConfigurations()
const logFolderPath = join(appConfigurations.data_folder, "logs"); const logFolderPath = join(appConfigurations.data_folder, 'logs')
if (!fs.existsSync(logFolderPath)) { if (!fs.existsSync(logFolderPath)) {
fs.mkdirSync(logFolderPath, { recursive: true }); fs.mkdirSync(logFolderPath, { recursive: true })
}
return join(logFolderPath, 'server.log')
} }
return join(logFolderPath, "server.log");
};
/** /**
* Utility function to get app log path * Utility function to get app log path
@ -74,13 +76,13 @@ export const getServerLogPath = (): string => {
* @returns {string} The log path. * @returns {string} The log path.
*/ */
export const getAppLogPath = (): string => { export const getAppLogPath = (): string => {
const appConfigurations = getAppConfigurations(); const appConfigurations = getAppConfigurations()
const logFolderPath = join(appConfigurations.data_folder, "logs"); const logFolderPath = join(appConfigurations.data_folder, 'logs')
if (!fs.existsSync(logFolderPath)) { if (!fs.existsSync(logFolderPath)) {
fs.mkdirSync(logFolderPath, { recursive: true }); fs.mkdirSync(logFolderPath, { recursive: true })
}
return join(logFolderPath, 'app.log')
} }
return join(logFolderPath, "app.log");
};
/** /**
* Utility function to get data folder path * Utility function to get data folder path
@ -88,9 +90,9 @@ export const getAppLogPath = (): string => {
* @returns {string} The data folder path. * @returns {string} The data folder path.
*/ */
export const getJanDataFolderPath = (): string => { export const getJanDataFolderPath = (): string => {
const appConfigurations = getAppConfigurations(); const appConfigurations = getAppConfigurations()
return appConfigurations.data_folder; return appConfigurations.data_folder
}; }
/** /**
* Utility function to get extension path * Utility function to get extension path
@ -98,6 +100,70 @@ export const getJanDataFolderPath = (): string => {
* @returns {string} The extensions path. * @returns {string} The extensions path.
*/ */
export const getJanExtensionsPath = (): string => { export const getJanExtensionsPath = (): string => {
const appConfigurations = getAppConfigurations(); const appConfigurations = getAppConfigurations()
return join(appConfigurations.data_folder, "extensions"); return join(appConfigurations.data_folder, 'extensions')
}; }
/**
* Utility function to physical cpu count
*
* @returns {number} The physical cpu count.
*/
export const physicalCpuCount = async (): Promise<number> => {
const platform = os.platform()
if (platform === 'linux') {
const output = await exec('lscpu -p | egrep -v "^#" | sort -u -t, -k 2,4 | wc -l')
return parseInt(output.trim(), 10)
} else if (platform === 'darwin') {
const output = await exec('sysctl -n hw.physicalcpu_max')
return parseInt(output.trim(), 10)
} else if (platform === 'win32') {
const output = await exec('WMIC CPU Get NumberOfCores')
return output
.split(os.EOL)
.map((line: string) => parseInt(line))
.filter((value: number) => !isNaN(value))
.reduce((sum: number, number: number) => sum + number, 1)
} else {
const cores = os.cpus().filter((cpu: any, index: number) => {
const hasHyperthreading = cpu.model.includes('Intel')
const isOdd = index % 2 === 1
return !hasHyperthreading || isOdd
})
return cores.length
}
}
const exec = async (command: string): Promise<string> => {
return new Promise((resolve, reject) => {
childProcess.exec(command, { encoding: 'utf8' }, (error, stdout) => {
if (error) {
reject(error)
} else {
resolve(stdout)
}
})
})
}
export const getSystemResourceInfo = async (): Promise<SystemResourceInfo> => {
const cpu = await physicalCpuCount()
const message = `[NITRO]::CPU informations - ${cpu}`
log(message)
logServer(message)
return {
numCpuPhysicalCore: cpu,
memAvailable: 0, // TODO: this should not be 0
}
}
export const getEngineConfiguration = async (engineId: string) => {
if (engineId !== 'openai') {
return undefined
}
const directoryPath = join(getJanDataFolderPath(), 'engines')
const filePath = join(directoryPath, `${engineId}.json`)
const data = fs.readFileSync(filePath, 'utf-8')
return JSON.parse(data)
}

View File

@ -2,6 +2,13 @@
* Assistant type defines the shape of an assistant object. * Assistant type defines the shape of an assistant object.
* @stored * @stored
*/ */
export type AssistantTool = {
type: string
enabled: boolean
settings: any
}
export type Assistant = { export type Assistant = {
/** Represents the avatar of the user. */ /** Represents the avatar of the user. */
avatar: string avatar: string
@ -22,7 +29,7 @@ export type Assistant = {
/** Represents the instructions for the object. */ /** Represents the instructions for the object. */
instructions?: string instructions?: string
/** Represents the tools associated with the object. */ /** Represents the tools associated with the object. */
tools?: any tools?: AssistantTool[]
/** Represents the file identifiers associated with the object. */ /** Represents the file identifiers associated with the object. */
file_ids: string[] file_ids: string[]
/** Represents the metadata of the object. */ /** Represents the metadata of the object. */

View File

@ -0,0 +1,6 @@
/**
* App configuration event name
*/
export enum AppConfigurationEventName {
OnConfigurationUpdate = 'OnConfigurationUpdate',
}

View File

@ -1 +1,2 @@
export * from './appConfigEntity' export * from './appConfigEntity'
export * from './appConfigEvent'

View File

@ -6,3 +6,4 @@ export * from './inference'
export * from './monitoring' export * from './monitoring'
export * from './file' export * from './file'
export * from './config' export * from './config'
export * from './miscellaneous'

View File

@ -1,3 +1,5 @@
import { ContentType, ContentValue } from '../message'
/** /**
* The role of the author of this message. * The role of the author of this message.
*/ */
@ -13,7 +15,32 @@ export enum ChatCompletionRole {
*/ */
export type ChatCompletionMessage = { export type ChatCompletionMessage = {
/** The contents of the message. **/ /** The contents of the message. **/
content?: string content?: ChatCompletionMessageContent
/** The role of the author of this message. **/ /** The role of the author of this message. **/
role: ChatCompletionRole role: ChatCompletionRole
} }
export type ChatCompletionMessageContent =
| string
| (ChatCompletionMessageContentText &
ChatCompletionMessageContentImage &
ChatCompletionMessageContentDoc)[]
export enum ChatCompletionMessageContentType {
Text = 'text',
Image = 'image_url',
Doc = 'doc_url',
}
export type ChatCompletionMessageContentText = {
type: ChatCompletionMessageContentType
text: string
}
export type ChatCompletionMessageContentImage = {
type: ChatCompletionMessageContentType
image_url: { url: string }
}
export type ChatCompletionMessageContentDoc = {
type: ChatCompletionMessageContentType
doc_url: { url: string }
}

View File

@ -1,5 +1,6 @@
import { ChatCompletionMessage, ChatCompletionRole } from '../inference' import { ChatCompletionMessage, ChatCompletionRole } from '../inference'
import { ModelInfo } from '../model' import { ModelInfo } from '../model'
import { Thread } from '../thread'
/** /**
* The `ThreadMessage` type defines the shape of a thread's message object. * The `ThreadMessage` type defines the shape of a thread's message object.
@ -35,7 +36,10 @@ export type ThreadMessage = {
export type MessageRequest = { export type MessageRequest = {
id?: string id?: string
/** The thread id of the message request. **/ /**
* @deprecated Use thread object instead
* The thread id of the message request.
*/
threadId: string threadId: string
/** /**
@ -48,6 +52,10 @@ export type MessageRequest = {
/** Settings for constructing a chat completion request **/ /** Settings for constructing a chat completion request **/
model?: ModelInfo model?: ModelInfo
/** The thread of this message is belong to. **/
// TODO: deprecate threadId field
thread?: Thread
} }
/** /**
@ -62,7 +70,7 @@ export enum MessageStatus {
/** Message loaded with error. **/ /** Message loaded with error. **/
Error = 'error', Error = 'error',
/** Message is cancelled streaming */ /** Message is cancelled streaming */
Stopped = "stopped" Stopped = 'stopped',
} }
/** /**
@ -71,6 +79,7 @@ export enum MessageStatus {
export enum ContentType { export enum ContentType {
Text = 'text', Text = 'text',
Image = 'image', Image = 'image',
Pdf = 'pdf',
} }
/** /**
@ -80,6 +89,8 @@ export enum ContentType {
export type ContentValue = { export type ContentValue = {
value: string value: string
annotations: string[] annotations: string[]
name?: string
size?: number
} }
/** /**

View File

@ -0,0 +1,2 @@
export * from './systemResourceInfo'
export * from './promptTemplate'

View File

@ -0,0 +1,6 @@
export type PromptTemplate = {
system_prompt?: string
ai_prompt?: string
user_prompt?: string
error?: string
}

View File

@ -0,0 +1,4 @@
export type SystemResourceInfo = {
numCpuPhysicalCore: number
memAvailable: number
}

View File

@ -7,6 +7,7 @@ export type ModelInfo = {
settings: ModelSettingParams settings: ModelSettingParams
parameters: ModelRuntimeParams parameters: ModelRuntimeParams
engine?: InferenceEngine engine?: InferenceEngine
proxyEngine?: InferenceEngine
} }
/** /**
@ -18,7 +19,8 @@ export enum InferenceEngine {
nitro = 'nitro', nitro = 'nitro',
openai = 'openai', openai = 'openai',
triton_trtllm = 'triton_trtllm', triton_trtllm = 'triton_trtllm',
hf_endpoint = 'hf_endpoint',
tool_retrieval_enabled = 'tool_retrieval_enabled',
} }
export type ModelArtifact = { export type ModelArtifact = {
@ -90,6 +92,13 @@ export type Model = {
* The model engine. * The model engine.
*/ */
engine: InferenceEngine engine: InferenceEngine
proxyEngine?: InferenceEngine
/**
* Is multimodal or not.
*/
visionModel?: boolean
} }
export type ModelMetadata = { export type ModelMetadata = {
@ -114,6 +123,7 @@ export type ModelSettingParams = {
user_prompt?: string user_prompt?: string
llama_model_path?: string llama_model_path?: string
mmproj?: string mmproj?: string
cont_batching?: boolean
} }
/** /**
@ -129,4 +139,5 @@ export type ModelRuntimeParams = {
stop?: string[] stop?: string[]
frequency_penalty?: number frequency_penalty?: number
presence_penalty?: number presence_penalty?: number
engine?: string
} }

View File

@ -1,2 +1,3 @@
export * from './threadEntity' export * from './threadEntity'
export * from './threadInterface' export * from './threadInterface'
export * from './threadEvent'

View File

@ -1,3 +1,4 @@
import { AssistantTool } from '../assistant'
import { ModelInfo } from '../model' import { ModelInfo } from '../model'
/** /**
@ -30,6 +31,7 @@ export type ThreadAssistantInfo = {
assistant_name: string assistant_name: string
model: ModelInfo model: ModelInfo
instructions?: string instructions?: string
tools?: AssistantTool[]
} }
/** /**
@ -41,5 +43,4 @@ export type ThreadState = {
waitingForResponse: boolean waitingForResponse: boolean
error?: Error error?: Error
lastMessage?: string lastMessage?: string
isFinishInit?: boolean
} }

View File

@ -0,0 +1,4 @@
export enum ThreadEvent {
/** The `OnThreadStarted` event is emitted when a thread is started. */
OnThreadStarted = 'OnThreadStarted',
}

View File

@ -1,5 +1,5 @@
GTM_ID=xxxx GTM_ID=xxxx
POSTHOG_PROJECT_API_KEY=xxxx UMAMI_PROJECT_API_KEY=xxxx
POSTHOG_APP_URL=xxxx UMAMI_APP_URL=xxxx
ALGOLIA_API_KEY=xxxx ALGOLIA_API_KEY=xxxx
ALGOLIA_APP_ID=xxxx ALGOLIA_APP_ID=xxxx

View File

@ -1,6 +1,6 @@
# [Release Version] QA Script # [Release Version] QA Script
**Release Version:** **Release Version:** v0.4.6
**Operating System:** **Operating System:**
@ -25,10 +25,10 @@
### 3. Users uninstall app ### 3. Users uninstall app
- [ ] :key: Check that the uninstallation process removes all components of the app from the system. - [ ] :key::warning: Check that the uninstallation process removes the app successfully from the system.
- [ ] Clean the Jan root directory and open the app to check if it creates all the necessary folders, especially models and extensions. - [ ] Clean the Jan root directory and open the app to check if it creates all the necessary folders, especially models and extensions.
- [ ] When updating the app, check if the `/models` directory has any JSON files that change according to the update. - [ ] When updating the app, check if the `/models` directory has any JSON files that change according to the update.
- [ ] Verify if updating the app also updates extensions correctly (test functionality changes; support notifications for necessary tests with each version related to extensions update). - [ ] Verify if updating the app also updates extensions correctly (test functionality changes, support notifications for necessary tests with each version related to extensions update).
### 4. Users close app ### 4. Users close app
@ -60,49 +60,45 @@
- [ ] :key: Ensure that the conversation thread is maintained without any loss of data upon sending multiple messages. - [ ] :key: Ensure that the conversation thread is maintained without any loss of data upon sending multiple messages.
- [ ] Test for the ability to send different types of messages (e.g., text, emojis, code blocks). - [ ] Test for the ability to send different types of messages (e.g., text, emojis, code blocks).
- [ ] :key: Validate the scroll functionality in the chat window for lengthy conversations. - [ ] :key: Validate the scroll functionality in the chat window for lengthy conversations.
- [ ] Check if the user can renew responses multiple times.
- [ ] Check if the user can copy the response. - [ ] Check if the user can copy the response.
- [ ] Check if the user can delete responses. - [ ] Check if the user can delete responses.
- [ ] :warning: Test if the user deletes the message midway, then the assistant stops that response.
- [ ] :key: Check the `clear message` button works. - [ ] :key: Check the `clear message` button works.
- [ ] :key: Check the `delete entire chat` works. - [ ] :key: Check the `delete entire chat` works.
- [ ] :warning: Check if deleting all the chat retains the system prompt. - [ ] Check if deleting all the chat retains the system prompt.
- [ ] Check the output format of the AI (code blocks, JSON, markdown, ...). - [ ] Check the output format of the AI (code blocks, JSON, markdown, ...).
- [ ] :key: Validate that there is appropriate error handling and messaging if the assistant fails to respond. - [ ] :key: Validate that there is appropriate error handling and messaging if the assistant fails to respond.
- [ ] Test assistant's ability to maintain context over multiple exchanges. - [ ] Test assistant's ability to maintain context over multiple exchanges.
- [ ] :key: Check the `create new chat` button works correctly - [ ] :key: Check the `create new chat` button works correctly
- [ ] Confirm that by changing `models` mid-thread the app can still handle it. - [ ] Confirm that by changing `models` mid-thread the app can still handle it.
- [ ] Check that by changing `instructions` mid-thread the app can still handle it. - [ ] Check the `regenerate` button renews the response (single / multiple times).
- [ ] Check the `regenerate` button renews the response. - [ ] Check the `Instructions` update correctly after the user updates it midway (mid-thread).
- [ ] Check the `Instructions` update correctly after the user updates it midway.
### 2. Users can customize chat settings like model parameters via both the GUI & thread.json ### 2. Users can customize chat settings like model parameters via both the GUI & thread.json
- [ ] :key: Confirm that the chat settings options are accessible via the GUI. - [ ] :key: Confirm that the Threads settings options are accessible.
- [ ] Test the functionality to adjust model parameters (e.g., Temperature, Top K, Top P) from the GUI and verify they are reflected in the chat behavior. - [ ] Test the functionality to adjust model parameters (e.g., Temperature, Top K, Top P) from the GUI and verify they are reflected in the chat behavior.
- [ ] :key: Ensure that changes can be saved and persisted between sessions. - [ ] :key: Ensure that changes can be saved and persisted between sessions.
- [ ] Validate that users can access and modify the thread.json file. - [ ] Validate that users can access and modify the thread.json file.
- [ ] :key: Check that changes made in thread.json are correctly applied to the chat session upon reload or restart. - [ ] :key: Check that changes made in thread.json are correctly applied to the chat session upon reload or restart.
- [ ] Verify if there is a revert option to go back to previous settings after changes are made.
- [ ] Test for user feedback or confirmation after saving changes to settings.
- [ ] Check the maximum and minimum limits of the adjustable parameters and how they affect the assistant's responses. - [ ] Check the maximum and minimum limits of the adjustable parameters and how they affect the assistant's responses.
- [ ] :key: Validate user permissions for those who can change settings and persist them. - [ ] :key: Validate user permissions for those who can change settings and persist them.
- [ ] :key: Ensure that users switch between threads with different models, the app can handle it. - [ ] :key: Ensure that users switch between threads with different models, the app can handle it.
### 3. Users can click on a history thread ### 3. Model dropdown
- [ ] :key: Model list should highlight recommended based on user RAM
- [ ] Model size should display (for both installed and imported models)
### 4. Users can click on a history thread
- [ ] Test the ability to click on any thread in the history panel. - [ ] Test the ability to click on any thread in the history panel.
- [ ] :key: Verify that clicking a thread brings up the past conversation in the main chat window. - [ ] :key: Verify that clicking a thread brings up the past conversation in the main chat window.
- [ ] :key: Ensure that the selected thread is highlighted or otherwise indicated in the history panel. - [ ] :key: Ensure that the selected thread is highlighted or otherwise indicated in the history panel.
- [ ] Confirm that the chat window displays the entire conversation from the selected history thread without any missing messages. - [ ] Confirm that the chat window displays the entire conversation from the selected history thread without any missing messages.
- [ ] :key: Check the performance and accuracy of the history feature when dealing with a large number of threads. - [ ] :key: Check the performance and accuracy of the history feature when dealing with a large number of threads.
- [ ] Validate that historical threads reflect the exact state of the chat at that time, including settings. - [ ] Validate that historical threads reflect the exact state of the chat at that time, including settings.
- [ ] :key: :warning: Test the search functionality within the history panel for quick navigation.
- [ ] :key: Verify the ability to delete or clean old threads. - [ ] :key: Verify the ability to delete or clean old threads.
- [ ] :key: Confirm that changing the title of the thread updates correctly. - [ ] :key: Confirm that changing the title of the thread updates correctly.
### 4. Users can config instructions for the assistant. ### 5. Users can config instructions for the assistant.
- [ ] Ensure there is a clear interface to input or change instructions for the assistant. - [ ] Ensure there is a clear interface to input or change instructions for the assistant.
- [ ] Test if the instructions set by the user are being followed by the assistant in subsequent conversations. - [ ] Test if the instructions set by the user are being followed by the assistant in subsequent conversations.
- [ ] :key: Validate that changes to instructions are updated in real time and do not require a restart of the application or session. - [ ] :key: Validate that changes to instructions are updated in real time and do not require a restart of the application or session.
@ -112,6 +108,8 @@
- [ ] Validate that instructions can be saved with descriptive names for easy retrieval. - [ ] Validate that instructions can be saved with descriptive names for easy retrieval.
- [ ] :key: Check if the assistant can handle conflicting instructions and how it resolves them. - [ ] :key: Check if the assistant can handle conflicting instructions and how it resolves them.
- [ ] Ensure that instruction configurations are documented for user reference. - [ ] Ensure that instruction configurations are documented for user reference.
- [ ] :key: RAG - Users can import documents and the system should process queries about the uploaded file, providing accurate and appropriate responses in the conversation thread.
## D. Hub ## D. Hub
@ -125,8 +123,7 @@
- [ ] Display the best model for their RAM at the top. - [ ] Display the best model for their RAM at the top.
- [ ] :key: Ensure that models are labeled with RAM requirements and compatibility. - [ ] :key: Ensure that models are labeled with RAM requirements and compatibility.
- [ ] :key: Validate that the download function is disabled for models that exceed the user's system capabilities. - [ ] :warning: Test that the platform provides alternative recommendations for models not suitable due to RAM limitations.
- [ ] Test that the platform provides alternative recommendations for models not suitable due to RAM limitations.
- [ ] :key: Check the download model functionality and validate if the cancel download feature works correctly. - [ ] :key: Check the download model functionality and validate if the cancel download feature works correctly.
### 3. Users can download models via a HuggingFace URL (coming soon) ### 3. Users can download models via a HuggingFace URL (coming soon)
@ -139,7 +136,7 @@
- [ ] :key: Have clear instructions so users can do their own. - [ ] :key: Have clear instructions so users can do their own.
- [ ] :key: Ensure the new model updates after restarting the app. - [ ] :key: Ensure the new model updates after restarting the app.
- [ ] Ensure it raises clear errors for users to fix the problem while adding a new model. - [ ] :warning:Ensure it raises clear errors for users to fix the problem while adding a new model.
### 5. Users can use the model as they want ### 5. Users can use the model as they want
@ -149,9 +146,13 @@
- [ ] Check if starting another model stops the other model entirely. - [ ] Check if starting another model stops the other model entirely.
- [ ] Check the `Explore models` navigate correctly to the model panel. - [ ] Check the `Explore models` navigate correctly to the model panel.
- [ ] :key: Check when deleting a model it will delete all the files on the user's computer. - [ ] :key: Check when deleting a model it will delete all the files on the user's computer.
- [ ] The recommended tags should present right for the user's hardware. - [ ] :warning:The recommended tags should present right for the user's hardware.
- [ ] Assess that the descriptions of models are accurate and informative. - [ ] Assess that the descriptions of models are accurate and informative.
### 6. Users can Integrate With a Remote Server
- [ ] :key: Import openAI GPT model https://jan.ai/guides/using-models/integrate-with-remote-server/ and the model displayed in Hub / Thread dropdown
- [ ] Users can use the remote model properly
## E. System Monitor ## E. System Monitor
### 1. Users can see disk and RAM utilization ### 1. Users can see disk and RAM utilization
@ -181,7 +182,7 @@
- [ ] Confirm that the application saves the theme preference and persists it across sessions. - [ ] Confirm that the application saves the theme preference and persists it across sessions.
- [ ] Validate that all elements of the UI are compatible with the theme changes and maintain legibility and contrast. - [ ] Validate that all elements of the UI are compatible with the theme changes and maintain legibility and contrast.
### 2. Users change the extensions ### 2. Users change the extensions [TBU]
- [ ] Confirm that the `Extensions` tab lists all available plugins. - [ ] Confirm that the `Extensions` tab lists all available plugins.
- [ ] :key: Test the toggle switch for each plugin to ensure it enables or disables the plugin correctly. - [ ] :key: Test the toggle switch for each plugin to ensure it enables or disables the plugin correctly.
@ -208,3 +209,19 @@
- [ ] :key: Test that the application prevents the installation of incompatible or corrupt plugin files. - [ ] :key: Test that the application prevents the installation of incompatible or corrupt plugin files.
- [ ] :key: Check that the user can uninstall or disable custom plugins as easily as pre-installed ones. - [ ] :key: Check that the user can uninstall or disable custom plugins as easily as pre-installed ones.
- [ ] Verify that the application's performance remains stable after the installation of custom plugins. - [ ] Verify that the application's performance remains stable after the installation of custom plugins.
### 5. Advanced Settings
- [ ] Attemp to test downloading model from hub using **HTTP Proxy** [guideline](https://github.com/janhq/jan/pull/1562)
- [ ] Users can move **Jan data folder**
- [ ] Users can click on Reset button to **factory reset** app settings to its original state & delete all usage data.
## G. Local API server
### 1. Local Server Usage with Server Options
- [ ] :key: Explore API Reference: Swagger API for sending/receiving requests
- [ ] Use default server option
- [ ] Configure and use custom server options
- [ ] Test starting/stopping the local API server with different Model/Model settings
- [ ] Server logs captured with correct Server Options provided
- [ ] Verify functionality of Open logs/Clear feature
- [ ] Ensure that threads and other functions impacting the model are disabled while the local server is running

View File

@ -1,5 +1,5 @@
import { app, ipcMain, dialog, shell } from 'electron' import { app, ipcMain, dialog, shell } from 'electron'
import { join, basename } from 'path' import { join, basename, relative as getRelative, isAbsolute } from 'path'
import { WindowManager } from './../managers/window' import { WindowManager } from './../managers/window'
import { getResourcePath } from './../utils/path' import { getResourcePath } from './../utils/path'
import { AppRoute, AppConfiguration } from '@janhq/core' import { AppRoute, AppConfiguration } from '@janhq/core'
@ -50,6 +50,27 @@ export function handleAppIPCs() {
join(...paths) join(...paths)
) )
/**
* Checks if the given path is a subdirectory of the given directory.
*
* @param _event - The IPC event object.
* @param from - The path to check.
* @param to - The directory to check against.
*
* @returns {Promise<boolean>} - A promise that resolves with the result.
*/
ipcMain.handle(
AppRoute.isSubdirectory,
async (_event, from: string, to: string) => {
const relative = getRelative(from, to)
const isSubdir =
relative && !relative.startsWith('..') && !isAbsolute(relative)
if (isSubdir === '') return false
else return isSubdir
}
)
/** /**
* Retrieve basename from given path, respect to the current OS. * Retrieve basename from given path, respect to the current OS.
*/ */

View File

@ -1,4 +1,4 @@
import { ipcMain } from 'electron' import { ipcMain, app } from 'electron'
// @ts-ignore // @ts-ignore
import reflect from '@alumna/reflect' import reflect from '@alumna/reflect'
@ -38,6 +38,10 @@ export function handleFileMangerIPCs() {
getResourcePath() getResourcePath()
) )
ipcMain.handle(FileManagerRoute.getUserHomePath, async (_event) =>
app.getPath('home')
)
// handle fs is directory here // handle fs is directory here
ipcMain.handle( ipcMain.handle(
FileManagerRoute.fileStat, FileManagerRoute.fileStat,
@ -59,4 +63,20 @@ export function handleFileMangerIPCs() {
return fileStat return fileStat
} }
) )
ipcMain.handle(
FileManagerRoute.writeBlob,
async (_event, path: string, data: string): Promise<void> => {
try {
const normalizedPath = normalizeFilePath(path)
const dataBuffer = Buffer.from(data, 'base64')
fs.writeFileSync(
join(getJanDataFolderPath(), normalizedPath),
dataBuffer
)
} catch (err) {
console.error(`writeFile ${path} result: ${err}`)
}
}
)
} }

View File

@ -1,9 +1,9 @@
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
import { FileSystemRoute } from '@janhq/core'
import { join } from 'path'
import { getJanDataFolderPath, normalizeFilePath } from '@janhq/core/node' import { getJanDataFolderPath, normalizeFilePath } from '@janhq/core/node'
import fs from 'fs'
import { FileManagerRoute, FileSystemRoute } from '@janhq/core'
import { join } from 'path'
/** /**
* Handles file system operations. * Handles file system operations.
*/ */
@ -15,7 +15,7 @@ export function handleFsIPCs() {
mdl[route]( mdl[route](
...args.map((arg) => ...args.map((arg) =>
typeof arg === 'string' && typeof arg === 'string' &&
(arg.includes(`file:/`) || arg.includes(`file:\\`)) (arg.startsWith(`file:/`) || arg.startsWith(`file:\\`))
? join(getJanDataFolderPath(), normalizeFilePath(arg)) ? join(getJanDataFolderPath(), normalizeFilePath(arg))
: arg : arg
) )

View File

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

View File

@ -0,0 +1,27 @@
const yaml = require('js-yaml')
const fs = require('fs')
// get two file paths from arguments:
const [, , ...args] = process.argv
const file1 = args[0]
const file2 = args[1]
const file3 = args[2]
// check that all arguments are present and throw error instead
if (!file1 || !file2 || !file3) {
throw new Error('Please provide 3 file paths as arguments: path to file1, to file2 and destination path')
}
const doc1 = yaml.load(fs.readFileSync(file1, 'utf8'))
console.log('doc1: ', doc1)
const doc2 = yaml.load(fs.readFileSync(file2, 'utf8'))
console.log('doc2: ', doc2)
const merged = { ...doc1, ...doc2 }
merged.files.push(...doc1.files)
console.log('merged', merged)
const mergedYml = yaml.dump(merged)
fs.writeFileSync(file3, mergedYml, 'utf8')

View File

@ -63,11 +63,11 @@
"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 --x64 --arm64", "build:darwin": "tsc -p . && electron-builder -p never -m",
"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": "run-script-os",
"build:publish:darwin": "tsc -p . && electron-builder -p always -m --x64 --arm64", "build:publish:darwin": "tsc -p . && electron-builder -p always -m",
"build:publish:win32": "tsc -p . && electron-builder -p always -w", "build:publish: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"
}, },
@ -86,7 +86,7 @@
"request": "^2.88.2", "request": "^2.88.2",
"request-progress": "^3.0.0", "request-progress": "^3.0.0",
"rimraf": "^5.0.5", "rimraf": "^5.0.5",
"typescript": "^5.3.3", "typescript": "^5.2.2",
"ulid": "^2.3.0", "ulid": "^2.3.0",
"use-debounce": "^9.0.4" "use-debounce": "^9.0.4"
}, },
@ -99,6 +99,7 @@
"@typescript-eslint/parser": "^6.7.3", "@typescript-eslint/parser": "^6.7.3",
"electron": "28.0.0", "electron": "28.0.0",
"electron-builder": "^24.9.1", "electron-builder": "^24.9.1",
"electron-devtools-installer": "^3.2.0",
"electron-playwright-helpers": "^1.6.0", "electron-playwright-helpers": "^1.6.0",
"eslint-plugin-react": "^7.33.2", "eslint-plugin-react": "^7.33.2",
"run-script-os": "^1.1.6" "run-script-os": "^1.1.6"

View File

@ -1,9 +1,16 @@
import { PlaywrightTestConfig } from "@playwright/test"; import { PlaywrightTestConfig } from '@playwright/test'
const config: PlaywrightTestConfig = { const config: PlaywrightTestConfig = {
testDir: "./tests", testDir: './tests/e2e',
retries: 0, retries: 0,
timeout: 120000, globalTimeout: 300000,
}; use: {
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'retain-on-failure',
},
export default config; reporter: [['html', { outputFolder: './playwright-report' }]],
}
export default config

View File

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

View File

@ -0,0 +1,38 @@
import { expect } from '@playwright/test'
import {
page,
setupElectron,
TIMEOUT,
test,
teardownElectron,
} from '../pages/basePage'
test.beforeAll(async () => {
await setupElectron()
})
test.afterAll(async () => {
await teardownElectron()
})
test('renders left navigation panel', async () => {
const systemMonitorBtn = await page
.getByTestId('System Monitor')
.first()
.isEnabled({
timeout: TIMEOUT,
})
const settingsBtn = await page
.getByTestId('Thread')
.first()
.isEnabled({ timeout: TIMEOUT })
expect([systemMonitorBtn, settingsBtn].filter((e) => !e).length).toBe(0)
// Chat section should be there
await page.getByTestId('Local API Server').first().click({
timeout: TIMEOUT,
})
const localServer = page.getByTestId('local-server-testid').first()
await expect(localServer).toBeVisible({
timeout: TIMEOUT,
})
})

View File

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

View File

@ -1,41 +0,0 @@
import { _electron as electron } from 'playwright'
import { ElectronApplication, Page, expect, test } from '@playwright/test'
import {
findLatestBuild,
parseElectronApp,
stubDialog,
} from 'electron-playwright-helpers'
let electronApp: ElectronApplication
let page: Page
test.beforeAll(async () => {
process.env.CI = 'e2e'
const latestBuild = findLatestBuild('dist')
expect(latestBuild).toBeTruthy()
// parse the packaged Electron app and find paths and other info
const appInfo = parseElectronApp(latestBuild)
expect(appInfo).toBeTruthy()
electronApp = await electron.launch({
args: [appInfo.main], // main file from package.json
executablePath: appInfo.executable, // path to the Electron executable
})
await stubDialog(electronApp, 'showMessageBox', { response: 1 })
page = await electronApp.firstWindow()
})
test.afterAll(async () => {
await electronApp.close()
await page.close()
})
test('explores models', async () => {
await page.getByTestId('Hub').first().click()
await page.getByTestId('testid-explore-models').isVisible()
// More test cases here...
})

View File

@ -1,55 +0,0 @@
import { _electron as electron } from 'playwright'
import { ElectronApplication, Page, expect, test } from '@playwright/test'
import {
findLatestBuild,
parseElectronApp,
stubDialog,
} from 'electron-playwright-helpers'
let electronApp: ElectronApplication
let page: Page
test.beforeAll(async () => {
process.env.CI = 'e2e'
const latestBuild = findLatestBuild('dist')
expect(latestBuild).toBeTruthy()
// parse the packaged Electron app and find paths and other info
const appInfo = parseElectronApp(latestBuild)
expect(appInfo).toBeTruthy()
expect(appInfo.asar).toBe(true)
expect(appInfo.executable).toBeTruthy()
expect(appInfo.main).toBeTruthy()
expect(appInfo.name).toBe('jan')
expect(appInfo.packageJson).toBeTruthy()
expect(appInfo.packageJson.name).toBe('jan')
expect(appInfo.platform).toBeTruthy()
expect(appInfo.platform).toBe(process.platform)
expect(appInfo.resourcesDir).toBeTruthy()
electronApp = await electron.launch({
args: [appInfo.main], // main file from package.json
executablePath: appInfo.executable, // path to the Electron executable
})
await stubDialog(electronApp, 'showMessageBox', { response: 1 })
page = await electronApp.firstWindow()
})
test.afterAll(async () => {
await electronApp.close()
await page.close()
})
test('renders the home page', async () => {
expect(page).toBeDefined()
// Welcome text is available
const welcomeText = await page
.getByTestId('testid-welcome-title')
.first()
.isVisible()
expect(welcomeText).toBe(false)
})

View File

@ -1,54 +0,0 @@
import { _electron as electron } from 'playwright'
import { ElectronApplication, Page, expect, test } from '@playwright/test'
import {
findLatestBuild,
parseElectronApp,
stubDialog,
} from 'electron-playwright-helpers'
let electronApp: ElectronApplication
let page: Page
test.beforeAll(async () => {
process.env.CI = 'e2e'
const latestBuild = findLatestBuild('dist')
expect(latestBuild).toBeTruthy()
// parse the packaged Electron app and find paths and other info
const appInfo = parseElectronApp(latestBuild)
expect(appInfo).toBeTruthy()
electronApp = await electron.launch({
args: [appInfo.main], // main file from package.json
executablePath: appInfo.executable, // path to the Electron executable
})
await stubDialog(electronApp, 'showMessageBox', { response: 1 })
page = await electronApp.firstWindow()
})
test.afterAll(async () => {
await electronApp.close()
await page.close()
})
test('renders left navigation panel', async () => {
// Chat section should be there
const chatSection = await page.getByTestId('Chat').first().isVisible()
expect(chatSection).toBe(false)
// Home actions
/* Disable unstable feature tests
** const botBtn = await page.getByTestId("Bot").first().isEnabled();
** Enable back when it is whitelisted
*/
const systemMonitorBtn = await page
.getByTestId('System Monitor')
.first()
.isEnabled()
const settingsBtn = await page.getByTestId('Settings').first().isEnabled()
expect([systemMonitorBtn, settingsBtn].filter((e) => !e).length).toBe(0)
})

View File

@ -0,0 +1,67 @@
import {
expect,
test as base,
_electron as electron,
ElectronApplication,
Page,
} from '@playwright/test'
import {
findLatestBuild,
parseElectronApp,
stubDialog,
} from 'electron-playwright-helpers'
export const TIMEOUT: number = parseInt(process.env.TEST_TIMEOUT || '300000')
export let electronApp: ElectronApplication
export let page: Page
export async function setupElectron() {
process.env.CI = 'e2e'
const latestBuild = findLatestBuild('dist')
expect(latestBuild).toBeTruthy()
// parse the packaged Electron app and find paths and other info
const appInfo = parseElectronApp(latestBuild)
expect(appInfo).toBeTruthy()
electronApp = await electron.launch({
args: [appInfo.main], // main file from package.json
executablePath: appInfo.executable, // path to the Electron executable
})
await stubDialog(electronApp, 'showMessageBox', { response: 1 })
page = await electronApp.firstWindow({
timeout: TIMEOUT,
})
// Return appInfo for future use
return appInfo
}
export async function teardownElectron() {
await page.close()
await electronApp.close()
}
export const test = base.extend<{
attachScreenshotsToReport: void
}>({
attachScreenshotsToReport: [
async ({ request }, use, testInfo) => {
await use()
// After the test, we can check whether the test passed or failed.
if (testInfo.status !== testInfo.expectedStatus) {
const screenshot = await page.screenshot()
await testInfo.attach('screenshot', {
body: screenshot,
contentType: 'image/png',
})
}
},
{ auto: true },
],
})
test.setTimeout(TIMEOUT)

View File

@ -1,40 +0,0 @@
import { _electron as electron } from 'playwright'
import { ElectronApplication, Page, expect, test } from '@playwright/test'
import {
findLatestBuild,
parseElectronApp,
stubDialog,
} from 'electron-playwright-helpers'
let electronApp: ElectronApplication
let page: Page
test.beforeAll(async () => {
process.env.CI = 'e2e'
const latestBuild = findLatestBuild('dist')
expect(latestBuild).toBeTruthy()
// parse the packaged Electron app and find paths and other info
const appInfo = parseElectronApp(latestBuild)
expect(appInfo).toBeTruthy()
electronApp = await electron.launch({
args: [appInfo.main], // main file from package.json
executablePath: appInfo.executable, // path to the Electron executable
})
await stubDialog(electronApp, 'showMessageBox', { response: 1 })
page = await electronApp.firstWindow()
})
test.afterAll(async () => {
await electronApp.close()
await page.close()
})
test('shows settings', async () => {
await page.getByTestId('Settings').first().click()
await page.getByTestId('testid-setting-description').isVisible()
})

View File

@ -1,41 +0,0 @@
import { _electron as electron } from 'playwright'
import { ElectronApplication, Page, expect, test } from '@playwright/test'
import {
findLatestBuild,
parseElectronApp,
stubDialog,
} from 'electron-playwright-helpers'
let electronApp: ElectronApplication
let page: Page
test.beforeAll(async () => {
process.env.CI = 'e2e'
const latestBuild = findLatestBuild('dist')
expect(latestBuild).toBeTruthy()
// parse the packaged Electron app and find paths and other info
const appInfo = parseElectronApp(latestBuild)
expect(appInfo).toBeTruthy()
electronApp = await electron.launch({
args: [appInfo.main], // main file from package.json
executablePath: appInfo.executable, // path to the Electron executable
})
await stubDialog(electronApp, 'showMessageBox', { response: 1 })
page = await electronApp.firstWindow()
})
test.afterAll(async () => {
await electronApp.close()
await page.close()
})
test('shows system monitor', async () => {
await page.getByTestId('System Monitor').first().click()
await page.getByTestId('testid-system-monitor').isVisible()
// More test cases here...
})

View File

@ -3,26 +3,50 @@
"version": "1.0.0", "version": "1.0.0",
"description": "This extension enables assistants, including Jan, a default assistant that can call all downloaded models", "description": "This extension enables assistants, including Jan, a default assistant that can call all downloaded models",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/module.js", "node": "dist/node/index.js",
"author": "Jan <service@jan.ai>", "author": "Jan <service@jan.ai>",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
"build": "tsc -b . && webpack --config webpack.config.js", "build": "tsc --module commonjs && rollup -c rollup.config.ts",
"build:publish": "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 ../../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": "run-script-os"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-replace": "^5.0.5",
"@types/pdf-parse": "^1.1.4",
"cpx": "^1.5.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"webpack": "^5.88.2", "rollup": "^2.38.5",
"webpack-cli": "^5.1.4" "rollup-plugin-define": "^1.0.1",
"rollup-plugin-sourcemaps": "^0.6.3",
"rollup-plugin-typescript2": "^0.36.0",
"typescript": "^5.3.3",
"run-script-os": "^1.1.6"
}, },
"dependencies": { "dependencies": {
"@janhq/core": "file:../../core", "@janhq/core": "file:../../core",
"@langchain/community": "0.0.13",
"hnswlib-node": "^1.4.2",
"langchain": "^0.0.214",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"pdf-parse": "^1.1.1",
"ts-loader": "^9.5.0" "ts-loader": "^9.5.0"
}, },
"files": [ "files": [
"dist/*", "dist/*",
"package.json", "package.json",
"README.md" "README.md"
],
"bundleDependencies": [
"@janhq/core",
"@langchain/community",
"hnswlib-node",
"langchain",
"pdf-parse"
] ]
} }

View File

@ -0,0 +1,81 @@
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import sourceMaps from "rollup-plugin-sourcemaps";
import typescript from "rollup-plugin-typescript2";
import json from "@rollup/plugin-json";
import replace from "@rollup/plugin-replace";
const packageJson = require("./package.json");
const pkg = require("./package.json");
export default [
{
input: `src/index.ts`,
output: [{ file: pkg.main, format: "es", sourcemap: true }],
// Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash')
external: [],
watch: {
include: "src/**",
},
plugins: [
replace({
NODE: JSON.stringify(`${packageJson.name}/${packageJson.node}`),
EXTENSION_NAME: JSON.stringify(packageJson.name),
VERSION: JSON.stringify(packageJson.version),
}),
// Allow json resolution
json(),
// Compile TypeScript files
typescript({ useTsconfigDeclarationDir: true }),
// Compile TypeScript files
// Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs)
commonjs(),
// Allow node_modules resolution, so you can use 'external' to control
// which external modules to include in the bundle
// https://github.com/rollup/rollup-plugin-node-resolve#usage
resolve({
extensions: [".js", ".ts", ".svelte"],
}),
// Resolve source maps to the original source
sourceMaps(),
],
},
{
input: `src/node/index.ts`,
output: [{ dir: "dist/node", format: "cjs", sourcemap: false }],
// Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash')
external: [
"@janhq/core/node",
"@langchain/community",
"langchain",
"langsmith",
"path",
"hnswlib-node",
],
watch: {
include: "src/node/**",
},
// inlineDynamicImports: true,
plugins: [
// Allow json resolution
json(),
// Compile TypeScript files
typescript({ useTsconfigDeclarationDir: true }),
// Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs)
commonjs({
ignoreDynamicRequires: true,
}),
// Allow node_modules resolution, so you can use 'external' to control
// which external modules to include in the bundle
// https://github.com/rollup/rollup-plugin-node-resolve#usage
resolve({
extensions: [".ts", ".js", ".json"],
}),
// Resolve source maps to the original source
// sourceMaps(),
],
},
];

View File

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

View File

@ -1,33 +1,174 @@
import { fs, Assistant } from "@janhq/core"; import {
import { AssistantExtension } from "@janhq/core"; fs,
import { join } from "path"; Assistant,
MessageRequest,
events,
InferenceEngine,
MessageEvent,
InferenceEvent,
joinPath,
executeOnMain,
AssistantExtension,
} from "@janhq/core";
export default class JanAssistantExtension extends AssistantExtension { export default class JanAssistantExtension extends AssistantExtension {
private static readonly _homeDir = "file://assistants"; private static readonly _homeDir = "file://assistants";
controller = new AbortController();
isCancelled = false;
retrievalThreadId: string | undefined = undefined;
async onLoad() { async onLoad() {
// making the assistant directory // making the assistant directory
if (!(await fs.existsSync(JanAssistantExtension._homeDir))) const assistantDirExist = await fs.existsSync(
fs.mkdirSync(JanAssistantExtension._homeDir).then(() => { JanAssistantExtension._homeDir,
);
if (
localStorage.getItem(`${EXTENSION_NAME}-version`) !== VERSION ||
!assistantDirExist
) {
if (!assistantDirExist)
await fs.mkdirSync(JanAssistantExtension._homeDir);
// Write assistant metadata
this.createJanAssistant(); this.createJanAssistant();
// Finished migration
localStorage.setItem(`${EXTENSION_NAME}-version`, VERSION);
}
// Events subscription
events.on(MessageEvent.OnMessageSent, (data: MessageRequest) =>
JanAssistantExtension.handleMessageRequest(data, this),
);
events.on(InferenceEvent.OnInferenceStopped, () => {
JanAssistantExtension.handleInferenceStopped(this);
}); });
} }
private static async handleInferenceStopped(instance: JanAssistantExtension) {
instance.isCancelled = true;
instance.controller?.abort();
}
private static async handleMessageRequest(
data: MessageRequest,
instance: JanAssistantExtension,
) {
instance.isCancelled = false;
instance.controller = new AbortController();
if (
data.model?.engine !== InferenceEngine.tool_retrieval_enabled ||
!data.messages ||
!data.thread?.assistants[0]?.tools
) {
return;
}
const latestMessage = data.messages[data.messages.length - 1];
// Ingest the document if needed
if (
latestMessage &&
latestMessage.content &&
typeof latestMessage.content !== "string"
) {
const docFile = latestMessage.content[1]?.doc_url?.url;
if (docFile) {
await executeOnMain(
NODE,
"toolRetrievalIngestNewDocument",
docFile,
data.model?.proxyEngine,
);
}
}
// Load agent on thread changed
if (instance.retrievalThreadId !== data.threadId) {
await executeOnMain(NODE, "toolRetrievalLoadThreadMemory", data.threadId);
instance.retrievalThreadId = data.threadId;
// Update the text splitter
await executeOnMain(
NODE,
"toolRetrievalUpdateTextSplitter",
data.thread.assistants[0].tools[0]?.settings?.chunk_size ?? 4000,
data.thread.assistants[0].tools[0]?.settings?.chunk_overlap ?? 200,
);
}
if (latestMessage.content) {
const prompt =
typeof latestMessage.content === "string"
? latestMessage.content
: latestMessage.content[0].text;
// Retrieve the result
console.debug("toolRetrievalQuery", latestMessage.content);
const retrievalResult = await executeOnMain(
NODE,
"toolRetrievalQueryResult",
prompt,
);
// Update the message content
// Using the retrieval template with the result and query
if (data.thread?.assistants[0].tools)
data.messages[data.messages.length - 1].content =
data.thread.assistants[0].tools[0].settings?.retrieval_template
?.replace("{CONTEXT}", retrievalResult)
.replace("{QUESTION}", prompt);
}
// Filter out all the messages that are not text
data.messages = data.messages.map((message) => {
if (
message.content &&
typeof message.content !== "string" &&
(message.content.length ?? 0) > 0
) {
return {
...message,
content: [message.content[0]],
};
}
return message;
});
// Reroute the result to inference engine
const output = {
...data,
model: {
...data.model,
engine: data.model.proxyEngine,
},
};
events.emit(MessageEvent.OnMessageSent, output);
}
/** /**
* Called when the extension is unloaded. * Called when the extension is unloaded.
*/ */
onUnload(): void {} onUnload(): void {}
async createAssistant(assistant: Assistant): Promise<void> { async createAssistant(assistant: Assistant): Promise<void> {
const assistantDir = join(JanAssistantExtension._homeDir, assistant.id); const assistantDir = await joinPath([
JanAssistantExtension._homeDir,
assistant.id,
]);
if (!(await fs.existsSync(assistantDir))) await fs.mkdirSync(assistantDir); if (!(await fs.existsSync(assistantDir))) await fs.mkdirSync(assistantDir);
// store the assistant metadata json // store the assistant metadata json
const assistantMetadataPath = join(assistantDir, "assistant.json"); const assistantMetadataPath = await joinPath([
assistantDir,
"assistant.json",
]);
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);
@ -39,14 +180,17 @@ 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 = join(JanAssistantExtension._homeDir, fileName); const filePath = await joinPath([
JanAssistantExtension._homeDir,
fileName,
]);
if (filePath.includes(".DS_Store")) continue; if (filePath.includes(".DS_Store")) continue;
const jsonFiles: string[] = (await fs.readdirSync(filePath)).filter( 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) {
@ -55,8 +199,8 @@ export default class JanAssistantExtension extends AssistantExtension {
} }
const content = await fs.readFileSync( const content = await fs.readFileSync(
join(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);
@ -73,7 +217,10 @@ export default class JanAssistantExtension extends AssistantExtension {
} }
// remove the directory // remove the directory
const assistantDir = join(JanAssistantExtension._homeDir, assistant.id); const assistantDir = await joinPath([
JanAssistantExtension._homeDir,
assistant.id,
]);
await fs.rmdirSync(assistantDir); await fs.rmdirSync(assistantDir);
return Promise.resolve(); return Promise.resolve();
} }
@ -89,7 +236,24 @@ export default class JanAssistantExtension extends AssistantExtension {
description: "A default assistant that can use all downloaded models", description: "A default assistant that can use all downloaded models",
model: "*", model: "*",
instructions: "", instructions: "",
tools: undefined, tools: [
{
type: "retrieval",
enabled: false,
settings: {
top_k: 2,
chunk_size: 1024,
chunk_overlap: 64,
retrieval_template: `Use the following pieces of context to answer the question at the end. If you don't know the answer, just say that you don't know, don't try to make up an answer.
----------------
CONTEXT: {CONTEXT}
----------------
QUESTION: {QUESTION}
----------------
Helpful Answer:`,
},
},
],
file_ids: [], file_ids: [],
metadata: undefined, metadata: undefined,
}; };

View File

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

View File

@ -0,0 +1,39 @@
import { getJanDataFolderPath, normalizeFilePath } from "@janhq/core/node";
import { Retrieval } from "./tools/retrieval";
import path from "path";
const retrieval = new Retrieval();
export async function toolRetrievalUpdateTextSplitter(
chunkSize: number,
chunkOverlap: number,
) {
retrieval.updateTextSplitter(chunkSize, chunkOverlap);
return Promise.resolve();
}
export async function toolRetrievalIngestNewDocument(
file: string,
engine: string,
) {
const filePath = path.join(getJanDataFolderPath(), normalizeFilePath(file));
const threadPath = path.dirname(filePath.replace("files", ""));
retrieval.updateEmbeddingEngine(engine);
await retrieval.ingestAgentKnowledge(filePath, `${threadPath}/memory`);
return Promise.resolve();
}
export async function toolRetrievalLoadThreadMemory(threadId: string) {
try {
await retrieval.loadRetrievalAgent(
path.join(getJanDataFolderPath(), "threads", threadId, "memory"),
);
return Promise.resolve();
} catch (err) {
console.debug(err);
}
}
export async function toolRetrievalQueryResult(query: string) {
const res = await retrieval.generateResult(query);
return Promise.resolve(res);
}

View File

@ -0,0 +1,77 @@
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { formatDocumentsAsString } from "langchain/util/document";
import { PDFLoader } from "langchain/document_loaders/fs/pdf";
import { HNSWLib } from "langchain/vectorstores/hnswlib";
import { OpenAIEmbeddings } from "langchain/embeddings/openai";
import { readEmbeddingEngine } from "../../engine";
export class Retrieval {
public chunkSize: number = 100;
public chunkOverlap?: number = 0;
private retriever: any;
private embeddingModel?: OpenAIEmbeddings = undefined;
private textSplitter?: RecursiveCharacterTextSplitter;
constructor(chunkSize: number = 4000, chunkOverlap: number = 200) {
this.updateTextSplitter(chunkSize, chunkOverlap);
}
public updateTextSplitter(chunkSize: number, chunkOverlap: number): void {
this.chunkSize = chunkSize;
this.chunkOverlap = chunkOverlap;
this.textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: chunkSize,
chunkOverlap: chunkOverlap,
});
}
public updateEmbeddingEngine(engine: string): void {
// Engine settings are not compatible with the current embedding model params
// Switch case manually for now
const settings = readEmbeddingEngine(engine);
if (engine === "nitro") {
this.embeddingModel = new OpenAIEmbeddings(
{ openAIApiKey: "nitro-embedding" },
{ basePath: "http://127.0.0.1:3928/v1" },
);
} else {
// Fallback to OpenAI Settings
this.embeddingModel = new OpenAIEmbeddings({
openAIApiKey: settings.api_key,
});
}
}
public ingestAgentKnowledge = async (
filePath: string,
memoryPath: string,
): Promise<any> => {
const loader = new PDFLoader(filePath, {
splitPages: true,
});
if (!this.embeddingModel) return Promise.reject();
const doc = await loader.load();
const docs = await this.textSplitter!.splitDocuments(doc);
const vectorStore = await HNSWLib.fromDocuments(docs, this.embeddingModel);
return vectorStore.save(memoryPath);
};
public loadRetrievalAgent = async (memoryPath: string): Promise<void> => {
if (!this.embeddingModel) return Promise.reject();
const vectorStore = await HNSWLib.load(memoryPath, this.embeddingModel);
this.retriever = vectorStore.asRetriever(2);
return Promise.resolve();
};
public generateResult = async (query: string): Promise<string> => {
if (!this.retriever) {
return Promise.resolve(" ");
}
const relevantDocs = await this.retriever.getRelevantDocuments(query);
const serializedDoc = formatDocumentsAsString(relevantDocs);
return Promise.resolve(serializedDoc);
};
}

View File

@ -1,14 +1,20 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es2016",
"module": "ES6",
"moduleResolution": "node", "moduleResolution": "node",
"outDir": "./dist", "target": "es5",
"esModuleInterop": true, "module": "ES2020",
"forceConsistentCasingInFileNames": true, "lib": ["es2015", "es2016", "es2017", "dom"],
"strict": false, "strict": true,
"sourceMap": true,
"declaration": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"declarationDir": "dist/types",
"outDir": "dist",
"importHelpers": true,
"typeRoots": ["node_modules/@types"],
"skipLibCheck": true, "skipLibCheck": true,
"rootDir": "./src"
}, },
"include": ["./src"] "include": ["src"],
} }

View File

@ -1,38 +0,0 @@
const path = require("path");
const webpack = require("webpack");
const packageJson = require("./package.json");
module.exports = {
experiments: { outputModule: true },
entry: "./src/index.ts", // Adjust the entry point to match your project's main file
mode: "production",
module: {
rules: [
{
test: /\.tsx?$/,
use: "ts-loader",
exclude: /node_modules/,
},
],
},
output: {
filename: "index.js", // Adjust the output file name as needed
path: path.resolve(__dirname, "dist"),
library: { type: "module" }, // Specify ESM output format
},
plugins: [
new webpack.DefinePlugin({
MODULE: JSON.stringify(`${packageJson.name}/${packageJson.module}`),
}),
],
resolve: {
extensions: [".ts", ".js"],
fallback: {
path: require.resolve("path-browserify"),
},
},
optimization: {
minimize: false,
},
// Add loaders and other configuration as needed for your project
};

View File

@ -4,16 +4,15 @@ import {
ConversationalExtension, ConversationalExtension,
Thread, Thread,
ThreadMessage, ThreadMessage,
events,
} from '@janhq/core' } from '@janhq/core'
/** /**
* JSONConversationalExtension is a ConversationalExtension implementation that provides * JSONConversationalExtension is a ConversationalExtension implementation that provides
* functionality for managing threads. * functionality for managing threads.
*/ */
export default class JSONConversationalExtension export default class JSONConversationalExtension extends ConversationalExtension {
extends ConversationalExtension private static readonly _threadFolder = 'file://threads'
{
private static readonly _homeDir = 'file://threads'
private static readonly _threadInfoFileName = 'thread.json' private static readonly _threadInfoFileName = 'thread.json'
private static readonly _threadMessagesFileName = 'messages.jsonl' private static readonly _threadMessagesFileName = 'messages.jsonl'
@ -21,8 +20,8 @@ export default class JSONConversationalExtension
* Called when the extension is loaded. * Called when the extension is loaded.
*/ */
async onLoad() { async onLoad() {
if (!(await fs.existsSync(JSONConversationalExtension._homeDir))) if (!(await fs.existsSync(JSONConversationalExtension._threadFolder)))
await fs.mkdirSync(JSONConversationalExtension._homeDir) await fs.mkdirSync(JSONConversationalExtension._threadFolder)
console.debug('JSONConversationalExtension loaded') console.debug('JSONConversationalExtension loaded')
} }
@ -69,7 +68,7 @@ export default class JSONConversationalExtension
async saveThread(thread: Thread): Promise<void> { async saveThread(thread: Thread): Promise<void> {
try { try {
const threadDirPath = await joinPath([ const threadDirPath = await joinPath([
JSONConversationalExtension._homeDir, JSONConversationalExtension._threadFolder,
thread.id, thread.id,
]) ])
const threadJsonPath = await joinPath([ const threadJsonPath = await joinPath([
@ -93,7 +92,7 @@ export default class JSONConversationalExtension
*/ */
async deleteThread(threadId: string): Promise<void> { async deleteThread(threadId: string): Promise<void> {
const path = await joinPath([ const path = await joinPath([
JSONConversationalExtension._homeDir, JSONConversationalExtension._threadFolder,
`${threadId}`, `${threadId}`,
]) ])
try { try {
@ -110,7 +109,7 @@ export default class JSONConversationalExtension
async addNewMessage(message: ThreadMessage): Promise<void> { async addNewMessage(message: ThreadMessage): Promise<void> {
try { try {
const threadDirPath = await joinPath([ const threadDirPath = await joinPath([
JSONConversationalExtension._homeDir, JSONConversationalExtension._threadFolder,
message.thread_id, message.thread_id,
]) ])
const threadMessagePath = await joinPath([ const threadMessagePath = await joinPath([
@ -119,6 +118,33 @@ export default class JSONConversationalExtension
]) ])
if (!(await fs.existsSync(threadDirPath))) if (!(await fs.existsSync(threadDirPath)))
await fs.mkdirSync(threadDirPath) await fs.mkdirSync(threadDirPath)
if (message.content[0]?.type === 'image') {
const filesPath = await joinPath([threadDirPath, 'files'])
if (!(await fs.existsSync(filesPath))) await fs.mkdirSync(filesPath)
const imagePath = await joinPath([filesPath, `${message.id}.png`])
const base64 = message.content[0].text.annotations[0]
await this.storeImage(base64, imagePath)
if ((await fs.existsSync(imagePath)) && message.content?.length) {
// Use file path instead of blob
message.content[0].text.annotations[0] = `threads/${message.thread_id}/files/${message.id}.png`
}
}
if (message.content[0]?.type === 'pdf') {
const filesPath = await joinPath([threadDirPath, 'files'])
if (!(await fs.existsSync(filesPath))) await fs.mkdirSync(filesPath)
const filePath = await joinPath([filesPath, `${message.id}.pdf`])
const blob = message.content[0].text.annotations[0]
await this.storeFile(blob, filePath)
if ((await fs.existsSync(filePath)) && message.content?.length) {
// Use file path instead of blob
message.content[0].text.annotations[0] = `threads/${message.thread_id}/files/${message.id}.pdf`
}
}
await fs.appendFileSync(threadMessagePath, JSON.stringify(message) + '\n') await fs.appendFileSync(threadMessagePath, JSON.stringify(message) + '\n')
Promise.resolve() Promise.resolve()
} catch (err) { } catch (err) {
@ -126,13 +152,32 @@ export default class JSONConversationalExtension
} }
} }
async storeImage(base64: string, filePath: string): Promise<void> {
const base64Data = base64.replace(/^data:image\/\w+;base64,/, '')
try {
await fs.writeBlob(filePath, base64Data)
} catch (err) {
console.error(err)
}
}
async storeFile(base64: string, filePath: string): Promise<void> {
const base64Data = base64.replace(/^data:application\/pdf;base64,/, '')
try {
await fs.writeBlob(filePath, base64Data)
} catch (err) {
console.error(err)
}
}
async writeMessages( async writeMessages(
threadId: string, threadId: string,
messages: ThreadMessage[] messages: ThreadMessage[]
): Promise<void> { ): Promise<void> {
try { try {
const threadDirPath = await joinPath([ const threadDirPath = await joinPath([
JSONConversationalExtension._homeDir, JSONConversationalExtension._threadFolder,
threadId, threadId,
]) ])
const threadMessagePath = await joinPath([ const threadMessagePath = await joinPath([
@ -160,7 +205,7 @@ export default class JSONConversationalExtension
private async readThread(threadDirName: string): Promise<any> { private async readThread(threadDirName: string): Promise<any> {
return fs.readFileSync( return fs.readFileSync(
await joinPath([ await joinPath([
JSONConversationalExtension._homeDir, JSONConversationalExtension._threadFolder,
threadDirName, threadDirName,
JSONConversationalExtension._threadInfoFileName, JSONConversationalExtension._threadInfoFileName,
]), ]),
@ -174,14 +219,14 @@ export default class JSONConversationalExtension
*/ */
private async getValidThreadDirs(): Promise<string[]> { private async getValidThreadDirs(): Promise<string[]> {
const fileInsideThread: string[] = await fs.readdirSync( const fileInsideThread: string[] = await fs.readdirSync(
JSONConversationalExtension._homeDir JSONConversationalExtension._threadFolder
) )
const threadDirs: string[] = [] const threadDirs: string[] = []
for (let i = 0; i < fileInsideThread.length; i++) { for (let i = 0; i < fileInsideThread.length; i++) {
if (fileInsideThread[i].includes('.DS_Store')) continue if (fileInsideThread[i].includes('.DS_Store')) continue
const path = await joinPath([ const path = await joinPath([
JSONConversationalExtension._homeDir, JSONConversationalExtension._threadFolder,
fileInsideThread[i], fileInsideThread[i],
]) ])
@ -201,7 +246,7 @@ export default class JSONConversationalExtension
async getAllMessages(threadId: string): Promise<ThreadMessage[]> { async getAllMessages(threadId: string): Promise<ThreadMessage[]> {
try { try {
const threadDirPath = await joinPath([ const threadDirPath = await joinPath([
JSONConversationalExtension._homeDir, JSONConversationalExtension._threadFolder,
threadId, threadId,
]) ])
@ -218,18 +263,17 @@ export default class JSONConversationalExtension
JSONConversationalExtension._threadMessagesFileName, JSONConversationalExtension._threadMessagesFileName,
]) ])
const result = await fs let readResult = await fs.readFileSync(messageFilePath, 'utf-8')
.readFileSync(messageFilePath, 'utf-8')
.then((content) => if (typeof readResult === 'object') {
content readResult = JSON.stringify(readResult)
.toString() }
.split('\n')
.filter((line) => line !== '') const result = readResult.split('\n').filter((line) => line !== '')
)
const messages: ThreadMessage[] = [] const messages: ThreadMessage[] = []
result.forEach((line: string) => { result.forEach((line: string) => {
messages.push(JSON.parse(line) as ThreadMessage) messages.push(JSON.parse(line))
}) })
return messages return messages
} catch (err) { } catch (err) {

View File

@ -1 +1 @@
0.2.12 0.3.5

View File

@ -35,11 +35,12 @@
"rollup-plugin-sourcemaps": "^0.6.3", "rollup-plugin-sourcemaps": "^0.6.3",
"rollup-plugin-typescript2": "^0.36.0", "rollup-plugin-typescript2": "^0.36.0",
"run-script-os": "^1.1.6", "run-script-os": "^1.1.6",
"typescript": "^5.3.3" "typescript": "^5.2.2"
}, },
"dependencies": { "dependencies": {
"@janhq/core": "file:../../core", "@janhq/core": "file:../../core",
"@rollup/plugin-replace": "^5.0.5", "@rollup/plugin-replace": "^5.0.5",
"@types/os-utils": "^0.0.4",
"fetch-retry": "^5.0.6", "fetch-retry": "^5.0.6",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",

View File

@ -27,6 +27,9 @@ export default [
TROUBLESHOOTING_URL: JSON.stringify( TROUBLESHOOTING_URL: JSON.stringify(
"https://jan.ai/guides/troubleshooting" "https://jan.ai/guides/troubleshooting"
), ),
JAN_SERVER_INFERENCE_URL: JSON.stringify(
"http://localhost:1337/v1/chat/completions"
),
}), }),
// Allow json resolution // Allow json resolution
json(), json(),

View File

@ -1,22 +1,7 @@
declare const NODE: string; declare const NODE: string;
declare const INFERENCE_URL: string; declare const INFERENCE_URL: string;
declare const TROUBLESHOOTING_URL: string; declare const TROUBLESHOOTING_URL: string;
declare const JAN_SERVER_INFERENCE_URL: string;
/**
* The parameters for the initModel function.
* @property settings - The settings for the machine learning model.
* @property settings.ctx_len - The context length.
* @property settings.ngl - The number of generated tokens.
* @property settings.cont_batching - Whether to use continuous batching.
* @property settings.embedding - Whether to use embedding.
*/
interface EngineSettings {
ctx_len: number;
ngl: number;
cpu_threads: number;
cont_batching: boolean;
embedding: boolean;
}
/** /**
* The response from the initModel function. * The response from the initModel function.
@ -26,8 +11,3 @@ interface ModelOperationResponse {
error?: any; error?: any;
modelFile?: string; modelFile?: string;
} }
interface ResourcesInfo {
numCpuPhysicalCore: number;
memAvailable: number;
}

View File

@ -6,6 +6,7 @@ import { Observable } from "rxjs";
* @returns An Observable that emits the generated response as a string. * @returns An Observable that emits the generated response as a string.
*/ */
export function requestInference( export function requestInference(
inferenceUrl: string,
recentMessages: any[], recentMessages: any[],
model: Model, model: Model,
controller?: AbortController controller?: AbortController
@ -17,7 +18,7 @@ export function requestInference(
stream: true, stream: true,
...model.parameters, ...model.parameters,
}); });
fetch(INFERENCE_URL, { fetch(inferenceUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

View File

@ -24,6 +24,7 @@ import {
MessageEvent, MessageEvent,
ModelEvent, ModelEvent,
InferenceEvent, InferenceEvent,
ModelSettingParams,
} from "@janhq/core"; } from "@janhq/core";
import { requestInference } from "./helpers/sse"; import { requestInference } from "./helpers/sse";
import { ulid } from "ulid"; import { ulid } from "ulid";
@ -45,12 +46,12 @@ export default class JanInferenceNitroExtension extends InferenceExtension {
private _currentModel: Model | undefined; private _currentModel: Model | undefined;
private _engineSettings: EngineSettings = { private _engineSettings: ModelSettingParams = {
ctx_len: 2048, ctx_len: 2048,
ngl: 100, ngl: 100,
cpu_threads: 1, cpu_threads: 1,
cont_batching: false, cont_batching: false,
embedding: false, embedding: true,
}; };
controller = new AbortController(); controller = new AbortController();
@ -67,15 +68,28 @@ export default class JanInferenceNitroExtension extends InferenceExtension {
*/ */
private nitroProcessInfo: any = undefined; private nitroProcessInfo: any = undefined;
private inferenceUrl = "";
/** /**
* Subscribes to events emitted by the @janhq/core package. * Subscribes to events emitted by the @janhq/core package.
*/ */
async onLoad() { async onLoad() {
if (!(await fs.existsSync(JanInferenceNitroExtension._homeDir))) { if (!(await fs.existsSync(JanInferenceNitroExtension._homeDir))) {
await fs try {
.mkdirSync(JanInferenceNitroExtension._homeDir) await fs.mkdirSync(JanInferenceNitroExtension._homeDir);
.catch((err: Error) => console.debug(err)); } catch (e) {
console.debug(e);
} }
}
// init inference url
// @ts-ignore
const electronApi = window?.electronAPI;
this.inferenceUrl = INFERENCE_URL;
if (!electronApi) {
this.inferenceUrl = JAN_SERVER_INFERENCE_URL;
}
console.debug("Inference url: ", this.inferenceUrl);
if (!(await fs.existsSync(JanInferenceNitroExtension._settingsDir))) if (!(await fs.existsSync(JanInferenceNitroExtension._settingsDir)))
await fs.mkdirSync(JanInferenceNitroExtension._settingsDir); await fs.mkdirSync(JanInferenceNitroExtension._settingsDir);
@ -133,6 +147,7 @@ export default class JanInferenceNitroExtension extends InferenceExtension {
const modelFullPath = await joinPath(["models", model.id]); const modelFullPath = await joinPath(["models", model.id]);
this._currentModel = model;
const nitroInitResult = await executeOnMain(NODE, "runModel", { const nitroInitResult = await executeOnMain(NODE, "runModel", {
modelFullPath, modelFullPath,
model, model,
@ -143,7 +158,6 @@ export default class JanInferenceNitroExtension extends InferenceExtension {
return; return;
} }
this._currentModel = model;
events.emit(ModelEvent.OnModelReady, model); events.emit(ModelEvent.OnModelReady, model);
this.getNitroProcesHealthIntervalId = setInterval( this.getNitroProcesHealthIntervalId = setInterval(
@ -205,7 +219,11 @@ export default class JanInferenceNitroExtension extends InferenceExtension {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
if (!this._currentModel) return Promise.reject("No model loaded"); if (!this._currentModel) return Promise.reject("No model loaded");
requestInference(data.messages ?? [], this._currentModel).subscribe({ requestInference(
this.inferenceUrl,
data.messages ?? [],
this._currentModel
).subscribe({
next: (_content: any) => {}, next: (_content: any) => {},
complete: async () => { complete: async () => {
resolve(message); resolve(message);
@ -250,7 +268,12 @@ export default class JanInferenceNitroExtension extends InferenceExtension {
...(this._currentModel || {}), ...(this._currentModel || {}),
...(data.model || {}), ...(data.model || {}),
}; };
requestInference(data.messages ?? [], model, this.controller).subscribe({ requestInference(
this.inferenceUrl,
data.messages ?? [],
model,
this.controller
).subscribe({
next: (content: any) => { next: (content: any) => {
const messageContent: ThreadContent = { const messageContent: ThreadContent = {
type: ContentType.Text, type: ContentType.Text,

View File

@ -25,12 +25,12 @@ export const executableNitroFile = (): NitroExecutableOptions => {
if (nvidiaInfo["run_mode"] === "cpu") { if (nvidiaInfo["run_mode"] === "cpu") {
binaryFolder = path.join(binaryFolder, "win-cpu"); binaryFolder = path.join(binaryFolder, "win-cpu");
} else { } else {
if (nvidiaInfo["cuda"].version === "12") { if (nvidiaInfo["cuda"].version === "11") {
binaryFolder = path.join(binaryFolder, "win-cuda-12-0");
} else {
binaryFolder = path.join(binaryFolder, "win-cuda-11-7"); binaryFolder = path.join(binaryFolder, "win-cuda-11-7");
} else {
binaryFolder = path.join(binaryFolder, "win-cuda-12-0");
} }
cudaVisibleDevices = nvidiaInfo["gpu_highest_vram"]; cudaVisibleDevices = nvidiaInfo["gpus_in_use"].join(",");
} }
binaryName = "nitro.exe"; binaryName = "nitro.exe";
} else if (process.platform === "darwin") { } else if (process.platform === "darwin") {
@ -50,12 +50,12 @@ export const executableNitroFile = (): NitroExecutableOptions => {
if (nvidiaInfo["run_mode"] === "cpu") { if (nvidiaInfo["run_mode"] === "cpu") {
binaryFolder = path.join(binaryFolder, "linux-cpu"); binaryFolder = path.join(binaryFolder, "linux-cpu");
} else { } else {
if (nvidiaInfo["cuda"].version === "12") { if (nvidiaInfo["cuda"].version === "11") {
binaryFolder = path.join(binaryFolder, "linux-cuda-12-0");
} else {
binaryFolder = path.join(binaryFolder, "linux-cuda-11-7"); binaryFolder = path.join(binaryFolder, "linux-cuda-11-7");
} else {
binaryFolder = path.join(binaryFolder, "linux-cuda-12-0");
} }
cudaVisibleDevices = nvidiaInfo["gpu_highest_vram"]; cudaVisibleDevices = nvidiaInfo["gpus_in_use"].join(",");
} }
} }
return { return {

View File

@ -3,11 +3,19 @@ import path from "path";
import { ChildProcessWithoutNullStreams, spawn } from "child_process"; import { ChildProcessWithoutNullStreams, spawn } from "child_process";
import tcpPortUsed from "tcp-port-used"; import tcpPortUsed from "tcp-port-used";
import fetchRT from "fetch-retry"; import fetchRT from "fetch-retry";
import { log, getJanDataFolderPath } from "@janhq/core/node"; import {
log,
getJanDataFolderPath,
getSystemResourceInfo,
} from "@janhq/core/node";
import { getNitroProcessInfo, updateNvidiaInfo } from "./nvidia"; import { getNitroProcessInfo, updateNvidiaInfo } from "./nvidia";
import { Model, InferenceEngine, ModelSettingParams } from "@janhq/core"; import {
Model,
InferenceEngine,
ModelSettingParams,
PromptTemplate,
} from "@janhq/core";
import { executableNitroFile } from "./execute"; import { executableNitroFile } from "./execute";
import { physicalCpuCount } from "./utils";
// Polyfill fetch with retry // Polyfill fetch with retry
const fetchRetry = fetchRT(fetch); const fetchRetry = fetchRT(fetch);
@ -19,25 +27,6 @@ interface ModelInitOptions {
modelFullPath: string; modelFullPath: string;
model: Model; model: Model;
} }
/**
* The response object of Prompt Template parsing.
*/
interface PromptTemplate {
system_prompt?: string;
ai_prompt?: string;
user_prompt?: string;
error?: string;
}
/**
* Model setting args for Nitro model load.
*/
interface ModelSettingArgs extends ModelSettingParams {
llama_model_path: string;
cpu_threads: number;
}
// The PORT to use for the Nitro subprocess // The PORT to use for the Nitro subprocess
const PORT = 3928; const PORT = 3928;
// The HOST address to use for the Nitro subprocess // The HOST address to use for the Nitro subprocess
@ -60,7 +49,7 @@ let subprocess: ChildProcessWithoutNullStreams | undefined = undefined;
// The current model file url // The current model file url
let currentModelFile: string = ""; let currentModelFile: string = "";
// The current model settings // The current model settings
let currentSettings: ModelSettingArgs | undefined = undefined; let currentSettings: ModelSettingParams | undefined = undefined;
/** /**
* Stops a Nitro subprocess. * Stops a Nitro subprocess.
@ -78,7 +67,7 @@ function stopModel(): Promise<void> {
* TODO: Should pass absolute of the model file instead of just the name - So we can modurize the module.ts to npm package * TODO: Should pass absolute of the model file instead of just the name - So we can modurize the module.ts to npm package
*/ */
async function runModel( async function runModel(
wrapper: ModelInitOptions wrapper: ModelInitOptions,
): Promise<ModelOperationResponse | void> { ): Promise<ModelOperationResponse | void> {
if (wrapper.model.engine !== InferenceEngine.nitro) { if (wrapper.model.engine !== InferenceEngine.nitro) {
// Not a nitro model // Not a nitro model
@ -96,7 +85,7 @@ async function runModel(
const ggufBinFile = files.find( const ggufBinFile = files.find(
(file) => (file) =>
file === path.basename(currentModelFile) || file === path.basename(currentModelFile) ||
file.toLowerCase().includes(SUPPORTED_MODEL_FORMAT) file.toLowerCase().includes(SUPPORTED_MODEL_FORMAT),
); );
if (!ggufBinFile) return Promise.reject("No GGUF model file found"); if (!ggufBinFile) return Promise.reject("No GGUF model file found");
@ -106,7 +95,7 @@ async function runModel(
if (wrapper.model.engine !== InferenceEngine.nitro) { if (wrapper.model.engine !== InferenceEngine.nitro) {
return Promise.reject("Not a nitro model"); return Promise.reject("Not a nitro model");
} else { } else {
const nitroResourceProbe = await getResourcesInfo(); const nitroResourceProbe = await getSystemResourceInfo();
// Convert settings.prompt_template to system_prompt, user_prompt, ai_prompt // Convert settings.prompt_template to system_prompt, user_prompt, ai_prompt
if (wrapper.model.settings.prompt_template) { if (wrapper.model.settings.prompt_template) {
const promptTemplate = wrapper.model.settings.prompt_template; const promptTemplate = wrapper.model.settings.prompt_template;
@ -133,7 +122,6 @@ async function runModel(
mmproj: path.join(modelFolderPath, wrapper.model.settings.mmproj), mmproj: path.join(modelFolderPath, wrapper.model.settings.mmproj),
}), }),
}; };
console.log(currentSettings);
return runNitroAndLoadModel(); return runNitroAndLoadModel();
} }
} }
@ -192,10 +180,10 @@ function promptTemplateConverter(promptTemplate: string): PromptTemplate {
const system_prompt = promptTemplate.substring(0, systemIndex); const system_prompt = promptTemplate.substring(0, systemIndex);
const user_prompt = promptTemplate.substring( const user_prompt = promptTemplate.substring(
systemIndex + systemMarker.length, systemIndex + systemMarker.length,
promptIndex promptIndex,
); );
const ai_prompt = promptTemplate.substring( const ai_prompt = promptTemplate.substring(
promptIndex + promptMarker.length promptIndex + promptMarker.length,
); );
// Return the split parts // Return the split parts
@ -205,7 +193,7 @@ function promptTemplateConverter(promptTemplate: string): PromptTemplate {
const promptIndex = promptTemplate.indexOf(promptMarker); const promptIndex = promptTemplate.indexOf(promptMarker);
const user_prompt = promptTemplate.substring(0, promptIndex); const user_prompt = promptTemplate.substring(0, promptIndex);
const ai_prompt = promptTemplate.substring( const ai_prompt = promptTemplate.substring(
promptIndex + promptMarker.length promptIndex + promptMarker.length,
); );
// Return the split parts // Return the split parts
@ -221,6 +209,9 @@ function promptTemplateConverter(promptTemplate: string): PromptTemplate {
* @returns A Promise that resolves when the model is loaded successfully, or rejects with an error message if the model is not found or fails to load. * @returns A Promise that resolves when the model is loaded successfully, or rejects with an error message if the model is not found or fails to load.
*/ */
function loadLLMModel(settings: any): Promise<Response> { function loadLLMModel(settings: any): Promise<Response> {
if (!settings?.ngl) {
settings.ngl = 100;
}
log(`[NITRO]::Debug: Loading model with params ${JSON.stringify(settings)}`); log(`[NITRO]::Debug: Loading model with params ${JSON.stringify(settings)}`);
return fetchRetry(NITRO_HTTP_LOAD_MODEL_URL, { return fetchRetry(NITRO_HTTP_LOAD_MODEL_URL, {
method: "POST", method: "POST",
@ -234,14 +225,14 @@ function loadLLMModel(settings: any): Promise<Response> {
.then((res) => { .then((res) => {
log( log(
`[NITRO]::Debug: Load model success with response ${JSON.stringify( `[NITRO]::Debug: Load model success with response ${JSON.stringify(
res res,
)}` )}`,
); );
return Promise.resolve(res); return Promise.resolve(res);
}) })
.catch((err) => { .catch((err) => {
log(`[NITRO]::Error: Load model failed with error ${err}`); log(`[NITRO]::Error: Load model failed with error ${err}`);
return Promise.reject(); return Promise.reject(err);
}); });
} }
@ -263,8 +254,8 @@ async function validateModelStatus(): Promise<void> {
retryDelay: 500, retryDelay: 500,
}).then(async (res: Response) => { }).then(async (res: Response) => {
log( log(
`[NITRO]::Debug: Validate model state success with response ${JSON.stringify( `[NITRO]::Debug: Validate model state with response ${JSON.stringify(
res res.status
)}` )}`
); );
// If the response is OK, check model_loaded status. // If the response is OK, check model_loaded status.
@ -273,9 +264,19 @@ async function validateModelStatus(): Promise<void> {
// If the model is loaded, return an empty object. // If the model is loaded, return an empty object.
// Otherwise, return an object with an error message. // Otherwise, return an object with an error message.
if (body.model_loaded) { if (body.model_loaded) {
log(
`[NITRO]::Debug: Validate model state success with response ${JSON.stringify(
body
)}`
);
return Promise.resolve(); return Promise.resolve();
} }
} }
log(
`[NITRO]::Debug: Validate model state failed with response ${JSON.stringify(
res.statusText
)}`
);
return Promise.reject("Validate model status failed"); return Promise.reject("Validate model status failed");
}); });
} }
@ -316,7 +317,7 @@ function spawnNitroProcess(): Promise<any> {
const args: string[] = ["1", LOCAL_HOST, PORT.toString()]; const args: string[] = ["1", LOCAL_HOST, PORT.toString()];
// Execute the binary // Execute the binary
log( log(
`[NITRO]::Debug: Spawn nitro at path: ${executableOptions.executablePath}, and args: ${args}` `[NITRO]::Debug: Spawn nitro at path: ${executableOptions.executablePath}, and args: ${args}`,
); );
subprocess = spawn( subprocess = spawn(
executableOptions.executablePath, executableOptions.executablePath,
@ -327,7 +328,7 @@ function spawnNitroProcess(): Promise<any> {
...process.env, ...process.env,
CUDA_VISIBLE_DEVICES: executableOptions.cudaVisibleDevices, CUDA_VISIBLE_DEVICES: executableOptions.cudaVisibleDevices,
}, },
} },
); );
// Handle subprocess output // Handle subprocess output
@ -352,22 +353,6 @@ function spawnNitroProcess(): Promise<any> {
}); });
} }
/**
* Get the system resources information
* TODO: Move to Core so that it can be reused
*/
function getResourcesInfo(): Promise<ResourcesInfo> {
return new Promise(async (resolve) => {
const cpu = await physicalCpuCount();
log(`[NITRO]::CPU informations - ${cpu}`);
const response: ResourcesInfo = {
numCpuPhysicalCore: cpu,
memAvailable: 0,
};
resolve(response);
});
}
/** /**
* Every module should have a dispose function * Every module should have a dispose function
* This will be called when the extension is unloaded and should clean up any resources * This will be called when the extension is unloaded and should clean up any resources

View File

@ -19,6 +19,8 @@ const DEFALT_SETTINGS = {
}, },
gpus: [], gpus: [],
gpu_highest_vram: "", gpu_highest_vram: "",
gpus_in_use: [],
is_initial: true,
}; };
/** /**
@ -48,11 +50,15 @@ export interface NitroProcessInfo {
*/ */
export async function updateNvidiaInfo() { export async function updateNvidiaInfo() {
if (process.platform !== "darwin") { if (process.platform !== "darwin") {
await Promise.all([ let data;
updateNvidiaDriverInfo(), try {
updateCudaExistence(), data = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf-8"));
updateGpuInfo(), } catch (error) {
]); data = DEFALT_SETTINGS;
writeFileSync(NVIDIA_INFO_FILE, JSON.stringify(data, null, 2));
}
updateNvidiaDriverInfo();
updateGpuInfo();
} }
} }
@ -73,12 +79,7 @@ export async function updateNvidiaDriverInfo(): Promise<void> {
exec( exec(
"nvidia-smi --query-gpu=driver_version --format=csv,noheader", "nvidia-smi --query-gpu=driver_version --format=csv,noheader",
(error, stdout) => { (error, stdout) => {
let data; let data = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf-8"));
try {
data = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf-8"));
} catch (error) {
data = DEFALT_SETTINGS;
}
if (!error) { if (!error) {
const firstLine = stdout.split("\n")[0].trim(); const firstLine = stdout.split("\n")[0].trim();
@ -107,7 +108,7 @@ export function checkFileExistenceInPaths(
/** /**
* Validate cuda for linux and windows * Validate cuda for linux and windows
*/ */
export function updateCudaExistence() { export function updateCudaExistence(data: Record<string, any> = DEFALT_SETTINGS): Record<string, any> {
let filesCuda12: string[]; let filesCuda12: string[];
let filesCuda11: string[]; let filesCuda11: string[];
let paths: string[]; let paths: string[];
@ -141,19 +142,14 @@ export function updateCudaExistence() {
cudaVersion = "12"; cudaVersion = "12";
} }
let data;
try {
data = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf-8"));
} catch (error) {
data = DEFALT_SETTINGS;
}
data["cuda"].exist = cudaExists; data["cuda"].exist = cudaExists;
data["cuda"].version = cudaVersion; data["cuda"].version = cudaVersion;
if (cudaExists) { console.log(data["is_initial"], data["gpus_in_use"]);
if (cudaExists && data["is_initial"] && data["gpus_in_use"].length > 0) {
data.run_mode = "gpu"; data.run_mode = "gpu";
} }
writeFileSync(NVIDIA_INFO_FILE, JSON.stringify(data, null, 2)); data.is_initial = false;
return data;
} }
/** /**
@ -161,14 +157,9 @@ export function updateCudaExistence() {
*/ */
export async function updateGpuInfo(): Promise<void> { export async function updateGpuInfo(): Promise<void> {
exec( exec(
"nvidia-smi --query-gpu=index,memory.total --format=csv,noheader,nounits", "nvidia-smi --query-gpu=index,memory.total,name --format=csv,noheader,nounits",
(error, stdout) => { (error, stdout) => {
let data; let data = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf-8"));
try {
data = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf-8"));
} catch (error) {
data = DEFALT_SETTINGS;
}
if (!error) { if (!error) {
// Get GPU info and gpu has higher memory first // Get GPU info and gpu has higher memory first
@ -178,21 +169,27 @@ export async function updateGpuInfo(): Promise<void> {
.trim() .trim()
.split("\n") .split("\n")
.map((line) => { .map((line) => {
let [id, vram] = line.split(", "); let [id, vram, name] = line.split(", ");
vram = vram.replace(/\r/g, ""); vram = vram.replace(/\r/g, "");
if (parseFloat(vram) > highestVram) { if (parseFloat(vram) > highestVram) {
highestVram = parseFloat(vram); highestVram = parseFloat(vram);
highestVramId = id; highestVramId = id;
} }
return { id, vram }; return { id, vram, name };
}); });
data["gpus"] = gpus; data.gpus = gpus;
data["gpu_highest_vram"] = highestVramId; data.gpu_highest_vram = highestVramId;
} else { } else {
data["gpus"] = []; data.gpus = [];
data.gpu_highest_vram = "";
} }
if (!data["gpus_in_use"] || data["gpus_in_use"].length === 0) {
data.gpus_in_use = [data["gpu_highest_vram"]];
}
data = updateCudaExistence(data);
writeFileSync(NVIDIA_INFO_FILE, JSON.stringify(data, null, 2)); writeFileSync(NVIDIA_INFO_FILE, JSON.stringify(data, null, 2));
Promise.resolve(); Promise.resolve();
} }

View File

@ -1,56 +0,0 @@
import os from "os";
import childProcess from "child_process";
function exec(command: string): Promise<string> {
return new Promise((resolve, reject) => {
childProcess.exec(command, { encoding: "utf8" }, (error, stdout) => {
if (error) {
reject(error);
} else {
resolve(stdout);
}
});
});
}
let amount: number;
const platform = os.platform();
export async function physicalCpuCount(): Promise<number> {
return new Promise((resolve, reject) => {
if (platform === "linux") {
exec('lscpu -p | egrep -v "^#" | sort -u -t, -k 2,4 | wc -l')
.then((output) => {
amount = parseInt(output.trim(), 10);
resolve(amount);
})
.catch(reject);
} else if (platform === "darwin") {
exec("sysctl -n hw.physicalcpu_max")
.then((output) => {
amount = parseInt(output.trim(), 10);
resolve(amount);
})
.catch(reject);
} else if (platform === "win32") {
exec("WMIC CPU Get NumberOfCores")
.then((output) => {
amount = output
.split(os.EOL)
.map((line: string) => parseInt(line))
.filter((value: number) => !isNaN(value))
.reduce((sum: number, number: number) => sum + number, 1);
resolve(amount);
})
.catch(reject);
} else {
const cores = os.cpus().filter((cpu: any, index: number) => {
const hasHyperthreading = cpu.model.includes("Intel");
const isOdd = index % 2 === 1;
return !hasHyperthreading || isOdd;
});
amount = cores.length;
resolve(amount);
}
});
}

View File

@ -15,10 +15,13 @@ import {
ThreadMessage, ThreadMessage,
events, events,
fs, fs,
InferenceEngine,
BaseExtension, BaseExtension,
MessageEvent, MessageEvent,
ModelEvent, ModelEvent,
InferenceEvent, InferenceEvent,
AppConfigurationEventName,
joinPath,
} from "@janhq/core"; } from "@janhq/core";
import { requestInference } from "./helpers/sse"; import { requestInference } from "./helpers/sse";
import { ulid } from "ulid"; import { ulid } from "ulid";
@ -30,7 +33,7 @@ import { join } from "path";
* It also subscribes to events emitted by the @janhq/core package and handles new message requests. * It also subscribes to events emitted by the @janhq/core package and handles new message requests.
*/ */
export default class JanInferenceOpenAIExtension extends BaseExtension { export default class JanInferenceOpenAIExtension extends BaseExtension {
private static readonly _homeDir = "file://engines"; private static readonly _engineDir = "file://engines";
private static readonly _engineMetadataFileName = "openai.json"; private static readonly _engineMetadataFileName = "openai.json";
private static _currentModel: OpenAIModel; private static _currentModel: OpenAIModel;
@ -47,9 +50,9 @@ export default class JanInferenceOpenAIExtension extends BaseExtension {
* Subscribes to events emitted by the @janhq/core package. * Subscribes to events emitted by the @janhq/core package.
*/ */
async onLoad() { async onLoad() {
if (!(await fs.existsSync(JanInferenceOpenAIExtension._homeDir))) { if (!(await fs.existsSync(JanInferenceOpenAIExtension._engineDir))) {
await fs await fs
.mkdirSync(JanInferenceOpenAIExtension._homeDir) .mkdirSync(JanInferenceOpenAIExtension._engineDir)
.catch((err) => console.debug(err)); .catch((err) => console.debug(err));
} }
@ -57,7 +60,7 @@ export default class JanInferenceOpenAIExtension extends BaseExtension {
// Events subscription // Events subscription
events.on(MessageEvent.OnMessageSent, (data) => events.on(MessageEvent.OnMessageSent, (data) =>
JanInferenceOpenAIExtension.handleMessageRequest(data, this) JanInferenceOpenAIExtension.handleMessageRequest(data, this),
); );
events.on(ModelEvent.OnModelInit, (model: OpenAIModel) => { events.on(ModelEvent.OnModelInit, (model: OpenAIModel) => {
@ -70,6 +73,20 @@ export default class JanInferenceOpenAIExtension extends BaseExtension {
events.on(InferenceEvent.OnInferenceStopped, () => { events.on(InferenceEvent.OnInferenceStopped, () => {
JanInferenceOpenAIExtension.handleInferenceStopped(this); JanInferenceOpenAIExtension.handleInferenceStopped(this);
}); });
const settingsFilePath = await joinPath([
JanInferenceOpenAIExtension._engineDir,
JanInferenceOpenAIExtension._engineMetadataFileName,
]);
events.on(
AppConfigurationEventName.OnConfigurationUpdate,
(settingsKey: string) => {
// Update settings on changes
if (settingsKey === settingsFilePath)
JanInferenceOpenAIExtension.writeDefaultEngineSettings();
},
);
} }
/** /**
@ -80,8 +97,8 @@ export default class JanInferenceOpenAIExtension extends BaseExtension {
static async writeDefaultEngineSettings() { static async writeDefaultEngineSettings() {
try { try {
const engineFile = join( const engineFile = join(
JanInferenceOpenAIExtension._homeDir, JanInferenceOpenAIExtension._engineDir,
JanInferenceOpenAIExtension._engineMetadataFileName JanInferenceOpenAIExtension._engineMetadataFileName,
); );
if (await fs.existsSync(engineFile)) { if (await fs.existsSync(engineFile)) {
const engine = await fs.readFileSync(engineFile, "utf-8"); const engine = await fs.readFileSync(engineFile, "utf-8");
@ -90,7 +107,7 @@ export default class JanInferenceOpenAIExtension extends BaseExtension {
} else { } else {
await fs.writeFileSync( await fs.writeFileSync(
engineFile, engineFile,
JSON.stringify(JanInferenceOpenAIExtension._engineSettings, null, 2) JSON.stringify(JanInferenceOpenAIExtension._engineSettings, null, 2),
); );
} }
} catch (err) { } catch (err) {
@ -98,7 +115,7 @@ export default class JanInferenceOpenAIExtension extends BaseExtension {
} }
} }
private static async handleModelInit(model: OpenAIModel) { private static async handleModelInit(model: OpenAIModel) {
if (model.engine !== "openai") { if (model.engine !== InferenceEngine.openai) {
return; return;
} else { } else {
JanInferenceOpenAIExtension._currentModel = model; JanInferenceOpenAIExtension._currentModel = model;
@ -116,7 +133,7 @@ export default class JanInferenceOpenAIExtension extends BaseExtension {
} }
private static async handleInferenceStopped( private static async handleInferenceStopped(
instance: JanInferenceOpenAIExtension instance: JanInferenceOpenAIExtension,
) { ) {
instance.isCancelled = true; instance.isCancelled = true;
instance.controller?.abort(); instance.controller?.abort();
@ -130,7 +147,7 @@ export default class JanInferenceOpenAIExtension extends BaseExtension {
*/ */
private static async handleMessageRequest( private static async handleMessageRequest(
data: MessageRequest, data: MessageRequest,
instance: JanInferenceOpenAIExtension instance: JanInferenceOpenAIExtension,
) { ) {
if (data.model.engine !== "openai") { if (data.model.engine !== "openai") {
return; return;
@ -160,7 +177,7 @@ export default class JanInferenceOpenAIExtension extends BaseExtension {
...JanInferenceOpenAIExtension._currentModel, ...JanInferenceOpenAIExtension._currentModel,
parameters: data.model.parameters, parameters: data.model.parameters,
}, },
instance.controller instance.controller,
).subscribe({ ).subscribe({
next: (content) => { next: (content) => {
const messageContent: ThreadContent = { const messageContent: ThreadContent = {
@ -181,7 +198,7 @@ export default class JanInferenceOpenAIExtension extends BaseExtension {
}, },
error: async (err) => { error: async (err) => {
if (instance.isCancelled || message.content.length > 0) { if (instance.isCancelled || message.content.length > 0) {
message.status = MessageStatus.Error; message.status = MessageStatus.Stopped;
events.emit(MessageEvent.OnMessageUpdate, message); events.emit(MessageEvent.OnMessageUpdate, message);
return; return;
} }
@ -193,7 +210,7 @@ export default class JanInferenceOpenAIExtension extends BaseExtension {
}, },
}; };
message.content = [messageContent]; message.content = [messageContent];
message.status = MessageStatus.Ready; message.status = MessageStatus.Error;
events.emit(MessageEvent.OnMessageUpdate, message); events.emit(MessageEvent.OnMessageUpdate, message);
}, },
}); });

View File

@ -3,13 +3,12 @@
"target": "es2016", "target": "es2016",
"module": "ES6", "module": "ES6",
"moduleResolution": "node", "moduleResolution": "node",
"outDir": "./dist", "outDir": "./dist",
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"strict": false, "strict": false,
"skipLibCheck": true, "skipLibCheck": true,
"rootDir": "./src" "rootDir": "./src",
}, },
"include": ["./src"] "include": ["./src"],
} }

View File

@ -3,13 +3,12 @@
"target": "es2016", "target": "es2016",
"module": "ES6", "module": "ES6",
"moduleResolution": "node", "moduleResolution": "node",
"outDir": "./dist", "outDir": "./dist",
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"strict": false, "strict": false,
"skipLibCheck": true, "skipLibCheck": true,
"rootDir": "./src" "rootDir": "./src",
}, },
"include": ["./src"] "include": ["./src"],
} }

View File

@ -286,6 +286,7 @@ export default class JanModelExtension extends ModelExtension {
* model.json file associated with it. * model.json file associated with it.
* *
* This function will create a model.json file for the model. * This function will create a model.json file for the model.
* It works only with single binary file model.
* *
* @param dirName the director which reside in ~/jan/models but does not have model.json file. * @param dirName the director which reside in ~/jan/models but does not have model.json file.
*/ */
@ -302,9 +303,7 @@ export default class JanModelExtension extends ModelExtension {
let binaryFileSize: number | undefined = undefined let binaryFileSize: number | undefined = undefined
for (const file of files) { for (const file of files) {
if (file.endsWith(JanModelExtension._incompletedModelFileName)) continue if (file.endsWith(JanModelExtension._supportedModelFormat)) {
if (file.endsWith('.json')) continue
const path = await joinPath([JanModelExtension._homeDir, dirName, file]) const path = await joinPath([JanModelExtension._homeDir, dirName, file])
const fileStats = await fs.fileStat(path) const fileStats = await fs.fileStat(path)
if (fileStats.isDirectory) continue if (fileStats.isDirectory) continue
@ -312,13 +311,14 @@ export default class JanModelExtension extends ModelExtension {
binaryFileName = file binaryFileName = file
break break
} }
}
if (!binaryFileName) { if (!binaryFileName) {
console.warn(`Unable to find binary file for model ${dirName}`) console.warn(`Unable to find binary file for model ${dirName}`)
return return
} }
const defaultModel = await this.getDefaultModel() 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
@ -326,8 +326,19 @@ export default class JanModelExtension extends ModelExtension {
const model: Model = { const model: Model = {
...defaultModel, ...defaultModel,
// Overwrite default N/A fields
id: dirName, id: dirName,
name: dirName, name: dirName,
sources: [
{
url: binaryFileName,
filename: binaryFileName,
},
],
settings: {
...defaultModel.settings,
llama_model_path: binaryFileName,
},
created: Date.now(), created: Date.now(),
description: `${dirName} - user self import model`, description: `${dirName} - user self import model`,
metadata: { metadata: {

View File

@ -1,6 +1,6 @@
{ {
"name": "@janhq/monitoring-extension", "name": "@janhq/monitoring-extension",
"version": "1.0.9", "version": "1.0.10",
"description": "This extension provides system health and OS level data", "description": "This extension provides system health and OS level data",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/module.js", "module": "dist/module.js",
@ -26,6 +26,7 @@
"README.md" "README.md"
], ],
"bundleDependencies": [ "bundleDependencies": [
"node-os-utils" "node-os-utils",
"@janhq/core"
] ]
} }

View File

@ -1,5 +1,4 @@
import { MonitoringExtension } from "@janhq/core"; import { MonitoringExtension, executeOnMain } from "@janhq/core";
import { executeOnMain } from "@janhq/core";
/** /**
* JanMonitoringExtension is a extension that provides system monitoring functionality. * JanMonitoringExtension is a extension that provides system monitoring functionality.

View File

@ -1,4 +1,14 @@
const nodeOsUtils = require("node-os-utils"); const nodeOsUtils = require("node-os-utils");
const getJanDataFolderPath = require("@janhq/core/node").getJanDataFolderPath;
const path = require("path");
const { readFileSync } = require("fs");
const exec = require("child_process").exec;
const NVIDIA_INFO_FILE = path.join(
getJanDataFolderPath(),
"settings",
"settings.json"
);
const getResourcesInfo = () => const getResourcesInfo = () =>
new Promise((resolve) => { new Promise((resolve) => {
@ -16,14 +26,44 @@ const getResourcesInfo = () =>
}); });
const getCurrentLoad = () => const getCurrentLoad = () =>
new Promise((resolve) => { new Promise((resolve, reject) => {
nodeOsUtils.cpu.usage().then((cpuPercentage) => { nodeOsUtils.cpu.usage().then((cpuPercentage) => {
const response = { let data = {
cpu: { run_mode: "cpu",
usage: cpuPercentage, gpus_in_use: [],
},
}; };
resolve(response); if (process.platform !== "darwin") {
data = JSON.parse(readFileSync(NVIDIA_INFO_FILE, "utf-8"));
}
if (data.run_mode === "gpu" && data.gpus_in_use.length > 0) {
const gpuIds = data["gpus_in_use"].join(",");
if (gpuIds !== "") {
exec(
`nvidia-smi --query-gpu=index,name,temperature.gpu,utilization.gpu,memory.total,memory.free,utilization.memory --format=csv,noheader,nounits --id=${gpuIds}`,
(error, stdout, stderr) => {
if (error) {
console.error(`exec error: ${error}`);
reject(error);
return;
}
const gpuInfo = stdout.trim().split("\n").map((line) => {
const [id, name, temperature, utilization, memoryTotal, memoryFree, memoryUtilization] = line.split(", ").map(item => item.replace(/\r/g, ""));
return { id, name, temperature, utilization, memoryTotal, memoryFree, memoryUtilization };
});
resolve({
cpu: { usage: cpuPercentage },
gpu: gpuInfo
});
}
);
} else {
// Handle the case where gpuIds is empty
resolve({ cpu: { usage: cpuPercentage }, gpu: [] });
}
} else {
// Handle the case where run_mode is not 'gpu' or no GPUs are in use
resolve({ cpu: { usage: cpuPercentage }, gpu: [] });
}
}); });
}); });

View File

@ -26,6 +26,8 @@
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"fastify": "^4.24.3", "fastify": "^4.24.3",
"request": "^2.88.2", "request": "^2.88.2",
"fetch-retry": "^5.0.6",
"tcp-port-used": "^1.0.2",
"request-progress": "^3.0.0" "request-progress": "^3.0.0"
}, },
"devDependencies": { "devDependencies": {
@ -35,6 +37,7 @@
"@typescript-eslint/parser": "^6.7.3", "@typescript-eslint/parser": "^6.7.3",
"eslint-plugin-react": "^7.33.2", "eslint-plugin-react": "^7.33.2",
"run-script-os": "^1.1.6", "run-script-os": "^1.1.6",
"@types/tcp-port-used": "^1.0.4",
"typescript": "^5.2.2" "typescript": "^5.2.2"
} }
} }

View File

@ -18,6 +18,7 @@
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-context": "^1.0.1", "@radix-ui/react-context": "^1.0.1",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",

View File

@ -9,7 +9,7 @@
} }
&-secondary-blue { &-secondary-blue {
@apply bg-blue-200 text-blue-600 hover:bg-blue-500/50; @apply bg-blue-200 text-blue-600 hover:bg-blue-300/50 dark:hover:bg-blue-200/80;
} }
&-danger { &-danger {
@ -17,7 +17,7 @@
} }
&-secondary-danger { &-secondary-danger {
@apply bg-red-200 text-red-600 hover:bg-red-500/50; @apply bg-red-200 text-red-600 hover:bg-red-300/50 dark:hover:bg-red-200/80;
} }
&-outline { &-outline {
@ -67,14 +67,18 @@
[type='submit'] { [type='submit'] {
&.btn-primary { &.btn-primary {
@apply bg-primary hover:bg-primary/90; @apply bg-primary hover:bg-primary/90;
@apply disabled:pointer-events-none disabled:bg-zinc-100 disabled:text-zinc-400;
} }
&.btn-secondary { &.btn-secondary {
@apply bg-secondary hover:bg-secondary/80; @apply bg-secondary hover:bg-secondary/80;
@apply disabled:pointer-events-none disabled:bg-zinc-100 disabled:text-zinc-400;
} }
&.btn-secondary-blue { &.btn-secondary-blue {
@apply bg-blue-200 text-blue-900 hover:bg-blue-200/80; @apply bg-blue-200 text-blue-900 hover:bg-blue-200/80;
@apply disabled:pointer-events-none disabled:bg-zinc-100 disabled:text-zinc-400;
} }
&.btn-danger { &.btn-danger {
@apply bg-danger hover:bg-danger/90; @apply bg-danger hover:bg-danger/90;
@apply disabled:pointer-events-none disabled:bg-zinc-100 disabled:text-zinc-400;
} }
} }

View File

@ -0,0 +1,29 @@
'use client'
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { CheckIcon } from '@radix-ui/react-icons'
import { twMerge } from 'tailwind-merge'
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={twMerge('checkbox', className)}
{...props}
>
<CheckboxPrimitive.Indicator
className={twMerge(
'flex flex-shrink-0 items-center justify-center text-current'
)}
>
<CheckIcon className="checkbox--icon" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@ -0,0 +1,7 @@
.checkbox {
@apply border-border data-[state=checked]:bg-primary h-5 w-5 flex-shrink-0 rounded-md border data-[state=checked]:text-white;
&--icon {
@apply h-4 w-4;
}
}

View File

@ -12,3 +12,4 @@ export * from './command'
export * from './textarea' export * from './textarea'
export * from './select' export * from './select'
export * from './slider' export * from './slider'
export * from './checkbox'

View File

@ -1,6 +1,6 @@
.input { .input {
@apply border-border placeholder:text-muted-foreground flex h-9 w-full rounded-lg border bg-transparent px-3 py-1 transition-colors; @apply border-border placeholder:text-muted-foreground flex h-9 w-full rounded-lg border bg-transparent px-3 py-1 transition-colors;
@apply disabled:cursor-not-allowed disabled:bg-zinc-100; @apply disabled:cursor-not-allowed disabled:bg-zinc-100 disabled:dark:bg-zinc-800 disabled:dark:text-zinc-600;
@apply focus-within:outline-none focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1; @apply focus-within:outline-none focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1;
@apply file:border-0 file:bg-transparent file:font-medium; @apply file:border-0 file:bg-transparent file:font-medium;
} }

View File

@ -16,6 +16,7 @@
@import './textarea/styles.scss'; @import './textarea/styles.scss';
@import './select/styles.scss'; @import './select/styles.scss';
@import './slider/styles.scss'; @import './slider/styles.scss';
@import './checkbox/styles.scss';
.animate-spin { .animate-spin {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;

View File

@ -1,5 +1,6 @@
.select { .select {
@apply placeholder:text-muted-foreground border-border flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1; @apply placeholder:text-muted-foreground border-border flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm disabled:cursor-not-allowed [&>span]:line-clamp-1;
@apply disabled:cursor-not-allowed disabled:bg-zinc-100 disabled:dark:bg-zinc-800 disabled:dark:text-zinc-600;
@apply focus-within:outline-none focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1; @apply focus-within:outline-none focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1;
&-caret { &-caret {

View File

@ -13,10 +13,13 @@ import { useClickOutside } from '@/hooks/useClickOutside'
import { usePath } from '@/hooks/usePath' import { usePath } from '@/hooks/usePath'
import { openFileTitle } from '@/utils/titleUtils'
import { activeThreadAtom } from '@/helpers/atoms/Thread.atom' import { activeThreadAtom } from '@/helpers/atoms/Thread.atom'
interface Props { interface Props {
children: ReactNode children: ReactNode
rightAction?: ReactNode
title: string title: string
asChild?: boolean asChild?: boolean
hideMoreVerticalAction?: boolean hideMoreVerticalAction?: boolean
@ -25,6 +28,7 @@ export default function CardSidebar({
children, children,
title, title,
asChild, asChild,
rightAction,
hideMoreVerticalAction, hideMoreVerticalAction,
}: Props) { }: Props) {
const [show, setShow] = useState(true) const [show, setShow] = useState(true)
@ -36,13 +40,6 @@ export default function CardSidebar({
useClickOutside(() => setMore(false), null, [menu, toggle]) useClickOutside(() => setMore(false), null, [menu, toggle])
let openFolderTitle: string = 'Open Containing Folder'
if (isMac) {
openFolderTitle = 'Show in Finder'
} else if (isWindows) {
openFolderTitle = 'Show in File Explorer'
}
return ( return (
<div <div
className={twMerge( className={twMerge(
@ -53,27 +50,16 @@ export default function CardSidebar({
<div <div
className={twMerge( className={twMerge(
'relative flex items-center justify-between pl-4', 'relative flex items-center justify-between pl-4',
show && 'border-b border-border' show && children && 'border-b border-border'
)} )}
> >
<span className="font-bold">{title}</span> <div className="flex items-center ">
<div className="flex">
{!asChild && (
<>
{!hideMoreVerticalAction && (
<div
ref={setToggle}
className="cursor-pointer rounded-lg bg-zinc-100 p-2 pr-0 dark:bg-zinc-900"
onClick={() => setMore(!more)}
>
<MoreVerticalIcon className="h-5 w-5" />
</div>
)}
</>
)}
<button <button
onClick={() => setShow(!show)} onClick={() => {
className="flex w-full flex-1 items-center space-x-2 rounded-lg bg-zinc-100 px-3 py-2 dark:bg-zinc-900" if (!children) return
setShow(!show)
}}
className="flex w-full flex-1 items-center space-x-2 rounded-lg bg-zinc-100 py-2 pr-2 dark:bg-zinc-900"
> >
<ChevronDownIcon <ChevronDownIcon
className={twMerge( className={twMerge(
@ -82,6 +68,23 @@ export default function CardSidebar({
)} )}
/> />
</button> </button>
<span className="font-bold">{title}</span>
</div>
<div className="flex">
{rightAction && rightAction}
{!asChild && (
<>
{!hideMoreVerticalAction && (
<div
ref={setToggle}
className="cursor-pointer rounded-lg bg-zinc-100 p-2 px-3 dark:bg-zinc-900"
onClick={() => setMore(!more)}
>
<MoreVerticalIcon className="h-5 w-5" />
</div>
)}
</>
)}
</div> </div>
{more && ( {more && (
@ -110,7 +113,7 @@ export default function CardSidebar({
{title === 'Model' ? ( {title === 'Model' ? (
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium text-black dark:text-muted-foreground"> <span className="font-medium text-black dark:text-muted-foreground">
{openFolderTitle} {openFileTitle()}
</span> </span>
<span className="mt-1 text-muted-foreground"> <span className="mt-1 text-muted-foreground">
Opens thread.json. Changes affect this thread only. Opens thread.json. Changes affect this thread only.
@ -118,7 +121,7 @@ export default function CardSidebar({
</div> </div>
) : ( ) : (
<span className="text-bold text-black dark:text-muted-foreground"> <span className="text-bold text-black dark:text-muted-foreground">
Show in Finder {openFileTitle()}
</span> </span>
)} )}
</> </>

View File

@ -9,54 +9,26 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@janhq/uikit' } from '@janhq/uikit'
import { useAtomValue, useSetAtom } from 'jotai'
import { InfoIcon } from 'lucide-react' import { InfoIcon } from 'lucide-react'
import { useActiveModel } from '@/hooks/useActiveModel'
import useUpdateModelParameters from '@/hooks/useUpdateModelParameters'
import { getConfigurationsData } from '@/utils/componentSettings'
import { toSettingParams } from '@/utils/modelParam'
import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom'
import {
engineParamsUpdateAtom,
getActiveThreadIdAtom,
getActiveThreadModelParamsAtom,
} from '@/helpers/atoms/Thread.atom'
type Props = { type Props = {
name: string name: string
title: string title: string
enabled?: boolean
description: string description: string
checked: boolean checked: boolean
onValueChanged?: (e: string | number | boolean) => void
} }
const Checkbox: React.FC<Props> = ({ name, title, checked, description }) => { const Checkbox: React.FC<Props> = ({
const { updateModelParameter } = useUpdateModelParameters() title,
const threadId = useAtomValue(getActiveThreadIdAtom) checked,
enabled = true,
const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom) description,
onValueChanged,
const modelSettingParams = toSettingParams(activeModelParams) }) => {
const engineParams = getConfigurationsData(modelSettingParams)
const setEngineParamsUpdate = useSetAtom(engineParamsUpdateAtom)
const serverEnabled = useAtomValue(serverEnabledAtom)
const { stopModel } = useActiveModel()
const onCheckedChange = (checked: boolean) => { const onCheckedChange = (checked: boolean) => {
if (!threadId) return onValueChanged?.(checked)
if (engineParams.some((x) => x.name.includes(name))) {
setEngineParamsUpdate(true)
stopModel()
} else {
setEngineParamsUpdate(false)
}
updateModelParameter(threadId, name, checked)
} }
return ( return (
@ -80,7 +52,7 @@ const Checkbox: React.FC<Props> = ({ name, title, checked, description }) => {
<Switch <Switch
checked={checked} checked={checked}
onCheckedChange={onCheckedChange} onCheckedChange={onCheckedChange}
disabled={serverEnabled} disabled={!enabled}
/> />
</div> </div>
) )

View File

@ -26,6 +26,8 @@ import { useMainViewState } from '@/hooks/useMainViewState'
import useRecommendedModel from '@/hooks/useRecommendedModel' import useRecommendedModel from '@/hooks/useRecommendedModel'
import useUpdateModelParameters from '@/hooks/useUpdateModelParameters'
import { toGibibytes } from '@/utils/converter' import { toGibibytes } from '@/utils/converter'
import ModelLabel from '../ModelLabel' import ModelLabel from '../ModelLabel'
@ -34,68 +36,40 @@ import OpenAiKeyInput from '../OpenAiKeyInput'
import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom'
import { import {
ModelParams,
activeThreadAtom, activeThreadAtom,
getActiveThreadIdAtom,
setThreadModelParamsAtom, setThreadModelParamsAtom,
threadStatesAtom,
} from '@/helpers/atoms/Thread.atom' } from '@/helpers/atoms/Thread.atom'
export const selectedModelAtom = atom<Model | undefined>(undefined) export const selectedModelAtom = atom<Model | undefined>(undefined)
export default function DropdownListSidebar() { // TODO: Move all of the unscoped logics outside of the component
const activeThreadId = useAtomValue(getActiveThreadIdAtom) const DropdownListSidebar = ({
strictedThread = true,
}: {
strictedThread?: boolean
}) => {
const activeThread = useAtomValue(activeThreadAtom) const activeThread = useAtomValue(activeThreadAtom)
const threadStates = useAtomValue(threadStatesAtom)
const [selectedModel, setSelectedModel] = useAtom(selectedModelAtom) const [selectedModel, setSelectedModel] = useAtom(selectedModelAtom)
const setThreadModelParams = useSetAtom(setThreadModelParamsAtom) const setThreadModelParams = useSetAtom(setThreadModelParamsAtom)
const { activeModel, stateModel } = useActiveModel()
const { stateModel } = useActiveModel()
const [serverEnabled, setServerEnabled] = useAtom(serverEnabledAtom) const [serverEnabled, setServerEnabled] = useAtom(serverEnabledAtom)
const { setMainViewState } = useMainViewState() const { setMainViewState } = useMainViewState()
const [loader, setLoader] = useState(0)
const { recommendedModel, downloadedModels } = useRecommendedModel() const { recommendedModel, downloadedModels } = useRecommendedModel()
const { updateModelParameter } = useUpdateModelParameters()
/**
* Default value for max_tokens and ctx_len
* Its to avoid OOM issue since a model can set a big number for these settings
*/
const defaultValue = (value?: number) => {
if (value && value < 4096) return value
return 4096
}
useEffect(() => { useEffect(() => {
setSelectedModel(selectedModel || activeModel || recommendedModel) if (!activeThread) return
if (activeThread) { let model = downloadedModels.find(
const finishInit = threadStates[activeThread.id].isFinishInit ?? true (model) => model.id === activeThread.assistants[0].model.id
if (finishInit) return )
const modelParams: ModelParams = { if (!model) {
...recommendedModel?.parameters, model = recommendedModel
...recommendedModel?.settings,
/**
* This is to set default value for these settings instead of maximum value
* Should only apply when model.json has these settings
*/
...(recommendedModel?.parameters.max_tokens && {
max_tokens: defaultValue(recommendedModel?.parameters.max_tokens),
}),
...(recommendedModel?.settings.ctx_len && {
ctx_len: defaultValue(recommendedModel?.settings.ctx_len),
}),
} }
setThreadModelParams(activeThread.id, modelParams) setSelectedModel(model)
} }, [recommendedModel, activeThread, downloadedModels, setSelectedModel])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
recommendedModel,
activeThread,
setSelectedModel,
setThreadModelParams,
threadStates,
])
const [loader, setLoader] = useState(0)
// This is fake loader please fix this when we have realtime percentage when load model // This is fake loader please fix this when we have realtime percentage when load model
useEffect(() => { useEffect(() => {
@ -132,25 +106,35 @@ export default function DropdownListSidebar() {
setServerEnabled(false) setServerEnabled(false)
} }
if (activeThreadId) { if (activeThread) {
const modelParams = { const modelParams = {
...model?.parameters, ...model?.parameters,
...model?.settings, ...model?.settings,
} }
setThreadModelParams(activeThreadId, modelParams) // Update model paramter to the thread state
setThreadModelParams(activeThread.id, modelParams)
// Update model parameter to the thread file
if (model)
updateModelParameter(activeThread.id, {
params: modelParams,
modelId: model.id,
engine: model.engine,
})
} }
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps
[ [
downloadedModels, downloadedModels,
serverEnabled, serverEnabled,
activeThreadId, activeThread,
activeModel, setSelectedModel,
setServerEnabled,
setThreadModelParams, setThreadModelParams,
updateModelParameter,
] ]
) )
if (!activeThread) { if (strictedThread && !activeThread) {
return null return null
} }
@ -236,10 +220,9 @@ export default function DropdownListSidebar() {
</Select> </Select>
</div> </div>
<OpenAiKeyInput <OpenAiKeyInput />
selectedModel={selectedModel}
serverEnabled={serverEnabled}
/>
</> </>
) )
} }
export default DropdownListSidebar

View File

@ -26,11 +26,12 @@ import { MainViewState } from '@/constants/screens'
import { useActiveModel } from '@/hooks/useActiveModel' import { useActiveModel } from '@/hooks/useActiveModel'
import { useDownloadState } from '@/hooks/useDownloadState' import { useDownloadState } from '@/hooks/useDownloadState'
import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels'
import useGetSystemResources from '@/hooks/useGetSystemResources' import useGetSystemResources from '@/hooks/useGetSystemResources'
import { useMainViewState } from '@/hooks/useMainViewState' import { useMainViewState } from '@/hooks/useMainViewState'
import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom'
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
const menuLinks = [ const menuLinks = [
{ {
@ -47,14 +48,22 @@ const menuLinks = [
const BottomBar = () => { const BottomBar = () => {
const { activeModel, stateModel } = useActiveModel() const { activeModel, stateModel } = useActiveModel()
const { ram, cpu } = useGetSystemResources() const { ram, cpu, gpus } = useGetSystemResources()
const progress = useAtomValue(appDownloadProgress) const progress = useAtomValue(appDownloadProgress)
const { downloadedModels } = useGetDownloadedModels() const downloadedModels = useAtomValue(downloadedModelsAtom)
const { setMainViewState } = useMainViewState() const { setMainViewState } = useMainViewState()
const { downloadStates } = useDownloadState() const { downloadStates } = useDownloadState()
const setShowSelectModelModal = useSetAtom(showSelectModelModalAtom) const setShowSelectModelModal = useSetAtom(showSelectModelModalAtom)
const [serverEnabled] = useAtom(serverEnabledAtom) const [serverEnabled] = useAtom(serverEnabledAtom)
const calculateGpuMemoryUsage = (gpu: Record<string, never>) => {
const total = parseInt(gpu.memoryTotal)
const free = parseInt(gpu.memoryFree)
if (!total || !free) return 0
return Math.round(((total - free) / total) * 100)
}
return ( return (
<div className="fixed bottom-0 left-16 z-20 flex h-12 w-[calc(100%-64px)] items-center justify-between border-t border-border bg-background/80 px-3"> <div className="fixed bottom-0 left-16 z-20 flex h-12 w-[calc(100%-64px)] items-center justify-between border-t border-border bg-background/80 px-3">
<div className="flex flex-shrink-0 items-center gap-x-2"> <div className="flex flex-shrink-0 items-center gap-x-2">
@ -117,6 +126,17 @@ const BottomBar = () => {
<SystemItem name="CPU:" value={`${cpu}%`} /> <SystemItem name="CPU:" value={`${cpu}%`} />
<SystemItem name="Mem:" value={`${ram}%`} /> <SystemItem name="Mem:" value={`${ram}%`} />
</div> </div>
{gpus.length > 0 && (
<div className="flex items-center gap-x-2">
{gpus.map((gpu, index) => (
<SystemItem
key={index}
name={`GPU ${gpu.id}:`}
value={`${gpu.utilization}% Util, ${calculateGpuMemoryUsage(gpu)}% Mem`}
/>
))}
</div>
)}
{/* VERSION is defined by webpack, please see next.config.js */} {/* VERSION is defined by webpack, please see next.config.js */}
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Jan v{VERSION ?? ''} Jan v{VERSION ?? ''}

View File

@ -11,7 +11,7 @@ import {
Badge, Badge,
} from '@janhq/uikit' } from '@janhq/uikit'
import { useAtom } from 'jotai' import { useAtom, useAtomValue } from 'jotai'
import { DatabaseIcon, CpuIcon } from 'lucide-react' import { DatabaseIcon, CpuIcon } from 'lucide-react'
import { showSelectModelModalAtom } from '@/containers/Providers/KeyListener' import { showSelectModelModalAtom } from '@/containers/Providers/KeyListener'
@ -19,14 +19,14 @@ import { showSelectModelModalAtom } from '@/containers/Providers/KeyListener'
import { MainViewState } from '@/constants/screens' import { MainViewState } from '@/constants/screens'
import { useActiveModel } from '@/hooks/useActiveModel' import { useActiveModel } from '@/hooks/useActiveModel'
import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels'
import { useMainViewState } from '@/hooks/useMainViewState' import { useMainViewState } from '@/hooks/useMainViewState'
import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom'
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
export default function CommandListDownloadedModel() { export default function CommandListDownloadedModel() {
const { setMainViewState } = useMainViewState() const { setMainViewState } = useMainViewState()
const { downloadedModels } = useGetDownloadedModels() const downloadedModels = useAtomValue(downloadedModelsAtom)
const { activeModel, startModel, stopModel } = useActiveModel() const { activeModel, startModel, stopModel } = useActiveModel()
const [serverEnabled] = useAtom(serverEnabledAtom) const [serverEnabled] = useAtom(serverEnabledAtom)
const [showSelectModelModal, setShowSelectModelModal] = useAtom( const [showSelectModelModal, setShowSelectModelModal] = useAtom(

View File

@ -20,20 +20,22 @@ import { MainViewState } from '@/constants/screens'
import { useClickOutside } from '@/hooks/useClickOutside' import { useClickOutside } from '@/hooks/useClickOutside'
import { useCreateNewThread } from '@/hooks/useCreateNewThread' import { useCreateNewThread } from '@/hooks/useCreateNewThread'
import useGetAssistants, { getAssistants } from '@/hooks/useGetAssistants'
import { useMainViewState } from '@/hooks/useMainViewState' import { useMainViewState } from '@/hooks/useMainViewState'
import { usePath } from '@/hooks/usePath' import { usePath } from '@/hooks/usePath'
import { showRightSideBarAtom } from '@/screens/Chat/Sidebar' import { showRightSideBarAtom } from '@/screens/Chat/Sidebar'
import { openFileTitle } from '@/utils/titleUtils'
import { assistantsAtom } from '@/helpers/atoms/Assistant.atom'
import { activeThreadAtom } from '@/helpers/atoms/Thread.atom' import { activeThreadAtom } from '@/helpers/atoms/Thread.atom'
const TopBar = () => { const TopBar = () => {
const activeThread = useAtomValue(activeThreadAtom) const activeThread = useAtomValue(activeThreadAtom)
const { mainViewState } = useMainViewState() const { mainViewState } = useMainViewState()
const { requestCreateNewThread } = useCreateNewThread() const { requestCreateNewThread } = useCreateNewThread()
const { assistants } = useGetAssistants() const assistants = useAtomValue(assistantsAtom)
const [showRightSideBar, setShowRightSideBar] = useAtom(showRightSideBarAtom) const [showRightSideBar, setShowRightSideBar] = useAtom(showRightSideBarAtom)
const [showLeftSideBar, setShowLeftSideBar] = useAtom(showLeftSideBarAtom) const [showLeftSideBar, setShowLeftSideBar] = useAtom(showLeftSideBarAtom)
const showing = useAtomValue(showRightSideBarAtom) const showing = useAtomValue(showRightSideBarAtom)
@ -59,12 +61,7 @@ const TopBar = () => {
const onCreateConversationClick = async () => { const onCreateConversationClick = async () => {
if (assistants.length === 0) { if (assistants.length === 0) {
const res = await getAssistants()
if (res.length === 0) {
alert('No assistant available') alert('No assistant available')
return
}
requestCreateNewThread(res[0])
} else { } else {
requestCreateNewThread(assistants[0]) requestCreateNewThread(assistants[0])
} }
@ -126,7 +123,8 @@ const TopBar = () => {
showing && 'border-l border-border' showing && 'border-l border-border'
)} )}
> >
{activeThread && ( {((activeThread && mainViewState === MainViewState.Thread) ||
mainViewState === MainViewState.LocalServer) && (
<div className="flex h-full w-52 items-center justify-between px-4"> <div className="flex h-full w-52 items-center justify-between px-4">
{showing && ( {showing && (
<div className="relative flex h-full items-center"> <div className="relative flex h-full items-center">
@ -161,7 +159,7 @@ const TopBar = () => {
className="text-muted-foreground" className="text-muted-foreground"
/> />
<span className="font-medium text-black dark:text-muted-foreground"> <span className="font-medium text-black dark:text-muted-foreground">
Show in Finder {openFileTitle()}
</span> </span>
</div> </div>
<div <div
@ -206,7 +204,7 @@ const TopBar = () => {
/> />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium text-black dark:text-muted-foreground"> <span className="font-medium text-black dark:text-muted-foreground">
Show in Finder {openFileTitle()}
</span> </span>
</div> </div>
</div> </div>

View File

@ -12,7 +12,8 @@ import TopBar from '@/containers/Layout/TopBar'
import { MainViewState } from '@/constants/screens' import { MainViewState } from '@/constants/screens'
import { useMainViewState } from '@/hooks/useMainViewState' import { useMainViewState } from '@/hooks/useMainViewState'
import { SUCCESS_SET_NEW_DESTINATION } from '@/hooks/useVaultDirectory'
import { SUCCESS_SET_NEW_DESTINATION } from '@/screens/Settings/Advanced/DataFolder'
const BaseLayout = (props: PropsWithChildren) => { const BaseLayout = (props: PropsWithChildren) => {
const { children } = props const { children } = props
@ -28,7 +29,7 @@ const BaseLayout = (props: PropsWithChildren) => {
if (localStorage.getItem(SUCCESS_SET_NEW_DESTINATION) === 'true') { if (localStorage.getItem(SUCCESS_SET_NEW_DESTINATION) === 'true') {
setMainViewState(MainViewState.Settings) setMainViewState(MainViewState.Settings)
} }
}, []) }, [setMainViewState])
return ( return (
<div className="flex h-screen w-screen flex-1 overflow-hidden"> <div className="flex h-screen w-screen flex-1 overflow-hidden">

View File

@ -0,0 +1,39 @@
import React, { useEffect, useState } from 'react'
export default function GenerateResponse() {
const [loader, setLoader] = useState(0)
// This is fake loader please fix this when we have realtime percentage when load model
useEffect(() => {
if (loader === 24) {
setTimeout(() => {
setLoader(loader + 1)
}, 250)
} else if (loader === 50) {
setTimeout(() => {
setLoader(loader + 1)
}, 250)
} else if (loader === 78) {
setTimeout(() => {
setLoader(loader + 1)
}, 250)
} else if (loader === 85) {
setLoader(85)
} else {
setLoader(loader + 1)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loader])
return (
<div className=" mb-1 mt-2 py-2 text-center">
<div className="relative inline-block overflow-hidden rounded-lg border border-neutral-50 bg-gray-50 px-4 py-2 font-semibold text-gray-600 shadow-lg">
<div
className="absolute left-0 top-0 h-full bg-gray-200"
style={{ width: `${loader}%` }}
/>
<span className="relative z-10">Generating response...</span>
</div>
</div>
)
}

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