Merge pull request #4071 from janhq/dev

Release cut 0.5.9 - sync dev into main
This commit is contained in:
Louis 2024-11-21 15:39:54 +07:00 committed by GitHub
commit 7de6800c54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 956 additions and 587 deletions

View File

@ -114,15 +114,14 @@ jobs:
- name: Upload latest-mac.yml - name: Upload latest-mac.yml
if: ${{ needs.set-public-provider.outputs.public_provider == 'aws-s3' }} if: ${{ needs.set-public-provider.outputs.public_provider == 'aws-s3' }}
run: | run: |
aws s3 cp ./latest-mac.yml "s3://${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}/temp-latest/latest-mac.yml" aws s3 cp ./latest-mac.yml "s3://${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}/temp-nightly/latest-mac.yml"
aws s3 sync s3://${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}/temp-latest/ s3://${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}/latest/ aws s3 sync s3://${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}/temp-nightly/ s3://${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}/nightly/
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.DELTA_AWS_REGION }} AWS_DEFAULT_REGION: ${{ secrets.DELTA_AWS_REGION }}
AWS_EC2_METADATA_DISABLED: "true" AWS_EC2_METADATA_DISABLED: "true"
noti-discord-nightly-and-update-url-readme: noti-discord-nightly-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] 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

View File

@ -60,16 +60,16 @@ jobs:
mv /tmp/package.json electron/package.json mv /tmp/package.json electron/package.json
jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json
mv /tmp/package.json web/package.json mv /tmp/package.json web/package.json
jq '.build.publish = [{"provider": "generic", "url": "https://delta.jan.ai/latest", "channel": "latest"}, {"provider": "s3", "acl": null, "bucket": "${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}", "region": "${{ secrets.DELTA_AWS_REGION}}", "path": "temp-latest", "channel": "latest"}]' electron/package.json > /tmp/package.json jq '.build.publish = [{"provider": "generic", "url": "https://delta.jan.ai/nightly", "channel": "latest"}, {"provider": "s3", "acl": null, "bucket": "${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}", "region": "${{ secrets.DELTA_AWS_REGION}}", "path": "temp-nightly", "channel": "latest"}]' electron/package.json > /tmp/package.json
mv /tmp/package.json electron/package.json mv /tmp/package.json electron/package.json
cat electron/package.json cat electron/package.json
# chmod +x .github/scripts/rename-app.sh chmod +x .github/scripts/rename-app.sh
# .github/scripts/rename-app.sh ./electron/package.json nightly .github/scripts/rename-app.sh ./electron/package.json nightly
# chmod +x .github/scripts/rename-workspace.sh chmod +x .github/scripts/rename-workspace.sh
# .github/scripts/rename-workspace.sh ./package.json nightly .github/scripts/rename-workspace.sh ./package.json nightly
# echo "------------------------" echo "------------------------"
# cat ./electron/package.json cat ./electron/package.json
# echo "------------------------" echo "------------------------"
- name: Change App Name for beta version - name: Change App Name for beta version
if: inputs.beta == true if: inputs.beta == true

View File

@ -72,20 +72,20 @@ jobs:
jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json
mv /tmp/package.json web/package.json mv /tmp/package.json web/package.json
jq '.build.publish = [{"provider": "generic", "url": "https://delta.jan.ai/latest", "channel": "latest"}, {"provider": "s3", "acl": null, "bucket": "${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}", "region": "${{ secrets.DELTA_AWS_REGION}}", "path": "temp-latest", "channel": "latest"}]' electron/package.json > /tmp/package.json jq '.build.publish = [{"provider": "generic", "url": "https://delta.jan.ai/nightly", "channel": "latest"}, {"provider": "s3", "acl": null, "bucket": "${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}", "region": "${{ secrets.DELTA_AWS_REGION}}", "path": "temp-nightly", "channel": "latest"}]' electron/package.json > /tmp/package.json
mv /tmp/package.json electron/package.json mv /tmp/package.json electron/package.json
jq --arg teamid "${{ secrets.APPLE_TEAM_ID }}" '.build.mac.notarize.teamId = $teamid' electron/package.json > /tmp/package.json jq --arg teamid "${{ secrets.APPLE_TEAM_ID }}" '.build.mac.notarize.teamId = $teamid' electron/package.json > /tmp/package.json
mv /tmp/package.json electron/package.json mv /tmp/package.json electron/package.json
# cat electron/package.json # cat electron/package.json
# chmod +x .github/scripts/rename-app.sh chmod +x .github/scripts/rename-app.sh
# .github/scripts/rename-app.sh ./electron/package.json nightly .github/scripts/rename-app.sh ./electron/package.json nightly
# chmod +x .github/scripts/rename-workspace.sh chmod +x .github/scripts/rename-workspace.sh
# .github/scripts/rename-workspace.sh ./package.json nightly .github/scripts/rename-workspace.sh ./package.json nightly
# echo "------------------------" echo "------------------------"
# cat ./electron/package.json cat ./electron/package.json
# echo "------------------------" echo "------------------------"
- name: Change App Name for beta version - name: Change App Name for beta version
if: inputs.beta == true if: inputs.beta == true

View File

@ -72,20 +72,20 @@ jobs:
jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json
mv /tmp/package.json web/package.json mv /tmp/package.json web/package.json
jq '.build.publish = [{"provider": "generic", "url": "https://delta.jan.ai/latest", "channel": "latest"}, {"provider": "s3", "acl": null, "bucket": "${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}", "region": "${{ secrets.DELTA_AWS_REGION}}", "path": "temp-latest", "channel": "latest"}]' electron/package.json > /tmp/package.json jq '.build.publish = [{"provider": "generic", "url": "https://delta.jan.ai/nightly", "channel": "latest"}, {"provider": "s3", "acl": null, "bucket": "${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}", "region": "${{ secrets.DELTA_AWS_REGION}}", "path": "temp-nightly", "channel": "latest"}]' electron/package.json > /tmp/package.json
mv /tmp/package.json electron/package.json mv /tmp/package.json electron/package.json
jq --arg teamid "${{ secrets.APPLE_TEAM_ID }}" '.build.mac.notarize.teamId = $teamid' electron/package.json > /tmp/package.json jq --arg teamid "${{ secrets.APPLE_TEAM_ID }}" '.build.mac.notarize.teamId = $teamid' electron/package.json > /tmp/package.json
mv /tmp/package.json electron/package.json mv /tmp/package.json electron/package.json
# cat electron/package.json cat electron/package.json
# chmod +x .github/scripts/rename-app.sh chmod +x .github/scripts/rename-app.sh
# .github/scripts/rename-app.sh ./electron/package.json nightly .github/scripts/rename-app.sh ./electron/package.json nightly
# chmod +x .github/scripts/rename-workspace.sh chmod +x .github/scripts/rename-workspace.sh
# .github/scripts/rename-workspace.sh ./package.json nightly .github/scripts/rename-workspace.sh ./package.json nightly
# echo "------------------------" echo "------------------------"
# cat ./electron/package.json cat ./electron/package.json
# echo "------------------------" echo "------------------------"
- name: Change App Name for beta version - name: Change App Name for beta version
if: inputs.beta == true if: inputs.beta == true

View File

@ -73,24 +73,24 @@ jobs:
jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json
mv /tmp/package.json web/package.json mv /tmp/package.json web/package.json
jq '.build.publish = [{"provider": "generic", "url": "https://delta.jan.ai/latest", "channel": "latest"}, {"provider": "s3", "acl": null, "bucket": "${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}", "region": "${{ secrets.DELTA_AWS_REGION}}", "path": "temp-latest", "channel": "latest"}]' electron/package.json > /tmp/package.json jq '.build.publish = [{"provider": "generic", "url": "https://delta.jan.ai/nightly", "channel": "latest"}, {"provider": "s3", "acl": null, "bucket": "${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}", "region": "${{ secrets.DELTA_AWS_REGION}}", "path": "temp-nightly", "channel": "latest"}]' electron/package.json > /tmp/package.json
mv /tmp/package.json electron/package.json mv /tmp/package.json electron/package.json
jq '.build.win.sign = "./sign.js"' electron/package.json > /tmp/package.json jq '.build.win.sign = "./sign.js"' electron/package.json > /tmp/package.json
mv /tmp/package.json electron/package.json mv /tmp/package.json electron/package.json
cat electron/package.json cat electron/package.json
# chmod +x .github/scripts/rename-app.sh chmod +x .github/scripts/rename-app.sh
# .github/scripts/rename-app.sh ./electron/package.json nightly .github/scripts/rename-app.sh ./electron/package.json nightly
# chmod +x .github/scripts/rename-workspace.sh chmod +x .github/scripts/rename-workspace.sh
# .github/scripts/rename-workspace.sh ./package.json nightly .github/scripts/rename-workspace.sh ./package.json nightly
# chmod +x .github/scripts/rename-uninstaller.sh chmod +x .github/scripts/rename-uninstaller.sh
# .github/scripts/rename-uninstaller.sh nightly .github/scripts/rename-uninstaller.sh nightly
# echo "------------------------" echo "------------------------"
# cat ./electron/package.json cat ./electron/package.json
# echo "------------------------" echo "------------------------"
# cat ./package.json cat ./package.json
# echo "------------------------" echo "------------------------"
- name: Change App Name for beta version - name: Change App Name for beta version
if: inputs.beta == true if: inputs.beta == true

View File

@ -47,11 +47,11 @@ jobs:
with: with:
args: | args: |
Jan App ${{ inputs.build_reason }} build artifact version {{ VERSION }}: Jan App ${{ inputs.build_reason }} build artifact version {{ VERSION }}:
- Windows: https://delta.jan.ai/latest/jan-win-x64-{{ VERSION }}.exe - Windows: https://delta.jan.ai/nightly/jan-nightly-win-x64-{{ VERSION }}.exe
- macOS Intel: https://delta.jan.ai/latest/jan-mac-x64-{{ VERSION }}.dmg - macOS Intel: https://delta.jan.ai/nightly/jan-nightly-mac-x64-{{ VERSION }}.dmg
- macOS Apple Silicon: https://delta.jan.ai/latest/jan-mac-arm64-{{ VERSION }}.dmg - macOS Apple Silicon: https://delta.jan.ai/nightly/jan-nightly-mac-arm64-{{ VERSION }}.dmg
- Linux Deb: https://delta.jan.ai/latest/jan-linux-amd64-{{ VERSION }}.deb - Linux Deb: https://delta.jan.ai/nightly/jan-nightly-linux-amd64-{{ VERSION }}.deb
- Linux AppImage: https://delta.jan.ai/latest/jan-linux-x86_64-{{ VERSION }}.AppImage - Linux AppImage: https://delta.jan.ai/nightly/jan-nightly-linux-x86_64-{{ VERSION }}.AppImage
- Github action run: https://github.com/janhq/jan/actions/runs/{{ GITHUB_RUN_ID }} - Github action run: https://github.com/janhq/jan/actions/runs/{{ GITHUB_RUN_ID }}
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

1
.gitignore vendored
View File

@ -47,3 +47,4 @@ coverage
.yarnrc .yarnrc
test_results.html test_results.html
*.tsbuildinfo *.tsbuildinfo
electron/shared/**

View File

@ -113,7 +113,6 @@ export abstract class BaseExtension implements ExtensionType {
for (const model of models) { for (const model of models) {
ModelManager.instance().register(model) ModelManager.instance().register(model)
} }
events.emit(ModelEvent.OnModelsUpdate, {})
} }
/** /**

View File

@ -38,14 +38,16 @@ export function requestInference(
errorCode = ErrorCode.InvalidApiKey errorCode = ErrorCode.InvalidApiKey
} }
const error = { const error = {
message: data.error?.message ?? 'Error occurred.', message: data.error?.message ?? data.message ?? 'Error occurred.',
code: errorCode, code: errorCode,
} }
subscriber.error(error) subscriber.error(error)
subscriber.complete() subscriber.complete()
return return
} }
if (model.parameters?.stream === false) { // There could be overriden stream parameter in the model
// that is set in request body (transformed payload)
if (requestBody?.stream === false || model.parameters?.stream === false) {
const data = await response.json() const data = await response.json()
if (transformResponse) { if (transformResponse) {
subscriber.next(transformResponse(data)) subscriber.next(transformResponse(data))

View File

@ -12,6 +12,7 @@ export abstract class ModelExtension extends BaseExtension implements ModelInter
return ExtensionTypeEnum.Model return ExtensionTypeEnum.Model
} }
abstract configurePullOptions(configs: { [key: string]: any }): Promise<any>
abstract getModels(): Promise<Model[]> abstract getModels(): Promise<Model[]>
abstract pullModel(model: string, id?: string, name?: string): Promise<void> abstract pullModel(model: string, id?: string, name?: string): Promise<void>
abstract cancelModelPull(modelId: string): Promise<void> abstract cancelModelPull(modelId: string): Promise<void>

View File

@ -28,6 +28,7 @@ class WindowManager {
...mainWindowConfig, ...mainWindowConfig,
width: bounds.width, width: bounds.width,
height: bounds.height, height: bounds.height,
show: false,
x: bounds.x, x: bounds.x,
y: bounds.y, y: bounds.y,
webPreferences: { webPreferences: {
@ -78,6 +79,10 @@ class WindowManager {
windowManager.hideMainWindow() windowManager.hideMainWindow()
} }
}) })
windowManager.mainWindow?.on('ready-to-show', function () {
windowManager.mainWindow?.show()
})
} }
createQuickAskWindow(preloadPath: string, startUrl: string): void { createQuickAskWindow(preloadPath: string, startUrl: string): void {

View File

@ -1,38 +0,0 @@
import fs from 'fs'
import path from 'path'
import { SettingComponentProps, getJanDataFolderPath } from '@janhq/core/node'
// Sec: Do not send engine settings over requests
// Read it manually instead
export const readEmbeddingEngine = (engineName: string) => {
if (engineName !== 'openai' && engineName !== 'groq') {
const engineSettings = fs.readFileSync(
path.join(getJanDataFolderPath(), 'engines', `${engineName}.json`),
'utf-8'
)
return JSON.parse(engineSettings)
} else {
const settingDirectoryPath = path.join(
getJanDataFolderPath(),
'settings',
'@janhq',
// TODO: James - To be removed
engineName === 'openai'
? 'inference-openai-extension'
: 'inference-groq-extension',
'settings.json'
)
const content = fs.readFileSync(settingDirectoryPath, 'utf-8')
const settings: SettingComponentProps[] = JSON.parse(content)
const apiKeyId = engineName === 'openai' ? 'openai-api-key' : 'groq-api-key'
const keySetting = settings.find((setting) => setting.key === apiKeyId)
let apiKey = keySetting?.controllerProps.value
if (typeof apiKey !== 'string') apiKey = ''
return {
api_key: apiKey,
}
}
}

View File

@ -8,7 +8,6 @@ import { MemoryVectorStore } from 'langchain/vectorstores/memory'
import { HNSWLib } from 'langchain/vectorstores/hnswlib' import { HNSWLib } from 'langchain/vectorstores/hnswlib'
import { OpenAIEmbeddings } from 'langchain/embeddings/openai' import { OpenAIEmbeddings } from 'langchain/embeddings/openai'
import { readEmbeddingEngine } from './engine'
export class Retrieval { export class Retrieval {
public chunkSize: number = 100 public chunkSize: number = 100
@ -28,8 +27,8 @@ export class Retrieval {
// declare time-weighted retriever and storage // declare time-weighted retriever and storage
this.timeWeightedVectorStore = new MemoryVectorStore( this.timeWeightedVectorStore = new MemoryVectorStore(
new OpenAIEmbeddings( new OpenAIEmbeddings(
{ openAIApiKey: 'nitro-embedding' }, { openAIApiKey: 'cortex-embedding' },
{ basePath: 'http://127.0.0.1:3928/v1' } { basePath: 'http://127.0.0.1:39291/v1' }
) )
) )
this.timeWeightedretriever = new TimeWeightedVectorStoreRetriever({ this.timeWeightedretriever = new TimeWeightedVectorStoreRetriever({
@ -49,21 +48,11 @@ export class Retrieval {
} }
public updateEmbeddingEngine(model: string, engine: string): void { public updateEmbeddingEngine(model: string, engine: string): void {
// Engine settings are not compatible with the current embedding model params this.embeddingModel = new OpenAIEmbeddings(
// Switch case manually for now { openAIApiKey: 'cortex-embedding', model },
if (engine === 'nitro') { // TODO: Raw settings
this.embeddingModel = new OpenAIEmbeddings( { basePath: 'http://127.0.0.1:39291/v1' }
{ openAIApiKey: 'nitro-embedding', model }, )
// TODO: Raw settings
{ basePath: 'http://127.0.0.1:3928/v1' },
)
} else {
// Fallback to OpenAI Settings
const settings = readEmbeddingEngine(engine)
this.embeddingModel = new OpenAIEmbeddings({
openAIApiKey: settings.api_key,
})
}
// update time-weighted embedding model // update time-weighted embedding model
this.timeWeightedVectorStore.embeddings = this.embeddingModel this.timeWeightedVectorStore.embeddings = this.embeddingModel

View File

@ -113,6 +113,8 @@ export default class JanInferenceCohereExtension extends RemoteOAIEngine {
} }
transformResponse = (data: any) => { transformResponse = (data: any) => {
return typeof data === 'object' ? data.text : JSON.parse(data).text ?? '' return typeof data === 'object'
? data.text
: (JSON.parse(data.replace('data: ', '').trim()).text ?? '')
} }
} }

View File

@ -1 +1 @@
1.0.2 1.0.3-rc5

View File

@ -2,23 +2,24 @@
set BIN_PATH=./bin set BIN_PATH=./bin
set SHARED_PATH=./../../electron/shared set SHARED_PATH=./../../electron/shared
set /p CORTEX_VERSION=<./bin/version.txt set /p CORTEX_VERSION=<./bin/version.txt
set ENGINE_VERSION=0.1.39
@REM Download cortex.llamacpp binaries @REM Download cortex.llamacpp binaries
set VERSION=v0.1.35 set VERSION=v0.1.39
set DOWNLOAD_URL=https://github.com/janhq/cortex.llamacpp/releases/download/%VERSION%/cortex.llamacpp-0.1.35-windows-amd64 set DOWNLOAD_URL=https://github.com/janhq/cortex.llamacpp/releases/download/%VERSION%/cortex.llamacpp-0.1.39-windows-amd64
set CUDA_DOWNLOAD_URL=https://github.com/janhq/cortex.llamacpp/releases/download/%VERSION% set CUDA_DOWNLOAD_URL=https://github.com/janhq/cortex.llamacpp/releases/download/%VERSION%
set SUBFOLDERS=noavx-cuda-12-0 noavx-cuda-11-7 avx2-cuda-12-0 avx2-cuda-11-7 noavx avx avx2 avx512 vulkan set SUBFOLDERS=windows-amd64-noavx-cuda-12-0 windows-amd64-noavx-cuda-11-7 windows-amd64-avx2-cuda-12-0 windows-amd64-avx2-cuda-11-7 windows-amd64-noavx windows-amd64-avx windows-amd64-avx2 windows-amd64-avx512 windows-amd64-vulkan
call .\node_modules\.bin\download -e --strip 1 -o %BIN_PATH% https://github.com/janhq/cortex/releases/download/v%CORTEX_VERSION%/cortex-%CORTEX_VERSION%-windows-amd64.tar.gz call .\node_modules\.bin\download -e --strip 1 -o %BIN_PATH% https://github.com/janhq/cortex.cpp/releases/download/v%CORTEX_VERSION%/cortex-%CORTEX_VERSION%-windows-amd64.tar.gz
call .\node_modules\.bin\download %DOWNLOAD_URL%-avx2-cuda-12-0.tar.gz -e --strip 1 -o %BIN_PATH%/avx2-cuda-12-0/engines/cortex.llamacpp call .\node_modules\.bin\download %DOWNLOAD_URL%-avx2-cuda-12-0.tar.gz -e --strip 1 -o %SHARED_PATH%/engines/cortex.llamacpp/windows-amd64-avx2-cuda-12-0/v%ENGINE_VERSION%
call .\node_modules\.bin\download %DOWNLOAD_URL%-avx2-cuda-11-7.tar.gz -e --strip 1 -o %BIN_PATH%/avx2-cuda-11-7/engines/cortex.llamacpp call .\node_modules\.bin\download %DOWNLOAD_URL%-avx2-cuda-11-7.tar.gz -e --strip 1 -o %SHARED_PATH%/engines/cortex.llamacpp/windows-amd64-avx2-cuda-11-7/v%ENGINE_VERSION%
call .\node_modules\.bin\download %DOWNLOAD_URL%-noavx-cuda-12-0.tar.gz -e --strip 1 -o %BIN_PATH%/noavx-cuda-12-0/engines/cortex.llamacpp call .\node_modules\.bin\download %DOWNLOAD_URL%-noavx-cuda-12-0.tar.gz -e --strip 1 -o %SHARED_PATH%/engines/cortex.llamacpp/windows-amd64-noavx-cuda-12-0/v%ENGINE_VERSION%
call .\node_modules\.bin\download %DOWNLOAD_URL%-noavx-cuda-11-7.tar.gz -e --strip 1 -o %BIN_PATH%/noavx-cuda-11-7/engines/cortex.llamacpp call .\node_modules\.bin\download %DOWNLOAD_URL%-noavx-cuda-11-7.tar.gz -e --strip 1 -o %SHARED_PATH%/engines/cortex.llamacpp/windows-amd64-noavx-cuda-11-7/v%ENGINE_VERSION%
call .\node_modules\.bin\download %DOWNLOAD_URL%-noavx.tar.gz -e --strip 1 -o %BIN_PATH%/noavx/engines/cortex.llamacpp call .\node_modules\.bin\download %DOWNLOAD_URL%-noavx.tar.gz -e --strip 1 -o %SHARED_PATH%/engines/cortex.llamacpp/windows-amd64-noavx/v%ENGINE_VERSION%
call .\node_modules\.bin\download %DOWNLOAD_URL%-avx.tar.gz -e --strip 1 -o %BIN_PATH%/avx/engines/cortex.llamacpp call .\node_modules\.bin\download %DOWNLOAD_URL%-avx.tar.gz -e --strip 1 -o %SHARED_PATH%/engines/cortex.llamacpp/windows-amd64-avx/v%ENGINE_VERSION%
call .\node_modules\.bin\download %DOWNLOAD_URL%-avx2.tar.gz -e --strip 1 -o %BIN_PATH%/avx2/engines/cortex.llamacpp call .\node_modules\.bin\download %DOWNLOAD_URL%-avx2.tar.gz -e --strip 1 -o %SHARED_PATH%/engines/cortex.llamacpp/windows-amd64-avx2/v%ENGINE_VERSION%
call .\node_modules\.bin\download %DOWNLOAD_URL%-avx512.tar.gz -e --strip 1 -o %BIN_PATH%/avx512/engines/cortex.llamacpp call .\node_modules\.bin\download %DOWNLOAD_URL%-avx512.tar.gz -e --strip 1 -o %SHARED_PATH%/engines/cortex.llamacpp/windows-amd64-avx512/v%ENGINE_VERSION%
call .\node_modules\.bin\download %DOWNLOAD_URL%-vulkan.tar.gz -e --strip 1 -o %BIN_PATH%/vulkan/engines/cortex.llamacpp call .\node_modules\.bin\download %DOWNLOAD_URL%-vulkan.tar.gz -e --strip 1 -o %SHARED_PATH%/engines/cortex.llamacpp/windows-amd64-vulkan/v%ENGINE_VERSION%
call .\node_modules\.bin\download %CUDA_DOWNLOAD_URL%/cuda-12-0-windows-amd64.tar.gz -e --strip 1 -o %SHARED_PATH% call .\node_modules\.bin\download %CUDA_DOWNLOAD_URL%/cuda-12-0-windows-amd64.tar.gz -e --strip 1 -o %SHARED_PATH%
call .\node_modules\.bin\download %CUDA_DOWNLOAD_URL%/cuda-11-7-windows-amd64.tar.gz -e --strip 1 -o %SHARED_PATH% call .\node_modules\.bin\download %CUDA_DOWNLOAD_URL%/cuda-11-7-windows-amd64.tar.gz -e --strip 1 -o %SHARED_PATH%
@ -28,10 +29,10 @@ del %BIN_PATH%\cortex.exe
@REM Loop through each folder and move DLLs (excluding engine.dll) @REM Loop through each folder and move DLLs (excluding engine.dll)
for %%F in (%SUBFOLDERS%) do ( for %%F in (%SUBFOLDERS%) do (
echo Processing folder: %BIN_PATH%\%%F echo Processing folder: %SHARED_PATH%\engines\cortex.llamacpp\%%F\v%ENGINE_VERSION%
@REM Move all .dll files except engine.dll @REM Move all .dll files except engine.dll
for %%D in (%BIN_PATH%\%%F\engines\cortex.llamacpp\*.dll) do ( for %%D in (%SHARED_PATH%\engines\cortex.llamacpp\%%F\v%ENGINE_VERSION%\*.dll) do (
if /I not "%%~nxD"=="engine.dll" ( if /I not "%%~nxD"=="engine.dll" (
move "%%D" "%BIN_PATH%" move "%%D" "%BIN_PATH%"
) )

View File

@ -2,9 +2,11 @@
# Read CORTEX_VERSION # Read CORTEX_VERSION
CORTEX_VERSION=$(cat ./bin/version.txt) CORTEX_VERSION=$(cat ./bin/version.txt)
CORTEX_RELEASE_URL="https://github.com/janhq/cortex/releases/download" ENGINE_VERSION=0.1.39
ENGINE_DOWNLOAD_URL="https://github.com/janhq/cortex.llamacpp/releases/download/v0.1.35/cortex.llamacpp-0.1.35" CORTEX_RELEASE_URL="https://github.com/janhq/cortex.cpp/releases/download"
CUDA_DOWNLOAD_URL="https://github.com/janhq/cortex.llamacpp/releases/download/v0.1.35" ENGINE_DOWNLOAD_URL="https://github.com/janhq/cortex.llamacpp/releases/download/v${ENGINE_VERSION}/cortex.llamacpp-${ENGINE_VERSION}"
CUDA_DOWNLOAD_URL="https://github.com/janhq/cortex.llamacpp/releases/download/v${ENGINE_VERSION}"
SHARED_PATH="../../electron/shared"
# Detect platform # Detect platform
OS_TYPE=$(uname) OS_TYPE=$(uname)
@ -17,17 +19,19 @@ if [ "$OS_TYPE" == "Linux" ]; then
chmod +x "./bin/cortex-server" chmod +x "./bin/cortex-server"
# Download engines for Linux # Download engines for Linux
download "${ENGINE_DOWNLOAD_URL}-linux-amd64-noavx.tar.gz" -e --strip 1 -o "./bin/noavx/engines/cortex.llamacpp" 1 download "${ENGINE_DOWNLOAD_URL}-linux-amd64-noavx.tar.gz" -e --strip 1 -o "${SHARED_PATH}/engines/cortex.llamacpp/linux-amd64-noavx/v${ENGINE_VERSION}" 1
download "${ENGINE_DOWNLOAD_URL}-linux-amd64-avx.tar.gz" -e --strip 1 -o "./bin/avx/engines/cortex.llamacpp" 1 download "${ENGINE_DOWNLOAD_URL}-linux-amd64-avx.tar.gz" -e --strip 1 -o "${SHARED_PATH}/engines/cortex.llamacpp/linux-amd64-avx/v${ENGINE_VERSION}" 1
download "${ENGINE_DOWNLOAD_URL}-linux-amd64-avx2.tar.gz" -e --strip 1 -o "./bin/avx2/engines/cortex.llamacpp" 1 download "${ENGINE_DOWNLOAD_URL}-linux-amd64-avx2.tar.gz" -e --strip 1 -o "${SHARED_PATH}/engines/cortex.llamacpp/linux-amd64-avx2/v${ENGINE_VERSION}" 1
download "${ENGINE_DOWNLOAD_URL}-linux-amd64-avx512.tar.gz" -e --strip 1 -o "./bin/avx512/engines/cortex.llamacpp" 1 download "${ENGINE_DOWNLOAD_URL}-linux-amd64-avx512.tar.gz" -e --strip 1 -o "${SHARED_PATH}/engines/cortex.llamacpp/linux-amd64-avx512/v${ENGINE_VERSION}" 1
download "${ENGINE_DOWNLOAD_URL}-linux-amd64-avx2-cuda-12-0.tar.gz" -e --strip 1 -o "./bin/avx2-cuda-12-0/engines/cortex.llamacpp" 1 download "${ENGINE_DOWNLOAD_URL}-linux-amd64-avx2-cuda-12-0.tar.gz" -e --strip 1 -o "${SHARED_PATH}/engines/cortex.llamacpp/linux-amd64-avx2-cuda-12-0/v${ENGINE_VERSION}" 1
download "${ENGINE_DOWNLOAD_URL}-linux-amd64-avx2-cuda-11-7.tar.gz" -e --strip 1 -o "./bin/avx2-cuda-11-7/engines/cortex.llamacpp" 1 download "${ENGINE_DOWNLOAD_URL}-linux-amd64-avx2-cuda-11-7.tar.gz" -e --strip 1 -o "${SHARED_PATH}/engines/cortex.llamacpp/linux-amd64-avx2-cuda-11-7/v${ENGINE_VERSION}" 1
download "${ENGINE_DOWNLOAD_URL}-linux-amd64-noavx-cuda-12-0.tar.gz" -e --strip 1 -o "./bin/noavx-cuda-12-0/engines/cortex.llamacpp" 1 download "${ENGINE_DOWNLOAD_URL}-linux-amd64-noavx-cuda-12-0.tar.gz" -e --strip 1 -o "${SHARED_PATH}/engines/cortex.llamacpp/linux-amd64-noavx-cuda-12-0/v${ENGINE_VERSION}" 1
download "${ENGINE_DOWNLOAD_URL}-linux-amd64-noavx-cuda-11-7.tar.gz" -e --strip 1 -o "./bin/noavx-cuda-11-7/engines/cortex.llamacpp" 1 download "${ENGINE_DOWNLOAD_URL}-linux-amd64-noavx-cuda-11-7.tar.gz" -e --strip 1 -o "${SHARED_PATH}/engines/cortex.llamacpp/linux-amd64-noavx-cuda-11-7/v${ENGINE_VERSION}" 1
download "${ENGINE_DOWNLOAD_URL}-linux-amd64-vulkan.tar.gz" -e --strip 1 -o "./bin/vulkan/engines/cortex.llamacpp" 1 download "${ENGINE_DOWNLOAD_URL}-linux-amd64-vulkan.tar.gz" -e --strip 1 -o "${SHARED_PATH}/engines/cortex.llamacpp/linux-amd64-vulkan/v${ENGINE_VERSION}" 1
download "${CUDA_DOWNLOAD_URL}/cuda-12-0-linux-amd64.tar.gz" -e --strip 1 -o "../../electron/shared" 1 download "${CUDA_DOWNLOAD_URL}/cuda-12-0-linux-amd64.tar.gz" -e --strip 1 -o "${SHARED_PATH}" 1
download "${CUDA_DOWNLOAD_URL}/cuda-11-7-linux-amd64.tar.gz" -e --strip 1 -o "../../electron/shared" 1 download "${CUDA_DOWNLOAD_URL}/cuda-11-7-linux-amd64.tar.gz" -e --strip 1 -o "${SHARED_PATH}" 1
mkdir -p "${SHARED_PATH}/engines/cortex.llamacpp/deps"
touch "${SHARED_PATH}/engines/cortex.llamacpp/deps/keep"
elif [ "$OS_TYPE" == "Darwin" ]; then elif [ "$OS_TYPE" == "Darwin" ]; then
# macOS downloads # macOS downloads
@ -38,8 +42,8 @@ elif [ "$OS_TYPE" == "Darwin" ]; then
chmod +x "./bin/cortex-server" chmod +x "./bin/cortex-server"
# Download engines for macOS # Download engines for macOS
download "${ENGINE_DOWNLOAD_URL}-mac-arm64.tar.gz" -e --strip 1 -o ./bin/arm64/engines/cortex.llamacpp download "${ENGINE_DOWNLOAD_URL}-mac-arm64.tar.gz" -e --strip 1 -o "${SHARED_PATH}/engines/cortex.llamacpp/mac-arm64/v0.1.39"
download "${ENGINE_DOWNLOAD_URL}-mac-amd64.tar.gz" -e --strip 1 -o ./bin/x64/engines/cortex.llamacpp download "${ENGINE_DOWNLOAD_URL}-mac-amd64.tar.gz" -e --strip 1 -o "${SHARED_PATH}/engines/cortex.llamacpp/mac-amd64/v0.1.39"
else else
echo "Unsupported operating system: $OS_TYPE" echo "Unsupported operating system: $OS_TYPE"

View File

@ -120,6 +120,7 @@ export default [
DEFAULT_SETTINGS: JSON.stringify(defaultSettingJson), DEFAULT_SETTINGS: JSON.stringify(defaultSettingJson),
CORTEX_API_URL: JSON.stringify('http://127.0.0.1:39291'), CORTEX_API_URL: JSON.stringify('http://127.0.0.1:39291'),
CORTEX_SOCKET_URL: JSON.stringify('ws://127.0.0.1:39291'), CORTEX_SOCKET_URL: JSON.stringify('ws://127.0.0.1:39291'),
CORTEX_ENGINE_VERSION: JSON.stringify('v0.1.39'),
}), }),
// Allow json resolution // Allow json resolution
json(), json(),

View File

@ -1,6 +1,7 @@
declare const NODE: string declare const NODE: string
declare const CORTEX_API_URL: string declare const CORTEX_API_URL: string
declare const CORTEX_SOCKET_URL: string declare const CORTEX_SOCKET_URL: string
declare const CORTEX_ENGINE_VERSION: string
declare const DEFAULT_SETTINGS: Array<any> declare const DEFAULT_SETTINGS: Array<any>
declare const MODELS: Array<any> declare const MODELS: Array<any>

View File

@ -18,6 +18,8 @@ import {
fs, fs,
events, events,
ModelEvent, ModelEvent,
SystemInformation,
dirName,
} from '@janhq/core' } from '@janhq/core'
import PQueue from 'p-queue' import PQueue from 'p-queue'
import ky from 'ky' import ky from 'ky'
@ -67,13 +69,13 @@ export default class JanInferenceCortexExtension extends LocalOAIEngine {
super.onLoad() super.onLoad()
this.queue.add(() => this.clean())
// Run the process watchdog // Run the process watchdog
const systemInfo = await systemInformation() const systemInfo = await systemInformation()
await this.clean() this.queue.add(() => executeOnMain(NODE, 'run', systemInfo))
await executeOnMain(NODE, 'run', systemInfo)
this.queue.add(() => this.healthz()) this.queue.add(() => this.healthz())
this.queue.add(() => this.setDefaultEngine(systemInfo))
this.subscribeToEvents() this.subscribeToEvents()
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
@ -93,7 +95,7 @@ export default class JanInferenceCortexExtension extends LocalOAIEngine {
model: Model & { file_path?: string } model: Model & { file_path?: string }
): Promise<void> { ): Promise<void> {
if ( if (
model.engine === InferenceEngine.nitro && (model.engine === InferenceEngine.nitro || model.settings.vision_model) &&
model.settings.llama_model_path model.settings.llama_model_path
) { ) {
// Legacy chat model support // Legacy chat model support
@ -109,7 +111,10 @@ export default class JanInferenceCortexExtension extends LocalOAIEngine {
model.settings = settings model.settings = settings
} }
if (model.engine === InferenceEngine.nitro && model.settings.mmproj) { if (
(model.engine === InferenceEngine.nitro || model.settings.vision_model) &&
model.settings.mmproj
) {
// Legacy clip vision model support // Legacy clip vision model support
model.settings = { model.settings = {
...model.settings, ...model.settings,
@ -131,6 +136,7 @@ export default class JanInferenceCortexExtension extends LocalOAIEngine {
? InferenceEngine.cortex_llamacpp ? InferenceEngine.cortex_llamacpp
: model.engine, : model.engine,
}, },
timeout: false,
}) })
.json() .json()
.catch(async (e) => { .catch(async (e) => {
@ -153,25 +159,46 @@ export default class JanInferenceCortexExtension extends LocalOAIEngine {
* Do health check on cortex.cpp * Do health check on cortex.cpp
* @returns * @returns
*/ */
healthz(): Promise<void> { private healthz(): Promise<void> {
return ky return ky
.get(`${CORTEX_API_URL}/healthz`, { .get(`${CORTEX_API_URL}/healthz`, {
retry: { retry: {
limit: 10, limit: 20,
delay: () => 500,
methods: ['get'], methods: ['get'],
}, },
}) })
.then(() => {}) .then(() => {})
} }
/**
* Set default engine variant on launch
*/
private async setDefaultEngine(systemInfo: SystemInformation) {
const variant = await executeOnMain(
NODE,
'engineVariant',
systemInfo.gpuSetting
)
return ky
.post(
`${CORTEX_API_URL}/v1/engines/${InferenceEngine.cortex_llamacpp}/default?version=${CORTEX_ENGINE_VERSION}&variant=${variant}`,
{ json: {} }
)
.then(() => {})
}
/** /**
* Clean cortex processes * Clean cortex processes
* @returns * @returns
*/ */
clean(): Promise<any> { private clean(): Promise<any> {
return ky return ky
.delete(`${CORTEX_API_URL}/processmanager/destroy`, { .delete(`${CORTEX_API_URL}/processmanager/destroy`, {
timeout: 2000, // maximum 2 seconds timeout: 2000, // maximum 2 seconds
retry: {
limit: 0,
},
}) })
.catch(() => { .catch(() => {
// Do nothing // Do nothing
@ -181,7 +208,7 @@ export default class JanInferenceCortexExtension extends LocalOAIEngine {
/** /**
* Subscribe to cortex.cpp websocket events * Subscribe to cortex.cpp websocket events
*/ */
subscribeToEvents() { private subscribeToEvents() {
this.queue.add( this.queue.add(
() => () =>
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
@ -215,7 +242,9 @@ export default class JanInferenceCortexExtension extends LocalOAIEngine {
// Delay for the state update from cortex.cpp // Delay for the state update from cortex.cpp
// Just to be sure // Just to be sure
setTimeout(() => { setTimeout(() => {
events.emit(ModelEvent.OnModelsUpdate, {}) events.emit(ModelEvent.OnModelsUpdate, {
fetch: true,
})
}, 500) }, 500)
} }
}) })
@ -235,8 +264,8 @@ export default class JanInferenceCortexExtension extends LocalOAIEngine {
} }
/// Legacy /// Legacy
export const getModelFilePath = async ( const getModelFilePath = async (
model: Model, model: Model & { file_path?: string },
file: string file: string
): Promise<string> => { ): Promise<string> => {
// Symlink to the model file // Symlink to the model file
@ -246,6 +275,9 @@ export const getModelFilePath = async (
) { ) {
return model.sources[0]?.url return model.sources[0]?.url
} }
if (model.file_path) {
await joinPath([await dirName(model.file_path), file])
}
return joinPath([await getJanDataFolderPath(), 'models', model.id, file]) return joinPath([await getJanDataFolderPath(), 'models', model.id, file])
} }
/// ///

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from '@jest/globals' import { describe, expect, it } from '@jest/globals'
import { executableCortexFile } from './execute' import { engineVariant, executableCortexFile } from './execute'
import { GpuSetting } from '@janhq/core' import { GpuSetting } from '@janhq/core/node'
import { cpuInfo } from 'cpu-instructions' import { cpuInfo } from 'cpu-instructions'
let testSettings: GpuSetting = { let testSettings: GpuSetting = {
@ -30,6 +30,11 @@ jest.mock('cpu-instructions', () => ({
let mockCpuInfo = cpuInfo.cpuInfo as jest.Mock let mockCpuInfo = cpuInfo.cpuInfo as jest.Mock
mockCpuInfo.mockReturnValue([]) mockCpuInfo.mockReturnValue([])
jest.mock('@janhq/core/node', () => ({
appResourcePath: () => ".",
log: jest.fn()
}))
describe('test executable cortex file', () => { describe('test executable cortex file', () => {
afterAll(function () { afterAll(function () {
Object.defineProperty(process, 'platform', { Object.defineProperty(process, 'platform', {
@ -46,8 +51,7 @@ describe('test executable cortex file', () => {
}) })
expect(executableCortexFile(testSettings)).toEqual( expect(executableCortexFile(testSettings)).toEqual(
expect.objectContaining({ expect.objectContaining({
enginePath: expect.stringContaining(`arm64`), enginePath: expect.stringContaining("shared"),
binPath: expect.stringContaining(`bin`),
executablePath: executablePath:
originalPlatform === 'darwin' originalPlatform === 'darwin'
? expect.stringContaining(`cortex-server`) ? expect.stringContaining(`cortex-server`)
@ -56,13 +60,13 @@ describe('test executable cortex file', () => {
vkVisibleDevices: '', vkVisibleDevices: '',
}) })
) )
expect(engineVariant(testSettings)).toEqual('mac-arm64')
Object.defineProperty(process, 'arch', { Object.defineProperty(process, 'arch', {
value: 'x64', value: 'x64',
}) })
expect(executableCortexFile(testSettings)).toEqual( expect(executableCortexFile(testSettings)).toEqual(
expect.objectContaining({ expect.objectContaining({
enginePath: expect.stringContaining(`x64`), enginePath: expect.stringContaining("shared"),
binPath: expect.stringContaining(`bin`),
executablePath: executablePath:
originalPlatform === 'darwin' originalPlatform === 'darwin'
? expect.stringContaining(`cortex-server`) ? expect.stringContaining(`cortex-server`)
@ -71,6 +75,7 @@ describe('test executable cortex file', () => {
vkVisibleDevices: '', vkVisibleDevices: '',
}) })
) )
expect(engineVariant(testSettings)).toEqual('mac-amd64')
}) })
it('executes on Windows CPU', () => { it('executes on Windows CPU', () => {
@ -84,13 +89,13 @@ describe('test executable cortex file', () => {
mockCpuInfo.mockReturnValue(['avx']) mockCpuInfo.mockReturnValue(['avx'])
expect(executableCortexFile(settings)).toEqual( expect(executableCortexFile(settings)).toEqual(
expect.objectContaining({ expect.objectContaining({
enginePath: expect.stringContaining(`avx`), enginePath: expect.stringContaining("shared"),
binPath: expect.stringContaining(`bin`),
executablePath: expect.stringContaining(`cortex-server.exe`), executablePath: expect.stringContaining(`cortex-server.exe`),
cudaVisibleDevices: '', cudaVisibleDevices: '',
vkVisibleDevices: '', vkVisibleDevices: '',
}) })
) )
expect(engineVariant()).toEqual('windows-amd64-avx')
}) })
it('executes on Windows Cuda 11', () => { it('executes on Windows Cuda 11', () => {
@ -120,13 +125,13 @@ describe('test executable cortex file', () => {
mockCpuInfo.mockReturnValue(['avx2']) mockCpuInfo.mockReturnValue(['avx2'])
expect(executableCortexFile(settings)).toEqual( expect(executableCortexFile(settings)).toEqual(
expect.objectContaining({ expect.objectContaining({
enginePath: expect.stringContaining(`avx2-cuda-11-7`), enginePath: expect.stringContaining("shared"),
binPath: expect.stringContaining(`bin`),
executablePath: expect.stringContaining(`cortex-server.exe`), executablePath: expect.stringContaining(`cortex-server.exe`),
cudaVisibleDevices: '0', cudaVisibleDevices: '0',
vkVisibleDevices: '0', vkVisibleDevices: '0',
}) })
) )
expect(engineVariant(settings)).toEqual('windows-amd64-avx2-cuda-11-7')
}) })
it('executes on Windows Cuda 12', () => { it('executes on Windows Cuda 12', () => {
@ -156,13 +161,15 @@ describe('test executable cortex file', () => {
mockCpuInfo.mockReturnValue(['noavx']) mockCpuInfo.mockReturnValue(['noavx'])
expect(executableCortexFile(settings)).toEqual( expect(executableCortexFile(settings)).toEqual(
expect.objectContaining({ expect.objectContaining({
enginePath: expect.stringContaining(`noavx-cuda-12-0`), enginePath: expect.stringContaining("shared"),
binPath: expect.stringContaining(`bin`),
executablePath: expect.stringContaining(`cortex-server.exe`), executablePath: expect.stringContaining(`cortex-server.exe`),
cudaVisibleDevices: '0', cudaVisibleDevices: '0',
vkVisibleDevices: '0', vkVisibleDevices: '0',
}) })
) )
expect(engineVariant(settings)).toEqual('windows-amd64-noavx-cuda-12-0')
mockCpuInfo.mockReturnValue(['avx512'])
expect(engineVariant(settings)).toEqual('windows-amd64-avx2-cuda-12-0')
}) })
it('executes on Linux CPU', () => { it('executes on Linux CPU', () => {
@ -176,12 +183,13 @@ describe('test executable cortex file', () => {
mockCpuInfo.mockReturnValue(['noavx']) mockCpuInfo.mockReturnValue(['noavx'])
expect(executableCortexFile(settings)).toEqual( expect(executableCortexFile(settings)).toEqual(
expect.objectContaining({ expect.objectContaining({
enginePath: expect.stringContaining(`noavx`), enginePath: expect.stringContaining("shared"),
executablePath: expect.stringContaining(`cortex-server`), executablePath: expect.stringContaining(`cortex-server`),
cudaVisibleDevices: '', cudaVisibleDevices: '',
vkVisibleDevices: '', vkVisibleDevices: '',
}) })
) )
expect(engineVariant()).toEqual('linux-amd64-noavx')
}) })
it('executes on Linux Cuda 11', () => { it('executes on Linux Cuda 11', () => {
@ -208,15 +216,16 @@ describe('test executable cortex file', () => {
}, },
], ],
} }
mockCpuInfo.mockReturnValue(['avx512'])
expect(executableCortexFile(settings)).toEqual( expect(executableCortexFile(settings)).toEqual(
expect.objectContaining({ expect.objectContaining({
enginePath: expect.stringContaining(`cuda-11-7`), enginePath: expect.stringContaining("shared"),
binPath: expect.stringContaining(`bin`),
executablePath: expect.stringContaining(`cortex-server`), executablePath: expect.stringContaining(`cortex-server`),
cudaVisibleDevices: '0', cudaVisibleDevices: '0',
vkVisibleDevices: '0', vkVisibleDevices: '0',
}) })
) )
expect(engineVariant(settings)).toEqual('linux-amd64-avx2-cuda-11-7')
}) })
it('executes on Linux Cuda 12', () => { it('executes on Linux Cuda 12', () => {
@ -245,13 +254,13 @@ describe('test executable cortex file', () => {
} }
expect(executableCortexFile(settings)).toEqual( expect(executableCortexFile(settings)).toEqual(
expect.objectContaining({ expect.objectContaining({
enginePath: expect.stringContaining(`cuda-12-0`), enginePath: expect.stringContaining("shared"),
binPath: expect.stringContaining(`bin`),
executablePath: expect.stringContaining(`cortex-server`), executablePath: expect.stringContaining(`cortex-server`),
cudaVisibleDevices: '0', cudaVisibleDevices: '0',
vkVisibleDevices: '0', vkVisibleDevices: '0',
}) })
) )
expect(engineVariant(settings)).toEqual('linux-amd64-avx2-cuda-12-0')
}) })
// Generate test for different cpu instructions on Linux // Generate test for different cpu instructions on Linux
@ -270,14 +279,14 @@ describe('test executable cortex file', () => {
expect(executableCortexFile(settings)).toEqual( expect(executableCortexFile(settings)).toEqual(
expect.objectContaining({ expect.objectContaining({
enginePath: expect.stringContaining(instruction), enginePath: expect.stringContaining('shared'),
binPath: expect.stringContaining(`bin`),
executablePath: expect.stringContaining(`cortex-server`), executablePath: expect.stringContaining(`cortex-server`),
cudaVisibleDevices: '', cudaVisibleDevices: '',
vkVisibleDevices: '', vkVisibleDevices: '',
}) })
) )
expect(engineVariant(settings)).toEqual(`linux-amd64-${instruction}`)
}) })
}) })
// Generate test for different cpu instructions on Windows // Generate test for different cpu instructions on Windows
@ -294,13 +303,13 @@ describe('test executable cortex file', () => {
mockCpuInfo.mockReturnValue([instruction]) mockCpuInfo.mockReturnValue([instruction])
expect(executableCortexFile(settings)).toEqual( expect(executableCortexFile(settings)).toEqual(
expect.objectContaining({ expect.objectContaining({
enginePath: expect.stringContaining(instruction), enginePath: expect.stringContaining('shared'),
binPath: expect.stringContaining(`bin`),
executablePath: expect.stringContaining(`cortex-server.exe`), executablePath: expect.stringContaining(`cortex-server.exe`),
cudaVisibleDevices: '', cudaVisibleDevices: '',
vkVisibleDevices: '', vkVisibleDevices: '',
}) })
) )
expect(engineVariant(settings)).toEqual(`windows-amd64-${instruction}`)
}) })
}) })
@ -334,13 +343,15 @@ describe('test executable cortex file', () => {
mockCpuInfo.mockReturnValue([instruction]) mockCpuInfo.mockReturnValue([instruction])
expect(executableCortexFile(settings)).toEqual( expect(executableCortexFile(settings)).toEqual(
expect.objectContaining({ expect.objectContaining({
enginePath: expect.stringContaining(`cuda-12-0`), enginePath: expect.stringContaining("shared"),
binPath: expect.stringContaining(`bin`),
executablePath: expect.stringContaining(`cortex-server.exe`), executablePath: expect.stringContaining(`cortex-server.exe`),
cudaVisibleDevices: '0', cudaVisibleDevices: '0',
vkVisibleDevices: '0', vkVisibleDevices: '0',
}) })
) )
expect(engineVariant(settings)).toEqual(
`windows-amd64-${instruction === 'avx512' || instruction === 'avx2' ? 'avx2' : 'noavx'}-cuda-12-0`
)
}) })
}) })
@ -374,13 +385,15 @@ describe('test executable cortex file', () => {
mockCpuInfo.mockReturnValue([instruction]) mockCpuInfo.mockReturnValue([instruction])
expect(executableCortexFile(settings)).toEqual( expect(executableCortexFile(settings)).toEqual(
expect.objectContaining({ expect.objectContaining({
enginePath: expect.stringContaining(`cuda-12-0`), enginePath: expect.stringContaining("shared"),
binPath: expect.stringContaining(`bin`),
executablePath: expect.stringContaining(`cortex-server`), executablePath: expect.stringContaining(`cortex-server`),
cudaVisibleDevices: '0', cudaVisibleDevices: '0',
vkVisibleDevices: '0', vkVisibleDevices: '0',
}) })
) )
expect(engineVariant(settings)).toEqual(
`linux-amd64-${instruction === 'avx512' || instruction === 'avx2' ? 'avx2' : 'noavx'}-cuda-12-0`
)
}) })
}) })
@ -415,13 +428,13 @@ describe('test executable cortex file', () => {
mockCpuInfo.mockReturnValue([instruction]) mockCpuInfo.mockReturnValue([instruction])
expect(executableCortexFile(settings)).toEqual( expect(executableCortexFile(settings)).toEqual(
expect.objectContaining({ expect.objectContaining({
enginePath: expect.stringContaining(`vulkan`), enginePath: expect.stringContaining("shared"),
binPath: expect.stringContaining(`bin`),
executablePath: expect.stringContaining(`cortex-server`), executablePath: expect.stringContaining(`cortex-server`),
cudaVisibleDevices: '0', cudaVisibleDevices: '0',
vkVisibleDevices: '0', vkVisibleDevices: '0',
}) })
) )
expect(engineVariant(settings)).toEqual(`linux-amd64-vulkan`)
}) })
}) })
@ -442,8 +455,7 @@ describe('test executable cortex file', () => {
mockCpuInfo.mockReturnValue([]) mockCpuInfo.mockReturnValue([])
expect(executableCortexFile(settings)).toEqual( expect(executableCortexFile(settings)).toEqual(
expect.objectContaining({ expect.objectContaining({
enginePath: expect.stringContaining(`x64`), enginePath: expect.stringContaining("shared"),
binPath: expect.stringContaining(`bin`),
executablePath: executablePath:
originalPlatform === 'darwin' originalPlatform === 'darwin'
? expect.stringContaining(`cortex-server`) ? expect.stringContaining(`cortex-server`)

View File

@ -1,10 +1,9 @@
import { GpuSetting } from '@janhq/core'
import * as path from 'path' import * as path from 'path'
import { cpuInfo } from 'cpu-instructions' import { cpuInfo } from 'cpu-instructions'
import { GpuSetting, appResourcePath, log } from '@janhq/core/node'
export interface CortexExecutableOptions { export interface CortexExecutableOptions {
enginePath: string enginePath: string
binPath: string
executablePath: string executablePath: string
cudaVisibleDevices: string cudaVisibleDevices: string
vkVisibleDevices: string vkVisibleDevices: string
@ -21,11 +20,7 @@ const gpuRunMode = (settings?: GpuSetting): string => {
if (!settings) return '' if (!settings) return ''
return settings.vulkan === true return settings.vulkan === true || settings.run_mode === 'cpu' ? '' : 'cuda'
? 'vulkan'
: settings.run_mode === 'cpu'
? ''
: 'cuda'
} }
/** /**
@ -34,12 +29,12 @@ const gpuRunMode = (settings?: GpuSetting): string => {
*/ */
const os = (): string => { const os = (): string => {
return process.platform === 'win32' return process.platform === 'win32'
? 'win' ? 'windows-amd64'
: process.platform === 'darwin' : process.platform === 'darwin'
? process.arch === 'arm64' ? process.arch === 'arm64'
? 'arm64' ? 'mac-arm64'
: 'x64' : 'mac-amd64'
: 'linux' : 'linux-amd64'
} }
/** /**
@ -57,7 +52,7 @@ const extension = (): '.exe' | '' => {
*/ */
const cudaVersion = (settings?: GpuSetting): '11-7' | '12-0' | undefined => { const cudaVersion = (settings?: GpuSetting): '11-7' | '12-0' | undefined => {
const isUsingCuda = const isUsingCuda =
settings?.vulkan !== true && settings?.run_mode === 'gpu' && os() !== 'mac' settings?.vulkan !== true && settings?.run_mode === 'gpu' && !os().includes('mac')
if (!isUsingCuda) return undefined if (!isUsingCuda) return undefined
return settings?.cuda?.version === '11' ? '11-7' : '12-0' return settings?.cuda?.version === '11' ? '11-7' : '12-0'
@ -79,36 +74,45 @@ const cpuInstructions = (): string => {
} }
/** /**
* Find which executable file to run based on the current platform. * The executable options for the cortex.cpp extension.
* @returns The name of the executable file to run.
*/ */
export const executableCortexFile = ( export const executableCortexFile = (
gpuSetting?: GpuSetting gpuSetting?: GpuSetting
): CortexExecutableOptions => { ): CortexExecutableOptions => {
const cpuInstruction = cpuInstructions()
let engineFolder = gpuSetting?.vulkan
? 'vulkan'
: process.platform === 'darwin'
? os()
: [
gpuRunMode(gpuSetting) !== 'cuda' ||
cpuInstruction === 'avx2' || cpuInstruction === 'avx512'
? cpuInstruction
: 'noavx',
gpuRunMode(gpuSetting),
cudaVersion(gpuSetting),
]
.filter((e) => !!e)
.join('-')
let cudaVisibleDevices = gpuSetting?.gpus_in_use.join(',') ?? '' let cudaVisibleDevices = gpuSetting?.gpus_in_use.join(',') ?? ''
let vkVisibleDevices = gpuSetting?.gpus_in_use.join(',') ?? '' let vkVisibleDevices = gpuSetting?.gpus_in_use.join(',') ?? ''
let binaryName = `cortex-server${extension()}` let binaryName = `cortex-server${extension()}`
const binPath = path.join(__dirname, '..', 'bin') const binPath = path.join(__dirname, '..', 'bin')
return { return {
enginePath: path.join(binPath, engineFolder), enginePath: path.join(appResourcePath(), 'shared'),
executablePath: path.join(binPath, binaryName), executablePath: path.join(binPath, binaryName),
binPath: binPath,
cudaVisibleDevices, cudaVisibleDevices,
vkVisibleDevices, vkVisibleDevices,
} }
} }
/**
* Find which variant to run based on the current platform.
*/
export const engineVariant = (gpuSetting?: GpuSetting): string => {
const cpuInstruction = cpuInstructions()
let engineVariant = [
os(),
gpuSetting?.vulkan
? 'vulkan'
: gpuRunMode(gpuSetting) !== 'cuda'
? // CPU mode - support all variants
cpuInstruction
: // GPU mode - packaged CUDA variants of avx2 and noavx
cpuInstruction === 'avx2' || cpuInstruction === 'avx512'
? 'avx2'
: 'noavx',
gpuRunMode(gpuSetting),
cudaVersion(gpuSetting),
]
.filter((e) => !!e)
.join('-')
log(`[CORTEX]: Engine variant: ${engineVariant}`)
return engineVariant
}

View File

@ -1,8 +1,7 @@
import path from 'path' import path from 'path'
import { getJanDataFolderPath, log, SystemInformation } from '@janhq/core/node' import { getJanDataFolderPath, log, SystemInformation } from '@janhq/core/node'
import { executableCortexFile } from './execute' import { engineVariant, executableCortexFile } from './execute'
import { ProcessWatchdog } from './watchdog' import { ProcessWatchdog } from './watchdog'
import { appResourcePath } from '@janhq/core/node'
// The HOST address to use for the Nitro subprocess // The HOST address to use for the Nitro subprocess
const LOCAL_PORT = '39291' const LOCAL_PORT = '39291'
@ -20,9 +19,9 @@ function run(systemInfo?: SystemInformation): Promise<any> {
// If ngl is not set or equal to 0, run on CPU with correct instructions // If ngl is not set or equal to 0, run on CPU with correct instructions
systemInfo?.gpuSetting systemInfo?.gpuSetting
? { ? {
...systemInfo.gpuSetting, ...systemInfo.gpuSetting,
run_mode: systemInfo.gpuSetting.run_mode, run_mode: systemInfo.gpuSetting.run_mode,
} }
: undefined : undefined
) )
@ -30,16 +29,13 @@ function run(systemInfo?: SystemInformation): Promise<any> {
log(`[CORTEX]:: Spawn cortex at path: ${executableOptions.executablePath}`) log(`[CORTEX]:: Spawn cortex at path: ${executableOptions.executablePath}`)
log(`[CORTEX]:: Cortex engine path: ${executableOptions.enginePath}`) log(`[CORTEX]:: Cortex engine path: ${executableOptions.enginePath}`)
addEnvPaths(path.join(appResourcePath(), 'shared'))
addEnvPaths(executableOptions.binPath)
addEnvPaths(executableOptions.enginePath) addEnvPaths(executableOptions.enginePath)
// Add the cortex.llamacpp path to the PATH and LD_LIBRARY_PATH
// This is required for the cortex engine to run for now since dlls are not moved to the root
addEnvPaths(
path.join(executableOptions.enginePath, 'engines', 'cortex.llamacpp')
)
const dataFolderPath = getJanDataFolderPath() const dataFolderPath = getJanDataFolderPath()
if (watchdog) {
watchdog.terminate()
}
watchdog = new ProcessWatchdog( watchdog = new ProcessWatchdog(
executableOptions.executablePath, executableOptions.executablePath,
[ [
@ -81,17 +77,12 @@ function dispose() {
function addEnvPaths(dest: string) { function addEnvPaths(dest: string) {
// Add engine path to the PATH and LD_LIBRARY_PATH // Add engine path to the PATH and LD_LIBRARY_PATH
if (process.platform === 'win32') { if (process.platform === 'win32') {
process.env.PATH = (process.env.PATH || '').concat( process.env.PATH = (process.env.PATH || '').concat(path.delimiter, dest)
path.delimiter,
dest,
)
log(`[CORTEX] PATH: ${process.env.PATH}`)
} else { } else {
process.env.LD_LIBRARY_PATH = (process.env.LD_LIBRARY_PATH || '').concat( process.env.LD_LIBRARY_PATH = (process.env.LD_LIBRARY_PATH || '').concat(
path.delimiter, path.delimiter,
dest, dest
) )
log(`[CORTEX] LD_LIBRARY_PATH: ${process.env.LD_LIBRARY_PATH}`)
} }
} }
@ -105,4 +96,5 @@ export interface CortexProcessInfo {
export default { export default {
run, run,
dispose, dispose,
engineVariant,
} }

View File

@ -1,7 +1,7 @@
{ {
"name": "@janhq/inference-openai-extension", "name": "@janhq/inference-openai-extension",
"productName": "OpenAI Inference Engine", "productName": "OpenAI Inference Engine",
"version": "1.0.3", "version": "1.0.4",
"description": "This extension enables OpenAI chat completion API calls", "description": "This extension enables OpenAI chat completion API calls",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/module.js", "module": "dist/module.js",

View File

@ -97,11 +97,10 @@
"format": "api", "format": "api",
"settings": {}, "settings": {},
"parameters": { "parameters": {
"max_tokens": 4096, "temperature": 1,
"temperature": 0.7, "top_p": 1,
"top_p": 0.95,
"stream": true, "stream": true,
"stop": [], "max_tokens": 32768,
"frequency_penalty": 0, "frequency_penalty": 0,
"presence_penalty": 0 "presence_penalty": 0
}, },
@ -125,11 +124,10 @@
"format": "api", "format": "api",
"settings": {}, "settings": {},
"parameters": { "parameters": {
"max_tokens": 4096, "temperature": 1,
"temperature": 0.7, "top_p": 1,
"top_p": 0.95, "max_tokens": 65536,
"stream": true, "stream": true,
"stop": [],
"frequency_penalty": 0, "frequency_penalty": 0,
"presence_penalty": 0 "presence_penalty": 0
}, },

View File

@ -76,11 +76,10 @@ export default class JanInferenceOpenAIExtension extends RemoteOAIEngine {
transformPayload = (payload: OpenAIPayloadType): OpenAIPayloadType => { transformPayload = (payload: OpenAIPayloadType): OpenAIPayloadType => {
// Transform the payload for preview models // Transform the payload for preview models
if (this.previewModels.includes(payload.model)) { if (this.previewModels.includes(payload.model)) {
const { max_tokens, temperature, top_p, stop, ...params } = payload const { max_tokens, stop, ...params } = payload
return { return {
...params, ...params,
max_completion_tokens: max_tokens, max_completion_tokens: max_tokens,
stream: false // o1 only support stream = false
} }
} }
// Pass through for non-preview models // Pass through for non-preview models

View File

@ -1,4 +1,4 @@
[ [
{ {
"sources": [ "sources": [
{ {
@ -13,7 +13,7 @@
"format": "api", "format": "api",
"settings": {}, "settings": {},
"parameters": { "parameters": {
"max_tokens": 1024, "max_tokens": 128000,
"temperature": 0.7, "temperature": 0.7,
"top_p": 0.95, "top_p": 0.95,
"frequency_penalty": 0, "frequency_penalty": 0,

View File

@ -83,6 +83,6 @@ export default class JanInferenceOpenRouterExtension extends RemoteOAIEngine {
transformPayload = (payload: PayloadType) => ({ transformPayload = (payload: PayloadType) => ({
...payload, ...payload,
model: this.model, model: payload.model !== 'open-router-auto' ? payload.model : this.model,
}) })
} }

View File

@ -1,7 +1,7 @@
{ {
"name": "@janhq/model-extension", "name": "@janhq/model-extension",
"productName": "Model Management", "productName": "Model Management",
"version": "1.0.34", "version": "1.0.35",
"description": "Model Management Extension provides model exploration and seamless downloads", "description": "Model Management Extension provides model exploration and seamless downloads",
"main": "dist/index.js", "main": "dist/index.js",
"author": "Jan <service@jan.ai>", "author": "Jan <service@jan.ai>",

View File

@ -1,6 +1,6 @@
import PQueue from 'p-queue' import PQueue from 'p-queue'
import ky from 'ky' import ky from 'ky'
import { extractModelLoadParams, Model } from '@janhq/core' import { extractModelLoadParams, Model } from '@janhq/core'
import { extractInferenceParams } from '@janhq/core' import { extractInferenceParams } from '@janhq/core'
/** /**
* cortex.cpp Model APIs interface * cortex.cpp Model APIs interface
@ -18,6 +18,7 @@ interface ICortexAPI {
deleteModel(model: string): Promise<void> deleteModel(model: string): Promise<void>
updateModel(model: object): Promise<void> updateModel(model: object): Promise<void>
cancelModelPull(model: string): Promise<void> cancelModelPull(model: string): Promise<void>
configs(body: { [key: string]: any }): Promise<void>
} }
type ModelList = { type ModelList = {
@ -52,7 +53,7 @@ export class CortexAPI implements ICortexAPI {
*/ */
getModels(): Promise<Model[]> { getModels(): Promise<Model[]> {
return this.queue return this.queue
.add(() => ky.get(`${API_URL}/models`).json<ModelList>()) .add(() => ky.get(`${API_URL}/v1/models`).json<ModelList>())
.then((e) => .then((e) =>
typeof e === 'object' ? e.data.map((e) => this.transformModel(e)) : [] typeof e === 'object' ? e.data.map((e) => this.transformModel(e)) : []
) )
@ -104,7 +105,7 @@ export class CortexAPI implements ICortexAPI {
*/ */
deleteModel(model: string): Promise<void> { deleteModel(model: string): Promise<void> {
return this.queue.add(() => return this.queue.add(() =>
ky.delete(`${API_URL}/models/${model}`).json().then() ky.delete(`${API_URL}/v1/models/${model}`).json().then()
) )
} }
@ -130,7 +131,7 @@ export class CortexAPI implements ICortexAPI {
cancelModelPull(model: string): Promise<void> { cancelModelPull(model: string): Promise<void> {
return this.queue.add(() => return this.queue.add(() =>
ky ky
.delete(`${API_URL}/models/pull`, { json: { taskId: model } }) .delete(`${API_URL}/v1/models/pull`, { json: { taskId: model } })
.json() .json()
.then() .then()
) )
@ -142,7 +143,7 @@ export class CortexAPI implements ICortexAPI {
*/ */
async getModelStatus(model: string): Promise<boolean> { async getModelStatus(model: string): Promise<boolean> {
return this.queue return this.queue
.add(() => ky.get(`${API_URL}/models/status/${model}`)) .add(() => ky.get(`${API_URL}/v1/models/status/${model}`))
.then((e) => true) .then((e) => true)
.catch(() => false) .catch(() => false)
} }
@ -155,13 +156,24 @@ export class CortexAPI implements ICortexAPI {
return ky return ky
.get(`${API_URL}/healthz`, { .get(`${API_URL}/healthz`, {
retry: { retry: {
limit: 10, limit: 20,
delay: () => 500,
methods: ['get'], methods: ['get'],
}, },
}) })
.then(() => {}) .then(() => {})
} }
/**
* Configure model pull options
* @param body
*/
configs(body: { [key: string]: any }): Promise<void> {
return this.queue.add(() =>
ky.patch(`${API_URL}/v1/configs`, { json: body }).then(() => {})
)
}
/** /**
* TRansform model to the expected format (e.g. parameters, settings, metadata) * TRansform model to the expected format (e.g. parameters, settings, metadata)
* @param model * @param model

View File

@ -20,11 +20,8 @@ import { deleteModelFiles } from './legacy/delete'
declare const SETTINGS: Array<any> declare const SETTINGS: Array<any>
/** export enum Settings {
* Extension enum huggingfaceToken = 'hugging-face-access-token',
*/
enum ExtensionEnum {
downloadedModels = 'downloadedModels',
} }
/** /**
@ -40,15 +37,29 @@ export default class JanModelExtension extends ModelExtension {
async onLoad() { async onLoad() {
this.registerSettings(SETTINGS) this.registerSettings(SETTINGS)
// Try get models from cortex.cpp // Configure huggingface token if available
this.getModels().then((models) => { const huggingfaceToken = await this.getSetting<string>(
this.registerModels(models) Settings.huggingfaceToken,
}) undefined
)
if (huggingfaceToken)
this.cortexAPI.configs({ huggingface_token: huggingfaceToken })
// Listen to app download events // Listen to app download events
this.handleDesktopEvents() this.handleDesktopEvents()
} }
/**
* Subscribe to settings update and make change accordingly
* @param key
* @param value
*/
onSettingUpdate<T>(key: string, value: T): void {
if (key === Settings.huggingfaceToken) {
this.cortexAPI.configs({ huggingface_token: value })
}
}
/** /**
* Called when the extension is unloaded. * Called when the extension is unloaded.
* @override * @override
@ -127,55 +138,43 @@ export default class JanModelExtension extends ModelExtension {
* @returns A Promise that resolves with an array of all models. * @returns A Promise that resolves with an array of all models.
*/ */
async getModels(): Promise<Model[]> { async getModels(): Promise<Model[]> {
/**
* In this action, if return empty array right away
* it would reset app cache and app will not function properly
* should compare and try import
*/
let currentModels: Model[] = []
/** /**
* Legacy models should be supported * Legacy models should be supported
*/ */
let legacyModels = await scanModelsFolder() let legacyModels = await scanModelsFolder()
try {
if (!localStorage.getItem(ExtensionEnum.downloadedModels)) {
// Updated from an older version than 0.5.5
// Scan through the models folder and import them (Legacy flow)
// Return models immediately
currentModels = legacyModels
} else {
currentModels = JSON.parse(
localStorage.getItem(ExtensionEnum.downloadedModels)
) as Model[]
}
} catch (e) {
currentModels = []
console.error(e)
}
/** /**
* Here we are filtering out the models that are not imported * Here we are filtering out the models that are not imported
* and are not using llama.cpp engine * and are not using llama.cpp engine
*/ */
var toImportModels = currentModels.filter( var toImportModels = legacyModels.filter(
(e) => e.engine === InferenceEngine.nitro (e) => e.engine === InferenceEngine.nitro
) )
await this.cortexAPI.getModels().then((models) => { /**
const existingIds = models.map((e) => e.id) * Fetch models from cortex.cpp
toImportModels = toImportModels.filter( */
(e: Model) => !existingIds.includes(e.id) && !e.settings?.vision_model var fetchedModels = await this.cortexAPI.getModels().catch(() => [])
// Checking if there are models to import
const existingIds = fetchedModels.map((e) => e.id)
toImportModels = toImportModels.filter(
(e: Model) => !existingIds.includes(e.id) && !e.settings?.vision_model
)
/**
* There is no model to import
* just return fetched models
*/
if (!toImportModels.length)
return fetchedModels.concat(
legacyModels.filter((e) => !fetchedModels.some((x) => x.id === e.id))
) )
})
console.log('To import models:', toImportModels.length) console.log('To import models:', toImportModels.length)
/** /**
* There are models to import * There are models to import
* do not return models from cortex.cpp yet */
* otherwise it will reset the app cache
* */
if (toImportModels.length > 0) { if (toImportModels.length > 0) {
// Import models // Import models
await Promise.all( await Promise.all(
@ -193,17 +192,19 @@ export default class JanModelExtension extends ModelExtension {
]) // Copied models ]) // Copied models
: model.sources[0].url, // Symlink models, : model.sources[0].url, // Symlink models,
model.name model.name
).then((e) => { )
this.updateModel({ .then((e) => {
id: model.id, this.updateModel({
...model.settings, id: model.id,
...model.parameters, ...model.settings,
} as Partial<Model>) ...model.parameters,
}) } as Partial<Model>)
})
.catch((e) => {
console.debug(e)
})
}) })
) )
return currentModels
} }
/** /**
@ -252,6 +253,13 @@ export default class JanModelExtension extends ModelExtension {
return this.cortexAPI.getModelStatus(model) return this.cortexAPI.getModelStatus(model)
} }
/**
* Configure pull options such as proxy, headers, etc.
*/
async configurePullOptions(options: { [key: string]: any }): Promise<any> {
return this.cortexAPI.configs(options).catch((e) => console.debug(e))
}
/** /**
* Handle download state from main app * Handle download state from main app
*/ */

View File

@ -1,10 +1,12 @@
import { fs, joinPath } from '@janhq/core' import { dirName, fs } from '@janhq/core'
import { scanModelsFolder } from './model-json'
export const deleteModelFiles = async (id: string) => { export const deleteModelFiles = async (id: string) => {
try { try {
const dirPath = await joinPath(['file://models', id]) const models = await scanModelsFolder()
const dirPath = models.find((e) => e.id === id)?.file_path
// remove model folder directory // remove model folder directory
await fs.rm(dirPath) if (dirPath) await fs.rm(await dirName(dirPath))
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} }

View File

@ -12,7 +12,9 @@ const LocalEngines = [
* Scan through models folder and return downloaded models * Scan through models folder and return downloaded models
* @returns * @returns
*/ */
export const scanModelsFolder = async (): Promise<Model[]> => { export const scanModelsFolder = async (): Promise<
(Model & { file_path?: string })[]
> => {
const _homeDir = 'file://models' const _homeDir = 'file://models'
try { try {
if (!(await fs.existsSync(_homeDir))) { if (!(await fs.existsSync(_homeDir))) {
@ -37,7 +39,7 @@ export const scanModelsFolder = async (): Promise<Model[]> => {
const jsonPath = await getModelJsonPath(folderFullPath) const jsonPath = await getModelJsonPath(folderFullPath)
if (await fs.existsSync(jsonPath)) { if (jsonPath && (await fs.existsSync(jsonPath))) {
// if we have the model.json file, read it // if we have the model.json file, read it
let model = await fs.readFileSync(jsonPath, 'utf-8') let model = await fs.readFileSync(jsonPath, 'utf-8')
@ -83,7 +85,10 @@ export const scanModelsFolder = async (): Promise<Model[]> => {
file.toLowerCase().endsWith('.gguf') || // GGUF file.toLowerCase().endsWith('.gguf') || // GGUF
file.toLowerCase().endsWith('.engine') // Tensort-LLM file.toLowerCase().endsWith('.engine') // Tensort-LLM
) )
})?.length >= (model.engine === InferenceEngine.nitro_tensorrt_llm ? 1 : (model.sources?.length ?? 1)) })?.length >=
(model.engine === InferenceEngine.nitro_tensorrt_llm
? 1
: (model.sources?.length ?? 1))
) )
}) })

View File

@ -259,15 +259,15 @@ const updateGpuInfo = async () =>
data.gpu_highest_vram = highestVramId data.gpu_highest_vram = highestVramId
} else { } else {
data.gpus = [] data.gpus = []
data.gpu_highest_vram = '' data.gpu_highest_vram = undefined
} }
if (!data.gpus_in_use || data.gpus_in_use.length === 0) { if (!data.gpus_in_use || data.gpus_in_use.length === 0) {
data.gpus_in_use = [data.gpu_highest_vram] data.gpus_in_use = data.gpu_highest_vram ? [data.gpu_highest_vram].filter(e => !!e) : []
} }
data = await updateCudaExistence(data) data = await updateCudaExistence(data)
console.log(data) console.log('[MONITORING]::Cuda info: ', data)
writeFileSync(GPU_INFO_FILE, JSON.stringify(data, null, 2)) writeFileSync(GPU_INFO_FILE, JSON.stringify(data, null, 2))
log(`[APP]::${JSON.stringify(data)}`) log(`[APP]::${JSON.stringify(data)}`)
resolve({}) resolve({})
@ -344,7 +344,7 @@ const updateCudaExistence = async (
data.cuda.version = match[1] data.cuda.version = match[1]
} }
} }
console.log(data) console.log('[MONITORING]::Finalized cuda info update: ', data)
resolve() resolve()
}) })
}) })

View File

@ -63,9 +63,6 @@ describe('ErrorMessage Component', () => {
render(<ErrorMessage message={message} />) render(<ErrorMessage message={message} />)
expect(
screen.getByText('Apologies, somethings amiss!')
).toBeInTheDocument()
expect(screen.getByText('troubleshooting assistance')).toBeInTheDocument() expect(screen.getByText('troubleshooting assistance')).toBeInTheDocument()
}) })

View File

@ -27,8 +27,6 @@ const ErrorMessage = ({ message }: { message: ThreadMessage }) => {
const getErrorTitle = () => { const getErrorTitle = () => {
switch (message.error_code) { switch (message.error_code) {
case ErrorCode.Unknown:
return 'Apologies, somethings amiss!'
case ErrorCode.InvalidApiKey: case ErrorCode.InvalidApiKey:
case ErrorCode.AuthenticationError: case ErrorCode.AuthenticationError:
case ErrorCode.InvalidRequestError: case ErrorCode.InvalidRequestError:
@ -55,17 +53,17 @@ const ErrorMessage = ({ message }: { message: ThreadMessage }) => {
) )
default: default:
return ( return (
<> <p>
{message.content[0]?.text?.value && ( {message.content[0]?.text?.value && (
<AutoLink text={message.content[0].text.value} /> <AutoLink text={message.content[0].text.value} />
)} )}
</> </p>
) )
} }
} }
return ( return (
<div className="mt-10"> <div className="mx-auto mt-10 max-w-[700px]">
{message.status === MessageStatus.Error && ( {message.status === MessageStatus.Error && (
<div <div
key={message.id} key={message.id}

View File

@ -25,6 +25,11 @@ const ListContainer = ({ children }: Props) => {
isUserManuallyScrollingUp.current = false isUserManuallyScrollingUp.current = false
} }
} }
if (isUserManuallyScrollingUp.current === true) {
event.preventDefault()
event.stopPropagation()
}
prevScrollTop.current = currentScrollTop prevScrollTop.current = currentScrollTop
}, []) }, [])

View File

@ -29,13 +29,21 @@ const DataLoader: React.FC<Props> = ({ children }) => {
const setQuickAskEnabled = useSetAtom(quickAskEnabledAtom) const setQuickAskEnabled = useSetAtom(quickAskEnabledAtom)
const setJanDefaultDataFolder = useSetAtom(defaultJanDataFolderAtom) const setJanDefaultDataFolder = useSetAtom(defaultJanDataFolderAtom)
const setJanSettingScreen = useSetAtom(janSettingScreenAtom) const setJanSettingScreen = useSetAtom(janSettingScreenAtom)
const { loadDataModel, configurePullOptions } = useModels()
useModels()
useThreads() useThreads()
useAssistants() useAssistants()
useGetSystemResources() useGetSystemResources()
useLoadTheme() useLoadTheme()
useEffect(() => {
// Load data once
loadDataModel()
// Configure pull options once
configurePullOptions()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => { useEffect(() => {
window.core?.api window.core?.api
?.getAppConfigurations() ?.getAppConfigurations()

View File

@ -180,7 +180,11 @@ export default function EventHandler({ children }: { children: ReactNode }) {
setIsGeneratingResponse(false) setIsGeneratingResponse(false)
} }
return return
} else if (message.status === MessageStatus.Error) { } else if (
message.status === MessageStatus.Error &&
activeModelRef.current?.engine &&
isLocalEngine(activeModelRef.current.engine)
) {
;(async () => { ;(async () => {
if ( if (
!(await extensionManager !(await extensionManager

View File

@ -112,8 +112,8 @@ const EventListenerWrapper = ({ children }: PropsWithChildren) => {
state.downloadState = 'end' state.downloadState = 'end'
setDownloadState(state) setDownloadState(state)
removeDownloadingModel(state.modelId) removeDownloadingModel(state.modelId)
events.emit(ModelEvent.OnModelsUpdate, { fetch: true })
} }
events.emit(ModelEvent.OnModelsUpdate, {})
}, },
[removeDownloadingModel, setDownloadState] [removeDownloadingModel, setDownloadState]
) )

View File

@ -43,7 +43,7 @@ const ModelImportListener = ({ children }: PropsWithChildren) => {
const onImportModelSuccess = useCallback( const onImportModelSuccess = useCallback(
(state: ImportingModel) => { (state: ImportingModel) => {
if (!state.modelId) return if (!state.modelId) return
events.emit(ModelEvent.OnModelsUpdate, {}) events.emit(ModelEvent.OnModelsUpdate, { fetch: true })
setImportingModelSuccess(state.importId, state.modelId) setImportingModelSuccess(state.importId, state.modelId)
}, },
[setImportingModelSuccess] [setImportingModelSuccess]

View File

@ -1,8 +1,8 @@
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
import { memo, useCallback, useEffect, useState } from 'react' import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { Button, useClipboard } from '@janhq/joi' import { Button, ScrollArea, useClipboard } from '@janhq/joi'
import { useAtomValue } from 'jotai' import { useAtomValue } from 'jotai'
import { FolderIcon, CheckIcon, CopyIcon } from 'lucide-react' import { FolderIcon, CheckIcon, CopyIcon } from 'lucide-react'
@ -22,6 +22,9 @@ const ServerLogs = (props: ServerLogsProps) => {
const { getLogs } = useLogs() const { getLogs } = useLogs()
const serverEnabled = useAtomValue(serverEnabledAtom) const serverEnabled = useAtomValue(serverEnabledAtom)
const [logs, setLogs] = useState<string[]>([]) const [logs, setLogs] = useState<string[]>([])
const listRef = useRef<HTMLDivElement>(null)
const prevScrollTop = useRef(0)
const isUserManuallyScrollingUp = useRef(false)
const updateLogs = useCallback( const updateLogs = useCallback(
() => () =>
@ -58,13 +61,45 @@ const ServerLogs = (props: ServerLogsProps) => {
const clipboard = useClipboard({ timeout: 1000 }) const clipboard = useClipboard({ timeout: 1000 })
const handleScroll = useCallback((event: React.UIEvent<HTMLElement>) => {
const currentScrollTop = event.currentTarget.scrollTop
if (prevScrollTop.current > currentScrollTop) {
isUserManuallyScrollingUp.current = true
} else {
const currentScrollTop = event.currentTarget.scrollTop
const scrollHeight = event.currentTarget.scrollHeight
const clientHeight = event.currentTarget.clientHeight
if (currentScrollTop + clientHeight >= scrollHeight) {
isUserManuallyScrollingUp.current = false
}
}
if (isUserManuallyScrollingUp.current === true) {
event.preventDefault()
event.stopPropagation()
}
prevScrollTop.current = currentScrollTop
}, [])
useEffect(() => {
if (isUserManuallyScrollingUp.current === true || !listRef.current) return
const scrollHeight = listRef.current?.scrollHeight ?? 0
listRef.current?.scrollTo({
top: scrollHeight,
behavior: 'instant',
})
}, [listRef.current?.scrollHeight, isUserManuallyScrollingUp, logs])
return ( return (
<div <ScrollArea
ref={listRef}
className={twMerge( className={twMerge(
'p-4 pb-0', 'h-[calc(100%-49px)] w-full p-4 py-0',
!withCopy && 'max-w-[38vw] lg:max-w-[40vw] xl:max-w-[50vw]',
logs.length === 0 && 'mx-auto' logs.length === 0 && 'mx-auto'
)} )}
onScroll={handleScroll}
> >
{withCopy && ( {withCopy && (
<div className="absolute right-2 top-7"> <div className="absolute right-2 top-7">
@ -107,7 +142,7 @@ const ServerLogs = (props: ServerLogsProps) => {
)} )}
<div className="flex h-full w-full flex-col"> <div className="flex h-full w-full flex-col">
{logs.length > 0 ? ( {logs.length > 0 ? (
<code className="inline-block whitespace-break-spaces text-[13px]"> <code className="inline-block max-w-[38vw] whitespace-break-spaces text-[13px] lg:max-w-[40vw] xl:max-w-[50vw]">
{logs.slice(-limit).map((log, i) => { {logs.slice(-limit).map((log, i) => {
return ( return (
<p key={i} className="my-2 leading-relaxed"> <p key={i} className="my-2 leading-relaxed">
@ -256,7 +291,7 @@ const ServerLogs = (props: ServerLogsProps) => {
</div> </div>
)} )}
</div> </div>
</div> </ScrollArea>
) )
} }

View File

@ -26,15 +26,13 @@ export const stateModelAtom = atom<ModelState>({
model: undefined, model: undefined,
}) })
const pendingModelLoadAtom = atom<boolean>(false)
export function useActiveModel() { export function useActiveModel() {
const [activeModel, setActiveModel] = useAtom(activeModelAtom) const [activeModel, setActiveModel] = useAtom(activeModelAtom)
const activeThread = useAtomValue(activeThreadAtom) const activeThread = useAtomValue(activeThreadAtom)
const [stateModel, setStateModel] = useAtom(stateModelAtom) const [stateModel, setStateModel] = useAtom(stateModelAtom)
const downloadedModels = useAtomValue(downloadedModelsAtom) const downloadedModels = useAtomValue(downloadedModelsAtom)
const setLoadModelError = useSetAtom(loadModelErrorAtom) const setLoadModelError = useSetAtom(loadModelErrorAtom)
const [pendingModelLoad, setPendingModelLoad] = useAtom(pendingModelLoadAtom) const pendingModelLoad = useRef(false)
const isVulkanEnabled = useAtomValue(vulkanEnabledAtom) const isVulkanEnabled = useAtomValue(vulkanEnabledAtom)
const downloadedModelsRef = useRef<Model[]>([]) const downloadedModelsRef = useRef<Model[]>([])
@ -55,7 +53,7 @@ export function useActiveModel() {
if (activeModel) { if (activeModel) {
await stopModel(activeModel) await stopModel(activeModel)
} }
setPendingModelLoad(true) pendingModelLoad.current = true
let model = downloadedModelsRef?.current.find((e) => e.id === modelId) let model = downloadedModelsRef?.current.find((e) => e.id === modelId)
@ -120,16 +118,16 @@ export function useActiveModel() {
undefined, undefined,
})) }))
if (!pendingModelLoad && abortable) { if (!pendingModelLoad.current && abortable) {
return Promise.reject(new Error('aborted')) return Promise.reject(new Error('aborted'))
} }
toaster({ toaster({
title: 'Failed!', title: 'Failed!',
description: `Model ${model.id} failed to start.`, description: `Model ${model.id} failed to start. ${error.message ?? ''}`,
type: 'error', type: 'error',
}) })
setLoadModelError(error) setLoadModelError(error.message ?? error)
return Promise.reject(error) return Promise.reject(error)
}) })
} }
@ -147,16 +145,10 @@ export function useActiveModel() {
.then(() => { .then(() => {
setActiveModel(undefined) setActiveModel(undefined)
setStateModel({ state: 'start', loading: false, model: undefined }) setStateModel({ state: 'start', loading: false, model: undefined })
setPendingModelLoad(false) pendingModelLoad.current = false
}) })
}, },
[ [activeModel, setStateModel, setActiveModel, stateModel]
activeModel,
setStateModel,
setActiveModel,
setPendingModelLoad,
stateModel,
]
) )
const stopInference = useCallback(async () => { const stopInference = useCallback(async () => {

View File

@ -9,7 +9,6 @@ import {
OptionType, OptionType,
events, events,
fs, fs,
baseName,
} from '@janhq/core' } from '@janhq/core'
import { atom, useAtomValue, useSetAtom } from 'jotai' import { atom, useAtomValue, useSetAtom } from 'jotai'

View File

@ -1,5 +1,5 @@
// useModels.test.ts // useModels.test.ts
import { renderHook, act } from '@testing-library/react' import { renderHook, act, waitFor } from '@testing-library/react'
import { events, ModelEvent, ModelManager } from '@janhq/core' import { events, ModelEvent, ModelManager } from '@janhq/core'
import { extensionManager } from '@/extension' import { extensionManager } from '@/extension'
@ -36,19 +36,98 @@ describe('useModels', () => {
}), }),
get: () => undefined, get: () => undefined,
has: () => true, has: () => true,
// set: () => {}
}, },
}) })
jest.spyOn(extensionManager, 'get').mockReturnValue(mockModelExtension) jest.spyOn(extensionManager, 'get').mockReturnValue(mockModelExtension)
act(() => { const { result } = renderHook(() => useModels())
renderHook(() => useModels()) await act(() => {
result.current?.loadDataModel()
}) })
expect(mockModelExtension.getModels).toHaveBeenCalled() expect(mockModelExtension.getModels).toHaveBeenCalled()
}) })
it('should return empty on error', async () => {
const mockModelExtension = {
getModels: jest.fn().mockRejectedValue(new Error('Error')),
} as any
;(ModelManager.instance as jest.Mock).mockReturnValue({
models: {
values: () => ({
toArray: () => ({
filter: () => models,
}),
}),
get: () => undefined,
has: () => true,
},
})
jest.spyOn(extensionManager, 'get').mockReturnValue(mockModelExtension)
const { result } = renderHook(() => useModels())
await act(() => {
result.current?.loadDataModel()
})
expect(mockModelExtension.getModels()).rejects.toThrow()
})
it('should update states on models update', async () => {
const mockModelExtension = {
getModels: jest.fn().mockResolvedValue(models),
} as any
;(ModelManager.instance as jest.Mock).mockReturnValue({
models: {
values: () => ({
toArray: () => ({
filter: () => models,
}),
}),
get: () => undefined,
has: () => true,
},
})
jest.spyOn(extensionManager, 'get').mockReturnValue(mockModelExtension)
jest.spyOn(events, 'on').mockImplementationOnce((event, cb) => {
cb({ fetch: false })
})
renderHook(() => useModels())
expect(mockModelExtension.getModels).not.toHaveBeenCalled()
})
it('should update states on models update', async () => {
const mockModelExtension = {
getModels: jest.fn().mockResolvedValue(models),
} as any
;(ModelManager.instance as jest.Mock).mockReturnValue({
models: {
values: () => ({
toArray: () => ({
filter: () => models,
}),
}),
get: () => undefined,
has: () => true,
},
})
jest.spyOn(extensionManager, 'get').mockReturnValue(mockModelExtension)
jest.spyOn(events, 'on').mockImplementationOnce((event, cb) => {
cb({ fetch: true })
})
renderHook(() => useModels())
expect(mockModelExtension.getModels).toHaveBeenCalled()
})
it('should remove event listener on unmount', async () => { it('should remove event listener on unmount', async () => {
const removeListenerSpy = jest.spyOn(events, 'off') const removeListenerSpy = jest.spyOn(events, 'off')

View File

@ -9,13 +9,18 @@ import {
ModelManager, ModelManager,
} from '@janhq/core' } from '@janhq/core'
import { useSetAtom } from 'jotai' import { useSetAtom, useAtom, useAtomValue } from 'jotai'
import { useDebouncedCallback } from 'use-debounce' import { useDebouncedCallback } from 'use-debounce'
import { isLocalEngine } from '@/utils/modelEngine' import { isLocalEngine } from '@/utils/modelEngine'
import { extensionManager } from '@/extension' import { extensionManager } from '@/extension'
import {
ignoreSslAtom,
proxyAtom,
proxyEnabledAtom,
} from '@/helpers/atoms/AppConfig.atom'
import { import {
configuredModelsAtom, configuredModelsAtom,
downloadedModelsAtom, downloadedModelsAtom,
@ -27,14 +32,17 @@ import {
* and updates the atoms accordingly. * and updates the atoms accordingly.
*/ */
const useModels = () => { const useModels = () => {
const setDownloadedModels = useSetAtom(downloadedModelsAtom) const [downloadedModels, setDownloadedModels] = useAtom(downloadedModelsAtom)
const setExtensionModels = useSetAtom(configuredModelsAtom) const setExtensionModels = useSetAtom(configuredModelsAtom)
const proxyEnabled = useAtomValue(proxyEnabledAtom)
const proxyUrl = useAtomValue(proxyAtom)
const proxyIgnoreSSL = useAtomValue(ignoreSslAtom)
const getData = useCallback(() => { const getData = useCallback(() => {
const getDownloadedModels = async () => { const getDownloadedModels = async () => {
const localModels = (await getModels()).map((e) => ({ const localModels = (await getModels()).map((e) => ({
...e, ...e,
name: ModelManager.instance().models.get(e.id)?.name ?? e.id, name: ModelManager.instance().models.get(e.id)?.name ?? e.name ?? e.id,
metadata: metadata:
ModelManager.instance().models.get(e.id)?.metadata ?? e.metadata, ModelManager.instance().models.get(e.id)?.metadata ?? e.metadata,
})) }))
@ -53,9 +61,11 @@ const useModels = () => {
setDownloadedModels(toUpdate) setDownloadedModels(toUpdate)
let isUpdated = false let isUpdated = false
toUpdate.forEach((model) => { toUpdate.forEach((model) => {
if (!ModelManager.instance().models.has(model.id)) { if (!ModelManager.instance().models.has(model.id)) {
ModelManager.instance().models.set(model.id, model) ModelManager.instance().models.set(model.id, model)
// eslint-disable-next-line react-hooks/exhaustive-deps
isUpdated = true isUpdated = true
} }
}) })
@ -75,21 +85,56 @@ const useModels = () => {
const reloadData = useDebouncedCallback(() => getData(), 300) const reloadData = useDebouncedCallback(() => getData(), 300)
useEffect(() => { const updateStates = useCallback(() => {
// Try get data on mount const cachedModels = ModelManager.instance().models.values().toArray()
reloadData() const toUpdate = [
...downloadedModels,
...cachedModels.filter(
(e: Model) => !downloadedModels.some((g: Model) => g.id === e.id)
),
]
setDownloadedModels(toUpdate)
}, [downloadedModels, setDownloadedModels])
const getModels = async (): Promise<Model[]> =>
extensionManager
.get<ModelExtension>(ExtensionTypeEnum.Model)
?.getModels()
.catch(() => []) ?? []
useEffect(() => {
// Listen for model updates // Listen for model updates
events.on(ModelEvent.OnModelsUpdate, async () => reloadData()) events.on(ModelEvent.OnModelsUpdate, async (data: { fetch?: boolean }) => {
if (data.fetch) reloadData()
else updateStates()
})
return () => { return () => {
// Remove listener on unmount // Remove listener on unmount
events.off(ModelEvent.OnModelsUpdate, async () => {}) events.off(ModelEvent.OnModelsUpdate, async () => {})
} }
}, [getData, reloadData]) }, [reloadData, updateStates])
const configurePullOptions = useCallback(() => {
extensionManager
.get<ModelExtension>(ExtensionTypeEnum.Model)
?.configurePullOptions(
proxyEnabled
? {
proxy_url: proxyUrl,
verify_peer_ssl: !proxyIgnoreSSL,
}
: {
proxy_url: '',
verify_peer_ssl: false,
}
)
}, [proxyEnabled, proxyUrl, proxyIgnoreSSL])
return {
loadDataModel: getData,
configurePullOptions,
}
} }
const getModels = async (): Promise<Model[]> =>
extensionManager.get<ModelExtension>(ExtensionTypeEnum.Model)?.getModels() ??
[]
export default useModels export default useModels

View File

@ -42,39 +42,6 @@ export const usePath = () => {
openFileExplorer(fullPath) openFileExplorer(fullPath)
} }
const onViewJson = async (type: string) => {
// TODO: this logic should be refactored.
if (type !== 'Model' && !activeThread) return
let filePath = undefined
const assistantId = activeThread?.assistants[0]?.assistant_id
switch (type) {
case 'Engine':
case 'Thread':
filePath = await joinPath([
'threads',
activeThread?.id ?? '',
'thread.json',
])
break
case 'Model':
if (!selectedModel) return
filePath = await joinPath(['models', selectedModel.id, 'model.json'])
break
case 'Assistant':
case 'Tools':
if (!assistantId) return
filePath = await joinPath(['assistants', assistantId, 'assistant.json'])
break
default:
break
}
if (!filePath) return
const fullPath = await joinPath([janDataFolderPath, filePath])
openFileExplorer(fullPath)
}
const onViewFile = async (id: string) => { const onViewFile = async (id: string) => {
if (!activeThread) return if (!activeThread) return
@ -99,7 +66,6 @@ export const usePath = () => {
return { return {
onRevealInFinder, onRevealInFinder,
onViewJson,
onViewFile, onViewFile,
onViewFileContainer, onViewFileContainer,
} }

View File

@ -27,7 +27,7 @@ import { MessageRequestBuilder } from '@/utils/messageRequestBuilder'
import { ThreadMessageBuilder } from '@/utils/threadMessageBuilder' import { ThreadMessageBuilder } from '@/utils/threadMessageBuilder'
import { loadModelErrorAtom, useActiveModel } from './useActiveModel' import { useActiveModel } from './useActiveModel'
import { extensionManager } from '@/extension/ExtensionManager' import { extensionManager } from '@/extension/ExtensionManager'
import { import {
@ -60,10 +60,8 @@ export default function useSendChatMessage() {
const currentMessages = useAtomValue(getCurrentChatMessagesAtom) const currentMessages = useAtomValue(getCurrentChatMessagesAtom)
const selectedModel = useAtomValue(selectedModelAtom) const selectedModel = useAtomValue(selectedModelAtom)
const { activeModel, startModel } = useActiveModel() const { activeModel, startModel } = useActiveModel()
const loadModelFailed = useAtomValue(loadModelErrorAtom)
const modelRef = useRef<Model | undefined>() const modelRef = useRef<Model | undefined>()
const loadModelFailedRef = useRef<string | undefined>()
const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom) const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom)
const engineParamsUpdate = useAtomValue(engineParamsUpdateAtom) const engineParamsUpdate = useAtomValue(engineParamsUpdateAtom)
@ -80,10 +78,6 @@ export default function useSendChatMessage() {
modelRef.current = activeModel modelRef.current = activeModel
}, [activeModel]) }, [activeModel])
useEffect(() => {
loadModelFailedRef.current = loadModelFailed
}, [loadModelFailed])
useEffect(() => { useEffect(() => {
activeThreadRef.current = activeThread activeThreadRef.current = activeThread
}, [activeThread]) }, [activeThread])

View File

@ -53,7 +53,7 @@ export const useSettings = () => {
const settings = await readSettings() const settings = await readSettings()
if (runMode != null) settings.run_mode = runMode if (runMode != null) settings.run_mode = runMode
if (notify != null) settings.notify = notify if (notify != null) settings.notify = notify
if (gpusInUse != null) settings.gpus_in_use = gpusInUse if (gpusInUse != null) settings.gpus_in_use = gpusInUse.filter((e) => !!e)
if (vulkan != null) { if (vulkan != null) {
settings.vulkan = vulkan settings.vulkan = vulkan
// GPU enabled, set run_mode to 'gpu' // GPU enabled, set run_mode to 'gpu'

View File

@ -13,6 +13,11 @@ const config = {
moduleNameMapper: { moduleNameMapper: {
// ... // ...
'^@/(.*)$': '<rootDir>/$1', '^@/(.*)$': '<rootDir>/$1',
'react-markdown': '<rootDir>/mock/empty-mock.tsx',
'rehype-highlight': '<rootDir>/mock/empty-mock.tsx',
'rehype-katex': '<rootDir>/mock/empty-mock.tsx',
'rehype-raw': '<rootDir>/mock/empty-mock.tsx',
'remark-math': '<rootDir>/mock/empty-mock.tsx',
}, },
// Add more setup options before each test is run // Add more setup options before each test is run
// setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'], // setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],

2
web/mock/empty-mock.tsx Normal file
View File

@ -0,0 +1,2 @@
const EmptyMock = {}
export default EmptyMock

View File

@ -1,6 +1,6 @@
{ {
"name": "@janhq/web", "name": "@janhq/web",
"version": "0.1.0", "version": "0.5.9",
"private": true, "private": true,
"homepage": "./", "homepage": "./",
"scripts": { "scripts": {
@ -14,13 +14,10 @@
"test": "jest" "test": "jest"
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.0.18",
"@hookform/resolvers": "^3.3.2",
"@janhq/core": "link:./core", "@janhq/core": "link:./core",
"@janhq/joi": "link:./joi", "@janhq/joi": "link:./joi",
"autoprefixer": "10.4.16", "autoprefixer": "10.4.16",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"csstype": "^3.0.10",
"framer-motion": "^10.16.4", "framer-motion": "^10.16.4",
"highlight.js": "^11.9.0", "highlight.js": "^11.9.0",
"jotai": "^2.6.0", "jotai": "^2.6.0",
@ -28,8 +25,6 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.291.0", "lucide-react": "^0.291.0",
"marked": "^9.1.2", "marked": "^9.1.2",
"marked-highlight": "^2.0.6",
"marked-katex-extension": "^5.0.2",
"next": "14.2.3", "next": "14.2.3",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"postcss": "8.4.31", "postcss": "8.4.31",
@ -39,22 +34,25 @@
"react-circular-progressbar": "^2.1.0", "react-circular-progressbar": "^2.1.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-dropzone": "14.2.3", "react-dropzone": "14.2.3",
"react-hook-form": "^7.47.0",
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
"react-icons": "^4.12.0", "react-icons": "^4.12.0",
"react-scroll-to-bottom": "^4.2.0", "react-markdown": "^9.0.1",
"react-toastify": "^9.1.3", "react-toastify": "^9.1.3",
"rehype-highlight": "^7.0.1",
"rehype-highlight-code-lines": "^1.0.4",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-math": "^6.0.0",
"sass": "^1.69.4", "sass": "^1.69.4",
"slate": "latest",
"slate-dom": "0.111.0",
"slate-history": "0.110.3",
"slate-react": "0.110.3",
"tailwind-merge": "^2.0.0", "tailwind-merge": "^2.0.0",
"tailwindcss": "3.3.5", "tailwindcss": "3.3.5",
"ulidx": "^2.3.0", "ulidx": "^2.3.0",
"use-debounce": "^10.0.0", "use-debounce": "^10.0.0",
"uuid": "^9.0.1", "uuid": "^9.0.1"
"zod": "^3.22.4",
"slate": "latest",
"slate-dom": "0.111.0",
"slate-react": "0.110.3",
"slate-history": "0.110.3"
}, },
"devDependencies": { "devDependencies": {
"@next/eslint-plugin-next": "^14.0.1", "@next/eslint-plugin-next": "^14.0.1",
@ -65,7 +63,7 @@
"@types/react": "18.2.34", "@types/react": "18.2.34",
"@types/react-dom": "18.2.14", "@types/react-dom": "18.2.14",
"@types/react-icons": "^3.0.0", "@types/react-icons": "^3.0.0",
"@types/react-scroll-to-bottom": "^4.2.4", "@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^9.0.6", "@types/uuid": "^9.0.6",
"@typescript-eslint/eslint-plugin": "^6.8.0", "@typescript-eslint/eslint-plugin": "^6.8.0",
"@typescript-eslint/parser": "^6.8.0", "@typescript-eslint/parser": "^6.8.0",

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Button, ScrollArea } from '@janhq/joi' import { Button } from '@janhq/joi'
import { CodeIcon, Paintbrush } from 'lucide-react' import { CodeIcon, Paintbrush } from 'lucide-react'
import { InfoIcon } from 'lucide-react' import { InfoIcon } from 'lucide-react'
@ -26,8 +26,8 @@ const LocalServerCenterPanel = () => {
return ( return (
<CenterPanelContainer> <CenterPanelContainer>
<div className="flex h-full w-full flex-col overflow-hidden"> <div className="flex h-full w-full flex-col">
<div className="sticky top-0 flex items-center justify-between border-b border-[hsla(var(--app-border))] px-4 py-2"> <div className="sticky top-0 z-10 flex items-center justify-between border-b border-[hsla(var(--app-border))] bg-[hsla(var(--app-bg))] px-4 py-2">
<h2 className="font-bold">Server Logs</h2> <h2 className="font-bold">Server Logs</h2>
<div className="space-x-2"> <div className="space-x-2">
<Button <Button
@ -72,9 +72,7 @@ const LocalServerCenterPanel = () => {
</div> </div>
</div> </div>
) : ( ) : (
<ScrollArea className="h-full w-full"> <ServerLogs />
<ServerLogs />
</ScrollArea>
)} )}
</div> </div>
</CenterPanelContainer> </CenterPanelContainer>

View File

@ -29,6 +29,7 @@ const LocalServerLeftPanel = () => {
const [errorRangePort, setErrorRangePort] = useState(false) const [errorRangePort, setErrorRangePort] = useState(false)
const [errorPrefix, setErrorPrefix] = useState(false) const [errorPrefix, setErrorPrefix] = useState(false)
const [serverEnabled, setServerEnabled] = useAtom(serverEnabledAtom) const [serverEnabled, setServerEnabled] = useAtom(serverEnabledAtom)
const [isLoading, setIsLoading] = useState(false)
const { startModel, stateModel } = useActiveModel() const { startModel, stateModel } = useActiveModel()
const selectedModel = useAtomValue(selectedModelAtom) const selectedModel = useAtomValue(selectedModelAtom)
@ -66,6 +67,7 @@ const LocalServerLeftPanel = () => {
const onStartServerClick = async () => { const onStartServerClick = async () => {
if (selectedModel == null) return if (selectedModel == null) return
try { try {
setIsLoading(true)
const isStarted = await window.core?.api?.startServer({ const isStarted = await window.core?.api?.startServer({
host, host,
port, port,
@ -79,8 +81,10 @@ const LocalServerLeftPanel = () => {
setFirstTimeVisitAPIServer(false) setFirstTimeVisitAPIServer(false)
} }
startModel(selectedModel.id, false).catch((e) => console.error(e)) startModel(selectedModel.id, false).catch((e) => console.error(e))
setIsLoading(false)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
setIsLoading(false)
toaster({ toaster({
title: `Failed to start server!`, title: `Failed to start server!`,
description: 'Please check Server Logs for more details.', description: 'Please check Server Logs for more details.',
@ -93,6 +97,7 @@ const LocalServerLeftPanel = () => {
window.core?.api?.stopServer() window.core?.api?.stopServer()
setServerEnabled(false) setServerEnabled(false)
setLoadModelError(undefined) setLoadModelError(undefined)
setIsLoading(false)
} }
const onToggleServer = async () => { const onToggleServer = async () => {
@ -117,6 +122,7 @@ const LocalServerLeftPanel = () => {
block block
theme={serverEnabled ? 'destructive' : 'primary'} theme={serverEnabled ? 'destructive' : 'primary'}
disabled={ disabled={
isLoading ||
stateModel.loading || stateModel.loading ||
errorRangePort || errorRangePort ||
errorPrefix || errorPrefix ||
@ -124,7 +130,11 @@ const LocalServerLeftPanel = () => {
} }
onClick={onToggleServer} onClick={onToggleServer}
> >
{serverEnabled ? 'Stop' : 'Start'} Server {isLoading
? 'Starting...'
: serverEnabled
? 'Stop Server'
: 'Start Server'}
</Button> </Button>
{serverEnabled && ( {serverEnabled && (
<Button variant="soft" asChild className="whitespace-nowrap"> <Button variant="soft" asChild className="whitespace-nowrap">

View File

@ -19,8 +19,10 @@ import { getConfigurationsData } from '@/utils/componentSettings'
import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom'
import { selectedModelAtom } from '@/helpers/atoms/Model.atom' import { selectedModelAtom } from '@/helpers/atoms/Model.atom'
import { getActiveThreadModelParamsAtom } from '@/helpers/atoms/Thread.atom'
const LocalServerRightPanel = () => { const LocalServerRightPanel = () => {
const activeModelParams = useAtomValue(getActiveThreadModelParamsAtom)
const loadModelError = useAtomValue(loadModelErrorAtom) const loadModelError = useAtomValue(loadModelErrorAtom)
const serverEnabled = useAtomValue(serverEnabledAtom) const serverEnabled = useAtomValue(serverEnabledAtom)
const setModalTroubleShooting = useSetAtom(modalTroubleShootingAtom) const setModalTroubleShooting = useSetAtom(modalTroubleShootingAtom)
@ -48,8 +50,17 @@ const LocalServerRightPanel = () => {
selectedModel selectedModel
) )
const modelEngineParams = extractModelLoadParams(
{
...selectedModel?.settings,
...activeModelParams,
},
selectedModel?.settings
)
const componentDataEngineSetting = getConfigurationsData( const componentDataEngineSetting = getConfigurationsData(
currentModelSettingParams modelEngineParams,
selectedModel
) )
const engineSettings = useMemo( const engineSettings = useMemo(
@ -57,6 +68,7 @@ const LocalServerRightPanel = () => {
componentDataEngineSetting.filter( componentDataEngineSetting.filter(
(x) => x.key !== 'prompt_template' && x.key !== 'embedding' (x) => x.key !== 'prompt_template' && x.key !== 'embedding'
), ),
[componentDataEngineSetting] [componentDataEngineSetting]
) )

View File

@ -20,9 +20,12 @@ import { AlertTriangleIcon, AlertCircleIcon } from 'lucide-react'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
import { useDebouncedCallback } from 'use-debounce'
import { snackbar, toaster } from '@/containers/Toast' import { snackbar, toaster } from '@/containers/Toast'
import { useActiveModel } from '@/hooks/useActiveModel' import { useActiveModel } from '@/hooks/useActiveModel'
import useModels from '@/hooks/useModels'
import { useSettings } from '@/hooks/useSettings' import { useSettings } from '@/hooks/useSettings'
import DataFolder from './DataFolder' import DataFolder from './DataFolder'
@ -65,6 +68,7 @@ const Advanced = () => {
const [dropdownOptions, setDropdownOptions] = useState<HTMLDivElement | null>( const [dropdownOptions, setDropdownOptions] = useState<HTMLDivElement | null>(
null null
) )
const { configurePullOptions } = useModels()
const [toggle, setToggle] = useState<HTMLDivElement | null>(null) const [toggle, setToggle] = useState<HTMLDivElement | null>(null)
@ -78,6 +82,15 @@ const Advanced = () => {
return y['name'] return y['name']
}) })
/**
* There could be a case where the state update is not synced
* so that retrieving state value from other hooks would not be accurate
* there is also a case where state update persist everytime user type in the input
*/
const updatePullOptions = useDebouncedCallback(
() => configurePullOptions(),
300
)
/** /**
* Handle proxy change * Handle proxy change
*/ */
@ -90,8 +103,9 @@ const Advanced = () => {
} else { } else {
setProxy('') setProxy('')
} }
updatePullOptions()
}, },
[setPartialProxy, setProxy] [setPartialProxy, setProxy, updatePullOptions]
) )
/** /**
@ -193,7 +207,12 @@ const Advanced = () => {
let updatedGpusInUse = [...gpusInUse] let updatedGpusInUse = [...gpusInUse]
if (updatedGpusInUse.includes(gpuId)) { if (updatedGpusInUse.includes(gpuId)) {
updatedGpusInUse = updatedGpusInUse.filter((id) => id !== gpuId) updatedGpusInUse = updatedGpusInUse.filter((id) => id !== gpuId)
if (gpuEnabled && updatedGpusInUse.length === 0) { if (
gpuEnabled &&
updatedGpusInUse.length === 0 &&
gpuId &&
gpuId.trim()
) {
// Vulkan support only allow 1 active device at a time // Vulkan support only allow 1 active device at a time
if (vulkanEnabled) { if (vulkanEnabled) {
updatedGpusInUse = [] updatedGpusInUse = []
@ -205,11 +224,13 @@ const Advanced = () => {
if (vulkanEnabled) { if (vulkanEnabled) {
updatedGpusInUse = [] updatedGpusInUse = []
} }
updatedGpusInUse.push(gpuId) if (gpuId && gpuId.trim()) updatedGpusInUse.push(gpuId)
} }
setGpusInUse(updatedGpusInUse) setGpusInUse(updatedGpusInUse)
await saveSettings({ gpusInUse: updatedGpusInUse }) await saveSettings({ gpusInUse: updatedGpusInUse.filter((e) => !!e) })
window.core?.api?.relaunch() // Reload window to apply changes
// This will trigger engine servers to restart
window.location.reload()
} }
const gpuSelectionPlaceHolder = const gpuSelectionPlaceHolder =
@ -452,7 +473,10 @@ const Advanced = () => {
<Switch <Switch
data-testid="proxy-switch" data-testid="proxy-switch"
checked={proxyEnabled} checked={proxyEnabled}
onChange={() => setProxyEnabled(!proxyEnabled)} onChange={() => {
setProxyEnabled(!proxyEnabled)
updatePullOptions()
}}
/> />
<div className="w-full"> <div className="w-full">
<Input <Input
@ -481,7 +505,10 @@ const Advanced = () => {
<Switch <Switch
data-testid="ignore-ssl-switch" data-testid="ignore-ssl-switch"
checked={ignoreSSL} checked={ignoreSSL}
onChange={(e) => setIgnoreSSL(e.target.checked)} onChange={(e) => {
setIgnoreSSL(e.target.checked)
updatePullOptions()
}}
/> />
</div> </div>

View File

@ -123,7 +123,6 @@ const InputExtraActions: React.FC<InputActionProps> = ({
return ( return (
<div className="flex flex-row space-x-2"> <div className="flex flex-row space-x-2">
{actions.map((action) => { {actions.map((action) => {
console.log(action)
switch (action) { switch (action) {
case 'copy': case 'copy':
return copied ? ( return copied ? (

View File

@ -3,8 +3,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { MessageStatus } from '@janhq/core' import { MessageStatus } from '@janhq/core'
import hljs from 'highlight.js' import hljs from 'highlight.js'
import { useAtom, useAtomValue } from 'jotai' import { useAtom, useAtomValue } from 'jotai'
import { BaseEditor, createEditor, Editor, Transforms } from 'slate' import { BaseEditor, createEditor, Editor, Transforms } from 'slate'
import { withHistory } from 'slate-history' // Import withHistory import { withHistory } from 'slate-history' // Import withHistory
import { import {
@ -270,7 +270,8 @@ const RichTextEditor = ({
textareaRef.current.style.height = activeSettingInputBox textareaRef.current.style.height = activeSettingInputBox
? '100px' ? '100px'
: '40px' : '40px'
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px' textareaRef.current.style.height =
textareaRef.current.scrollHeight + 2 + 'px'
textareaRef.current?.scrollTo({ textareaRef.current?.scrollTo({
top: textareaRef.current.scrollHeight, top: textareaRef.current.scrollHeight,
behavior: 'instant', behavior: 'instant',

View File

@ -249,19 +249,11 @@ const ChatInput = () => {
<li <li
className={twMerge( className={twMerge(
'text-[hsla(var(--text-secondary)] hover:bg-secondary flex w-full cursor-pointer items-center space-x-2 px-4 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]', 'text-[hsla(var(--text-secondary)] hover:bg-secondary flex w-full cursor-pointer items-center space-x-2 px-4 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]',
activeThread?.assistants[0].model.settings?.text_model === 'cursor-pointer'
false
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer'
)} )}
onClick={() => { onClick={() => {
if ( fileInputRef.current?.click()
activeThread?.assistants[0].model.settings setShowAttacmentMenus(false)
?.text_model !== false
) {
fileInputRef.current?.click()
setShowAttacmentMenus(false)
}
}} }}
> >
<FileTextIcon size={16} /> <FileTextIcon size={16} />
@ -270,22 +262,11 @@ const ChatInput = () => {
} }
content={ content={
(!activeThread?.assistants[0].tools || (!activeThread?.assistants[0].tools ||
!activeThread?.assistants[0].tools[0]?.enabled || !activeThread?.assistants[0].tools[0]?.enabled) && (
activeThread?.assistants[0].model.settings?.text_model === <span>
false) && ( Turn on Retrieval in Assistant Settings to use this
<> feature.
{activeThread?.assistants[0].model.settings </span>
?.text_model === false ? (
<span>
This model does not support text-based retrieval.
</span>
) : (
<span>
Turn on Retrieval in Assistant Settings to use this
feature.
</span>
)}
</>
) )
} }
/> />

View File

@ -9,8 +9,6 @@ import { MainViewState } from '@/constants/screens'
import { loadModelErrorAtom } from '@/hooks/useActiveModel' import { loadModelErrorAtom } from '@/hooks/useActiveModel'
import { useSettings } from '@/hooks/useSettings'
import { mainViewStateAtom } from '@/helpers/atoms/App.atom' import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
import { selectedSettingAtom } from '@/helpers/atoms/Setting.atom' import { selectedSettingAtom } from '@/helpers/atoms/Setting.atom'
import { activeThreadAtom } from '@/helpers/atoms/Thread.atom' import { activeThreadAtom } from '@/helpers/atoms/Thread.atom'
@ -21,25 +19,9 @@ const LoadModelError = () => {
const setMainState = useSetAtom(mainViewStateAtom) const setMainState = useSetAtom(mainViewStateAtom)
const setSelectedSettingScreen = useSetAtom(selectedSettingAtom) const setSelectedSettingScreen = useSetAtom(selectedSettingAtom)
const activeThread = useAtomValue(activeThreadAtom) const activeThread = useAtomValue(activeThreadAtom)
const { settings } = useSettings()
const PORT_NOT_AVAILABLE = 'PORT_NOT_AVAILABLE'
const ErrorMessage = () => { const ErrorMessage = () => {
if (loadModelError === PORT_NOT_AVAILABLE) { if (
return (
<p>
Port 3928 is currently unavailable. Check for conflicting apps, or
access&nbsp;
<span
className="cursor-pointer text-[hsla(var(--app-link))]"
onClick={() => setModalTroubleShooting(true)}
>
troubleshooting assistance
</span>
</p>
)
} else if (
typeof loadModelError?.includes === 'function' && typeof loadModelError?.includes === 'function' &&
loadModelError.includes('EXTENSION_IS_NOT_INSTALLED') loadModelError.includes('EXTENSION_IS_NOT_INSTALLED')
) { ) {
@ -66,10 +48,10 @@ const LoadModelError = () => {
) )
} else { } else {
return ( return (
<div> <div className="mx-6 flex flex-col items-center space-y-2 text-center font-medium text-[hsla(var(--text-secondary))]">
Apologies, {`Something's wrong.`}.&nbsp; {loadModelError && <p>{loadModelError}</p>}
<p> <p>
Access&nbsp; {`Something's wrong.`}&nbsp;Access&nbsp;
<span <span
className="cursor-pointer text-[hsla(var(--app-link))]" className="cursor-pointer text-[hsla(var(--app-link))]"
onClick={() => setModalTroubleShooting(true)} onClick={() => setModalTroubleShooting(true)}

View File

@ -1,4 +1,9 @@
import React, { useEffect, useRef, useState } from 'react' /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/naming-convention */
import React, { useEffect, useState } from 'react'
import Markdown from 'react-markdown'
import { import {
ChatCompletionRole, ChatCompletionRole,
@ -8,14 +13,15 @@ import {
} from '@janhq/core' } from '@janhq/core'
import { Tooltip } from '@janhq/joi' import { Tooltip } from '@janhq/joi'
import hljs from 'highlight.js'
import { useAtomValue } from 'jotai' import { useAtomValue } from 'jotai'
import { FolderOpenIcon } from 'lucide-react' import { FolderOpenIcon } from 'lucide-react'
import { Marked, Renderer } from 'marked' import rehypeHighlight from 'rehype-highlight'
import { markedHighlight } from 'marked-highlight' import rehypeHighlightCodeLines from 'rehype-highlight-code-lines'
import markedKatex from 'marked-katex-extension' import rehypeKatex from 'rehype-katex'
import rehypeRaw from 'rehype-raw'
import remarkMath from 'remark-math'
import 'katex/dist/katex.min.css'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
import LogoMark from '@/containers/Brand/Logo/Mark' import LogoMark from '@/containers/Brand/Logo/Mark'
@ -23,6 +29,7 @@ import LogoMark from '@/containers/Brand/Logo/Mark'
import { useClipboard } from '@/hooks/useClipboard' import { useClipboard } from '@/hooks/useClipboard'
import { usePath } from '@/hooks/usePath' import { usePath } from '@/hooks/usePath'
import { getLanguageFromExtension } from '@/utils/codeLanguageExtension'
import { toGibibytes } from '@/utils/converter' import { toGibibytes } from '@/utils/converter'
import { displayDate } from '@/utils/datetime' import { displayDate } from '@/utils/datetime'
@ -53,88 +60,183 @@ const SimpleTextMessage: React.FC<ThreadMessage> = (props) => {
const clipboard = useClipboard({ timeout: 1000 }) const clipboard = useClipboard({ timeout: 1000 })
function escapeHtml(html: string): string { function extractCodeLines(node: { children: { children: any[] }[] }) {
return html const codeLines: any[] = []
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;') // Helper function to extract text recursively from children
.replace(/>/g, '&gt;') function getTextFromNode(node: {
.replace(/"/g, '&quot;') type: string
.replace(/'/g, '&#039;') value: any
children: any[]
}): string {
if (node.type === 'text') {
return node.value
} else if (node.children) {
return node.children.map(getTextFromNode).join('')
}
return ''
}
// Traverse each line in the <code> block
node.children[0].children.forEach(
(lineNode: {
type: string
tagName: string
value: any
children: any[]
}) => {
if (lineNode.type === 'element' && lineNode.tagName === 'span') {
const lineContent = getTextFromNode(lineNode)
codeLines.push(lineContent)
}
}
)
// Join the lines with newline characters for proper formatting
return codeLines.join('\n')
}
function wrapCodeBlocksWithoutVisit() {
return (tree: { children: any[] }) => {
tree.children = tree.children.map((node) => {
if (node.tagName === 'pre' && node.children[0]?.tagName === 'code') {
const language = node.children[0].properties.className?.[1]?.replace(
'language-',
''
)
if (extractCodeLines(node) === '') {
return node
}
return {
type: 'element',
tagName: 'div',
properties: {
className: ['code-block-wrapper'],
},
children: [
{
type: 'element',
tagName: 'div',
properties: {
className: [
'code-block',
'group/item',
'relative',
'my-4',
'overflow-auto',
],
},
children: [
{
type: 'element',
tagName: 'div',
properties: {
className:
'code-header bg-[hsla(var(--app-code-block))] flex justify-between items-center py-2 px-3 border-b border-[hsla(var(--app-border))] rounded-t-lg',
},
children: [
{
type: 'element',
tagName: 'span',
properties: {
className: 'text-xs font-medium text-gray-300',
},
children: [
{
type: 'text',
value: language
? `${getLanguageFromExtension(language)}`
: '',
},
],
},
{
type: 'element',
tagName: 'button',
properties: {
className:
'copy-button ml-auto flex items-center gap-1 text-xs font-medium text-gray-400 hover:text-gray-600 focus:outline-none',
onClick: (event: Event) => {
clipboard.copy(extractCodeLines(node))
const button = event.currentTarget as HTMLElement
button.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check pointer-events-none text-green-600"><path d="M20 6 9 17l-5-5"/></svg>
<span>Copied</span>
`
setTimeout(() => {
button.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy pointer-events-none text-gray-400"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
<span>Copy</span>
`
}, 2000)
},
},
children: [
{
type: 'element',
tagName: 'svg',
properties: {
xmlns: 'http://www.w3.org/2000/svg',
width: '16',
height: '16',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: '2',
strokeLinecap: 'round',
strokeLinejoin: 'round',
className:
'lucide lucide-copy pointer-events-none text-gray-400',
},
children: [
{
type: 'element',
tagName: 'rect',
properties: {
width: '14',
height: '14',
x: '8',
y: '8',
rx: '2',
ry: '2',
},
children: [],
},
{
type: 'element',
tagName: 'path',
properties: {
d: 'M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2',
},
children: [],
},
],
},
{ type: 'text', value: 'Copy' },
],
},
],
},
node,
],
},
],
}
}
return node
})
}
} }
const marked: Marked = new Marked(
markedHighlight({
langPrefix: 'hljs',
highlight(code, lang) {
if (lang === undefined || lang === '') {
return hljs.highlight(code, { language: 'plaintext' }).value
}
try {
return hljs.highlight(code, { language: lang }).value
} catch (err) {
return hljs.highlight(code, { language: 'javascript' }).value
}
},
}),
{
renderer: {
html: (html: string) => {
return escapeHtml(html) // Escape any HTML
},
link: (href, title, text) => {
return Renderer.prototype.link
?.apply(this, [href, title, text])
.replace('<a', "<a target='_blank'")
},
code(code, lang) {
return `
<div class="relative code-block group/item overflow-auto">
<button class='text-xs copy-action hidden group-hover/item:block p-2 rounded-lg absolute top-6 right-2'>
${
clipboard.copied
? `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check pointer-events-none text-green-600"><path d="M20 6 9 17l-5-5"/></svg>`
: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy pointer-events-none text-gray-400"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`
}
</button>
<pre class="hljs">
<code class="language-${lang ?? ''}">${code}</code>
</pre>
</div>
`
},
},
}
)
marked.use(markedKatex({ throwOnError: false }))
const { onViewFile, onViewFileContainer } = usePath() const { onViewFile, onViewFileContainer } = usePath()
const parsedText = marked.parse(text)
const [tokenCount, setTokenCount] = useState(0) const [tokenCount, setTokenCount] = useState(0)
const [lastTimestamp, setLastTimestamp] = useState<number | undefined>() const [lastTimestamp, setLastTimestamp] = useState<number | undefined>()
const [tokenSpeed, setTokenSpeed] = useState(0) const [tokenSpeed, setTokenSpeed] = useState(0)
const messages = useAtomValue(getCurrentChatMessagesAtom) const messages = useAtomValue(getCurrentChatMessagesAtom)
const codeBlockCopyEvent = useRef((e: Event) => {
const target: HTMLElement = e.target as HTMLElement
if (typeof target.className !== 'string') return null
const isCopyActionClassName = target?.className.includes('copy-action')
if (isCopyActionClassName) {
const content = target?.parentNode?.querySelector('code')?.innerText ?? ''
clipboard.copy(content)
}
})
useEffect(() => {
document.addEventListener('click', codeBlockCopyEvent.current)
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
document.removeEventListener('click', codeBlockCopyEvent.current)
}
}, [])
useEffect(() => { useEffect(() => {
if (props.status !== MessageStatus.Pending) { if (props.status !== MessageStatus.Pending) {
return return
@ -283,10 +385,24 @@ const SimpleTextMessage: React.FC<ThreadMessage> = (props) => {
) : ( ) : (
<div <div
className={twMerge( className={twMerge(
'message max-width-[100%] flex flex-col gap-y-2 overflow-auto break-all leading-relaxed ' 'message max-width-[100%] flex flex-col gap-y-2 overflow-auto leading-relaxed'
)} )}
dangerouslySetInnerHTML={{ __html: parsedText }} dir="ltr"
/> >
<Markdown
remarkPlugins={[remarkMath]}
rehypePlugins={[
[rehypeKatex, { throwOnError: false }],
rehypeRaw,
rehypeHighlight,
[rehypeHighlightCodeLines, { showLineNumbers: true }],
wrapCodeBlocksWithoutVisit,
]}
skipHtml={true}
>
{text}
</Markdown>
</div>
)} )}
</> </>
</div> </div>

View File

@ -55,22 +55,25 @@
.hljs { .hljs {
overflow: auto; overflow: auto;
display: block; display: block;
width: auto;
background: hsla(var(--app-code-block));
color: #f8f8f2;
padding: 16px;
font-size: 14px; font-size: 14px;
word-wrap: normal; border-bottom-left-radius: 0.4rem;
border-bottom-right-radius: 0.4rem;
color: #f8f8f2;
}
pre {
background: hsla(var(--app-code-block));
overflow: auto;
border-radius: 0.4rem; border-radius: 0.4rem;
margin-top: 1rem;
margin-bottom: 1rem;
white-space: normal;
} }
pre > code { pre > code {
display: block;
text-indent: 0;
white-space: pre; white-space: pre;
max-width: 10vw; font-size: 14px;
overflow: auto;
color: #f8f8f2;
display: block;
padding: 16px;
} }
.hljs-emphasis { .hljs-emphasis {
@ -81,6 +84,14 @@ pre > code {
font-weight: bold; font-weight: bold;
} }
.code-block {
pre {
padding: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
}
@media screen and (-ms-high-contrast: active) { @media screen and (-ms-high-contrast: active) {
.hljs-addition, .hljs-addition,
.hljs-attribute, .hljs-attribute,
@ -105,3 +116,51 @@ pre > code {
font-weight: bold; font-weight: bold;
} }
} }
.code-block-wrapper {
white-space: nowrap;
}
.code-line {
// padding-left: 12px;
padding-right: 12px;
margin-left: -12px;
margin-right: -12px;
border-left: 4px solid transparent;
}
div.code-line:empty {
height: 21.5938px;
}
span.code-line {
// min-width: 100%;
white-space: pre;
display: inline-block;
max-width: 10vw;
}
.code-line.inserted {
background-color: var(--color-inserted-line);
}
.code-line.deleted {
background-color: var(--color-deleted-line);
}
.highlighted-code-line {
background-color: var(--color-highlighted-line);
border-left: 4px solid var(--color-highlighted-line-indicator);
}
.numbered-code-line::before {
content: attr(data-line-number);
margin-left: -4px;
margin-right: 16px;
width: 1.2rem;
font-size: 12px;
color: hsla(var(--text-tertiary));
text-align: right;
display: inline-block;
}

View File

@ -27,11 +27,3 @@
@apply inline-flex flex-col border-s-4 border-[hsla(var(--primary-bg))] bg-[hsla(var(--primary-bg-soft))] px-4 py-2; @apply inline-flex flex-col border-s-4 border-[hsla(var(--primary-bg))] bg-[hsla(var(--primary-bg-soft))] px-4 py-2;
} }
} }
.code-block {
white-space: normal;
}
pre {
max-width: 95vw;
}

View File

@ -0,0 +1,34 @@
// Utility function using switch-case for extension to language mapping
export function getLanguageFromExtension(extension: string): string {
switch (extension.toLowerCase()) {
case 'ts':
case 'tsx':
return 'typescript'
case 'js':
case 'jsx':
return 'javascript'
case 'py':
return 'python'
case 'java':
return 'java'
case 'rb':
return 'ruby'
case 'cs':
return 'csharp'
case 'md':
return 'markdown'
case 'yaml':
case 'yml':
return 'yaml'
case 'sh':
return 'bash'
case 'rs':
return 'rust'
case 'kt':
return 'kotlin'
case 'swift':
return 'swift'
default:
return extension
}
}