diff --git a/.github/workflows/jan-electron-build-nightly.yml b/.github/workflows/jan-electron-build-nightly.yml index 1b29b84af..d79080990 100644 --- a/.github/workflows/jan-electron-build-nightly.yml +++ b/.github/workflows/jan-electron-build-nightly.yml @@ -114,8 +114,8 @@ jobs: - name: Upload latest-mac.yml if: ${{ needs.set-public-provider.outputs.public_provider == 'aws-s3' }} run: | - 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-nightly/ s3://${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}/nightly/ + aws s3 cp ./latest-mac.yml "s3://${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}/temp-latest/latest-mac.yml" + aws s3 sync s3://${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}/temp-latest/ s3://${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}/latest/ env: AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }} @@ -123,35 +123,35 @@ jobs: AWS_EC2_METADATA_DISABLED: "true" - # 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] - # secrets: inherit - # if: github.event_name == 'schedule' - # uses: ./.github/workflows/template-noti-discord-and-update-url-readme.yml - # with: - # ref: refs/heads/dev - # build_reason: Nightly - # push_to_branch: dev - # new_version: ${{ needs.get-update-version.outputs.new_version }} + 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] + secrets: inherit + if: github.event_name == 'schedule' + uses: ./.github/workflows/template-noti-discord-and-update-url-readme.yml + with: + ref: refs/heads/dev + build_reason: Nightly + push_to_branch: dev + new_version: ${{ needs.get-update-version.outputs.new_version }} - # noti-discord-pre-release-and-update-url-readme: - # needs: [build-macos-x64, build-macos-arm64, build-windows-x64, build-linux-x64, get-update-version, set-public-provider, combine-latest-mac-yml] - # secrets: inherit - # if: github.event_name == 'push' - # uses: ./.github/workflows/template-noti-discord-and-update-url-readme.yml - # with: - # ref: refs/heads/dev - # build_reason: Pre-release - # push_to_branch: dev - # new_version: ${{ needs.get-update-version.outputs.new_version }} + noti-discord-pre-release-and-update-url-readme: + needs: [build-macos-x64, build-macos-arm64, build-windows-x64, build-linux-x64, get-update-version, set-public-provider, combine-latest-mac-yml] + secrets: inherit + if: github.event_name == 'push' + uses: ./.github/workflows/template-noti-discord-and-update-url-readme.yml + with: + ref: refs/heads/dev + build_reason: Pre-release + push_to_branch: dev + new_version: ${{ needs.get-update-version.outputs.new_version }} - # noti-discord-manual-and-update-url-readme: - # needs: [build-macos-x64, build-macos-arm64, build-windows-x64, build-linux-x64, get-update-version, set-public-provider, combine-latest-mac-yml] - # secrets: inherit - # if: github.event_name == 'workflow_dispatch' && github.event.inputs.public_provider == 'aws-s3' - # uses: ./.github/workflows/template-noti-discord-and-update-url-readme.yml - # with: - # ref: refs/heads/dev - # build_reason: Manual - # push_to_branch: dev - # new_version: ${{ needs.get-update-version.outputs.new_version }} + noti-discord-manual-and-update-url-readme: + needs: [build-macos-x64, build-macos-arm64, build-windows-x64, build-linux-x64, get-update-version, set-public-provider, combine-latest-mac-yml] + secrets: inherit + if: github.event_name == 'workflow_dispatch' && github.event.inputs.public_provider == 'aws-s3' + uses: ./.github/workflows/template-noti-discord-and-update-url-readme.yml + with: + ref: refs/heads/dev + build_reason: Manual + push_to_branch: dev + new_version: ${{ needs.get-update-version.outputs.new_version }} diff --git a/.github/workflows/template-build-linux-x64.yml b/.github/workflows/template-build-linux-x64.yml index 0280b1014..92188c364 100644 --- a/.github/workflows/template-build-linux-x64.yml +++ b/.github/workflows/template-build-linux-x64.yml @@ -60,7 +60,7 @@ jobs: mv /tmp/package.json electron/package.json jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json mv /tmp/package.json web/package.json - jq '.build.publish = [{"provider": "generic", "url": "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 + 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 mv /tmp/package.json electron/package.json cat electron/package.json # chmod +x .github/scripts/rename-app.sh diff --git a/.github/workflows/template-build-macos-arm64.yml b/.github/workflows/template-build-macos-arm64.yml index e23ee5ed5..a23e34cf9 100644 --- a/.github/workflows/template-build-macos-arm64.yml +++ b/.github/workflows/template-build-macos-arm64.yml @@ -72,7 +72,7 @@ jobs: jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json mv /tmp/package.json web/package.json - jq '.build.publish = [{"provider": "generic", "url": "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 + 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 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 diff --git a/.github/workflows/template-build-macos-x64.yml b/.github/workflows/template-build-macos-x64.yml index 06a9baaa1..18309fca0 100644 --- a/.github/workflows/template-build-macos-x64.yml +++ b/.github/workflows/template-build-macos-x64.yml @@ -72,7 +72,7 @@ jobs: jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json mv /tmp/package.json web/package.json - jq '.build.publish = [{"provider": "generic", "url": "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 + 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 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 diff --git a/.github/workflows/template-build-windows-x64.yml b/.github/workflows/template-build-windows-x64.yml index c683392f5..2a1d3f15b 100644 --- a/.github/workflows/template-build-windows-x64.yml +++ b/.github/workflows/template-build-windows-x64.yml @@ -73,7 +73,7 @@ jobs: jq --arg version "${{ inputs.new_version }}" '.version = $version' web/package.json > /tmp/package.json mv /tmp/package.json web/package.json - jq '.build.publish = [{"provider": "generic", "url": "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 + 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 mv /tmp/package.json electron/package.json jq '.build.win.sign = "./sign.js"' electron/package.json > /tmp/package.json diff --git a/core/src/browser/extensions/model.ts b/core/src/browser/extensions/model.ts index 1fb94fba3..e224ec5cc 100644 --- a/core/src/browser/extensions/model.ts +++ b/core/src/browser/extensions/model.ts @@ -15,7 +15,13 @@ export abstract class ModelExtension extends BaseExtension implements ModelInter abstract getModels(): Promise abstract pullModel(model: string, id?: string, name?: string): Promise abstract cancelModelPull(modelId: string): Promise - abstract importModel(model: string, modePath: string, name?: string, optionType?: OptionType): Promise + abstract importModel( + model: string, + modePath: string, + name?: string, + optionType?: OptionType + ): Promise abstract updateModel(modelInfo: Partial): Promise abstract deleteModel(model: string): Promise + abstract isModelLoaded(model: string): Promise } diff --git a/core/src/node/api/processors/download.ts b/core/src/node/api/processors/download.ts index 20b87294b..ebeb7c299 100644 --- a/core/src/node/api/processors/download.ts +++ b/core/src/node/api/processors/download.ts @@ -1,6 +1,6 @@ import { resolve, sep } from 'path' import { DownloadEvent } from '../../../types/api' -import { normalizeFilePath, validatePath } from '../../helper/path' +import { normalizeFilePath } from '../../helper/path' import { getJanDataFolderPath } from '../../helper' import { DownloadManager } from '../../helper/download' import { createWriteStream, renameSync } from 'fs' @@ -37,7 +37,6 @@ export class Downloader implements Processor { const modelId = downloadRequest.modelId ?? array.pop() ?? '' const destination = resolve(getJanDataFolderPath(), normalizedPath) - validatePath(destination) const rq = request({ url, strictSSL, proxy }) // Put request to download manager instance diff --git a/core/src/node/api/processors/fs.ts b/core/src/node/api/processors/fs.ts index 0557d2187..ada744d53 100644 --- a/core/src/node/api/processors/fs.ts +++ b/core/src/node/api/processors/fs.ts @@ -1,5 +1,5 @@ import { join, resolve } from 'path' -import { normalizeFilePath, validatePath } from '../../helper/path' +import { normalizeFilePath } from '../../helper/path' import { getJanDataFolderPath } from '../../helper' import { Processor } from './Processor' import fs from 'fs' @@ -36,7 +36,6 @@ export class FileSystem implements Processor { return path } const absolutePath = resolve(path) - validatePath(absolutePath) return absolutePath }) ) @@ -55,7 +54,6 @@ export class FileSystem implements Processor { } const absolutePath = resolve(path) - validatePath(absolutePath) return new Promise((resolve, reject) => { fs.rm(absolutePath, { recursive: true, force: true }, (err) => { @@ -79,7 +77,6 @@ export class FileSystem implements Processor { } const absolutePath = resolve(path) - validatePath(absolutePath) return new Promise((resolve, reject) => { fs.mkdir(absolutePath, { recursive: true }, (err) => { diff --git a/core/src/node/api/processors/fsExt.ts b/core/src/node/api/processors/fsExt.ts index 4d113e1ee..846d0c26a 100644 --- a/core/src/node/api/processors/fsExt.ts +++ b/core/src/node/api/processors/fsExt.ts @@ -1,6 +1,6 @@ import { basename, join } from 'path' import fs, { readdirSync } from 'fs' -import { appResourcePath, normalizeFilePath, validatePath } from '../../helper/path' +import { appResourcePath, normalizeFilePath } from '../../helper/path' import { defaultAppConfig, getJanDataFolderPath, getJanDataFolderPath as getPath } from '../../helper' import { Processor } from './Processor' import { FileStat } from '../../../types' @@ -61,7 +61,6 @@ export class FSExt implements Processor { const dataBuffer = Buffer.from(data, 'base64') const writePath = join(getJanDataFolderPath(), normalizedPath) - validatePath(writePath) fs.writeFileSync(writePath, dataBuffer) } catch (err) { console.error(`writeFile ${path} result: ${err}`) @@ -69,7 +68,6 @@ export class FSExt implements Processor { } copyFile(src: string, dest: string): Promise { - validatePath(dest) return new Promise((resolve, reject) => { fs.copyFile(src, dest, (err) => { if (err) { diff --git a/core/src/node/api/restful/common.ts b/core/src/node/api/restful/common.ts index c8061c34a..39f7b8d8b 100644 --- a/core/src/node/api/restful/common.ts +++ b/core/src/node/api/restful/common.ts @@ -10,6 +10,7 @@ import { getMessages, retrieveMessage, updateThread, + models, } from './helper/builder' import { JanApiRouteConfiguration } from './helper/configuration' @@ -26,9 +27,12 @@ export const commonRouter = async (app: HttpServer) => { // Common Routes // Read & Delete :: Threads | Models | Assistants Object.keys(JanApiRouteConfiguration).forEach((key) => { - app.get(`/${key}`, async (_request) => - getBuilder(JanApiRouteConfiguration[key]).then(normalizeData) - ) + app.get(`/${key}`, async (_req, _res) => { + if (key === 'models') { + return models(_req, _res) + } + return getBuilder(JanApiRouteConfiguration[key]).then(normalizeData) + }) app.get(`/${key}/:id`, async (request: any) => retrieveBuilder(JanApiRouteConfiguration[key], request.params.id) diff --git a/core/src/node/api/restful/helper/builder.test.ts b/core/src/node/api/restful/helper/builder.test.ts index eb21e9401..f21257098 100644 --- a/core/src/node/api/restful/helper/builder.test.ts +++ b/core/src/node/api/restful/helper/builder.test.ts @@ -220,22 +220,6 @@ describe('builder helper functions', () => { }) describe('chatCompletions', () => { - it('should return an error if model is not found', async () => { - const request = { body: { model: 'nonexistentModel' } } - const reply = { code: jest.fn().mockReturnThis(), send: jest.fn() } - - await chatCompletions(request, reply) - expect(reply.code).toHaveBeenCalledWith(404) - expect(reply.send).toHaveBeenCalledWith({ - error: { - message: 'The model nonexistentModel does not exist', - type: 'invalid_request_error', - param: null, - code: 'model_not_found', - }, - }) - }) - it('should return the error on status not ok', async () => { const request = { body: { model: 'model1' } } const mockSend = jest.fn() diff --git a/core/src/node/api/restful/helper/builder.ts b/core/src/node/api/restful/helper/builder.ts index da33808dc..c3493a8be 100644 --- a/core/src/node/api/restful/helper/builder.ts +++ b/core/src/node/api/restful/helper/builder.ts @@ -10,9 +10,9 @@ import { } from 'fs' import { JanApiRouteConfiguration, RouteConfiguration } from './configuration' import { join } from 'path' -import { ContentType, MessageStatus, Model, ThreadMessage } from '../../../../types' -import { getEngineConfiguration, getJanDataFolderPath } from '../../../helper' -import { DEFAULT_CHAT_COMPLETION_URL } from './consts' +import { ContentType, InferenceEngine, MessageStatus, ThreadMessage } from '../../../../types' +import { getJanDataFolderPath } from '../../../helper' +import { CORTEX_API_URL } from './consts' // TODO: Refactor these export const getBuilder = async (configuration: RouteConfiguration) => { @@ -297,57 +297,56 @@ export const downloadModel = async ( } } -export const chatCompletions = async (request: any, reply: any) => { - const modelList = await getBuilder(JanApiRouteConfiguration.models) - const modelId = request.body.model - - const matchedModels = modelList.filter((model: Model) => model.id === modelId) - if (matchedModels.length === 0) { - const error = { - error: { - message: `The model ${request.body.model} does not exist`, - type: 'invalid_request_error', - param: null, - code: 'model_not_found', - }, - } - reply.code(404).send(error) - return - } - - const requestedModel = matchedModels[0] - - const engineConfiguration = await getEngineConfiguration(requestedModel.engine) - - let apiKey: string | undefined = undefined - let apiUrl: string = DEFAULT_CHAT_COMPLETION_URL - - if (engineConfiguration) { - apiKey = engineConfiguration.api_key - apiUrl = engineConfiguration.full_url ?? DEFAULT_CHAT_COMPLETION_URL - } - +/** + * Proxy /models to cortex + * @param request + * @param reply + */ +export const models = async (request: any, reply: any) => { + const fetch = require('node-fetch') const headers: Record = { 'Content-Type': 'application/json', } - if (apiKey) { - headers['Authorization'] = `Bearer ${apiKey}` - headers['api-key'] = apiKey - } + const response = await fetch(`${CORTEX_API_URL}/models`, { + method: request.method, + headers: headers, + body: JSON.stringify(request.body), + }) - if (requestedModel.engine === 'openai' && request.body.stop) { - // openai only allows max 4 stop words - request.body.stop = request.body.stop.slice(0, 4) + if (response.status !== 200) { + // Forward the error response to client via reply + const responseBody = await response.text() + const responseHeaders = Object.fromEntries(response.headers) + reply.code(response.status).headers(responseHeaders).send(responseBody) + } else { + reply.raw.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + }) + response.body.pipe(reply.raw) + } +} + +/** + * Proxy chat completions + * @param request + * @param reply + */ +export const chatCompletions = async (request: any, reply: any) => { + const headers: Record = { + 'Content-Type': 'application/json', } // add engine for new cortex cpp engine - if (requestedModel.engine === 'nitro') { - request.body.engine = 'llama-cpp' + if (request.body.engine === InferenceEngine.nitro) { + request.body.engine = InferenceEngine.cortex_llamacpp } const fetch = require('node-fetch') - const response = await fetch(apiUrl, { + const response = await fetch(`${CORTEX_API_URL}/chat/completions`, { method: 'POST', headers: headers, body: JSON.stringify(request.body), diff --git a/core/src/node/api/restful/helper/consts.ts b/core/src/node/api/restful/helper/consts.ts index 0f57bb5ff..412d304ee 100644 --- a/core/src/node/api/restful/helper/consts.ts +++ b/core/src/node/api/restful/helper/consts.ts @@ -1,9 +1,7 @@ -// The PORT to use for the Nitro subprocess export const CORTEX_DEFAULT_PORT = 39291 -// The HOST address to use for the Nitro subprocess export const LOCAL_HOST = '127.0.0.1' export const SUPPORTED_MODEL_FORMAT = '.gguf' -export const DEFAULT_CHAT_COMPLETION_URL = `http://${LOCAL_HOST}:${CORTEX_DEFAULT_PORT}/v1/chat/completions` // default nitro url +export const CORTEX_API_URL = `http://${LOCAL_HOST}:${CORTEX_DEFAULT_PORT}/v1` diff --git a/core/src/node/helper/path.ts b/core/src/node/helper/path.ts index 8115383bb..4efbea463 100644 --- a/core/src/node/helper/path.ts +++ b/core/src/node/helper/path.ts @@ -34,18 +34,4 @@ export function appResourcePath() { // server return join(global.core.appPath(), '../../..') -} - -export function validatePath(path: string) { - const appDataFolderPath = getJanDataFolderPath() - const resourcePath = appResourcePath() - const applicationSupportPath = global.core?.appPath() ?? resourcePath - const absolutePath = resolve(__dirname, path) - if ( - ![appDataFolderPath, resourcePath, applicationSupportPath].some((whiteListedPath) => - absolutePath.startsWith(whiteListedPath) - ) - ) { - throw new Error(`Invalid path: ${absolutePath}`) - } -} +} \ No newline at end of file diff --git a/electron/utils/migration.ts b/electron/utils/migration.ts index 7295fa15d..80851f9de 100644 --- a/electron/utils/migration.ts +++ b/electron/utils/migration.ts @@ -61,6 +61,9 @@ async function checkAndMigrateTheme( ) if (existingTheme) { const desTheme = join(janDataThemesFolder, existingTheme) + if (!lstatSync(desTheme).isDirectory()) { + return + } console.debug('Updating theme', existingTheme) rmdirSync(desTheme, { recursive: true }) cpSync(sourceThemePath, join(janDataThemesFolder, sourceThemeName), { diff --git a/extensions/inference-cortex-extension/bin/version.txt b/extensions/inference-cortex-extension/bin/version.txt index c89636bcf..e6d5cb833 100644 --- a/extensions/inference-cortex-extension/bin/version.txt +++ b/extensions/inference-cortex-extension/bin/version.txt @@ -1 +1 @@ -1.0.2-rc4 \ No newline at end of file +1.0.2 \ No newline at end of file diff --git a/extensions/inference-openai-extension/src/index.ts b/extensions/inference-openai-extension/src/index.ts index 44c243adf..64880b678 100644 --- a/extensions/inference-openai-extension/src/index.ts +++ b/extensions/inference-openai-extension/src/index.ts @@ -70,16 +70,17 @@ export default class JanInferenceOpenAIExtension extends RemoteOAIEngine { * Tranform the payload before sending it to the inference endpoint. * The new preview models such as o1-mini and o1-preview replaced max_tokens by max_completion_tokens parameter. * Others do not. - * @param payload - * @returns + * @param payload + * @returns */ transformPayload = (payload: OpenAIPayloadType): OpenAIPayloadType => { // Transform the payload for preview models if (this.previewModels.includes(payload.model)) { - const { max_tokens, ...params } = payload + const { max_tokens, temperature, top_p, stop, ...params } = payload return { ...params, max_completion_tokens: max_tokens, + stream: false // o1 only support stream = false } } // Pass through for non-preview models diff --git a/extensions/model-extension/src/cortex.ts b/extensions/model-extension/src/cortex.ts index 024aa2223..b7111c859 100644 --- a/extensions/model-extension/src/cortex.ts +++ b/extensions/model-extension/src/cortex.ts @@ -9,7 +9,12 @@ interface ICortexAPI { getModel(model: string): Promise getModels(): Promise pullModel(model: string, id?: string, name?: string): Promise - importModel(path: string, modelPath: string, name?: string, option?: string): Promise + importModel( + path: string, + modelPath: string, + name?: string, + option?: string + ): Promise deleteModel(model: string): Promise updateModel(model: object): Promise cancelModelPull(model: string): Promise @@ -118,10 +123,10 @@ export class CortexAPI implements ICortexAPI { * @param model * @returns */ - updateModel(model: object): Promise { + updateModel(model: Partial): Promise { return this.queue.add(() => ky - .patch(`${API_URL}/v1/models/${model}`, { json: { model } }) + .patch(`${API_URL}/v1/models/${model.id}`, { json: { ...model } }) .json() .then() ) @@ -141,6 +146,17 @@ export class CortexAPI implements ICortexAPI { ) } + /** + * Check model status + * @param model + */ + async getModelStatus(model: string): Promise { + return this.queue + .add(() => ky.get(`${API_URL}/models/status/${model}`)) + .then((e) => true) + .catch(() => false) + } + /** * Do health check on cortex.cpp * @returns @@ -215,7 +231,7 @@ export class CortexAPI implements ICortexAPI { } model.metadata = model.metadata ?? { tags: [], - size: model.size ?? model.metadata?.size ?? 0 + size: model.size ?? model.metadata?.size ?? 0, } return model as Model } diff --git a/extensions/model-extension/src/index.ts b/extensions/model-extension/src/index.ts index 7d7514f3b..b3ad2a012 100644 --- a/extensions/model-extension/src/index.ts +++ b/extensions/model-extension/src/index.ts @@ -179,8 +179,8 @@ export default class JanModelExtension extends ModelExtension { if (toImportModels.length > 0) { // Import models await Promise.all( - toImportModels.map(async (model: Model & { file_path: string }) => - this.importModel( + toImportModels.map(async (model: Model & { file_path: string }) => { + return this.importModel( model.id, model.sources[0].url.startsWith('http') || !(await fs.existsSync(model.sources[0].url)) @@ -193,8 +193,14 @@ export default class JanModelExtension extends ModelExtension { ]) // Copied models : model.sources[0].url, // Symlink models, model.name - ) - ) + ).then((e) => { + this.updateModel({ + id: model.id, + ...model.settings, + ...model.parameters, + } as Partial) + }) + }) ) return currentModels @@ -238,6 +244,14 @@ export default class JanModelExtension extends ModelExtension { return this.cortexAPI.importModel(model, modelPath, name, option) } + /** + * Check model status + * @param model + */ + async isModelLoaded(model: string): Promise { + return this.cortexAPI.getModelStatus(model) + } + /** * Handle download state from main app */ diff --git a/web/containers/ErrorMessage/index.tsx b/web/containers/ErrorMessage/index.tsx index add2bd89b..18558c1d8 100644 --- a/web/containers/ErrorMessage/index.tsx +++ b/web/containers/ErrorMessage/index.tsx @@ -73,7 +73,7 @@ const ErrorMessage = ({ message }: { message: ThreadMessage }) => { > {getErrorTitle()}

- Jan’s in beta. Access  + {`Something's wrong.`} Access  setModalTroubleShooting(true)} diff --git a/web/containers/Layout/BottomPanel/SystemMonitor/TableActiveModel/index.tsx b/web/containers/Layout/BottomPanel/SystemMonitor/TableActiveModel/index.tsx index 00d528f99..8ad16eeba 100644 --- a/web/containers/Layout/BottomPanel/SystemMonitor/TableActiveModel/index.tsx +++ b/web/containers/Layout/BottomPanel/SystemMonitor/TableActiveModel/index.tsx @@ -35,7 +35,7 @@ const TableActiveModel = () => { : '-'} - + { + const { getLogs } = useLogs() + const [logs, setLogs] = useState([]) + const { onRevealInFinder } = usePath() + + useEffect(() => { + getLogs('cortex').then((log) => { + if (typeof log?.split === 'function') { + if (log.length > 0) { + setLogs(log.split(/\r?\n|\r|\n/g)) + } + } + }) + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const clipboard = useClipboard({ timeout: 1000 }) + + return ( +

+
+
+ + +
+
+
+ {logs.length > 0 ? ( + + {logs.slice(-100).map((log, i) => { + return ( +

+ {log} +

+ ) + })} +
+ ) : ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Empty logs

+
+ )} +
+
+ ) +} + +export default memo(CortexLogs) diff --git a/web/containers/ModalTroubleShoot/index.tsx b/web/containers/ModalTroubleShoot/index.tsx index 67ccbe22f..77ee51034 100644 --- a/web/containers/ModalTroubleShoot/index.tsx +++ b/web/containers/ModalTroubleShoot/index.tsx @@ -8,10 +8,11 @@ import { twMerge } from 'tailwind-merge' import ServerLogs from '@/containers/ServerLogs' import AppLogs from './AppLogs' +import CortexLogs from './CortexLogs' import DeviceSpecs from './DeviceSpecs' export const modalTroubleShootingAtom = atom(false) -const logOption = ['App Logs', 'Server Logs', 'Device Specs'] +const logOption = ['App Logs', 'Cortex Logs', 'Server Logs', 'Device Specs'] const ModalTroubleShooting = () => { const [modalTroubleShooting, setModalTroubleShooting] = useAtom( @@ -144,10 +145,15 @@ const ModalTroubleShooting = () => {
- +
+ +
+
diff --git a/web/containers/ModelDropdown/index.tsx b/web/containers/ModelDropdown/index.tsx index a58febabf..66a20a854 100644 --- a/web/containers/ModelDropdown/index.tsx +++ b/web/containers/ModelDropdown/index.tsx @@ -108,6 +108,11 @@ const ModelDropdown = ({ const filteredDownloadedModels = useMemo( () => configuredModels + .concat( + downloadedModels.filter( + (e) => !configuredModels.some((x) => x.id === e.id) + ) + ) .filter((e) => e.name.toLowerCase().includes(searchText.toLowerCase().trim()) ) diff --git a/web/containers/Providers/EventHandler.tsx b/web/containers/Providers/EventHandler.tsx index 0f5cf389d..6cad910f7 100644 --- a/web/containers/Providers/EventHandler.tsx +++ b/web/containers/Providers/EventHandler.tsx @@ -16,6 +16,7 @@ import { EngineManager, InferenceEngine, extractInferenceParams, + ModelExtension, } from '@janhq/core' import { useAtomValue, useSetAtom } from 'jotai' import { ulid } from 'ulidx' @@ -180,8 +181,16 @@ export default function EventHandler({ children }: { children: ReactNode }) { } return } else if (message.status === MessageStatus.Error) { - setActiveModel(undefined) - setStateModel({ state: 'start', loading: false, model: undefined }) + ;(async () => { + if ( + !(await extensionManager + .get(ExtensionTypeEnum.Model) + ?.isModelLoaded(activeModelRef.current?.id as string)) + ) { + setActiveModel(undefined) + setStateModel({ state: 'start', loading: false, model: undefined }) + } + })() } // Mark the thread as not waiting for response updateThreadWaiting(message.thread_id, false) diff --git a/web/containers/Providers/EventListener.tsx b/web/containers/Providers/EventListener.tsx index 9535bbfa6..5cb0debab 100644 --- a/web/containers/Providers/EventListener.tsx +++ b/web/containers/Providers/EventListener.tsx @@ -2,7 +2,17 @@ import { PropsWithChildren, useCallback, useEffect } from 'react' import React from 'react' -import { DownloadEvent, events, DownloadState, ModelEvent } from '@janhq/core' +import { + DownloadEvent, + events, + DownloadState, + ModelEvent, + ExtensionTypeEnum, + ModelExtension, + ModelManager, + Model, +} from '@janhq/core' + import { useSetAtom } from 'jotai' import { setDownloadStateAtom } from '@/hooks/useDownloadState' @@ -18,6 +28,7 @@ import EventHandler from './EventHandler' import ModelImportListener from './ModelImportListener' import QuickAskListener from './QuickAskListener' +import { extensionManager } from '@/extension' import { InstallingExtensionState, removeInstallingExtensionAtom, @@ -83,12 +94,24 @@ const EventListenerWrapper = ({ children }: PropsWithChildren) => { ) const onFileDownloadSuccess = useCallback( - (state: DownloadState) => { + async (state: DownloadState) => { console.debug('onFileDownloadSuccess', state) if (state.downloadType !== 'extension') { + // Update model metadata accordingly + const model = ModelManager.instance().models.get(state.modelId) + if (model) { + await extensionManager + .get(ExtensionTypeEnum.Model) + ?.updateModel({ + id: model.id, + ...model.settings, + ...model.parameters, + } as Partial) + .catch((e) => console.debug(e)) + } state.downloadState = 'end' setDownloadState(state) - if (state.percent !== 0) removeDownloadingModel(state.modelId) + removeDownloadingModel(state.modelId) } events.emit(ModelEvent.OnModelsUpdate, {}) }, diff --git a/web/hooks/useImportModel.ts b/web/hooks/useImportModel.ts index 093385f0d..a2b1f09cb 100644 --- a/web/hooks/useImportModel.ts +++ b/web/hooks/useImportModel.ts @@ -5,11 +5,11 @@ import { ImportingModel, LocalImportModelEvent, Model, - ModelEvent, ModelExtension, OptionType, events, fs, + baseName, } from '@janhq/core' import { atom, useSetAtom } from 'jotai' @@ -61,8 +61,8 @@ const useImportModel = () => { const importModels = useCallback( (models: ImportingModel[], optionType: OptionType) => { - models.map((model) => { - const modelId = model.modelId ?? model.path.split('/').pop() + models.map(async (model) => { + const modelId = model.modelId ?? (await baseName(model.path)) if (modelId) { addDownloadingModel(modelId) extensionManager diff --git a/web/package.json b/web/package.json index af3bce5d1..d3ee82a33 100644 --- a/web/package.json +++ b/web/package.json @@ -38,7 +38,7 @@ "react": "18.2.0", "react-circular-progressbar": "^2.1.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-icons": "^4.12.0", diff --git a/web/screens/Settings/Advanced/index.tsx b/web/screens/Settings/Advanced/index.tsx index 50e2a72a6..0b1438c47 100644 --- a/web/screens/Settings/Advanced/index.tsx +++ b/web/screens/Settings/Advanced/index.tsx @@ -306,7 +306,8 @@ const Advanced = () => { }) } // Stop any running model to apply the changes - if (e.target.checked !== gpuEnabled) stopModel() + if (e.target.checked !== gpuEnabled) + stopModel().then(() => window.core?.api?.relaunch()) }} /> } diff --git a/web/screens/Settings/SelectingModelModal/index.tsx b/web/screens/Settings/SelectingModelModal/index.tsx index 6273d0032..9a2f4fe82 100644 --- a/web/screens/Settings/SelectingModelModal/index.tsx +++ b/web/screens/Settings/SelectingModelModal/index.tsx @@ -21,7 +21,7 @@ const SelectingModelModal = () => { const onSelectFileClick = useCallback(async () => { const platform = (await systemInformation()).osInfo?.platform - if (platform === 'win32') { + if (platform !== 'darwin') { setImportModelStage('CHOOSE_WHAT_TO_IMPORT') return } diff --git a/web/screens/Settings/SettingDetail/SettingDetailItem/SettingDetailTextInputItem/index.tsx b/web/screens/Settings/SettingDetail/SettingDetailItem/SettingDetailTextInputItem/index.tsx index 3127f1578..647263ffe 100644 --- a/web/screens/Settings/SettingDetail/SettingDetailItem/SettingDetailTextInputItem/index.tsx +++ b/web/screens/Settings/SettingDetail/SettingDetailItem/SettingDetailTextInputItem/index.tsx @@ -7,7 +7,13 @@ import { } from '@janhq/core' import { Input } from '@janhq/joi' -import { CopyIcon, EyeIcon, FolderOpenIcon } from 'lucide-react' +import { + CheckIcon, + CopyIcon, + EyeIcon, + EyeOffIcon, + FolderOpenIcon, +} from 'lucide-react' import { Marked, Renderer } from 'marked' type Props = { @@ -34,6 +40,7 @@ const SettingDetailTextInputItem = ({ const { value, type, placeholder, textAlign, inputActions } = settingProps.controllerProps as InputComponentProps const [obscure, setObscure] = useState(type === 'password') + const [copied, setCopied] = useState(false) const description = marked.parse(settingProps.description ?? '', { async: false, @@ -45,6 +52,10 @@ const SettingDetailTextInputItem = ({ const copy = useCallback(() => { navigator.clipboard.writeText(value) + if (value.length > 0) { + setCopied(true) + } + setTimeout(() => setCopied(false), 2000) // Reset icon after 2 seconds }, [value]) const onAction = useCallback( @@ -84,6 +95,8 @@ const SettingDetailTextInputItem = ({ } /> @@ -95,33 +108,51 @@ const SettingDetailTextInputItem = ({ type InputActionProps = { actions: InputAction[] onAction: (action: InputAction) => void + copied: boolean + obscure: boolean } const InputExtraActions: React.FC = ({ actions, onAction, + copied, + obscure, }) => { if (actions.length === 0) return return (
{actions.map((action) => { + console.log(action) switch (action) { case 'copy': - return ( + return copied ? ( + onAction('copy')} + className="text-green-600" + /> + ) : ( onAction(action)} + onClick={() => onAction('copy')} /> ) case 'unobscure': - return ( + return obscure ? ( onAction(action)} + onClick={() => onAction('unobscure')} + /> + ) : ( + onAction('unobscure')} /> ) diff --git a/web/screens/Thread/ThreadCenterPanel/ChatInput/RichTextEditor.tsx b/web/screens/Thread/ThreadCenterPanel/ChatInput/RichTextEditor.tsx index 096ef51e0..70fecb8a9 100644 --- a/web/screens/Thread/ThreadCenterPanel/ChatInput/RichTextEditor.tsx +++ b/web/screens/Thread/ThreadCenterPanel/ChatInput/RichTextEditor.tsx @@ -69,6 +69,9 @@ const RichTextEditor = ({ }: RichTextEditorProps) => { const [editor] = useState(() => withHistory(withReact(createEditor()))) const currentLanguage = useRef('plaintext') + const hasStartBackticks = useRef(false) + const hasEndBackticks = useRef(false) + const [currentPrompt, setCurrentPrompt] = useAtom(currentPromptAtom) const textareaRef = useRef(null) const activeThreadId = useAtomValue(getActiveThreadIdAtom) @@ -133,20 +136,31 @@ const RichTextEditor = ({ node.children.forEach((child: { text: any }, childIndex: number) => { const text = child.text + const codeBlockStartRegex = /```(\w*)/g + const matches = [...currentPrompt.matchAll(codeBlockStartRegex)] + + if (matches.length % 2 !== 0) { + hasEndBackticks.current = false + } + // Match code block start and end - const startMatch = text.match(/^```(\w*)$/) + const lang = text.match(/^```(\w*)$/) const endMatch = text.match(/^```$/) - if (startMatch) { + if (lang) { // If it's the start of a code block, store the language - currentLanguage.current = startMatch[1] || 'plaintext' + currentLanguage.current = lang[1] || 'plaintext' } else if (endMatch) { // Reset language when code block ends currentLanguage.current = 'plaintext' - } else if (currentLanguage.current !== 'plaintext') { + } else if ( + hasStartBackticks.current && + hasEndBackticks.current && + currentLanguage.current !== 'plaintext' + ) { // Highlight entire code line if in a code block - const leadingSpaces = text.match(/^\s*/)?.[0] ?? '' // Capture leading spaces - const codeContent = text.trimStart() // Remove leading spaces for highlighting + + const codeContent = text.trim() // Remove leading spaces for highlighting let highlighted = '' highlighted = hljs.highlightAuto(codeContent).value @@ -168,21 +182,9 @@ const RichTextEditor = ({ let slateTextIndex = 0 - // Adjust to include leading spaces in the ranges and preserve formatting - ranges.push({ - anchor: { path: [...path, childIndex], offset: 0 }, - focus: { - path: [...path, childIndex], - offset: slateTextIndex, - }, - type: 'code', - code: true, - language: currentLanguage.current, - className: '', // No class for leading spaces - }) - doc.body.childNodes.forEach((childNode) => { const childText = childNode.textContent || '' + const length = childText.length const className = childNode.nodeType === Node.ELEMENT_NODE @@ -192,11 +194,11 @@ const RichTextEditor = ({ ranges.push({ anchor: { path: [...path, childIndex], - offset: slateTextIndex + leadingSpaces.length, + offset: slateTextIndex, }, focus: { path: [...path, childIndex], - offset: slateTextIndex + leadingSpaces.length + length, + offset: slateTextIndex + length, }, type: 'code', code: true, @@ -220,7 +222,7 @@ const RichTextEditor = ({ return ranges }, - [editor] + [currentPrompt, editor] ) // RenderLeaf applies the decoration styles @@ -269,6 +271,10 @@ const RichTextEditor = ({ ? '100px' : '40px' textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px' + textareaRef.current?.scrollTo({ + top: textareaRef.current.scrollHeight, + behavior: 'instant', + }) textareaRef.current.style.overflow = textareaRef.current.clientHeight >= 390 ? 'auto' : 'hidden' } @@ -336,9 +342,20 @@ const RichTextEditor = ({ currentLanguage.current = 'plaintext' } const hasCodeBlockStart = combinedText.match(/^```(\w*)/m) + const hasCodeBlockEnd = combinedText.match(/^```$/m) + // Set language to plaintext if no code block with language identifier is found if (!hasCodeBlockStart) { currentLanguage.current = 'plaintext' + hasStartBackticks.current = false + } else { + hasStartBackticks.current = true + } + if (!hasCodeBlockEnd) { + currentLanguage.current = 'plaintext' + hasEndBackticks.current = false + } else { + hasEndBackticks.current = true } }} > diff --git a/web/screens/Thread/ThreadCenterPanel/EditChatInput/index.tsx b/web/screens/Thread/ThreadCenterPanel/EditChatInput/index.tsx index 8b57a54b9..a98d14e7d 100644 --- a/web/screens/Thread/ThreadCenterPanel/EditChatInput/index.tsx +++ b/web/screens/Thread/ThreadCenterPanel/EditChatInput/index.tsx @@ -57,6 +57,8 @@ const EditChatInput: React.FC = ({ message }) => { useEffect(() => { if (textareaRef.current) { textareaRef.current.focus() + const length = textareaRef.current.value.length + textareaRef.current.setSelectionRange(length, length) } }, [activeThreadId]) diff --git a/web/screens/Thread/ThreadCenterPanel/LoadModelError/index.tsx b/web/screens/Thread/ThreadCenterPanel/LoadModelError/index.tsx index f5f74f9c9..19a1f628c 100644 --- a/web/screens/Thread/ThreadCenterPanel/LoadModelError/index.tsx +++ b/web/screens/Thread/ThreadCenterPanel/LoadModelError/index.tsx @@ -110,9 +110,9 @@ const LoadModelError = () => { } else { return (
- Apologies, something’s amiss! + Apologies, {`Something's wrong.`}. 

- Jan’s in beta. Access  + Access  setModalTroubleShooting(true)}