refactor: clean up deprecated components and events (#4769)

This commit is contained in:
Louis 2025-03-03 22:20:39 +07:00 committed by GitHub
parent c79c10c96b
commit a8aa938f42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 67 additions and 1243 deletions

View File

@ -25,7 +25,6 @@ export default defineConfig([
'@types/pacote', '@types/pacote',
'@npmcli/arborist', '@npmcli/arborist',
'ulidx', 'ulidx',
'node-fetch',
'fs', 'fs',
'request', 'request',
'crypto', 'crypto',

View File

@ -2,7 +2,6 @@ import { openExternalUrl } from './core'
import { joinPath } from './core' import { joinPath } from './core'
import { openFileExplorer } from './core' import { openFileExplorer } from './core'
import { getJanDataFolderPath } from './core' import { getJanDataFolderPath } from './core'
import { abortDownload } from './core'
import { executeOnMain } from './core' import { executeOnMain } from './core'
describe('test core apis', () => { describe('test core apis', () => {
@ -53,18 +52,6 @@ describe('test core apis', () => {
expect(result).toBe('/path/to/jan/data') expect(result).toBe('/path/to/jan/data')
}) })
it('should abort download', async () => {
const fileName = 'testFile'
globalThis.core = {
api: {
abortDownload: jest.fn().mockResolvedValue('aborted'),
},
}
const result = await abortDownload(fileName)
expect(globalThis.core.api.abortDownload).toHaveBeenCalledWith(fileName)
expect(result).toBe('aborted')
})
it('should execute function on main process', async () => { it('should execute function on main process', async () => {
const extension = 'testExtension' const extension = 'testExtension'
const method = 'testMethod' const method = 'testMethod'

View File

@ -1,9 +1,4 @@
import { import { SystemInformation } from '../types'
DownloadRequest,
FileStat,
NetworkConfig,
SystemInformation,
} from '../types'
/** /**
* Execute a extension module function in main process * Execute a extension module function in main process
@ -14,42 +9,19 @@ import {
* @returns Promise<any> * @returns Promise<any>
* *
*/ */
const executeOnMain: ( const executeOnMain: (extension: string, method: string, ...args: any[]) => Promise<any> = (
extension: string, extension,
method: string, method,
...args: any[] ...args
) => Promise<any> = (extension, method, ...args) => ) => globalThis.core?.api?.invokeExtensionFunc(extension, method, ...args)
globalThis.core?.api?.invokeExtensionFunc(extension, method, ...args)
/**
* Downloads a file from a URL and saves it to the local file system.
*
* @param {DownloadRequest} downloadRequest - The request to download the file.
* @param {NetworkConfig} network - Optional object to specify proxy/whether to ignore SSL certificates.
*
* @returns {Promise<any>} A promise that resolves when the file is downloaded.
*/
const downloadFile: (
downloadRequest: DownloadRequest,
network?: NetworkConfig
) => Promise<any> = (downloadRequest, network) =>
globalThis.core?.api?.downloadFile(downloadRequest, network)
/**
* Aborts the download of a specific file.
* @param {string} fileName - The name of the file whose download is to be aborted.
* @returns {Promise<any>} A promise that resolves when the download has been aborted.
*/
const abortDownload: (fileName: string) => Promise<any> = (fileName) =>
globalThis.core.api?.abortDownload(fileName)
/** /**
* Gets Jan's data folder path. * Gets Jan's data folder path.
* *
* @returns {Promise<string>} A Promise that resolves with Jan's data folder path. * @returns {Promise<string>} A Promise that resolves with Jan's data folder path.
*/ */
const getJanDataFolderPath = (): Promise<string> => const getJanDataFolderPath = (): Promise<string> => globalThis.core.api?.getJanDataFolderPath()
globalThis.core.api?.getJanDataFolderPath()
/** /**
* Opens the file explorer at a specific path. * Opens the file explorer at a specific path.
@ -72,16 +44,14 @@ const joinPath: (paths: string[]) => Promise<string> = (paths) =>
* @param path - The file path to retrieve dirname. * @param path - The file path to retrieve dirname.
* @returns {Promise<string>} A promise that resolves the dirname. * @returns {Promise<string>} A promise that resolves the dirname.
*/ */
const dirName: (path: string) => Promise<string> = (path) => const dirName: (path: string) => Promise<string> = (path) => globalThis.core.api?.dirName(path)
globalThis.core.api?.dirName(path)
/** /**
* Retrieve the basename from an url. * Retrieve the basename from an url.
* @param path - The path to retrieve. * @param path - The path to retrieve.
* @returns {Promise<string>} A promise that resolves with the basename. * @returns {Promise<string>} A promise that resolves with the basename.
*/ */
const baseName: (paths: string) => Promise<string> = (path) => const baseName: (paths: string) => Promise<string> = (path) => globalThis.core.api?.baseName(path)
globalThis.core.api?.baseName(path)
/** /**
* Opens an external URL in the default web browser. * Opens an external URL in the default web browser.
@ -97,15 +67,13 @@ const openExternalUrl: (url: string) => Promise<any> = (url) =>
* *
* @returns {Promise<string>} - A promise that resolves with the resource path. * @returns {Promise<string>} - A promise that resolves with the resource path.
*/ */
const getResourcePath: () => Promise<string> = () => const getResourcePath: () => Promise<string> = () => globalThis.core.api?.getResourcePath()
globalThis.core.api?.getResourcePath()
/** /**
* Gets the user's home path. * Gets the user's home path.
* @returns return user's home path * @returns return user's home path
*/ */
const getUserHomePath = (): Promise<string> => const getUserHomePath = (): Promise<string> => globalThis.core.api?.getUserHomePath()
globalThis.core.api?.getUserHomePath()
/** /**
* Log to file from browser processes. * Log to file from browser processes.
@ -123,10 +91,8 @@ const log: (message: string, fileName?: string) => void = (message, fileName) =>
* *
* @returns {Promise<boolean>} - A promise that resolves with a boolean indicating whether the path is a subdirectory. * @returns {Promise<boolean>} - A promise that resolves with a boolean indicating whether the path is a subdirectory.
*/ */
const isSubdirectory: (from: string, to: string) => Promise<boolean> = ( const isSubdirectory: (from: string, to: string) => Promise<boolean> = (from: string, to: string) =>
from: string, globalThis.core.api?.isSubdirectory(from, to)
to: string
) => globalThis.core.api?.isSubdirectory(from, to)
/** /**
* Get system information * Get system information
@ -159,8 +125,6 @@ export type RegisterExtensionPoint = (
*/ */
export { export {
executeOnMain, executeOnMain,
downloadFile,
abortDownload,
getJanDataFolderPath, getJanDataFolderPath,
openFileExplorer, openFileExplorer,
getResourcePath, getResourcePath,

View File

@ -39,11 +39,6 @@ describe('BaseExtension', () => {
expect(baseExtension.onUnload).toBeDefined() expect(baseExtension.onUnload).toBeDefined()
}) })
it('should have installationState() return "NotRequired"', async () => {
const installationState = await baseExtension.installationState()
expect(installationState).toBe('NotRequired')
})
it('should install the extension', async () => { it('should install the extension', async () => {
await baseExtension.install() await baseExtension.install()
// Add your assertions here // Add your assertions here
@ -84,11 +79,6 @@ describe('BaseExtension', () => {
expect(baseExtension.onUnload).toBeDefined() expect(baseExtension.onUnload).toBeDefined()
}) })
it('should have installationState() return "NotRequired"', async () => {
const installationState = await baseExtension.installationState()
expect(installationState).toBe('NotRequired')
})
it('should install the extension', async () => { it('should install the extension', async () => {
await baseExtension.install() await baseExtension.install()
// Add your assertions here // Add your assertions here

View File

@ -24,17 +24,6 @@ export interface Compatibility {
version: string version: string
} }
const ALL_INSTALLATION_STATE = [
'NotRequired', // not required.
'Installed', // require and installed. Good to go.
'NotInstalled', // require to be installed.
'Corrupted', // require but corrupted. Need to redownload.
'NotCompatible', // require but not compatible.
] as const
export type InstallationStateTuple = typeof ALL_INSTALLATION_STATE
export type InstallationState = InstallationStateTuple[number]
/** /**
* Represents a base extension. * Represents a base extension.
* This class should be extended by any class that represents an extension. * This class should be extended by any class that represents an extension.
@ -175,15 +164,6 @@ export abstract class BaseExtension implements ExtensionType {
return return
} }
/**
* Determine if the prerequisites for the extension are installed.
*
* @returns {boolean} true if the prerequisites are installed, false otherwise.
*/
async installationState(): Promise<InstallationState> {
return 'NotRequired'
}
/** /**
* Install the prerequisites for the extension. * Install the prerequisites for the extension.
* *

View File

@ -55,17 +55,23 @@ const unlinkSync = (...args: any[]) => globalThis.core.api?.unlinkSync(...args)
*/ */
const appendFileSync = (...args: any[]) => globalThis.core.api?.appendFileSync(...args) const appendFileSync = (...args: any[]) => globalThis.core.api?.appendFileSync(...args)
/**
* Copies a file from the source path to the destination path.
* @param src
* @param dest
* @returns
*/
const copyFile: (src: string, dest: string) => Promise<void> = (src, dest) => const copyFile: (src: string, dest: string) => Promise<void> = (src, dest) =>
globalThis.core.api?.copyFile(src, dest) globalThis.core.api?.copyFile(src, dest)
/** /**
* Gets the list of gguf files in a directory * Gets the list of gguf files in a directory
* *
* @param path - The paths to the file. * @param path - The paths to the file.
* @returns {Promise<{any}>} - A promise that resolves with the list of gguf and non-gguf files * @returns {Promise<{any}>} - A promise that resolves with the list of gguf and non-gguf files
*/ */
const getGgufFiles: (paths: string[]) => Promise<any> = ( const getGgufFiles: (paths: string[]) => Promise<any> = (paths) =>
paths) => globalThis.core.api?.getGgufFiles(paths) globalThis.core.api?.getGgufFiles(paths)
/** /**
* Gets the file's stats. * Gets the file's stats.

View File

@ -1,25 +1,21 @@
import { import {
AppRoute, AppRoute,
DownloadRoute,
ExtensionRoute, ExtensionRoute,
FileManagerRoute, FileManagerRoute,
FileSystemRoute, FileSystemRoute,
} from '../../../types/api' } from '../../../types/api'
import { Downloader } from '../processors/download'
import { FileSystem } from '../processors/fs' import { FileSystem } from '../processors/fs'
import { Extension } from '../processors/extension' import { Extension } from '../processors/extension'
import { FSExt } from '../processors/fsExt' import { FSExt } from '../processors/fsExt'
import { App } from '../processors/app' import { App } from '../processors/app'
export class RequestAdapter { export class RequestAdapter {
downloader: Downloader
fileSystem: FileSystem fileSystem: FileSystem
extension: Extension extension: Extension
fsExt: FSExt fsExt: FSExt
app: App app: App
constructor(observer?: Function) { constructor(observer?: Function) {
this.downloader = new Downloader(observer)
this.fileSystem = new FileSystem() this.fileSystem = new FileSystem()
this.extension = new Extension() this.extension = new Extension()
this.fsExt = new FSExt() this.fsExt = new FSExt()
@ -28,9 +24,7 @@ export class RequestAdapter {
// TODO: Clearer Factory pattern here // TODO: Clearer Factory pattern here
process(route: string, ...args: any) { process(route: string, ...args: any) {
if (route in DownloadRoute) { if (route in FileSystemRoute) {
return this.downloader.process(route, ...args)
} else if (route in FileSystemRoute) {
return this.fileSystem.process(route, ...args) return this.fileSystem.process(route, ...args)
} else if (route in ExtensionRoute) { } else if (route in ExtensionRoute) {
return this.extension.process(route, ...args) return this.extension.process(route, ...args)

View File

@ -1,125 +0,0 @@
import { Downloader } from './download'
import { DownloadEvent } from '../../../types/api'
import { DownloadManager } from '../../helper/download'
jest.mock('../../helper', () => ({
getJanDataFolderPath: jest.fn().mockReturnValue('path/to/folder'),
}))
jest.mock('../../helper/path', () => ({
validatePath: jest.fn().mockReturnValue('path/to/folder'),
normalizeFilePath: () =>
process.platform === 'win32' ? 'C:\\Users\\path\\to\\file.gguf' : '/Users/path/to/file.gguf',
}))
jest.mock(
'request',
jest.fn().mockReturnValue(() => ({
on: jest.fn(),
}))
)
jest.mock('fs', () => ({
createWriteStream: jest.fn(),
}))
const requestMock = jest.fn((options, callback) => {
callback(new Error('Test error'), null)
})
jest.mock('request', () => requestMock)
jest.mock('request-progress', () => {
return jest.fn().mockImplementation(() => {
return {
on: jest.fn().mockImplementation((event, callback) => {
if (event === 'error') {
callback(new Error('Download failed'))
}
return {
on: jest.fn().mockImplementation((event, callback) => {
if (event === 'error') {
callback(new Error('Download failed'))
}
return {
on: jest.fn().mockImplementation((event, callback) => {
if (event === 'error') {
callback(new Error('Download failed'))
}
return { pipe: jest.fn() }
}),
}
}),
}
}),
}
})
})
describe('Downloader', () => {
beforeEach(() => {
jest.resetAllMocks()
})
it('should pause download correctly', () => {
const observer = jest.fn()
const fileName = process.platform === 'win32' ? 'C:\\path\\to\\file' : 'path/to/file'
const downloader = new Downloader(observer)
const pauseMock = jest.fn()
DownloadManager.instance.networkRequests[fileName] = { pause: pauseMock }
downloader.pauseDownload(observer, fileName)
expect(pauseMock).toHaveBeenCalled()
})
it('should resume download correctly', () => {
const observer = jest.fn()
const fileName = process.platform === 'win32' ? 'C:\\path\\to\\file' : 'path/to/file'
const downloader = new Downloader(observer)
const resumeMock = jest.fn()
DownloadManager.instance.networkRequests[fileName] = { resume: resumeMock }
downloader.resumeDownload(observer, fileName)
expect(resumeMock).toHaveBeenCalled()
})
it('should handle aborting a download correctly', () => {
const observer = jest.fn()
const fileName = process.platform === 'win32' ? 'C:\\path\\to\\file' : 'path/to/file'
const downloader = new Downloader(observer)
const abortMock = jest.fn()
DownloadManager.instance.networkRequests[fileName] = { abort: abortMock }
downloader.abortDownload(observer, fileName)
expect(abortMock).toHaveBeenCalled()
expect(observer).toHaveBeenCalledWith(
DownloadEvent.onFileDownloadError,
expect.objectContaining({
error: 'aborted',
})
)
})
it('should handle download fail correctly', () => {
const observer = jest.fn()
const fileName = process.platform === 'win32' ? 'C:\\path\\to\\file' : 'path/to/file.gguf'
const downloader = new Downloader(observer)
downloader.downloadFile(observer, {
localPath: fileName,
url: 'http://127.0.0.1',
})
expect(observer).toHaveBeenCalledWith(
DownloadEvent.onFileDownloadError,
expect.objectContaining({
error: expect.anything(),
})
)
})
})

View File

@ -1,138 +0,0 @@
import { resolve, sep } from 'path'
import { DownloadEvent } from '../../../types/api'
import { normalizeFilePath } from '../../helper/path'
import { getJanDataFolderPath } from '../../helper'
import { DownloadManager } from '../../helper/download'
import { createWriteStream, renameSync } from 'fs'
import { Processor } from './Processor'
import { DownloadRequest, DownloadState, NetworkConfig } from '../../../types'
export class Downloader implements Processor {
observer?: Function
constructor(observer?: Function) {
this.observer = observer
}
process(key: string, ...args: any[]): any {
const instance = this as any
const func = instance[key]
return func(this.observer, ...args)
}
downloadFile(observer: any, downloadRequest: DownloadRequest, network?: NetworkConfig) {
const request = require('request')
const progress = require('request-progress')
const strictSSL = !network?.ignoreSSL
const proxy = network?.proxy?.startsWith('http') ? network.proxy : undefined
const { localPath, url } = downloadRequest
let normalizedPath = localPath
if (typeof localPath === 'string') {
normalizedPath = normalizeFilePath(localPath)
}
const array = normalizedPath.split(sep)
const fileName = array.pop() ?? ''
const modelId = downloadRequest.modelId ?? array.pop() ?? ''
const destination = resolve(getJanDataFolderPath(), normalizedPath)
const rq = request({ url, strictSSL, proxy })
// Put request to download manager instance
DownloadManager.instance.setRequest(normalizedPath, rq)
// Downloading file to a temp file first
const downloadingTempFile = `${destination}.download`
// adding initial download state
const initialDownloadState: DownloadState = {
modelId,
fileName,
percent: 0,
size: {
total: 0,
transferred: 0,
},
children: [],
downloadState: 'downloading',
extensionId: downloadRequest.extensionId,
downloadType: downloadRequest.downloadType,
localPath: normalizedPath,
}
DownloadManager.instance.downloadProgressMap[modelId] = initialDownloadState
DownloadManager.instance.downloadInfo[normalizedPath] = initialDownloadState
if (downloadRequest.downloadType === 'extension') {
observer?.(DownloadEvent.onFileDownloadUpdate, initialDownloadState)
}
progress(rq, {})
.on('progress', (state: any) => {
const currentDownloadState = DownloadManager.instance.downloadProgressMap[modelId]
const downloadState: DownloadState = {
...currentDownloadState,
...state,
fileName: fileName,
downloadState: 'downloading',
}
console.debug('progress: ', downloadState)
observer?.(DownloadEvent.onFileDownloadUpdate, downloadState)
DownloadManager.instance.downloadProgressMap[modelId] = downloadState
})
.on('error', (error: Error) => {
const currentDownloadState = DownloadManager.instance.downloadProgressMap[modelId]
const downloadState: DownloadState = {
...currentDownloadState,
fileName: fileName,
error: error.message,
downloadState: 'error',
}
observer?.(DownloadEvent.onFileDownloadError, downloadState)
DownloadManager.instance.downloadProgressMap[modelId] = downloadState
})
.on('end', () => {
const currentDownloadState = DownloadManager.instance.downloadProgressMap[modelId]
if (
currentDownloadState &&
DownloadManager.instance.networkRequests[normalizedPath] &&
DownloadManager.instance.downloadProgressMap[modelId]?.downloadState !== 'error'
) {
// Finished downloading, rename temp file to actual file
renameSync(downloadingTempFile, destination)
const downloadState: DownloadState = {
...currentDownloadState,
fileName: fileName,
downloadState: 'end',
}
observer?.(DownloadEvent.onFileDownloadSuccess, downloadState)
DownloadManager.instance.downloadProgressMap[modelId] = downloadState
}
})
.pipe(createWriteStream(downloadingTempFile))
}
abortDownload(observer: any, fileName: string) {
const rq = DownloadManager.instance.networkRequests[fileName]
if (rq) {
DownloadManager.instance.networkRequests[fileName] = undefined
rq?.abort()
}
const downloadInfo = DownloadManager.instance.downloadInfo[fileName]
observer?.(DownloadEvent.onFileDownloadError, {
...downloadInfo,
fileName,
error: 'aborted',
})
}
resumeDownload(_observer: any, fileName: any) {
DownloadManager.instance.networkRequests[fileName]?.resume()
}
pauseDownload(_observer: any, fileName: any) {
DownloadManager.instance.networkRequests[fileName]?.pause()
}
}

View File

@ -1,11 +0,0 @@
import { DownloadManager } from './download';
it('should set a network request for a specific file', () => {
const downloadManager = new DownloadManager();
const fileName = 'testFile';
const request = { url: 'http://example.com' };
downloadManager.setRequest(fileName, request);
expect(downloadManager.networkRequests[fileName]).toEqual(request);
});

View File

@ -1,30 +0,0 @@
import { DownloadState } from '../../types'
/**
* Manages file downloads and network requests.
*/
export class DownloadManager {
public networkRequests: Record<string, any> = {}
public static instance: DownloadManager = new DownloadManager()
// store the download information with key is model id
public downloadProgressMap: Record<string, DownloadState> = {}
// store the download information with key is normalized file path
public downloadInfo: Record<string, DownloadState> = {}
constructor() {
if (DownloadManager.instance) {
return DownloadManager.instance
}
}
/**
* Sets a network request for a specific file.
* @param {string} fileName - The name of the file.
* @param {Request | undefined} request - The network request to set, or undefined to clear the request.
*/
setRequest(fileName: string, request: any | undefined) {
this.networkRequests[fileName] = request
}
}

View File

@ -1,5 +1,4 @@
export * from './config' export * from './config'
export * from './download'
export * from './logger' export * from './logger'
export * from './module' export * from './module'
export * from './path' export * from './path'

View File

@ -65,30 +65,13 @@ export enum AppEvent {
onMainViewStateChange = 'onMainViewStateChange', onMainViewStateChange = 'onMainViewStateChange',
} }
export enum DownloadRoute {
abortDownload = 'abortDownload',
downloadFile = 'downloadFile',
pauseDownload = 'pauseDownload',
resumeDownload = 'resumeDownload',
getDownloadProgress = 'getDownloadProgress',
}
export enum DownloadEvent { export enum DownloadEvent {
onFileDownloadUpdate = 'onFileDownloadUpdate', onFileDownloadUpdate = 'onFileDownloadUpdate',
onFileDownloadError = 'onFileDownloadError', onFileDownloadError = 'onFileDownloadError',
onFileDownloadSuccess = 'onFileDownloadSuccess', onFileDownloadSuccess = 'onFileDownloadSuccess',
onFileDownloadStopped = 'onFileDownloadStopped', onFileDownloadStopped = 'onFileDownloadStopped',
onFileDownloadStarted = 'onFileDownloadStarted', onFileDownloadStarted = 'onFileDownloadStarted',
onFileUnzipSuccess = 'onFileUnzipSuccess',
} }
export enum LocalImportModelEvent {
onLocalImportModelUpdate = 'onLocalImportModelUpdate',
onLocalImportModelFailed = 'onLocalImportModelFailed',
onLocalImportModelSuccess = 'onLocalImportModelSuccess',
onLocalImportModelFinished = 'onLocalImportModelFinished',
}
export enum ExtensionRoute { export enum ExtensionRoute {
baseExtensions = 'baseExtensions', baseExtensions = 'baseExtensions',
getActiveExtensions = 'getActiveExtensions', getActiveExtensions = 'getActiveExtensions',
@ -131,10 +114,6 @@ export type AppEventFunctions = {
[K in AppEvent]: ApiFunction [K in AppEvent]: ApiFunction
} }
export type DownloadRouteFunctions = {
[K in DownloadRoute]: ApiFunction
}
export type DownloadEventFunctions = { export type DownloadEventFunctions = {
[K in DownloadEvent]: ApiFunction [K in DownloadEvent]: ApiFunction
} }
@ -154,7 +133,6 @@ export type FileManagerRouteFunctions = {
export type APIFunctions = NativeRouteFunctions & export type APIFunctions = NativeRouteFunctions &
AppRouteFunctions & AppRouteFunctions &
AppEventFunctions & AppEventFunctions &
DownloadRouteFunctions &
DownloadEventFunctions & DownloadEventFunctions &
ExtensionRouteFunctions & ExtensionRouteFunctions &
FileSystemRouteFunctions & FileSystemRouteFunctions &
@ -162,7 +140,6 @@ export type APIFunctions = NativeRouteFunctions &
export const CoreRoutes = [ export const CoreRoutes = [
...Object.values(AppRoute), ...Object.values(AppRoute),
...Object.values(DownloadRoute),
...Object.values(ExtensionRoute), ...Object.values(ExtensionRoute),
...Object.values(FileSystemRoute), ...Object.values(FileSystemRoute),
...Object.values(FileManagerRoute), ...Object.values(FileManagerRoute),
@ -172,7 +149,6 @@ export const APIRoutes = [...CoreRoutes, ...Object.values(NativeRoute)]
export const APIEvents = [ export const APIEvents = [
...Object.values(AppEvent), ...Object.values(AppEvent),
...Object.values(DownloadEvent), ...Object.values(DownloadEvent),
...Object.values(LocalImportModelEvent),
] ]
export type PayloadType = { export type PayloadType = {
messages: ChatCompletionMessage[] messages: ChatCompletionMessage[]

View File

@ -16,41 +16,9 @@ export type DownloadState = {
error?: string error?: string
extensionId?: string extensionId?: string
downloadType?: DownloadType | string
localPath?: string localPath?: string
} }
export type DownloadType = 'model' | 'extension'
export type DownloadRequest = {
/**
* The URL to download the file from.
*/
url: string
/**
* The local path to save the file to.
*/
localPath: string
/**
* The extension ID of the extension that initiated the download.
*
* Can be extension name.
*/
extensionId?: string
/**
* The model ID of the model that initiated the download.
*/
modelId?: string
/**
* The download type.
*/
downloadType?: DownloadType | string
}
type DownloadTime = { type DownloadTime = {
elapsed: number elapsed: number
remaining: number remaining: number
@ -60,7 +28,6 @@ type DownloadSize = {
total: number total: number
transferred: number transferred: number
} }
/** /**
* The file metadata * The file metadata
*/ */

View File

@ -1,8 +0,0 @@
export type FileDownloadRequest = {
downloadId: string
url: string
localPath: string
fileName: string
displayName: string
metadata: Record<string, string | number>
}

View File

@ -1,6 +1,4 @@
export * from './systemResourceInfo' export * from './systemResourceInfo'
export * from './promptTemplate' export * from './promptTemplate'
export * from './appUpdate' export * from './appUpdate'
export * from './fileDownloadRequest'
export * from './networkConfig'
export * from './selectFiles' export * from './selectFiles'

View File

@ -1,4 +0,0 @@
export type NetworkConfig = {
proxy?: string
ignoreSSL?: boolean
}

View File

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

View File

@ -1,6 +0,0 @@
import { ThreadEvent } from './threadEvent';
it('should have the correct values', () => {
expect(ThreadEvent.OnThreadStarted).toBe('OnThreadStarted');
});

View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "jan", "name": "jan",
"version": "0.1.1", "version": "0.1.1740752217",
"main": "./build/main.js", "main": "./build/main.js",
"author": "Jan <service@jan.ai>", "author": "Jan <service@jan.ai>",
"license": "MIT", "license": "MIT",
@ -113,7 +113,6 @@
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
"electron-updater": "^6.1.7", "electron-updater": "^6.1.7",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"node-fetch": "2",
"pacote": "^21.0.0", "pacote": "^21.0.0",
"request": "^2.88.2", "request": "^2.88.2",
"request-progress": "^3.0.0", "request-progress": "^3.0.0",

View File

@ -5,11 +5,6 @@ import {
joinPath, joinPath,
dirName, dirName,
fs, fs,
ModelManager,
abortDownload,
DownloadState,
events,
DownloadEvent,
OptionType, OptionType,
ModelSource, ModelSource,
extractInferenceParams, extractInferenceParams,
@ -55,9 +50,6 @@ export default class JanModelExtension extends ModelExtension {
this.updateCortexConfig({ huggingface_token: huggingfaceToken }) this.updateCortexConfig({ huggingface_token: huggingfaceToken })
} }
// Listen to app download events
this.handleDesktopEvents()
// Sync with cortexsohub // Sync with cortexsohub
this.fetchCortexsoModels() this.fetchCortexsoModels()
} }
@ -107,21 +99,6 @@ export default class JanModelExtension extends 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 cancelModelPull(model: string): Promise<void> { async cancelModelPull(model: string): Promise<void> {
if (model) {
const modelDto: Model = ModelManager.instance().get(model)
// Clip vision model - should not be handled by cortex.cpp
// TensorRT model - should not be handled by cortex.cpp
if (
modelDto &&
(modelDto.engine === InferenceEngine.nitro_tensorrt_llm ||
modelDto.settings.vision_model)
) {
for (const source of modelDto.sources) {
const path = await joinPath(['models', modelDto.id, source.filename])
await abortDownload(path)
}
}
}
/** /**
* Sending DELETE to /models/pull/{id} endpoint to cancel a model pull * Sending DELETE to /models/pull/{id} endpoint to cancel a model pull
*/ */
@ -362,32 +339,6 @@ export default class JanModelExtension extends ModelExtension {
// END: - Public API // END: - Public API
// BEGIN: - Private API // BEGIN: - Private API
/**
* Handle download state from main app
*/
private handleDesktopEvents() {
if (window && window.electronAPI) {
window.electronAPI.onFileDownloadUpdate(
async (_event: string, state: DownloadState | undefined) => {
if (!state) return
state.downloadState = 'downloading'
events.emit(DownloadEvent.onFileDownloadUpdate, state)
}
)
window.electronAPI.onFileDownloadError(
async (_event: string, state: DownloadState) => {
state.downloadState = 'error'
events.emit(DownloadEvent.onFileDownloadError, state)
}
)
window.electronAPI.onFileDownloadSuccess(
async (_event: string, state: DownloadState) => {
state.downloadState = 'end'
events.emit(DownloadEvent.onFileDownloadSuccess, state)
}
)
}
}
/** /**
* Transform model to the expected format (e.g. parameters, settings, metadata) * Transform model to the expected format (e.g. parameters, settings, metadata)

View File

@ -1,67 +0,0 @@
import {
downloadFile,
DownloadRequest,
fs,
joinPath,
Model,
} from '@janhq/core'
export const downloadModel = async (
model: Model,
network?: { ignoreSSL?: boolean; proxy?: string }
): Promise<void> => {
const homedir = 'file://models'
const supportedGpuArch = ['ampere', 'ada']
// Create corresponding directory
const modelDirPath = await joinPath([homedir, model.id])
if (!(await fs.existsSync(modelDirPath))) await fs.mkdir(modelDirPath)
const jsonFilePath = await joinPath([modelDirPath, 'model.json'])
// Write model.json on download
if (!(await fs.existsSync(jsonFilePath)))
await fs.writeFileSync(
jsonFilePath,
JSON.stringify(model, null, 2)
)
console.debug(`Download sources: ${JSON.stringify(model.sources)}`)
if (model.sources.length > 1) {
// path to model binaries
for (const source of model.sources) {
let path = extractFileName(source.url, '.gguf')
if (source.filename) {
path = await joinPath([modelDirPath, source.filename])
}
const downloadRequest: DownloadRequest = {
url: source.url,
localPath: path,
modelId: model.id,
}
downloadFile(downloadRequest, network)
}
} else {
const fileName = extractFileName(model.sources[0]?.url, '.gguf')
const path = await joinPath([modelDirPath, fileName])
const downloadRequest: DownloadRequest = {
url: model.sources[0]?.url,
localPath: path,
modelId: model.id,
}
downloadFile(downloadRequest, network)
}
}
/**
* try to retrieve the download file name from the source url
*/
function extractFileName(url: string, fileExtension: string): string {
if (!url) return fileExtension
const extractedFileName = url.split('/').pop()
const fileName = extractedFileName.toLowerCase().endsWith(fileExtension)
? extractedFileName
: extractedFileName + fileExtension
return fileName
}

View File

@ -1,79 +0,0 @@
import { useCallback, useEffect } from 'react'
import { abortDownload } from '@janhq/core'
import { Button, Modal, Progress } from '@janhq/joi'
import { atom, useAtom, useAtomValue } from 'jotai'
import {
formatDownloadPercentage,
formatExtensionsName,
} from '@/utils/converter'
import {
InstallingExtensionState,
installingExtensionAtom,
} from '@/helpers/atoms/Extension.atom'
export const showInstallingExtensionModalAtom = atom(false)
const InstallingExtensionModal = () => {
const [showInstallingExtensionModal, setShowInstallingExtensionModal] =
useAtom(showInstallingExtensionModalAtom)
const installingExtensions = useAtomValue(installingExtensionAtom)
useEffect(() => {
if (installingExtensions.length === 0) {
setShowInstallingExtensionModal(false)
}
}, [installingExtensions, setShowInstallingExtensionModal])
const onAbortInstallingExtensionClick = useCallback(
(item: InstallingExtensionState) => {
if (item.localPath) {
abortDownload(item.localPath)
}
},
[]
)
return (
<Modal
title="Installing Extension"
open={showInstallingExtensionModal}
onOpenChange={() => setShowInstallingExtensionModal(false)}
content={
<div>
{Object.values(installingExtensions).map((item) => (
<div className="pt-2" key={item.extensionId}>
<Progress
className="mb-2"
value={
formatDownloadPercentage(item.percentage, {
hidePercentage: true,
}) as number
}
/>
<div className="flex items-center justify-between gap-x-2">
<div className="flex gap-x-2">
<p className="line-clamp-1">
{formatExtensionsName(item.extensionId)}
</p>
<span>{formatDownloadPercentage(item.percentage)}</span>
</div>
<Button
theme="ghost"
size="small"
onClick={() => onAbortInstallingExtensionClick(item)}
>
Cancel
</Button>
</div>
</div>
))}
</div>
}
/>
)
}
export default InstallingExtensionModal

View File

@ -1,52 +0,0 @@
import { Fragment, useCallback } from 'react'
import { Progress } from '@janhq/joi'
import { useAtomValue, useSetAtom } from 'jotai'
import { showInstallingExtensionModalAtom } from './InstallingExtensionModal'
import { installingExtensionAtom } from '@/helpers/atoms/Extension.atom'
const InstallingExtension = () => {
const installingExtensions = useAtomValue(installingExtensionAtom)
const setShowInstallingExtensionModal = useSetAtom(
showInstallingExtensionModalAtom
)
const shouldShowInstalling = installingExtensions.length > 0
let totalPercentage = 0
let totalExtensions = 0
for (const installation of installingExtensions) {
totalPercentage += installation.percentage
totalExtensions++
}
const progress = (totalPercentage / totalExtensions) * 100
const onClick = useCallback(() => {
setShowInstallingExtensionModal(true)
}, [setShowInstallingExtensionModal])
return (
<Fragment>
{shouldShowInstalling ? (
<div
className="flex cursor-pointer flex-row items-center space-x-2"
onClick={onClick}
>
<p className="font-medium text-[hsla(var(--text-secondary))]">
Installing Additional Dependencies
</p>
<div className="flex flex-row items-center justify-center space-x-2 rounded-md px-2 py-[2px]">
<Progress size="small" className="w-20" value={progress} />
<span className=" font-medium text-[hsla(var(--primary-bg))]">
{progress.toFixed(2)}%
</span>
</div>
</div>
) : null}
</Fragment>
)
}
export default InstallingExtension

View File

@ -7,7 +7,6 @@ import { twMerge } from 'tailwind-merge'
import DownloadingState from './DownloadingState' import DownloadingState from './DownloadingState'
import ImportingModelState from './ImportingModelState' import ImportingModelState from './ImportingModelState'
import InstallingExtension from './InstallingExtension'
import SystemMonitor from './SystemMonitor' import SystemMonitor from './SystemMonitor'
import UpdateApp from './UpdateApp' import UpdateApp from './UpdateApp'
import UpdatedFailedModal from './UpdateFailedModal' import UpdatedFailedModal from './UpdateFailedModal'
@ -49,7 +48,6 @@ const BottomPanel = () => {
<ImportingModelState /> <ImportingModelState />
<DownloadingState /> <DownloadingState />
<UpdatedFailedModal /> <UpdatedFailedModal />
<InstallingExtension />
</div> </div>
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
<SystemMonitor /> <SystemMonitor />

View File

@ -35,8 +35,6 @@ import ModalAppUpdaterChangelog from '../ModalAppUpdaterChangelog'
import ModalAppUpdaterNotAvailable from '../ModalAppUpdaterNotAvailable' import ModalAppUpdaterNotAvailable from '../ModalAppUpdaterNotAvailable'
import InstallingExtensionModal from './BottomPanel/InstallingExtension/InstallingExtensionModal'
import { mainViewStateAtom } from '@/helpers/atoms/App.atom' import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
import { import {
productAnalyticAtom, productAnalyticAtom,
@ -167,7 +165,6 @@ const BaseLayout = () => {
{importModelStage === 'EDIT_MODEL_INFO' && <EditModelInfoModal />} {importModelStage === 'EDIT_MODEL_INFO' && <EditModelInfoModal />}
{importModelStage === 'CONFIRM_CANCEL' && <CancelModelImportModal />} {importModelStage === 'CONFIRM_CANCEL' && <CancelModelImportModal />}
<ChooseWhatToImportModal /> <ChooseWhatToImportModal />
<InstallingExtensionModal />
{showProductAnalyticPrompt && ( {showProductAnalyticPrompt && (
<div className="fixed bottom-4 z-50 m-4 max-w-full rounded-xl border border-[hsla(var(--app-border))] bg-[hsla(var(--app-bg))] p-6 shadow-2xl sm:bottom-8 sm:right-4 sm:m-0 sm:max-w-[400px]"> <div className="fixed bottom-4 z-50 m-4 max-w-full rounded-xl border border-[hsla(var(--app-border))] bg-[hsla(var(--app-bg))] p-6 shadow-2xl sm:bottom-8 sm:right-4 sm:m-0 sm:max-w-[400px]">
<div className="mb-4 flex items-center gap-x-2"> <div className="mb-4 flex items-center gap-x-2">

View File

@ -4,8 +4,8 @@ import React from 'react'
import { import {
DownloadEvent, DownloadEvent,
events,
DownloadState, DownloadState,
events,
ModelEvent, ModelEvent,
ExtensionTypeEnum, ExtensionTypeEnum,
ModelExtension, ModelExtension,
@ -17,23 +17,15 @@ import { useSetAtom } from 'jotai'
import { setDownloadStateAtom } from '@/hooks/useDownloadState' import { setDownloadStateAtom } from '@/hooks/useDownloadState'
import { formatExtensionsName } from '@/utils/converter'
import { toaster } from '../Toast' import { toaster } from '../Toast'
import AppUpdateListener from './AppUpdateListener' import AppUpdateListener from './AppUpdateListener'
import ClipboardListener from './ClipboardListener' import ClipboardListener from './ClipboardListener'
import ModelHandler from './ModelHandler' import ModelHandler from './ModelHandler'
import ModelImportListener from './ModelImportListener'
import QuickAskListener from './QuickAskListener' import QuickAskListener from './QuickAskListener'
import { extensionManager } from '@/extension' import { extensionManager } from '@/extension'
import {
InstallingExtensionState,
removeInstallingExtensionAtom,
setInstallingExtensionAtom,
} from '@/helpers/atoms/Extension.atom'
import { import {
addDownloadingModelAtom, addDownloadingModelAtom,
removeDownloadingModelAtom, removeDownloadingModelAtom,
@ -41,109 +33,76 @@ import {
const EventListener = () => { const EventListener = () => {
const setDownloadState = useSetAtom(setDownloadStateAtom) const setDownloadState = useSetAtom(setDownloadStateAtom)
const setInstallingExtension = useSetAtom(setInstallingExtensionAtom)
const removeInstallingExtension = useSetAtom(removeInstallingExtensionAtom)
const addDownloadingModel = useSetAtom(addDownloadingModelAtom) const addDownloadingModel = useSetAtom(addDownloadingModelAtom)
const removeDownloadingModel = useSetAtom(removeDownloadingModelAtom) const removeDownloadingModel = useSetAtom(removeDownloadingModelAtom)
const onFileDownloadUpdate = useCallback( const onFileDownloadUpdate = useCallback(
async (state: DownloadState) => { async (state: DownloadState) => {
console.debug('onFileDownloadUpdate', state) console.debug('onFileDownloadUpdate', state)
if (state.downloadType === 'extension') { addDownloadingModel(state.modelId)
const installingExtensionState: InstallingExtensionState = { setDownloadState(state)
extensionId: state.extensionId!,
percentage: state.percent,
localPath: state.localPath,
}
setInstallingExtension(state.extensionId!, installingExtensionState)
} else {
addDownloadingModel(state.modelId)
setDownloadState(state)
}
}, },
[addDownloadingModel, setDownloadState, setInstallingExtension] [addDownloadingModel, setDownloadState]
) )
const onFileDownloadError = useCallback( const onFileDownloadError = useCallback(
(state: DownloadState) => { (state: DownloadState) => {
console.debug('onFileDownloadError', state) console.debug('onFileDownloadError', state)
if (state.downloadType === 'extension') { state.downloadState = 'error'
removeInstallingExtension(state.extensionId!) setDownloadState(state)
} else { removeDownloadingModel(state.modelId)
state.downloadState = 'error'
setDownloadState(state)
removeDownloadingModel(state.modelId)
}
}, },
[removeInstallingExtension, setDownloadState, removeDownloadingModel] [setDownloadState, removeDownloadingModel]
) )
const onFileDownloadStopped = useCallback( const onFileDownloadStopped = useCallback(
(state: DownloadState) => { (state: DownloadState) => {
console.debug('onFileDownloadError', state) console.debug('onFileDownloadError', state)
if (state.downloadType === 'extension') {
removeInstallingExtension(state.extensionId!) state.downloadState = 'error'
} else { state.error = 'aborted'
state.downloadState = 'error' setDownloadState(state)
state.error = 'aborted' removeDownloadingModel(state.modelId)
setDownloadState(state)
removeDownloadingModel(state.modelId)
}
}, },
[removeInstallingExtension, setDownloadState, removeDownloadingModel] [setDownloadState, removeDownloadingModel]
) )
const onFileDownloadSuccess = useCallback( const onFileDownloadSuccess = useCallback(
async (state: DownloadState) => { async (state: DownloadState) => {
console.debug('onFileDownloadSuccess', state) 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<ModelExtension>(ExtensionTypeEnum.Model)
?.updateModel({
id: model.id,
...model.settings,
...model.parameters,
} as Partial<Model>)
.catch((e) => console.debug(e))
toaster({ // Update model metadata accordingly
title: 'Download Completed', const model = ModelManager.instance().models.get(state.modelId)
description: `Download ${state.modelId} completed`, if (model) {
type: 'success', await extensionManager
}) .get<ModelExtension>(ExtensionTypeEnum.Model)
} ?.updateModel({
state.downloadState = 'end' id: model.id,
setDownloadState(state) ...model.settings,
removeDownloadingModel(state.modelId) ...model.parameters,
events.emit(ModelEvent.OnModelsUpdate, { fetch: true }) } as Partial<Model>)
.catch((e) => console.debug(e))
toaster({
title: 'Download Completed',
description: `Download ${state.modelId} completed`,
type: 'success',
})
} }
state.downloadState = 'end'
setDownloadState(state)
removeDownloadingModel(state.modelId)
events.emit(ModelEvent.OnModelsUpdate, { fetch: true })
}, },
[removeDownloadingModel, setDownloadState] [removeDownloadingModel, setDownloadState]
) )
const onFileUnzipSuccess = useCallback(
(state: DownloadState) => {
console.debug('onFileUnzipSuccess', state)
toaster({
title: 'Success',
description: `Install ${formatExtensionsName(state.extensionId!)} successfully.`,
type: 'success',
})
removeInstallingExtension(state.extensionId!)
},
[removeInstallingExtension]
)
useEffect(() => { useEffect(() => {
console.debug('EventListenerWrapper: registering event listeners...') console.debug('EventListenerWrapper: registering event listeners...')
events.on(DownloadEvent.onFileDownloadUpdate, onFileDownloadUpdate) events.on(DownloadEvent.onFileDownloadUpdate, onFileDownloadUpdate)
events.on(DownloadEvent.onFileDownloadError, onFileDownloadError) events.on(DownloadEvent.onFileDownloadError, onFileDownloadError)
events.on(DownloadEvent.onFileDownloadSuccess, onFileDownloadSuccess) events.on(DownloadEvent.onFileDownloadSuccess, onFileDownloadSuccess)
events.on(DownloadEvent.onFileDownloadStopped, onFileDownloadStopped) events.on(DownloadEvent.onFileDownloadStopped, onFileDownloadStopped)
events.on(DownloadEvent.onFileUnzipSuccess, onFileUnzipSuccess)
return () => { return () => {
console.debug('EventListenerWrapper: unregistering event listeners...') console.debug('EventListenerWrapper: unregistering event listeners...')
@ -151,13 +110,11 @@ const EventListener = () => {
events.off(DownloadEvent.onFileDownloadError, onFileDownloadError) events.off(DownloadEvent.onFileDownloadError, onFileDownloadError)
events.off(DownloadEvent.onFileDownloadSuccess, onFileDownloadSuccess) events.off(DownloadEvent.onFileDownloadSuccess, onFileDownloadSuccess)
events.off(DownloadEvent.onFileDownloadStopped, onFileDownloadStopped) events.off(DownloadEvent.onFileDownloadStopped, onFileDownloadStopped)
events.off(DownloadEvent.onFileUnzipSuccess, onFileUnzipSuccess)
} }
}, [ }, [
onFileDownloadUpdate, onFileDownloadUpdate,
onFileDownloadError, onFileDownloadError,
onFileDownloadSuccess, onFileDownloadSuccess,
onFileUnzipSuccess,
onFileDownloadStopped, onFileDownloadStopped,
]) ])
@ -165,7 +122,6 @@ const EventListener = () => {
<> <>
<AppUpdateListener /> <AppUpdateListener />
<ClipboardListener /> <ClipboardListener />
<ModelImportListener />
<QuickAskListener /> <QuickAskListener />
<ModelHandler /> <ModelHandler />
</> </>

View File

@ -1,109 +0,0 @@
import { Fragment, useCallback, useEffect } from 'react'
import {
ImportingModel,
LocalImportModelEvent,
Model,
ModelEvent,
events,
} from '@janhq/core'
import { useSetAtom } from 'jotai'
import { snackbar } from '../Toast'
import {
setImportingModelErrorAtom,
setImportingModelSuccessAtom,
updateImportingModelProgressAtom,
} from '@/helpers/atoms/Model.atom'
const ModelImportListener = () => {
const updateImportingModelProgress = useSetAtom(
updateImportingModelProgressAtom
)
const setImportingModelSuccess = useSetAtom(setImportingModelSuccessAtom)
const setImportingModelFailed = useSetAtom(setImportingModelErrorAtom)
const onImportModelUpdate = useCallback(
async (state: ImportingModel) => {
if (!state.importId) return
updateImportingModelProgress(state.importId, state.percentage ?? 0)
},
[updateImportingModelProgress]
)
const onImportModelFailed = useCallback(
async (state: ImportingModel) => {
if (!state.importId) return
setImportingModelFailed(state.importId, state.error ?? '')
},
[setImportingModelFailed]
)
const onImportModelSuccess = useCallback(
(state: ImportingModel) => {
if (!state.modelId) return
events.emit(ModelEvent.OnModelsUpdate, { fetch: true })
setImportingModelSuccess(state.importId, state.modelId)
},
[setImportingModelSuccess]
)
const onImportModelFinished = useCallback((importedModels: Model[]) => {
const modelText = importedModels.length === 1 ? 'model' : 'models'
snackbar({
description: `Successfully imported ${importedModels.length} ${modelText}`,
type: 'success',
})
}, [])
useEffect(() => {
console.debug('ModelImportListener: registering event listeners..')
events.on(
LocalImportModelEvent.onLocalImportModelUpdate,
onImportModelUpdate
)
events.on(
LocalImportModelEvent.onLocalImportModelSuccess,
onImportModelSuccess
)
events.on(
LocalImportModelEvent.onLocalImportModelFinished,
onImportModelFinished
)
events.on(
LocalImportModelEvent.onLocalImportModelFailed,
onImportModelFailed
)
return () => {
console.debug('ModelImportListener: unregistering event listeners...')
events.off(
LocalImportModelEvent.onLocalImportModelUpdate,
onImportModelUpdate
)
events.off(
LocalImportModelEvent.onLocalImportModelSuccess,
onImportModelSuccess
)
events.off(
LocalImportModelEvent.onLocalImportModelFinished,
onImportModelFinished
)
events.off(
LocalImportModelEvent.onLocalImportModelFailed,
onImportModelFailed
)
}
}, [
onImportModelUpdate,
onImportModelSuccess,
onImportModelFinished,
onImportModelFailed,
])
return <Fragment></Fragment>
}
export default ModelImportListener

View File

@ -9,54 +9,6 @@ describe('Extension.atom.ts', () => {
jest.clearAllMocks() jest.clearAllMocks()
}) })
describe('installingExtensionAtom', () => {
it('should initialize as an empty array', () => {
const { result } = renderHook(() => useAtomValue(ExtensionAtoms.installingExtensionAtom))
expect(result.current).toEqual([])
})
})
describe('setInstallingExtensionAtom', () => {
it('should add a new installing extension', () => {
const { result: setAtom } = renderHook(() => useSetAtom(ExtensionAtoms.setInstallingExtensionAtom))
const { result: getAtom } = renderHook(() => useAtomValue(ExtensionAtoms.installingExtensionAtom))
act(() => {
setAtom.current('ext1', { extensionId: 'ext1', percentage: 0 })
})
expect(getAtom.current).toEqual([{ extensionId: 'ext1', percentage: 0 }])
})
it('should update an existing installing extension', () => {
const { result: setAtom } = renderHook(() => useSetAtom(ExtensionAtoms.setInstallingExtensionAtom))
const { result: getAtom } = renderHook(() => useAtomValue(ExtensionAtoms.installingExtensionAtom))
act(() => {
setAtom.current('ext1', { extensionId: 'ext1', percentage: 0 })
setAtom.current('ext1', { extensionId: 'ext1', percentage: 50 })
})
expect(getAtom.current).toEqual([{ extensionId: 'ext1', percentage: 50 }])
})
})
describe('removeInstallingExtensionAtom', () => {
it('should remove an installing extension', () => {
const { result: setAtom } = renderHook(() => useSetAtom(ExtensionAtoms.setInstallingExtensionAtom))
const { result: removeAtom } = renderHook(() => useSetAtom(ExtensionAtoms.removeInstallingExtensionAtom))
const { result: getAtom } = renderHook(() => useAtomValue(ExtensionAtoms.installingExtensionAtom))
act(() => {
setAtom.current('ext1', { extensionId: 'ext1', percentage: 0 })
setAtom.current('ext2', { extensionId: 'ext2', percentage: 50 })
removeAtom.current('ext1')
})
expect(getAtom.current).toEqual([{ extensionId: 'ext2', percentage: 50 }])
})
})
describe('inActiveEngineProviderAtom', () => { describe('inActiveEngineProviderAtom', () => {
it('should initialize as an empty array', () => { it('should initialize as an empty array', () => {
const { result } = renderHook(() => useAtomValue(ExtensionAtoms.inActiveEngineProviderAtom)) const { result } = renderHook(() => useAtomValue(ExtensionAtoms.inActiveEngineProviderAtom))

View File

@ -1,45 +1,5 @@
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils' import { atomWithStorage } from 'jotai/utils'
type ExtensionId = string
export type InstallingExtensionState = {
extensionId: ExtensionId
percentage: number
localPath?: string
}
export const installingExtensionAtom = atom<InstallingExtensionState[]>([])
export const setInstallingExtensionAtom = atom(
null,
(get, set, extensionId: string, state: InstallingExtensionState) => {
const current = get(installingExtensionAtom)
const isExists = current.some((e) => e.extensionId === extensionId)
if (isExists) {
const newCurrent = current.map((e) => {
if (e.extensionId === extensionId) {
return state
}
return e
})
set(installingExtensionAtom, newCurrent)
} else {
set(installingExtensionAtom, [...current, state])
}
}
)
export const removeInstallingExtensionAtom = atom(
null,
(get, set, extensionId: string) => {
const current = get(installingExtensionAtom)
const newCurrent = current.filter((e) => e.extensionId !== extensionId)
set(installingExtensionAtom, newCurrent)
}
)
const INACTIVE_ENGINE_PROVIDER = 'inActiveEngineProvider' const INACTIVE_ENGINE_PROVIDER = 'inActiveEngineProvider'
export const inActiveEngineProviderAtom = atomWithStorage<string[]>( export const inActiveEngineProviderAtom = atomWithStorage<string[]>(
INACTIVE_ENGINE_PROVIDER, INACTIVE_ENGINE_PROVIDER,

View File

@ -3,8 +3,8 @@ import { useCallback } from 'react'
import { import {
ExtensionTypeEnum, ExtensionTypeEnum,
ImportingModel, ImportingModel,
LocalImportModelEvent,
Model, Model,
ModelEvent,
ModelExtension, ModelExtension,
OptionType, OptionType,
events, events,
@ -25,6 +25,7 @@ import {
downloadedModelsAtom, downloadedModelsAtom,
importingModelsAtom, importingModelsAtom,
removeDownloadingModelAtom, removeDownloadingModelAtom,
setImportingModelSuccessAtom,
} from '@/helpers/atoms/Model.atom' } from '@/helpers/atoms/Model.atom'
export type ImportModelStage = export type ImportModelStage =
@ -59,6 +60,7 @@ const useImportModel = () => {
const addDownloadingModel = useSetAtom(addDownloadingModelAtom) const addDownloadingModel = useSetAtom(addDownloadingModelAtom)
const removeDownloadingModel = useSetAtom(removeDownloadingModelAtom) const removeDownloadingModel = useSetAtom(removeDownloadingModelAtom)
const downloadedModels = useAtomValue(downloadedModelsAtom) const downloadedModels = useAtomValue(downloadedModelsAtom)
const setImportingModelSuccess = useSetAtom(setImportingModelSuccessAtom)
const incrementalModelName = useCallback( const incrementalModelName = useCallback(
(name: string, startIndex: number = 0): string => { (name: string, startIndex: number = 0): string => {
@ -83,10 +85,9 @@ const useImportModel = () => {
?.importModel(modelId, model.path, model.name, optionType) ?.importModel(modelId, model.path, model.name, optionType)
.finally(() => { .finally(() => {
removeDownloadingModel(modelId) removeDownloadingModel(modelId)
events.emit(LocalImportModelEvent.onLocalImportModelSuccess, {
importId: model.importId, events.emit(ModelEvent.OnModelsUpdate, { fetch: true })
modelId: modelId, setImportingModelSuccess(model.importId, modelId)
})
}) })
} }
}) })

View File

@ -1,197 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import {
BaseExtension,
Compatibility,
InstallationState,
abortDownload,
} from '@janhq/core'
import { Button, Progress, Tooltip } from '@janhq/joi'
import { InfoCircledIcon } from '@radix-ui/react-icons'
import { useAtomValue } from 'jotai'
import { Marked, Renderer } from 'marked'
import { extensionManager } from '@/extension'
import { installingExtensionAtom } from '@/helpers/atoms/Extension.atom'
type Props = {
item: BaseExtension
}
const ExtensionItem: React.FC<Props> = ({ item }) => {
const [compatibility, setCompatibility] = useState<Compatibility | undefined>(
undefined
)
const [installState, setInstallState] =
useState<InstallationState>('NotRequired')
const installingExtensions = useAtomValue(installingExtensionAtom)
const isInstalling = installingExtensions.some(
(e) => e.extensionId === item.name
)
const progress = isInstalling
? (installingExtensions.find((e) => e.extensionId === item.name)
?.percentage ?? -1)
: -1
useEffect(() => {
const getExtensionInstallationState = async () => {
const extension = extensionManager.getByName(item.name)
if (!extension) return
if (typeof extension?.installationState === 'function') {
const installState = await extension.installationState()
setInstallState(installState)
}
}
getExtensionInstallationState()
}, [item.name, isInstalling])
useEffect(() => {
const extension = extensionManager.getByName(item.name)
if (!extension) return
setCompatibility(extension.compatibility())
}, [setCompatibility, item.name])
const onInstallClick = useCallback(async () => {
const extension = extensionManager.getByName(item.name)
if (!extension) return
await extension.install()
}, [item.name])
const onCancelInstallingClick = () => {
const extension = installingExtensions.find(
(e) => e.extensionId === item.name
)
if (extension?.localPath) {
abortDownload(extension.localPath)
}
}
const description = marked.parse(item.description ?? '', { async: false })
return (
<div className="mx-4 flex items-start justify-between border-b border-[hsla(var(--app-border))] py-6 first:pt-4 last:border-none">
<div className="flex-1 flex-shrink-0 space-y-1">
<div className="flex items-center gap-x-2">
<h6 className="font-semibold">Additional Dependencies</h6>
</div>
<div
dangerouslySetInnerHTML={{ __html: description }}
className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]"
/>
</div>
<div className="flex min-w-[150px] flex-row justify-end">
<InstallStateIndicator
installProgress={progress}
installState={installState}
compatibility={compatibility}
onInstallClick={onInstallClick}
onCancelClick={onCancelInstallingClick}
/>
</div>
</div>
)
}
type InstallStateProps = {
installProgress: number
compatibility?: Compatibility
installState: InstallationState
onInstallClick: () => void
onCancelClick: () => void
}
const InstallStateIndicator: React.FC<InstallStateProps> = ({
installProgress,
compatibility,
installState,
onInstallClick,
onCancelClick,
}) => {
if (installProgress !== -1) {
const progress = installProgress * 100
return (
<div className="text-primary dark flex h-10 flex-row items-center justify-center space-x-2 rounded-lg px-4">
<button onClick={onCancelClick} className="text-primary font-semibold">
Cancel
</button>
<div className="flex w-[113px] flex-row items-center justify-center space-x-2 rounded-md px-2 py-[2px]">
<Progress className="h-1 w-[69px]" value={progress} />
<span className="text-primary text-xs font-bold">
{progress.toFixed(0)}%
</span>
</div>
</div>
)
}
switch (installState) {
case 'Installed':
return (
<div className="rounded-md px-3 py-1.5 font-semibold text-[hsla(var(--text-secondary))]">
Installed
</div>
)
case 'NotCompatible':
return (
<div className="rounded-md px-3 py-1.5 font-semibold text-[hsla(var(--text-secondary))]">
<div className="flex flex-row items-center justify-center gap-1">
Incompatible
<Tooltip
trigger={
<InfoCircledIcon className="cursor-pointer text-[hsla(var(--text-secondary))]" />
}
content={
compatibility &&
!compatibility['platform']?.includes(PLATFORM) ? (
<span>
Only available on&nbsp;
{compatibility?.platform
?.map((e: string) =>
e === 'win32'
? 'Windows'
: e === 'linux'
? 'Linux'
: 'MacOS'
)
.join(', ')}
</span>
) : (
<span>Your GPUs are not compatible with this extension</span>
)
}
/>
</div>
</div>
)
case 'NotInstalled':
return (
<Button size="small" variant="soft" onClick={onInstallClick}>
Install
</Button>
)
default:
return <div></div>
}
}
const marked: Marked = new Marked({
renderer: {
link: (href, title, text) => {
return Renderer.prototype.link
?.apply(this, [href, title, text])
.replace(
'<a',
"<a class='text-[hsla(var(--app-link))]' target='_blank'"
)
},
},
})
export default ExtensionItem

View File

@ -40,10 +40,7 @@ const ExtensionCatalog = () => {
'provider' in extension && 'provider' in extension &&
typeof extension.provider === 'string' typeof extension.provider === 'string'
) { ) {
if ( if (settings && settings.length > 0) {
(settings && settings.length > 0) ||
(await extension.installationState()) !== 'NotRequired'
) {
engineMenu.push({ engineMenu.push({
...extension, ...extension,
provider: provider:

View File

@ -1,14 +1,9 @@
import React, { Fragment, useEffect, useMemo, useState } from 'react' import React, { Fragment, useEffect, useMemo, useState } from 'react'
import { import { SettingComponentProps } from '@janhq/core'
BaseExtension,
InstallationState,
SettingComponentProps,
} from '@janhq/core'
import { useAtomValue } from 'jotai' import { useAtomValue } from 'jotai'
import ExtensionItem from '../CoreExtensions/ExtensionItem'
import SettingDetailItem from '../SettingDetail/SettingDetailItem' import SettingDetailItem from '../SettingDetail/SettingDetailItem'
import { extensionManager } from '@/extension' import { extensionManager } from '@/extension'
@ -17,11 +12,6 @@ import { selectedSettingAtom } from '@/helpers/atoms/Setting.atom'
const ExtensionSetting = ({ extensionName }: { extensionName?: string }) => { const ExtensionSetting = ({ extensionName }: { extensionName?: string }) => {
const selectedExtensionName = useAtomValue(selectedSettingAtom) const selectedExtensionName = useAtomValue(selectedSettingAtom)
const [settings, setSettings] = useState<SettingComponentProps[]>([]) const [settings, setSettings] = useState<SettingComponentProps[]>([])
const [installationState, setInstallationState] =
useState<InstallationState>('NotRequired')
const [baseExtension, setBaseExtension] = useState<BaseExtension | undefined>(
undefined
)
const currentExtensionName = useMemo( const currentExtensionName = useMemo(
() => extensionName ?? selectedExtensionName, () => extensionName ?? selectedExtensionName,
@ -35,14 +25,11 @@ const ExtensionSetting = ({ extensionName }: { extensionName?: string }) => {
const baseExtension = extensionManager.getByName(currentExtensionName) const baseExtension = extensionManager.getByName(currentExtensionName)
if (!baseExtension) return if (!baseExtension) return
setBaseExtension(baseExtension)
if (typeof baseExtension.getSettings === 'function') { if (typeof baseExtension.getSettings === 'function') {
const setting = await baseExtension.getSettings() const setting = await baseExtension.getSettings()
if (setting) allSettings.push(...setting) if (setting) allSettings.push(...setting)
} }
setSettings(allSettings) setSettings(allSettings)
setInstallationState(await baseExtension.installationState())
} }
getExtensionSettings() getExtensionSettings()
}, [currentExtensionName]) }, [currentExtensionName])
@ -75,9 +62,6 @@ const ExtensionSetting = ({ extensionName }: { extensionName?: string }) => {
onValueUpdated={onValueChanged} onValueUpdated={onValueChanged}
/> />
)} )}
{baseExtension && installationState !== 'NotRequired' && (
<ExtensionItem item={baseExtension} />
)}
</Fragment> </Fragment>
) )
} }