feat: pre-populate Jan's /models folder (#796)

Signed-off-by: James <james@jan.ai>
Co-authored-by: James <james@jan.ai>
This commit is contained in:
NamH 2023-12-01 18:03:51 +07:00 committed by GitHub
parent e6812b1247
commit 1bf4c1b621
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 362 additions and 662 deletions

1
.gitignore vendored
View File

@ -10,6 +10,7 @@ dist
build build
.DS_Store .DS_Store
electron/renderer electron/renderer
electron/models
package-lock.json package-lock.json
*.log *.log

View File

@ -54,6 +54,9 @@ const getUserSpace = (): Promise<string> => window.core.api?.getUserSpace();
const openFileExplorer: (path: string) => Promise<any> = (path) => const openFileExplorer: (path: string) => Promise<any> = (path) =>
window.core.api?.openFileExplorer(path); window.core.api?.openFileExplorer(path);
const getResourcePath: () => Promise<string> = () =>
window.core.api?.getResourcePath();
/** /**
* Register extension point function type definition * Register extension point function type definition
*/ */
@ -74,4 +77,5 @@ export {
appDataPath, appDataPath,
getUserSpace, getUserSpace,
openFileExplorer, openFileExplorer,
getResourcePath,
}; };

View File

@ -1,5 +1,5 @@
import { BaseExtension } from "../extension"; import { BaseExtension } from "../extension";
import { Model, ModelCatalog } from "../types/index"; import { Model } from "../types/index";
/** /**
* Model extension for managing models. * Model extension for managing models.
@ -43,5 +43,5 @@ export abstract class ModelExtension extends BaseExtension {
* Gets a list of configured models. * Gets a list of configured models.
* @returns A Promise that resolves with an array of configured models. * @returns A Promise that resolves with an array of configured models.
*/ */
abstract getConfiguredModels(): Promise<ModelCatalog[]>; abstract getConfiguredModels(): Promise<Model[]>;
} }

View File

@ -62,6 +62,9 @@ const deleteFile: (path: string) => Promise<any> = (path) =>
const appendFile: (path: string, data: string) => Promise<any> = (path, data) => const appendFile: (path: string, data: string) => Promise<any> = (path, data) =>
window.core.api?.appendFile(path, data); window.core.api?.appendFile(path, data);
const copyFile: (src: string, dest: string) => Promise<any> = (src, dest) =>
window.core.api?.copyFile(src, dest);
/** /**
* Reads a file line by line. * Reads a file line by line.
* @param {string} path - The path of the file to read. * @param {string} path - The path of the file to read.
@ -80,4 +83,5 @@ export const fs = {
deleteFile, deleteFile,
appendFile, appendFile,
readLineByLine, readLineByLine,
copyFile,
}; };

View File

@ -180,7 +180,7 @@ export interface Model {
/** /**
* The version of the model. * The version of the model.
*/ */
version: string; version: number;
/** /**
* The model download source. It can be an external url or a local filepath. * The model download source. It can be an external url or a local filepath.
@ -197,12 +197,6 @@ export interface Model {
*/ */
name: string; name: string;
/**
* The organization that owns the model (you!)
* Default: "you"
*/
owned_by: string;
/** /**
* The Unix timestamp (in seconds) for when the model was created * The Unix timestamp (in seconds) for when the model was created
*/ */
@ -236,11 +230,16 @@ export interface Model {
metadata: ModelMetadata; metadata: ModelMetadata;
} }
export type ModelMetadata = {
author: string;
tags: string[];
size: number;
};
/** /**
* The Model transition states. * The Model transition states.
*/ */
export enum ModelState { export enum ModelState {
ToDownload = "to_download",
Downloading = "downloading", Downloading = "downloading",
Ready = "ready", Ready = "ready",
Running = "running", Running = "running",
@ -250,65 +249,27 @@ export enum ModelState {
* The available model settings. * The available model settings.
*/ */
export type ModelSettingParams = { export type ModelSettingParams = {
ctx_len: number; ctx_len?: number;
ngl: number; ngl?: number;
embedding: boolean; embedding?: boolean;
n_parallel: number; n_parallel?: number;
system_prompt?: string;
user_prompt?: string;
ai_prompt?: string;
}; };
/** /**
* The available model runtime parameters. * The available model runtime parameters.
*/ */
export type ModelRuntimeParam = { export type ModelRuntimeParam = {
temperature: number; temperature?: number;
token_limit: number; token_limit?: number;
top_k: number; top_k?: number;
top_p: number; top_p?: number;
stream: boolean; stream?: boolean;
max_tokens?: number;
}; };
/**
* The metadata of the model.
*/
export type ModelMetadata = {
engine: string;
quantization: string;
size: number;
binaries: string[];
maxRamRequired: number;
author: string;
avatarUrl: string;
};
/**
* Model type of the presentation object which will be presented to the user
* @data_transfer_object
*/
export interface ModelCatalog {
/** The unique id of the model.*/
id: string;
/** The name of the model.*/
name: string;
/** The avatar url of the model.*/
avatarUrl: string;
/** The short description of the model.*/
shortDescription: string;
/** The long description of the model.*/
longDescription: string;
/** The author name of the model.*/
author: string;
/** The version of the model.*/
version: string;
/** The origin url of the model repo.*/
modelUrl: string;
/** The timestamp indicating when this model was released.*/
releaseDate: number;
/** The tags attached to the model description **/
tags: string[];
/** The available versions of this model to download. */
availableVersions: Model[];
}
/** /**
* Assistant type defines the shape of an assistant object. * Assistant type defines the shape of an assistant object.
* @stored * @stored

View File

@ -1,9 +1,9 @@
import { app, ipcMain, shell } from 'electron' import { app, ipcMain, shell } from 'electron'
import { ModuleManager } from '../managers/module' import { ModuleManager } from './../managers/module'
import { join } from 'path' import { join } from 'path'
import { ExtensionManager } from '../managers/extension' import { ExtensionManager } from './../managers/extension'
import { WindowManager } from '../managers/window' import { WindowManager } from './../managers/window'
import { userSpacePath } from '../utils/path' import { userSpacePath } from './../utils/path'
export function handleAppIPCs() { export function handleAppIPCs() {
/** /**

View File

@ -1,9 +1,10 @@
import { app, ipcMain } from 'electron' import { app, ipcMain } from 'electron'
import { DownloadManager } from '../managers/download' import { DownloadManager } from './../managers/download'
import { resolve, join } from 'path' import { resolve, join } from 'path'
import { WindowManager } from '../managers/window' import { WindowManager } from './../managers/window'
import request from 'request' import request from 'request'
import { createWriteStream, unlink } from 'fs' import { createWriteStream } from 'fs'
import { getResourcePath } from './../utils/path'
const progress = require('request-progress') const progress = require('request-progress')
export function handleDownloaderIPCs() { export function handleDownloaderIPCs() {
@ -37,6 +38,10 @@ export function handleDownloaderIPCs() {
rq?.abort() rq?.abort()
}) })
ipcMain.handle('getResourcePath', async (_event) => {
return getResourcePath()
})
/** /**
* Downloads a file from a given URL. * Downloads a file from a given URL.
* @param _event - The IPC event object. * @param _event - The IPC event object.

View File

@ -1,19 +1,16 @@
import { app, ipcMain, webContents } from 'electron' import { ipcMain, webContents } from 'electron'
import { readdirSync, rmdir, writeFileSync } from 'fs' import { readdirSync } from 'fs'
import { ModuleManager } from '../managers/module' import { ModuleManager } from './../managers/module'
import { join, extname } from 'path' import { join, extname } from 'path'
import { ExtensionManager } from '../managers/extension'
import { WindowManager } from '../managers/window'
import { manifest, tarball } from 'pacote'
import { import {
getActiveExtensions, getActiveExtensions,
getAllExtensions, getAllExtensions,
installExtensions, installExtensions,
} from '../extension/store' } from './../extension/store'
import { getExtension } from '../extension/store' import { getExtension } from './../extension/store'
import { removeExtension } from '../extension/store' import { removeExtension } from './../extension/store'
import Extension from '../extension/extension' import Extension from './../extension/extension'
import { userSpacePath } from '../utils/path' import { getResourcePath, userSpacePath } from './../utils/path'
export function handleExtensionIPCs() { export function handleExtensionIPCs() {
/**MARK: General handlers */ /**MARK: General handlers */
@ -48,11 +45,7 @@ export function handleExtensionIPCs() {
* @returns An array of paths to the base extensions. * @returns An array of paths to the base extensions.
*/ */
ipcMain.handle('extension:baseExtensions', async (_event) => { ipcMain.handle('extension:baseExtensions', async (_event) => {
const baseExtensionPath = join( const baseExtensionPath = join(getResourcePath(), 'pre-install')
__dirname,
'../',
app.isPackaged ? '../../app.asar.unpacked/pre-install' : '../pre-install'
)
return readdirSync(baseExtensionPath) return readdirSync(baseExtensionPath)
.filter((file) => extname(file) === '.tgz') .filter((file) => extname(file) === '.tgz')
.map((file) => join(baseExtensionPath, file)) .map((file) => join(baseExtensionPath, file))

View File

@ -1,8 +1,9 @@
import { app, ipcMain } from 'electron' import { ipcMain } from 'electron'
import * as fs from 'fs' import * as fs from 'fs'
import fse from 'fs-extra'
import { join } from 'path' import { join } from 'path'
import readline from 'readline' import readline from 'readline'
import { userSpacePath } from '../utils/path' import { userSpacePath } from './../utils/path'
/** /**
* Handles file system operations. * Handles file system operations.
@ -145,6 +146,12 @@ export function handleFsIPCs() {
} }
}) })
ipcMain.handle('copyFile', async (_event, src: string, dest: string) => {
console.debug(`Copying file from ${src} to ${dest}`)
return fse.copySync(src, dest, { overwrite: false })
})
/** /**
* Reads a file line by line. * Reads a file line by line.
* @param event - The event object. * @param event - The event object.

View File

@ -1,5 +1,5 @@
import { app, dialog } from "electron"; import { app, dialog } from "electron";
import { WindowManager } from "../managers/window"; import { WindowManager } from "./../managers/window";
import { autoUpdater } from "electron-updater"; import { autoUpdater } from "electron-updater";
export function handleAppUpdates() { export function handleAppUpdates() {

View File

@ -67,6 +67,20 @@ export function fsInvokers() {
* @param {string} path - The path of the directory to remove. * @param {string} path - The path of the directory to remove.
*/ */
rmdir: (path: string) => ipcRenderer.invoke('rmdir', path), rmdir: (path: string) => ipcRenderer.invoke('rmdir', path),
/**
* Copies a file from the source path to the destination path.
* @param {string} src - The source path of the file to copy.
* @param {string} dest - The destination path where the file should be copied.
*/
copyFile: (src: string, dest: string) => ipcRenderer.invoke('copyFile', src, dest),
/**
* Retrieves the resource path.
* @returns {Promise<string>} A promise that resolves to the resource path.
*/
getResourcePath: () => ipcRenderer.invoke('getResourcePath'),
} }
return interfaces return interfaces

View File

@ -1,7 +1,7 @@
import { app, BrowserWindow } from 'electron' import { app, BrowserWindow } from 'electron'
import { join } from 'path' import { join } from 'path'
import { setupMenu } from './utils/menu' import { setupMenu } from './utils/menu'
import { handleFsIPCs } from './handlers/fs' import { createUserSpace, getResourcePath } from './utils/path'
/** /**
* Managers * Managers
@ -18,9 +18,11 @@ import { handleThemesIPCs } from './handlers/theme'
import { handleExtensionIPCs } from './handlers/extension' import { handleExtensionIPCs } from './handlers/extension'
import { handleAppIPCs } from './handlers/app' import { handleAppIPCs } from './handlers/app'
import { handleAppUpdates } from './handlers/update' import { handleAppUpdates } from './handlers/update'
import { handleFsIPCs } from './handlers/fs'
app app
.whenReady() .whenReady()
.then(createUserSpace)
.then(ExtensionManager.instance.migrateExtensions) .then(ExtensionManager.instance.migrateExtensions)
.then(ExtensionManager.instance.setupExtensions) .then(ExtensionManager.instance.setupExtensions)
.then(setupMenu) .then(setupMenu)
@ -56,7 +58,7 @@ function createMainWindow() {
}) })
const startURL = app.isPackaged const startURL = app.isPackaged
? `file://${join(__dirname, '../renderer/index.html')}` ? `file://${join(__dirname, '..', 'renderer', 'index.html')}`
: 'http://localhost:3000' : 'http://localhost:3000'
/* Load frontend app to the window */ /* Load frontend app to the window */

View File

@ -1,10 +1,10 @@
import { app } from 'electron' import { app } from 'electron'
import { init } from '../extension' import { init } from './../extension'
import { join, resolve } from 'path' import { join, resolve } from 'path'
import { rmdir } from 'fs' import { rmdir } from 'fs'
import Store from 'electron-store' import Store from 'electron-store'
import { existsSync, mkdirSync, writeFileSync } from 'fs' import { existsSync, mkdirSync, writeFileSync } from 'fs'
import { userSpacePath } from '../utils/path' import { userSpacePath } from './../utils/path'
/** /**
* Manages extension installation and migration. * Manages extension installation and migration.
*/ */

View File

@ -1,4 +1,4 @@
import { dispose } from "../utils/disposable"; import { dispose } from "./../utils/disposable";
/** /**
* Manages imported modules. * Manages imported modules.

View File

@ -13,10 +13,12 @@
"renderer/**/*", "renderer/**/*",
"build/*.{js,map}", "build/*.{js,map}",
"build/**/*.{js,map}", "build/**/*.{js,map}",
"pre-install" "pre-install",
"models/**/*"
], ],
"asarUnpack": [ "asarUnpack": [
"pre-install" "pre-install",
"models"
], ],
"publish": [ "publish": [
{ {
@ -70,6 +72,7 @@
"@uiball/loaders": "^1.3.0", "@uiball/loaders": "^1.3.0",
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
"electron-updater": "^6.1.4", "electron-updater": "^6.1.4",
"fs-extra": "^11.2.0",
"pacote": "^17.0.4", "pacote": "^17.0.4",
"request": "^2.88.2", "request": "^2.88.2",
"request-progress": "^3.0.0", "request-progress": "^3.0.0",

View File

@ -1,4 +1,19 @@
import { join } from 'path' import { join } from 'path'
import { app } from 'electron' import { app } from 'electron'
import { mkdir } from 'fs-extra'
export async function createUserSpace(): Promise<void> {
return mkdir(userSpacePath).catch(() => {})
}
export const userSpacePath = join(app.getPath('home'), 'jan') export const userSpacePath = join(app.getPath('home'), 'jan')
export function getResourcePath() {
let appPath = join(app.getAppPath(), '..', 'app.asar.unpacked')
if (!app.isPackaged) {
// for development mode
appPath = join(__dirname, '..', '..')
}
return appPath
}

View File

@ -146,7 +146,6 @@ export default class JanInferenceExtension implements InferenceExtension {
object: "thread.message", object: "thread.message",
}; };
events.emit(EventName.OnMessageResponse, message); events.emit(EventName.OnMessageResponse, message);
console.log(JSON.stringify(data, null, 2));
instance.isCancelled = false; instance.isCancelled = false;
instance.controller = new AbortController(); instance.controller = new AbortController();

View File

@ -1,3 +1,2 @@
declare const PLUGIN_NAME: string; declare const PLUGIN_NAME: string
declare const MODULE_PATH: string; declare const MODULE_PATH: string
declare const MODEL_CATALOG_URL: string;

View File

@ -1,21 +0,0 @@
interface Version {
name: string
quantMethod: string
bits: number
size: number
maxRamRequired: number
usecase: string
downloadLink: string
}
interface ModelSchema {
id: string
name: string
shortDescription: string
avatarUrl: string
longDescription: string
author: string
version: string
modelUrl: string
tags: string[]
versions: Version[]
}

View File

@ -1,46 +0,0 @@
import { ModelCatalog } from '@janhq/core'
export const parseToModel = (modelGroup): ModelCatalog => {
const modelVersions = []
modelGroup.versions.forEach((v) => {
const model = {
object: 'model',
version: modelGroup.version,
source_url: v.downloadLink,
id: v.name,
name: v.name,
owned_by: 'you',
created: 0,
description: modelGroup.longDescription,
state: 'to_download',
settings: v.settings,
parameters: v.parameters,
metadata: {
engine: '',
quantization: v.quantMethod,
size: v.size,
binaries: [],
maxRamRequired: v.maxRamRequired,
author: modelGroup.author,
avatarUrl: modelGroup.avatarUrl,
},
}
modelVersions.push(model)
})
const modelCatalog: ModelCatalog = {
id: modelGroup.id,
name: modelGroup.name,
avatarUrl: modelGroup.avatarUrl,
shortDescription: modelGroup.shortDescription,
longDescription: modelGroup.longDescription,
author: modelGroup.author,
version: modelGroup.version,
modelUrl: modelGroup.modelUrl,
releaseDate: modelGroup.createdAt,
tags: modelGroup.tags,
availableVersions: modelVersions,
}
return modelCatalog
}

View File

@ -1,6 +1,12 @@
import { ExtensionType, fs, downloadFile, abortDownload } from '@janhq/core' import {
import { ModelExtension, Model, ModelCatalog } from '@janhq/core' ExtensionType,
import { parseToModel } from './helpers/modelParser' fs,
downloadFile,
abortDownload,
getResourcePath,
getUserSpace,
} from '@janhq/core'
import { ModelExtension, Model, ModelState } from '@janhq/core'
import { join } from 'path' import { join } from 'path'
/** /**
@ -24,10 +30,7 @@ export default class JanModelExtension implements ModelExtension {
* @override * @override
*/ */
onLoad(): void { onLoad(): void {
/** Cloud Native this.copyModelsToHomeDir()
* TODO: Fetch all downloading progresses?
**/
fs.mkdir(JanModelExtension._homeDir)
} }
/** /**
@ -36,6 +39,30 @@ export default class JanModelExtension implements ModelExtension {
*/ */
onUnload(): void {} onUnload(): void {}
private async copyModelsToHomeDir() {
try {
// list all of the files under the home directory
const files = await fs.listFiles('')
if (files.includes(JanModelExtension._homeDir)) {
// ignore if the model is already downloaded
console.debug('Model already downloaded')
return
}
// copy models folder from resources to home directory
const resourePath = await getResourcePath()
const srcPath = join(resourePath, 'models')
const userSpace = await getUserSpace()
const destPath = join(userSpace, JanModelExtension._homeDir)
await fs.copyFile(srcPath, destPath)
} catch (err) {
console.error(err)
}
}
/** /**
* Downloads a machine learning model. * Downloads a machine learning model.
* @param model - The model to download. * @param model - The model to download.
@ -57,11 +84,11 @@ export default class JanModelExtension implements ModelExtension {
* @returns {Promise<void>} A promise that resolves when the download has been cancelled. * @returns {Promise<void>} A promise that resolves when the download has been cancelled.
*/ */
async cancelModelDownload(modelId: string): Promise<void> { async cancelModelDownload(modelId: string): Promise<void> {
return abortDownload(join(JanModelExtension._homeDir, modelId, modelId)).then( return abortDownload(
() => { join(JanModelExtension._homeDir, modelId, modelId)
fs.rmdir(join(JanModelExtension._homeDir, modelId)) ).then(() => {
} fs.deleteFile(join(JanModelExtension._homeDir, modelId, modelId))
) })
} }
/** /**
@ -72,7 +99,26 @@ export default class JanModelExtension implements ModelExtension {
async deleteModel(modelId: string): Promise<void> { async deleteModel(modelId: string): Promise<void> {
try { try {
const dirPath = join(JanModelExtension._homeDir, modelId) const dirPath = join(JanModelExtension._homeDir, modelId)
await fs.rmdir(dirPath)
// remove all files under dirPath except model.json
const files = await fs.listFiles(dirPath)
const deletePromises = files.map((fileName: string) => {
if (fileName !== JanModelExtension._modelMetadataFileName) {
return fs.deleteFile(join(dirPath, fileName))
}
})
await Promise.allSettled(deletePromises)
// update the state as default
const jsonFilePath = join(
dirPath,
JanModelExtension._modelMetadataFileName
)
const json = await fs.readFile(jsonFilePath)
const model = JSON.parse(json) as Model
delete model.state
await fs.writeFile(jsonFilePath, JSON.stringify(model, null, 2))
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} }
@ -91,7 +137,17 @@ export default class JanModelExtension implements ModelExtension {
) )
try { try {
await fs.writeFile(jsonFilePath, JSON.stringify(model, null, 2)) await fs.writeFile(
jsonFilePath,
JSON.stringify(
{
...model,
state: ModelState.Ready,
},
null,
2
)
)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} }
@ -102,39 +158,62 @@ export default class JanModelExtension implements ModelExtension {
* @returns A Promise that resolves with an array of all models. * @returns A Promise that resolves with an array of all models.
*/ */
async getDownloadedModels(): Promise<Model[]> { async getDownloadedModels(): Promise<Model[]> {
const results: Model[] = [] const models = await this.getModelsMetadata()
const allDirs: string[] = await fs.listFiles(JanModelExtension._homeDir) return models.filter((model) => model.state === ModelState.Ready)
for (const dir of allDirs) { }
const modelDirPath = join(JanModelExtension._homeDir, dir)
const isModelDir = await fs.isDirectory(modelDirPath) private async getModelsMetadata(): Promise<Model[]> {
if (!isModelDir) { try {
// if not a directory, ignore const filesUnderJanRoot = await fs.listFiles('')
continue if (!filesUnderJanRoot.includes(JanModelExtension._homeDir)) {
console.debug('model folder not found')
return []
} }
const jsonFiles: string[] = (await fs.listFiles(modelDirPath)).filter( const files: string[] = await fs.listFiles(JanModelExtension._homeDir)
(fileName: string) => fileName === JanModelExtension._modelMetadataFileName
)
for (const json of jsonFiles) { const allDirectories: string[] = []
const model: Model = JSON.parse( for (const file of files) {
await fs.readFile(join(modelDirPath, json)) const isDirectory = await fs.isDirectory(
join(JanModelExtension._homeDir, file)
) )
results.push(model) if (isDirectory) {
allDirectories.push(file)
}
} }
}
return results const readJsonPromises = allDirectories.map((dirName) => {
const jsonPath = join(
JanModelExtension._homeDir,
dirName,
JanModelExtension._modelMetadataFileName
)
return this.readModelMetadata(jsonPath)
})
const results = await Promise.allSettled(readJsonPromises)
const modelData = results.map((result) => {
if (result.status === 'fulfilled') {
return JSON.parse(result.value) as Model
} else {
console.error(result.reason)
}
})
return modelData
} catch (err) {
console.error(err)
return []
}
}
private readModelMetadata(path: string) {
return fs.readFile(join(path))
} }
/** /**
* Gets all available models. * Gets all available models.
* @returns A Promise that resolves with an array of all models. * @returns A Promise that resolves with an array of all models.
*/ */
getConfiguredModels(): Promise<ModelCatalog[]> { async getConfiguredModels(): Promise<Model[]> {
// Add a timestamp to the URL to prevent caching return this.getModelsMetadata()
return import(
/* webpackIgnore: true */ MODEL_CATALOG_URL + `?t=${Date.now()}`
).then((module) => module.default.map((e) => parseToModel(e)))
} }
} }

View File

@ -19,9 +19,6 @@ module.exports = {
new webpack.DefinePlugin({ new webpack.DefinePlugin({
PLUGIN_NAME: JSON.stringify(packageJson.name), PLUGIN_NAME: JSON.stringify(packageJson.name),
MODULE_PATH: JSON.stringify(`${packageJson.name}/${packageJson.module}`), MODULE_PATH: JSON.stringify(`${packageJson.name}/${packageJson.module}`),
MODEL_CATALOG_URL: JSON.stringify(
'https://cdn.jsdelivr.net/npm/@janhq/models@latest/dist/index.js'
),
}), }),
], ],
output: { output: {

View File

@ -6,8 +6,7 @@
"uikit", "uikit",
"core", "core",
"electron", "electron",
"web", "web"
"server"
], ],
"nohoist": [ "nohoist": [
"uikit", "uikit",
@ -17,15 +16,13 @@
"electron", "electron",
"electron/**", "electron/**",
"web", "web",
"web/**", "web/**"
"server",
"server/**"
] ]
}, },
"scripts": { "scripts": {
"lint": "yarn workspace jan lint && yarn workspace jan-web lint", "lint": "yarn workspace jan lint && yarn workspace jan-web lint",
"test": "yarn workspace jan test:e2e", "test": "yarn workspace jan test:e2e",
"dev:electron": "yarn workspace jan dev", "dev:electron": "cpx \"models/**\" \"electron/models/\" && yarn workspace jan dev",
"dev:web": "yarn workspace jan-web dev", "dev:web": "yarn workspace jan-web dev",
"dev": "concurrently --kill-others \"yarn dev:web\" \"wait-on http://localhost:3000 && yarn dev:electron\"", "dev": "concurrently --kill-others \"yarn dev:web\" \"wait-on http://localhost:3000 && yarn dev:electron\"",
"test-local": "yarn lint && yarn build:test && yarn test", "test-local": "yarn lint && yarn build:test && yarn test",
@ -33,7 +30,7 @@
"build:uikit": "yarn workspace @janhq/uikit install && yarn workspace @janhq/uikit build", "build:uikit": "yarn workspace @janhq/uikit install && yarn workspace @janhq/uikit build",
"build:core": "cd core && yarn install && yarn run build", "build:core": "cd core && yarn install && yarn run build",
"build:web": "yarn workspace jan-web build && cpx \"web/out/**\" \"electron/renderer/\"", "build:web": "yarn workspace jan-web build && cpx \"web/out/**\" \"electron/renderer/\"",
"build:electron": "yarn workspace jan build", "build:electron": "yarn workspace jan build && cpx \"models/**\" \"electron/models/\"",
"build:electron:test": "yarn workspace jan build:test", "build:electron:test": "yarn workspace jan build:test",
"build:extensions": "rimraf ./electron/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./extensions/conversational-extension && npm install && npm run build:publish\" \"cd ./extensions/inference-extension && npm install && npm run build:publish\" \"cd ./extensions/model-extension && npm install && npm run build:publish\" \"cd ./extensions/monitoring-extension && npm install && npm run build:publish\" \"cd ./extensions/assistant-extension && npm install && npm run build:publish\"", "build:extensions": "rimraf ./electron/pre-install/*.tgz && concurrently --kill-others-on-fail \"cd ./extensions/conversational-extension && npm install && npm run build:publish\" \"cd ./extensions/inference-extension && npm install && npm run build:publish\" \"cd ./extensions/model-extension && npm install && npm run build:publish\" \"cd ./extensions/monitoring-extension && npm install && npm run build:publish\" \"cd ./extensions/assistant-extension && npm install && npm run build:publish\"",
"build:test": "yarn build:web && yarn workspace jan build:test", "build:test": "yarn build:web && yarn workspace jan build:test",

View File

@ -1,9 +1,16 @@
type Props = { type Props = {
title: string title: string
description?: string description?: string
disabled?: boolean
onChange?: (text?: string) => void
} }
export default function ItemCardSidebar({ description, title }: Props) { export default function ItemCardSidebar({
description,
title,
disabled,
onChange,
}: Props) {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -11,9 +18,11 @@ export default function ItemCardSidebar({ description, title }: Props) {
</div> </div>
<input <input
value={description} value={description}
disabled={disabled}
type="text" type="text"
className="block w-full rounded-md border-0 px-1 py-1.5 text-white shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" className="block w-full rounded-md border-0 px-1 py-1.5 text-white shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
placeholder="" placeholder=""
onChange={(e) => onChange?.(e.target.value)}
/> />
</div> </div>
) )

View File

@ -69,18 +69,14 @@ export default function DownloadingState() {
/> />
<div className="flex items-center justify-between gap-x-2"> <div className="flex items-center justify-between gap-x-2">
<div className="flex gap-x-2"> <div className="flex gap-x-2">
<p className="line-clamp-1">{item?.fileName}</p> <p className="line-clamp-1">{item?.modelId}</p>
<span>{formatDownloadPercentage(item?.percent)}</span> <span>{formatDownloadPercentage(item?.percent)}</span>
</div> </div>
<Button <Button
themes="outline" themes="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
if (item?.fileName) { if (item?.modelId) {
const model = models.find(
(e) => e.id === item?.fileName
)
if (!model) return
extensionManager extensionManager
.get<ModelExtension>(ExtensionType.Model) .get<ModelExtension>(ExtensionType.Model)
?.cancelModelDownload(item.modelId) ?.cancelModelDownload(item.modelId)

View File

@ -1,4 +1,4 @@
import { Fragment, useState, useEffect, useContext } from 'react' import { Fragment, useState, useEffect } from 'react'
import { import {
Button, Button,
@ -11,7 +11,7 @@ import {
CommandList, CommandList,
} from '@janhq/uikit' } from '@janhq/uikit'
import { useSetAtom } from 'jotai' import { useAtomValue, useSetAtom } from 'jotai'
import { import {
MessageCircleIcon, MessageCircleIcon,
SettingsIcon, SettingsIcon,
@ -22,18 +22,19 @@ import {
import ShortCut from '@/containers/Shortcut' import ShortCut from '@/containers/Shortcut'
import { FeatureToggleContext } from '@/context/FeatureToggle'
import { MainViewState } from '@/constants/screens' import { MainViewState } from '@/constants/screens'
import { useMainViewState } from '@/hooks/useMainViewState' import { useMainViewState } from '@/hooks/useMainViewState'
import { showRightSideBarAtom } from '@/screens/Chat/Sidebar' import { showRightSideBarAtom } from '@/screens/Chat/Sidebar'
import { activeThreadAtom } from '@/helpers/atoms/Conversation.atom'
export default function CommandSearch() { export default function CommandSearch() {
const { setMainViewState } = useMainViewState() const { setMainViewState } = useMainViewState()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const setShowRightSideBar = useSetAtom(showRightSideBarAtom) const setShowRightSideBar = useSetAtom(showRightSideBarAtom)
const activeThread = useAtomValue(activeThreadAtom)
const menus = [ const menus = [
{ {
@ -123,13 +124,15 @@ export default function CommandSearch() {
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>
</CommandModal> </CommandModal>
<Button {activeThread && (
themes="outline" <Button
className="unset-drag justify-start text-left text-xs font-normal text-muted-foreground focus:ring-0" themes="outline"
onClick={() => setShowRightSideBar((show) => !show)} className="unset-drag justify-start text-left text-xs font-normal text-muted-foreground focus:ring-0"
> onClick={() => setShowRightSideBar((show) => !show)}
Toggle right >
</Button> Toggle right
</Button>
)}
</Fragment> </Fragment>
) )
} }

View File

@ -24,34 +24,30 @@ import { extensionManager } from '@/extension'
import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom' import { downloadingModelsAtom } from '@/helpers/atoms/Model.atom'
type Props = { type Props = {
suitableModel: Model model: Model
isFromList?: boolean isFromList?: boolean
} }
export default function ModalCancelDownload({ export default function ModalCancelDownload({ model, isFromList }: Props) {
suitableModel,
isFromList,
}: Props) {
const { modelDownloadStateAtom } = useDownloadState() const { modelDownloadStateAtom } = useDownloadState()
const downloadAtom = useMemo( const downloadAtom = useMemo(
() => atom((get) => get(modelDownloadStateAtom)[suitableModel.name]), () => atom((get) => get(modelDownloadStateAtom)[model.id]),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[suitableModel.name] [model.id]
) )
const models = useAtomValue(downloadingModelsAtom) const models = useAtomValue(downloadingModelsAtom)
const downloadState = useAtomValue(downloadAtom) const downloadState = useAtomValue(downloadAtom)
const cancelText = `Cancel ${formatDownloadPercentage(downloadState.percent)}`
return ( return (
<Modal> <Modal>
<ModalTrigger asChild> <ModalTrigger asChild>
{isFromList ? ( {isFromList ? (
<Button themes="outline" size="sm"> <Button themes="outline" size="sm">
Cancel ({formatDownloadPercentage(downloadState.percent)}) {cancelText}
</Button> </Button>
) : ( ) : (
<Button> <Button>{cancelText}</Button>
Cancel ({formatDownloadPercentage(downloadState.percent)})
</Button>
)} )}
</ModalTrigger> </ModalTrigger>
<ModalContent> <ModalContent>
@ -60,7 +56,7 @@ export default function ModalCancelDownload({
</ModalHeader> </ModalHeader>
<p> <p>
Are you sure you want to cancel the download of&nbsp; Are you sure you want to cancel the download of&nbsp;
{downloadState?.fileName}? {downloadState?.modelId}?
</p> </p>
<ModalFooter> <ModalFooter>
<div className="flex gap-x-2"> <div className="flex gap-x-2">
@ -71,11 +67,7 @@ export default function ModalCancelDownload({
<Button <Button
themes="danger" themes="danger"
onClick={() => { onClick={() => {
if (downloadState?.fileName) { if (downloadState?.modelId) {
const model = models.find(
(e) => e.id === downloadState?.fileName
)
if (!model) return
extensionManager extensionManager
.get<ModelExtension>(ExtensionType.Model) .get<ModelExtension>(ExtensionType.Model)
?.cancelModelDownload(downloadState.modelId) ?.cancelModelDownload(downloadState.modelId)

View File

@ -36,11 +36,11 @@ export default function EventListenerWrapper({ children }: PropsWithChildren) {
useEffect(() => { useEffect(() => {
if (window && window.electronAPI) { if (window && window.electronAPI) {
window.electronAPI.onFileDownloadUpdate( window.electronAPI.onFileDownloadUpdate(
(_event: string, state: DownloadState | undefined) => { (_event: string, state: any | undefined) => {
if (!state) return if (!state) return
setDownloadState({ setDownloadState({
...state, ...state,
fileName: state.fileName.split('/').pop() ?? '', modelId: state.fileName.split('/').pop() ?? '',
}) })
} }
) )
@ -48,18 +48,18 @@ export default function EventListenerWrapper({ children }: PropsWithChildren) {
window.electronAPI.onFileDownloadError( window.electronAPI.onFileDownloadError(
(_event: string, callback: any) => { (_event: string, callback: any) => {
console.error('Download error', callback) console.error('Download error', callback)
const fileName = callback.fileName.split('/').pop() ?? '' const modelId = callback.fileName.split('/').pop() ?? ''
setDownloadStateFailed(fileName) setDownloadStateFailed(modelId)
} }
) )
window.electronAPI.onFileDownloadSuccess( window.electronAPI.onFileDownloadSuccess(
(_event: string, callback: any) => { (_event: string, callback: any) => {
if (callback && callback.fileName) { if (callback && callback.fileName) {
const fileName = callback.fileName.split('/').pop() ?? '' const modelId = callback.fileName.split('/').pop() ?? ''
setDownloadStateSuccess(fileName) setDownloadStateSuccess(modelId)
const model = modelsRef.current.find((e) => e.id === fileName) const model = modelsRef.current.find((e) => e.id === modelId)
if (model) if (model)
extensionManager extensionManager
.get<ModelExtension>(ExtensionType.Model) .get<ModelExtension>(ExtensionType.Model)

View File

@ -48,9 +48,8 @@ export function useActiveModel() {
} }
const currentTime = Date.now() const currentTime = Date.now()
console.debug('Init model: ', modelId)
const res = await initModel(modelId, model?.settings) const res = await initModel(modelId, model?.settings)
if (res && res.error && res.modelFile === stateModel.model) { if (res && res.error) {
const errorMessage = `${res.error}` const errorMessage = `${res.error}`
alert(errorMessage) alert(errorMessage)
setStateModel(() => ({ setStateModel(() => ({

View File

@ -1,5 +1,7 @@
import { import {
Assistant, Assistant,
ConversationalExtension,
ExtensionType,
Thread, Thread,
ThreadAssistantInfo, ThreadAssistantInfo,
ThreadState, ThreadState,
@ -8,10 +10,13 @@ import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { generateThreadId } from '@/utils/conversation' import { generateThreadId } from '@/utils/conversation'
import { extensionManager } from '@/extension'
import { import {
threadsAtom, threadsAtom,
setActiveThreadIdAtom, setActiveThreadIdAtom,
threadStatesAtom, threadStatesAtom,
activeThreadAtom,
updateThreadAtom,
} from '@/helpers/atoms/Conversation.atom' } from '@/helpers/atoms/Conversation.atom'
const createNewThreadAtom = atom(null, (get, set, newThread: Thread) => { const createNewThreadAtom = atom(null, (get, set, newThread: Thread) => {
@ -35,6 +40,8 @@ export const useCreateNewThread = () => {
const setActiveThreadId = useSetAtom(setActiveThreadIdAtom) const setActiveThreadId = useSetAtom(setActiveThreadIdAtom)
const [threadStates, setThreadStates] = useAtom(threadStatesAtom) const [threadStates, setThreadStates] = useAtom(threadStatesAtom)
const threads = useAtomValue(threadsAtom) const threads = useAtomValue(threadsAtom)
const activeThread = useAtomValue(activeThreadAtom)
const updateThread = useSetAtom(updateThreadAtom)
const requestCreateNewThread = async (assistant: Assistant) => { const requestCreateNewThread = async (assistant: Assistant) => {
const unfinishedThreads = threads.filter((t) => t.isFinishInit === false) const unfinishedThreads = threads.filter((t) => t.isFinishInit === false)
@ -86,7 +93,20 @@ export const useCreateNewThread = () => {
setActiveThreadId(thread.id) setActiveThreadId(thread.id)
} }
function updateThreadTitle(title: string) {
if (!activeThread) return
const updatedConv: Thread = {
...activeThread,
title,
}
updateThread(updatedConv)
extensionManager
.get<ConversationalExtension>(ExtensionType.Conversational)
?.saveThread(updatedConv)
}
return { return {
requestCreateNewThread, requestCreateNewThread,
updateThreadTitle,
} }
} }

View File

@ -6,10 +6,10 @@ import { currentPromptAtom } from '@/containers/Providers/Jotai'
import { toaster } from '@/containers/Toast' import { toaster } from '@/containers/Toast'
import { extensionManager } from '../extension/ExtensionManager'
import { useActiveModel } from './useActiveModel' import { useActiveModel } from './useActiveModel'
import { extensionManager } from '@/extension/ExtensionManager'
import { import {
cleanConversationMessages, cleanConversationMessages,
deleteConversationMessage, deleteConversationMessage,

View File

@ -1,6 +1,6 @@
import { Model, ExtensionType, ModelExtension } from '@janhq/core' import { Model, ExtensionType, ModelExtension } from '@janhq/core'
import { useAtom } from 'jotai' import { useAtom, useAtomValue } from 'jotai'
import { useDownloadState } from './useDownloadState' import { useDownloadState } from './useDownloadState'
@ -27,7 +27,6 @@ export default function useDownloadModel() {
total: 0, total: 0,
transferred: 0, transferred: 0,
}, },
fileName: model.id,
}) })
setDownloadingModels([...downloadingModels, model]) setDownloadingModels([...downloadingModels, model])

View File

@ -8,39 +8,39 @@ const modelDownloadStateAtom = atom<Record<string, DownloadState>>({})
const setDownloadStateAtom = atom(null, (get, set, state: DownloadState) => { const setDownloadStateAtom = atom(null, (get, set, state: DownloadState) => {
const currentState = { ...get(modelDownloadStateAtom) } const currentState = { ...get(modelDownloadStateAtom) }
console.debug( console.debug(
`current download state for ${state.fileName} is ${JSON.stringify(state)}` `current download state for ${state.modelId} is ${JSON.stringify(state)}`
) )
currentState[state.fileName] = state currentState[state.modelId] = state
set(modelDownloadStateAtom, currentState) set(modelDownloadStateAtom, currentState)
}) })
const setDownloadStateSuccessAtom = atom(null, (get, set, fileName: string) => { const setDownloadStateSuccessAtom = atom(null, (get, set, modelId: string) => {
const currentState = { ...get(modelDownloadStateAtom) } const currentState = { ...get(modelDownloadStateAtom) }
const state = currentState[fileName] const state = currentState[modelId]
if (!state) { if (!state) {
console.error(`Cannot find download state for ${fileName}`) console.error(`Cannot find download state for ${modelId}`)
return return
} }
delete currentState[fileName] delete currentState[modelId]
set(modelDownloadStateAtom, currentState) set(modelDownloadStateAtom, currentState)
toaster({ toaster({
title: 'Download Completed', title: 'Download Completed',
description: `Download ${fileName} completed`, description: `Download ${modelId} completed`,
}) })
}) })
const setDownloadStateFailedAtom = atom(null, (get, set, fileName: string) => { const setDownloadStateFailedAtom = atom(null, (get, set, modelId: string) => {
const currentState = { ...get(modelDownloadStateAtom) } const currentState = { ...get(modelDownloadStateAtom) }
const state = currentState[fileName] const state = currentState[modelId]
if (!state) { if (!state) {
console.error(`Cannot find download state for ${fileName}`) console.error(`Cannot find download state for ${modelId}`)
toaster({ toaster({
title: 'Cancel Download', title: 'Cancel Download',
description: `Model ${fileName} cancel download`, description: `Model ${modelId} cancel download`,
}) })
return return
} }
delete currentState[fileName] delete currentState[modelId]
set(modelDownloadStateAtom, currentState) set(modelDownloadStateAtom, currentState)
}) })

View File

@ -1,25 +1,15 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ExtensionType, ModelExtension } from '@janhq/core' import { ExtensionType, ModelExtension } from '@janhq/core'
import { ModelCatalog } from '@janhq/core' import { Model } from '@janhq/core'
import { dummyModel } from '@/utils/dummy'
import { extensionManager } from '@/extension/ExtensionManager' import { extensionManager } from '@/extension/ExtensionManager'
export async function getConfiguredModels(): Promise<ModelCatalog[]> {
return (
extensionManager
.get<ModelExtension>(ExtensionType.Model)
?.getConfiguredModels() ?? []
)
}
export function useGetConfiguredModels() { export function useGetConfiguredModels() {
const [loading, setLoading] = useState<boolean>(false) const [loading, setLoading] = useState<boolean>(false)
const [models, setModels] = useState<ModelCatalog[]>([]) const [models, setModels] = useState<Model[]>([])
async function getConfiguredModels(): Promise<ModelCatalog[]> { const getConfiguredModels = async (): Promise<Model[]> => {
const models = await extensionManager const models = await extensionManager
.get<ModelExtension>(ExtensionType.Model) .get<ModelExtension>(ExtensionType.Model)
?.getConfiguredModels() ?.getConfiguredModels()
@ -28,9 +18,9 @@ export function useGetConfiguredModels() {
async function fetchModels() { async function fetchModels() {
setLoading(true) setLoading(true)
let models = await getConfiguredModels() const models = await getConfiguredModels()
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
models = [dummyModel, ...models] // models = [dummyModel, ...models] // TODO: NamH add back dummy model later
} }
setLoading(false) setLoading(false)
setModels(models) setModels(models)

View File

@ -1,27 +0,0 @@
import { useState } from 'react'
import { Model } from '@janhq/core'
import { useAtomValue } from 'jotai'
import { totalRamAtom } from '@/helpers/atoms/SystemBar.atom'
export default function useGetMostSuitableModelVersion() {
const [suitableModel, setSuitableModel] = useState<Model | undefined>()
const totalRam = useAtomValue(totalRamAtom)
const getMostSuitableModelVersion = async (modelVersions: Model[]) => {
// find the model version with the highest required RAM that is still below the user's RAM by 80%
const modelVersion = modelVersions.reduce((prev, current) => {
if (current.metadata.maxRamRequired > prev.metadata.maxRamRequired) {
if (current.metadata.maxRamRequired < totalRam * 0.8) {
return current
}
}
return prev
})
setSuitableModel(modelVersion)
}
return { suitableModel, getMostSuitableModelVersion }
}

View File

@ -1,50 +0,0 @@
import { Model } from '@janhq/core'
import { ModelPerformance, TagType } from '@/constants/tagType'
// Recommendation:
// `Recommended (green)`: "Max RAM required" is 80% of users max RAM.
// `Slow on your device (yellow)`: Max RAM required is 80-100% of users max RAM
// `Not enough RAM (red)`: User RAM is below "Max RAM required"
export default function useGetPerformanceTag() {
async function getPerformanceForModel(
model: Model,
totalRam: number
): Promise<{ title: string; performanceTag: TagType }> {
const requiredRam = model.metadata.maxRamRequired
const performanceTag = calculateRamPerformance(requiredRam, totalRam)
let title = ''
switch (performanceTag) {
case ModelPerformance.PerformancePositive:
title = 'Recommended'
break
case ModelPerformance.PerformanceNeutral:
title = 'Slow on your device'
break
case ModelPerformance.PerformanceNegative:
title = 'Not enough RAM'
break
}
return { title, performanceTag }
}
return { getPerformanceForModel }
}
const calculateRamPerformance = (
requiredRamAmt: number,
totalRamAmt: number
) => {
const percentage = requiredRamAmt / totalRamAmt
if (percentage < 0.8) {
return ModelPerformance.PerformancePositive
} else if (percentage >= 0.8 && percentage < 1) {
return ModelPerformance.PerformanceNeutral
} else {
return ModelPerformance.PerformanceNegative
}
}

View File

@ -45,48 +45,6 @@ export default function useSendChatMessage() {
const selectedModel = useAtomValue(selectedModelAtom) const selectedModel = useAtomValue(selectedModelAtom)
const { startModel } = useActiveModel() const { startModel } = useActiveModel()
function updateThreadTitle(newMessage: MessageRequest) {
if (
activeThread &&
newMessage.messages &&
newMessage.messages.length > 2 &&
(activeThread.title === '' || activeThread.title === activeModel?.name)
) {
const summaryMsg: ChatCompletionMessage = {
role: ChatCompletionRole.User,
content:
'Summarize this conversation in less than 5 words, the response should just include the summary',
}
// Request convo summary
setTimeout(async () => {
const result = await extensionManager
.get<InferenceExtension>(ExtensionType.Inference)
?.inferenceRequest({
...newMessage,
messages: newMessage.messages?.slice(0, -1).concat([summaryMsg]),
})
.catch(console.error)
const content = result?.content[0]?.text.value.trim()
if (
activeThread &&
activeThread.id === newMessage.threadId &&
content &&
content.length > 0 &&
content.split(' ').length <= 20
) {
const updatedConv: Thread = {
...activeThread,
title: content,
}
updateThread(updatedConv)
extensionManager
.get<ConversationalExtension>(ExtensionType.Conversational)
?.saveThread(updatedConv)
}
}, 1000)
}
}
const sendChatMessage = async () => { const sendChatMessage = async () => {
if (!currentPrompt || currentPrompt.trim().length === 0) { if (!currentPrompt || currentPrompt.trim().length === 0) {
return return
@ -172,7 +130,6 @@ export default function useSendChatMessage() {
} }
addNewMessage(threadMessage) addNewMessage(threadMessage)
updateThreadTitle(messageRequest)
await extensionManager await extensionManager
.get<ConversationalExtension>(ExtensionType.Conversational) .get<ConversationalExtension>(ExtensionType.Conversational)
@ -180,6 +137,10 @@ export default function useSendChatMessage() {
const modelId = selectedModel?.id ?? activeThread.assistants[0].model.id const modelId = selectedModel?.id ?? activeThread.assistants[0].model.id
if (activeModel?.id !== modelId) { if (activeModel?.id !== modelId) {
toaster({
title: 'Message queued.',
description: 'It will be sent once the model is done loading',
})
await startModel(modelId) await startModel(modelId)
} }
events.emit(EventName.OnMessageSent, messageRequest) events.emit(EventName.OnMessageSent, messageRequest)

View File

@ -22,11 +22,6 @@ export default function useSetActiveThread() {
return return
} }
if (!thread.isFinishInit) {
console.debug('Thread not finish init')
return
}
// load the corresponding messages // load the corresponding messages
const messages = await extensionManager const messages = await extensionManager
.get<ConversationalExtension>(ExtensionType.Conversational) .get<ConversationalExtension>(ExtensionType.Conversational)

View File

@ -43,8 +43,10 @@ const MessageToolbar = ({ message }: { message: ThreadMessage }) => {
.get<InferenceExtension>(ExtensionType.Inference) .get<InferenceExtension>(ExtensionType.Inference)
?.stopInference() ?.stopInference()
setTimeout(() => { setTimeout(() => {
message.status = MessageStatus.Ready events.emit(EventName.OnMessageUpdate, {
events.emit(EventName.OnMessageUpdate, message) ...message,
status: MessageStatus.Ready,
})
}, 300) }, 300)
} }

View File

@ -9,6 +9,8 @@ import DropdownListSidebar, {
} from '@/containers/DropdownListSidebar' } from '@/containers/DropdownListSidebar'
import ItemCardSidebar from '@/containers/ItemCardSidebar' import ItemCardSidebar from '@/containers/ItemCardSidebar'
import { useCreateNewThread } from '@/hooks/useCreateNewThread'
import { activeThreadAtom } from '@/helpers/atoms/Conversation.atom' import { activeThreadAtom } from '@/helpers/atoms/Conversation.atom'
export const showRightSideBarAtom = atom<boolean>(false) export const showRightSideBarAtom = atom<boolean>(false)
@ -17,6 +19,7 @@ export default function Sidebar() {
const showing = useAtomValue(showRightSideBarAtom) const showing = useAtomValue(showRightSideBarAtom)
const activeThread = useAtomValue(activeThreadAtom) const activeThread = useAtomValue(activeThreadAtom)
const selectedModel = useAtomValue(selectedModelAtom) const selectedModel = useAtomValue(selectedModelAtom)
const { updateThreadTitle } = useCreateNewThread()
const onReviewInFinderClick = async (type: string) => { const onReviewInFinderClick = async (type: string) => {
if (!activeThread) return if (!activeThread) return
@ -47,7 +50,6 @@ export default function Sidebar() {
if (!filePath) return if (!filePath) return
const fullPath = join(userSpace, filePath) const fullPath = join(userSpace, filePath)
console.log(fullPath)
openFileExplorer(fullPath) openFileExplorer(fullPath)
} }
@ -80,7 +82,6 @@ export default function Sidebar() {
if (!filePath) return if (!filePath) return
const fullPath = join(userSpace, filePath) const fullPath = join(userSpace, filePath)
console.log(fullPath)
openFileExplorer(fullPath) openFileExplorer(fullPath)
} }
@ -96,8 +97,16 @@ export default function Sidebar() {
onRevealInFinderClick={onReviewInFinderClick} onRevealInFinderClick={onReviewInFinderClick}
onViewJsonClick={onViewJsonClick} onViewJsonClick={onViewJsonClick}
> >
<ItemCardSidebar description={activeThread?.id} title="Thread ID" /> <ItemCardSidebar
<ItemCardSidebar title="Thread title" /> description={activeThread?.id}
title="Thread ID"
disabled
/>
<ItemCardSidebar
title="Thread title"
description={activeThread?.title}
onChange={(title) => updateThreadTitle(title ?? '')}
/>
</CardSidebar> </CardSidebar>
<CardSidebar <CardSidebar
title="Assistant" title="Assistant"
@ -107,6 +116,7 @@ export default function Sidebar() {
<ItemCardSidebar <ItemCardSidebar
description={activeThread?.assistants[0].assistant_name ?? ''} description={activeThread?.assistants[0].assistant_name ?? ''}
title="Assistant" title="Assistant"
disabled
/> />
</CardSidebar> </CardSidebar>
<CardSidebar <CardSidebar

View File

@ -1,71 +1,46 @@
/* eslint-disable react/display-name */ /* eslint-disable react/display-name */
import { forwardRef, useEffect, useState } from 'react' import { forwardRef } from 'react'
import { ModelCatalog } from '@janhq/core' import { Model } from '@janhq/core'
import { Badge } from '@janhq/uikit' import { Badge } from '@janhq/uikit'
import useGetMostSuitableModelVersion from '@/hooks/useGetMostSuitableModelVersion'
import ExploreModelItemHeader from '@/screens/ExploreModels/ExploreModelItemHeader' import ExploreModelItemHeader from '@/screens/ExploreModels/ExploreModelItemHeader'
import ModelVersionList from '@/screens/ExploreModels/ModelVersionList'
import { toGigabytes } from '@/utils/converter'
import { displayDate } from '@/utils/datetime'
type Props = { type Props = {
model: ModelCatalog model: Model
} }
const ExploreModelItem = forwardRef<HTMLDivElement, Props>(({ model }, ref) => { const ExploreModelItem = forwardRef<HTMLDivElement, Props>(({ model }, ref) => {
const [show, setShow] = useState(false)
const { availableVersions } = model
const { suitableModel, getMostSuitableModelVersion } =
useGetMostSuitableModelVersion()
useEffect(() => {
getMostSuitableModelVersion(availableVersions)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [availableVersions])
if (!suitableModel) {
return null
}
return ( return (
<div <div
ref={ref} ref={ref}
className="mb-4 flex flex-col rounded-md border border-border bg-background/60" className="mb-4 flex flex-col rounded-md border border-border bg-background/60"
> >
<ExploreModelItemHeader <ExploreModelItemHeader model={model} />
suitableModel={suitableModel}
exploreModel={model}
/>
<div className="flex flex-col p-4"> <div className="flex flex-col p-4">
<div className="mb-4 flex flex-col gap-1"> <div className="mb-4 flex flex-col gap-1">
<span className="font-semibold">About</span> <span className="font-semibold">About</span>
<p>{model.longDescription}</p> <p>{model.description}</p>
</div> </div>
<div className="mb-4 flex space-x-6 border-b border-border pb-4"> <div className="mb-4 flex space-x-6 border-b border-border pb-4">
<div> <div>
<span className="font-semibold">Author</span> <span className="font-semibold">Author</span>
<p className="mt-1 font-medium">{model.author}</p> <p className="mt-1 font-medium">{model.metadata.author}</p>
</div> </div>
<div> <div>
<span className="mb-1 font-semibold">Compatibility</span> <span className="mb-1 font-semibold">Compatibility</span>
<div className="mt-1 flex gap-2"> <div className="mt-1 flex gap-2">
<Badge {/* <Badge
themes="secondary" themes="secondary"
className="line-clamp-1 lg:line-clamp-none" className="line-clamp-1 lg:line-clamp-none"
title={`${toGigabytes( title={`${toGigabytes(
suitableModel.metadata.maxRamRequired model.metadata.maxRamRequired // TODO: check this
)} RAM required`} )} RAM required`}
> >
{toGigabytes(suitableModel.metadata.maxRamRequired)} RAM {toGigabytes(model.metadata.maxRamRequired)} RAM required
required </Badge> */}
</Badge>
</div> </div>
</div> </div>
</div> </div>
@ -75,21 +50,12 @@ const ExploreModelItem = forwardRef<HTMLDivElement, Props>(({ model }, ref) => {
<span className="font-semibold">Version</span> <span className="font-semibold">Version</span>
<div className="mt-2 flex space-x-2"> <div className="mt-2 flex space-x-2">
<Badge themes="outline">v{model.version}</Badge> <Badge themes="outline">v{model.version}</Badge>
{suitableModel.metadata.quantization && (
<Badge themes="outline">
{suitableModel.metadata.quantization}
</Badge>
)}
</div> </div>
</div> </div>
<div>
<span className="font-semibold">Release Date</span>
<p className="mt-1 ">{displayDate(model.releaseDate)}</p>
</div>
<div> <div>
<span className="font-semibold">Tags</span> <span className="font-semibold">Tags</span>
<div className="mt-2 flex space-x-2"> <div className="mt-2 flex space-x-2">
{model.tags.map((tag, i) => ( {model.metadata.tags.map((tag, i) => (
<Badge key={i} themes="outline"> <Badge key={i} themes="outline">
{tag} {tag}
</Badge> </Badge>
@ -97,23 +63,6 @@ const ExploreModelItem = forwardRef<HTMLDivElement, Props>(({ model }, ref) => {
</div> </div>
</div> </div>
</div> </div>
{model.availableVersions?.length > 0 && (
<div className="mt-5 w-full rounded-md border border-border bg-background p-2">
<button onClick={() => setShow(!show)} className="w-full">
{!show
? '+ Show Available Versions'
: '- Collapse Available Versions'}
</button>
{show && (
<ModelVersionList
models={model.availableVersions}
recommendedVersion={suitableModel?.name ?? ''}
/>
)}
</div>
)}
</div> </div>
</div> </div>
) )

View File

@ -1,7 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { Model, ModelCatalog } from '@janhq/core' import { Model } from '@janhq/core'
import { Badge, Button } from '@janhq/uikit' import { Badge, Button } from '@janhq/uikit'
import { atom, useAtomValue } from 'jotai' import { atom, useAtomValue } from 'jotai'
@ -15,67 +15,41 @@ import { ModelPerformance, TagType } from '@/constants/tagType'
import useDownloadModel from '@/hooks/useDownloadModel' import useDownloadModel from '@/hooks/useDownloadModel'
import { useDownloadState } from '@/hooks/useDownloadState' import { useDownloadState } from '@/hooks/useDownloadState'
import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels' import { useGetDownloadedModels } from '@/hooks/useGetDownloadedModels'
import useGetPerformanceTag from '@/hooks/useGetPerformanceTag'
import { useMainViewState } from '@/hooks/useMainViewState' import { useMainViewState } from '@/hooks/useMainViewState'
import { toGigabytes } from '@/utils/converter' import { toGigabytes } from '@/utils/converter'
import { totalRamAtom } from '@/helpers/atoms/SystemBar.atom'
type Props = { type Props = {
suitableModel: Model model: Model
exploreModel: ModelCatalog
} }
const ExploreModelItemHeader: React.FC<Props> = ({ const ExploreModelItemHeader: React.FC<Props> = ({ model }) => {
suitableModel,
exploreModel,
}) => {
const { downloadModel } = useDownloadModel() const { downloadModel } = useDownloadModel()
const { downloadedModels } = useGetDownloadedModels() const { downloadedModels } = useGetDownloadedModels()
const { modelDownloadStateAtom, downloadStates } = useDownloadState() const { modelDownloadStateAtom, downloadStates } = useDownloadState()
const { getPerformanceForModel } = useGetPerformanceTag()
const [title, setTitle] = useState<string>('Recommended') const [title, setTitle] = useState<string>('Recommended')
const totalRam = useAtomValue(totalRamAtom)
const [performanceTag, setPerformanceTag] = useState<TagType>( const [performanceTag, setPerformanceTag] = useState<TagType>(
ModelPerformance.PerformancePositive ModelPerformance.PerformancePositive
) )
const downloadAtom = useMemo( const downloadAtom = useMemo(
() => atom((get) => get(modelDownloadStateAtom)[suitableModel.name]), () => atom((get) => get(modelDownloadStateAtom)[model.id]),
[suitableModel.name] [model.id]
) )
const downloadState = useAtomValue(downloadAtom) const downloadState = useAtomValue(downloadAtom)
const { setMainViewState } = useMainViewState() const { setMainViewState } = useMainViewState()
const calculatePerformance = useCallback(
(suitableModel: Model) => async () => {
const { title, performanceTag } = await getPerformanceForModel(
suitableModel,
totalRam
)
setPerformanceTag(performanceTag)
setTitle(title)
},
[totalRam]
)
useEffect(() => {
calculatePerformance(suitableModel)
}, [suitableModel])
const onDownloadClick = useCallback(() => { const onDownloadClick = useCallback(() => {
downloadModel(suitableModel) downloadModel(model)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [suitableModel]) }, [model])
// TODO: Comparing between Model Id and Version Name? const isDownloaded = downloadedModels.find((md) => md.id === model.id) != null
const isDownloaded =
downloadedModels.find((model) => model.id === suitableModel.name) != null
let downloadButton = ( let downloadButton = (
<Button onClick={() => onDownloadClick()}> <Button onClick={() => onDownloadClick()}>
{suitableModel.metadata.size {model.metadata.size
? `Download (${toGigabytes(suitableModel.metadata.size)})` ? `Download (${toGigabytes(model.metadata.size)})`
: 'Download'} : 'Download'}
</Button> </Button>
) )
@ -93,7 +67,7 @@ const ExploreModelItemHeader: React.FC<Props> = ({
} }
if (downloadState != null && downloadStates.length > 0) { if (downloadState != null && downloadStates.length > 0) {
downloadButton = <ModalCancelDownload suitableModel={suitableModel} /> downloadButton = <ModalCancelDownload model={model} />
} }
const renderBadge = (performance: TagType) => { const renderBadge = (performance: TagType) => {
@ -115,7 +89,7 @@ const ExploreModelItemHeader: React.FC<Props> = ({
return ( return (
<div className="flex items-center justify-between rounded-t-md border-b border-border bg-background/50 px-4 py-2"> <div className="flex items-center justify-between rounded-t-md border-b border-border bg-background/50 px-4 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium">{exploreModel.name}</span> <span className="font-medium">{model.name}</span>
{performanceTag && renderBadge(performanceTag)} {performanceTag && renderBadge(performanceTag)}
</div> </div>
{downloadButton} {downloadButton}

View File

@ -1,16 +1,14 @@
import { ModelCatalog } from '@janhq/core' import { Model } from '@janhq/core'
import ExploreModelItem from '@/screens/ExploreModels/ExploreModelItem' import ExploreModelItem from '@/screens/ExploreModels/ExploreModelItem'
type Props = { type Props = {
models: ModelCatalog[] models: Model[]
} }
const ExploreModelList: React.FC<Props> = ({ models }) => ( const ExploreModelList: React.FC<Props> = ({ models }) => (
<div className="relative h-full w-full flex-shrink-0"> <div className="relative h-full w-full flex-shrink-0">
{models?.map((item, i) => ( {models?.map((model) => <ExploreModelItem key={model.id} model={model} />)}
<ExploreModelItem key={item.name + '/' + item.id} model={item} />
))}
</div> </div>
) )

View File

@ -2,7 +2,7 @@
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { Model } from '@janhq/core' import { Model } from '@janhq/core'
import { Badge, Button } from '@janhq/uikit' import { Button } from '@janhq/uikit'
import { atom, useAtomValue } from 'jotai' import { atom, useAtomValue } from 'jotai'
import ModalCancelDownload from '@/containers/ModalCancelDownload' import ModalCancelDownload from '@/containers/ModalCancelDownload'
@ -63,7 +63,7 @@ const ModelVersionItem: React.FC<Props> = ({ model }) => {
} }
if (downloadState != null && downloadStates.length > 0) { if (downloadState != null && downloadStates.length > 0) {
downloadButton = <ModalCancelDownload suitableModel={model} isFromList /> downloadButton = <ModalCancelDownload model={model} isFromList />
} }
return ( return (
@ -74,16 +74,7 @@ const ModelVersionItem: React.FC<Props> = ({ model }) => {
</span> </span>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2"></div>
<Badge
themes="secondary"
className="line-clamp-1"
title={`${toGigabytes(model.metadata.maxRamRequired)} RAM required`}
>{`${toGigabytes(
model.metadata.maxRamRequired
)} RAM required`}</Badge>
<Badge themes="secondary">{toGigabytes(model.metadata.size)}</Badge>
</div>
{downloadButton} {downloadButton}
</div> </div>
</div> </div>

View File

@ -55,7 +55,7 @@ export default function BlankStateMyModel() {
} }
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p>{item?.fileName}</p> <p>{item?.modelId}</p>
<span>{formatDownloadPercentage(item?.percent)}</span> <span>{formatDownloadPercentage(item?.percent)}</span>
</div> </div>
</div> </div>

View File

@ -63,10 +63,7 @@ const MyModelsScreen = () => {
<div className="flex items-start gap-x-4"> <div className="flex items-start gap-x-4">
<div className="inline-flex rounded-full border border-border p-1"> <div className="inline-flex rounded-full border border-border p-1">
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarImage <AvatarImage alt={model.metadata.author} />
src={model.metadata.avatarUrl}
alt={model.metadata.author}
/>
<AvatarFallback> <AvatarFallback>
{model.metadata.author.charAt(0)} {model.metadata.author.charAt(0)}
</AvatarFallback> </AvatarFallback>

View File

@ -30,7 +30,6 @@ const SettingsScreen = () => {
setMenus(menu) setMenus(menu)
}, []) }, [])
const preferenceExtensions = preferenceItems const preferenceExtensions = preferenceItems
.map((x) => x.extensionnName) .map((x) => x.extensionnName)
.filter((x, i) => { .filter((x, i) => {

View File

@ -4,7 +4,6 @@ type DownloadState = {
speed: number speed: number
percent: number percent: number
size: DownloadSize size: DownloadSize
fileName: string
error?: string error?: string
} }

View File

@ -1,119 +0,0 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { ModelCatalog, ModelState } from '@janhq/core'
export const dummyModel: ModelCatalog = {
id: 'aladar/TinyLLama-v0-GGUF',
name: 'TinyLLama-v0-GGUF',
shortDescription: 'TinyLlama-1.1B-Chat-v0.3-GGUF',
longDescription: 'https://huggingface.co/aladar/TinyLLama-v0-GGUF/tree/main',
avatarUrl: '',
releaseDate: Date.now(),
author: 'aladar',
version: '1.0.0',
modelUrl: 'aladar/TinyLLama-v0-GGUF',
tags: ['freeform', 'tags'],
availableVersions: [
{
object: 'model',
version: '1.0.0',
source_url:
'https://huggingface.co/aladar/TinyLLama-v0-GGUF/resolve/main/TinyLLama-v0.Q8_0.gguf',
id: 'TinyLLama-v0.Q8_0.gguf',
name: 'TinyLLama-v0.Q8_0.gguf',
owned_by: 'you',
created: 0,
description: '',
state: ModelState.ToDownload,
settings: {
ctx_len: 2048,
ngl: 100,
embedding: true,
n_parallel: 4,
},
parameters: {
temperature: 0.7,
token_limit: 2048,
top_k: 0,
top_p: 1,
stream: true,
},
metadata: {
engine: '',
quantization: '',
size: 5816320,
binaries: [],
maxRamRequired: 256000000,
author: 'aladar',
avatarUrl: '',
},
},
{
object: 'model',
version: '1.0.0',
source_url:
'https://huggingface.co/aladar/TinyLLama-v0-GGUF/resolve/main/TinyLLama-v0.f16.gguf',
id: 'TinyLLama-v0.f16.gguf',
name: 'TinyLLama-v0.f16.gguf',
owned_by: 'you',
created: 0,
description: '',
state: ModelState.ToDownload,
settings: {
ctx_len: 2048,
ngl: 100,
embedding: true,
n_parallel: 4,
},
parameters: {
temperature: 0.7,
token_limit: 2048,
top_k: 0,
top_p: 1,
stream: true,
},
metadata: {
engine: '',
quantization: '',
size: 5816320,
binaries: [],
maxRamRequired: 256000000,
author: 'aladar',
avatarUrl: '',
},
},
{
object: 'model',
version: '1.0.0',
source_url:
'https://huggingface.co/aladar/TinyLLama-v0-GGUF/resolve/main/TinyLLama-v0.f32.gguf',
id: 'TinyLLama-v0.f32.gguf',
name: 'TinyLLama-v0.f32.gguf',
owned_by: 'you',
created: 0,
description: '',
state: ModelState.ToDownload,
settings: {
ctx_len: 2048,
ngl: 100,
embedding: true,
n_parallel: 4,
},
parameters: {
temperature: 0.7,
token_limit: 2048,
top_k: 0,
top_p: 1,
stream: true,
},
metadata: {
engine: '',
quantization: '',
size: 5816320,
binaries: [],
maxRamRequired: 256000000,
author: 'aladar',
avatarUrl: '',
},
},
],
}