feat: add import model (#2104)
Signed-off-by: James <james@jan.ai> Co-authored-by: James <james@jan.ai>
This commit is contained in:
parent
92edd85a12
commit
773963a456
@ -7,6 +7,7 @@ export enum NativeRoute {
|
||||
openAppDirectory = 'openAppDirectory',
|
||||
openFileExplore = 'openFileExplorer',
|
||||
selectDirectory = 'selectDirectory',
|
||||
selectModelFiles = 'selectModelFiles',
|
||||
relaunch = 'relaunch',
|
||||
}
|
||||
|
||||
@ -46,6 +47,13 @@ export enum DownloadEvent {
|
||||
onFileDownloadSuccess = 'onFileDownloadSuccess',
|
||||
}
|
||||
|
||||
export enum LocalImportModelEvent {
|
||||
onLocalImportModelUpdate = 'onLocalImportModelUpdate',
|
||||
onLocalImportModelError = 'onLocalImportModelError',
|
||||
onLocalImportModelSuccess = 'onLocalImportModelSuccess',
|
||||
onLocalImportModelFinished = 'onLocalImportModelFinished',
|
||||
}
|
||||
|
||||
export enum ExtensionRoute {
|
||||
baseExtensions = 'baseExtensions',
|
||||
getActiveExtensions = 'getActiveExtensions',
|
||||
@ -67,6 +75,7 @@ export enum FileSystemRoute {
|
||||
}
|
||||
export enum FileManagerRoute {
|
||||
syncFile = 'syncFile',
|
||||
copyFile = 'copyFile',
|
||||
getJanDataFolderPath = 'getJanDataFolderPath',
|
||||
getResourcePath = 'getResourcePath',
|
||||
getUserHomePath = 'getUserHomePath',
|
||||
@ -126,4 +135,8 @@ export const CoreRoutes = [
|
||||
]
|
||||
|
||||
export const APIRoutes = [...CoreRoutes, ...Object.values(NativeRoute)]
|
||||
export const APIEvents = [...Object.values(AppEvent), ...Object.values(DownloadEvent)]
|
||||
export const APIEvents = [
|
||||
...Object.values(AppEvent),
|
||||
...Object.values(DownloadEvent),
|
||||
...Object.values(LocalImportModelEvent),
|
||||
]
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { BaseExtension, ExtensionTypeEnum } from '../extension'
|
||||
import { Model, ModelInterface } from '../index'
|
||||
import { ImportingModel, Model, ModelInterface, OptionType } from '../index'
|
||||
|
||||
/**
|
||||
* Model extension for managing models.
|
||||
@ -21,4 +21,6 @@ export abstract class ModelExtension extends BaseExtension implements ModelInter
|
||||
abstract saveModel(model: Model): Promise<void>
|
||||
abstract getDownloadedModels(): Promise<Model[]>
|
||||
abstract getConfiguredModels(): Promise<Model[]>
|
||||
abstract importModels(models: ImportingModel[], optionType: OptionType): Promise<void>
|
||||
abstract updateModelInfo(modelInfo: Partial<Model>): Promise<Model>
|
||||
}
|
||||
|
||||
@ -69,14 +69,20 @@ const syncFile: (src: string, dest: string) => Promise<any> = (src, dest) =>
|
||||
*/
|
||||
const copyFileSync = (...args: any[]) => global.core.api?.copyFileSync(...args)
|
||||
|
||||
const copyFile: (src: string, dest: string) => Promise<void> = (src, dest) =>
|
||||
global.core.api?.copyFile(src, dest)
|
||||
|
||||
/**
|
||||
* Gets the file's stats.
|
||||
*
|
||||
* @param path - The path to the file.
|
||||
* @param outsideJanDataFolder - Whether the file is outside the Jan data folder.
|
||||
* @returns {Promise<FileStat>} - A promise that resolves with the file's stats.
|
||||
*/
|
||||
const fileStat: (path: string) => Promise<FileStat | undefined> = (path) =>
|
||||
global.core.api?.fileStat(path)
|
||||
const fileStat: (path: string, outsideJanDataFolder?: boolean) => Promise<FileStat | undefined> = (
|
||||
path,
|
||||
outsideJanDataFolder
|
||||
) => global.core.api?.fileStat(path, outsideJanDataFolder)
|
||||
|
||||
// TODO: Export `dummy` fs functions automatically
|
||||
// Currently adding these manually
|
||||
@ -90,6 +96,7 @@ export const fs = {
|
||||
unlinkSync,
|
||||
appendFileSync,
|
||||
copyFileSync,
|
||||
copyFile,
|
||||
syncFile,
|
||||
fileStat,
|
||||
writeBlob,
|
||||
|
||||
@ -50,7 +50,7 @@ export class Downloader implements Processor {
|
||||
fileName,
|
||||
downloadState: 'downloading',
|
||||
}
|
||||
console.log('progress: ', downloadState)
|
||||
console.debug('progress: ', downloadState)
|
||||
observer?.(DownloadEvent.onFileDownloadUpdate, downloadState)
|
||||
DownloadManager.instance.downloadProgressMap[modelId] = downloadState
|
||||
})
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { join } from 'path'
|
||||
import fs from 'fs'
|
||||
import { FileManagerRoute } from '../../../api'
|
||||
import { appResourcePath, normalizeFilePath } from '../../helper/path'
|
||||
import { getJanDataFolderPath, getJanDataFolderPath as getPath } from '../../helper'
|
||||
import { Processor } from './Processor'
|
||||
@ -48,10 +47,12 @@ export class FSExt implements Processor {
|
||||
}
|
||||
|
||||
// handle fs is directory here
|
||||
fileStat(path: string) {
|
||||
fileStat(path: string, outsideJanDataFolder?: boolean) {
|
||||
const normalizedPath = normalizeFilePath(path)
|
||||
|
||||
const fullPath = join(getJanDataFolderPath(), normalizedPath)
|
||||
const fullPath = outsideJanDataFolder
|
||||
? normalizedPath
|
||||
: join(getJanDataFolderPath(), normalizedPath)
|
||||
const isExist = fs.existsSync(fullPath)
|
||||
if (!isExist) return undefined
|
||||
|
||||
@ -75,4 +76,16 @@ export class FSExt implements Processor {
|
||||
console.error(`writeFile ${path} result: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
copyFile(src: string, dest: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.copyFile(src, dest, (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from './modelEntity'
|
||||
export * from './modelInterface'
|
||||
export * from './modelEvent'
|
||||
export * from './modelImport'
|
||||
|
||||
22
core/src/types/model/modelImport.ts
Normal file
22
core/src/types/model/modelImport.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export type OptionType = 'SYMLINK' | 'MOVE_BINARY_FILE'
|
||||
|
||||
export type ModelImportOption = {
|
||||
type: OptionType
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export type ImportingModelStatus = 'PREPARING' | 'IMPORTING' | 'IMPORTED' | 'FAILED'
|
||||
|
||||
export type ImportingModel = {
|
||||
importId: string
|
||||
modelId: string | undefined
|
||||
name: string
|
||||
description: string
|
||||
path: string
|
||||
tags: string[]
|
||||
size: number
|
||||
status: ImportingModelStatus
|
||||
format: string
|
||||
percentage?: number
|
||||
}
|
||||
@ -83,4 +83,22 @@ export function handleAppIPCs() {
|
||||
return filePaths[0]
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(NativeRoute.selectModelFiles, async () => {
|
||||
const mainWindow = WindowManager.instance.currentWindow
|
||||
if (!mainWindow) {
|
||||
console.error('No main window found')
|
||||
return
|
||||
}
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'Select model files',
|
||||
buttonLabel: 'Select',
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
})
|
||||
if (canceled) {
|
||||
return
|
||||
} else {
|
||||
return filePaths
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -8,10 +8,9 @@ export const setupReactDevTool = async () => {
|
||||
) // Don't use import on top level, since the installer package is dev-only
|
||||
try {
|
||||
const name = await installExtension(REACT_DEVELOPER_TOOLS)
|
||||
console.log(`Added Extension: ${name}`)
|
||||
console.debug(`Added Extension: ${name}`)
|
||||
} catch (err) {
|
||||
console.log('An error occurred while installing devtools:')
|
||||
console.error(err)
|
||||
console.error('An error occurred while installing devtools:', err)
|
||||
// Only log the error and don't throw it because it's not critical
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,7 +35,7 @@ export function cleanLogs(
|
||||
console.error('Error deleting log file:', err)
|
||||
return
|
||||
}
|
||||
console.log(
|
||||
console.debug(
|
||||
`Deleted log file due to exceeding size limit: ${filePath}`
|
||||
)
|
||||
})
|
||||
@ -52,7 +52,7 @@ export function cleanLogs(
|
||||
console.error('Error deleting log file:', err)
|
||||
return
|
||||
}
|
||||
console.log(`Deleted old log file: ${filePath}`)
|
||||
console.debug(`Deleted old log file: ${filePath}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -149,7 +149,7 @@ export function updateCudaExistence(
|
||||
|
||||
data['cuda'].exist = cudaExists
|
||||
data['cuda'].version = cudaVersion
|
||||
console.log(data['is_initial'], data['gpus_in_use'])
|
||||
console.debug(data['is_initial'], data['gpus_in_use'])
|
||||
if (cudaExists && data['is_initial'] && data['gpus_in_use'].length > 0) {
|
||||
data.run_mode = 'gpu'
|
||||
}
|
||||
|
||||
@ -13,6 +13,9 @@ import {
|
||||
DownloadRoute,
|
||||
ModelEvent,
|
||||
DownloadState,
|
||||
OptionType,
|
||||
ImportingModel,
|
||||
LocalImportModelEvent,
|
||||
} from '@janhq/core'
|
||||
|
||||
import { extractFileName } from './helpers/path'
|
||||
@ -158,18 +161,18 @@ export default class JanModelExtension extends ModelExtension {
|
||||
|
||||
/**
|
||||
* Cancels the download of a specific machine learning model.
|
||||
*
|
||||
* @param {string} modelId - The ID of the model whose download is to be cancelled.
|
||||
* @returns {Promise<void>} A promise that resolves when the download has been cancelled.
|
||||
*/
|
||||
async cancelModelDownload(modelId: string): Promise<void> {
|
||||
const model = await this.getConfiguredModels()
|
||||
return abortDownload(
|
||||
await joinPath([JanModelExtension._homeDir, modelId, modelId])
|
||||
).then(async () => {
|
||||
fs.unlinkSync(
|
||||
await joinPath([JanModelExtension._homeDir, modelId, modelId])
|
||||
)
|
||||
})
|
||||
const path = await joinPath([JanModelExtension._homeDir, modelId, modelId])
|
||||
try {
|
||||
await abortDownload(path)
|
||||
await fs.unlinkSync(path)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -180,6 +183,20 @@ export default class JanModelExtension extends ModelExtension {
|
||||
async deleteModel(modelId: string): Promise<void> {
|
||||
try {
|
||||
const dirPath = await joinPath([JanModelExtension._homeDir, modelId])
|
||||
const jsonFilePath = await joinPath([
|
||||
dirPath,
|
||||
JanModelExtension._modelMetadataFileName,
|
||||
])
|
||||
const modelInfo = JSON.parse(
|
||||
await this.readModelMetadata(jsonFilePath)
|
||||
) as Model
|
||||
|
||||
const isUserImportModel =
|
||||
modelInfo.metadata?.author?.toLowerCase() === 'user'
|
||||
if (isUserImportModel) {
|
||||
// just delete the folder
|
||||
return fs.rmdirSync(dirPath)
|
||||
}
|
||||
|
||||
// remove all files under dirPath except model.json
|
||||
const files = await fs.readdirSync(dirPath)
|
||||
@ -389,7 +406,7 @@ export default class JanModelExtension extends ModelExtension {
|
||||
llama_model_path: binaryFileName,
|
||||
},
|
||||
created: Date.now(),
|
||||
description: `${dirName} - user self import model`,
|
||||
description: '',
|
||||
metadata: {
|
||||
size: binaryFileSize,
|
||||
author: 'User',
|
||||
@ -455,4 +472,182 @@ export default class JanModelExtension extends ModelExtension {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async importModelSymlink(
|
||||
modelBinaryPath: string,
|
||||
modelFolderName: string,
|
||||
modelFolderPath: string
|
||||
): Promise<Model> {
|
||||
const fileStats = await fs.fileStat(modelBinaryPath, true)
|
||||
const binaryFileSize = fileStats.size
|
||||
|
||||
// Just need to generate model.json there
|
||||
const defaultModel = (await this.getDefaultModel()) as Model
|
||||
if (!defaultModel) {
|
||||
console.error('Unable to find default model')
|
||||
return
|
||||
}
|
||||
|
||||
const binaryFileName = extractFileName(modelBinaryPath, '')
|
||||
|
||||
const model: Model = {
|
||||
...defaultModel,
|
||||
id: modelFolderName,
|
||||
name: modelFolderName,
|
||||
sources: [
|
||||
{
|
||||
url: modelBinaryPath,
|
||||
filename: binaryFileName,
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
...defaultModel.settings,
|
||||
llama_model_path: binaryFileName,
|
||||
},
|
||||
created: Date.now(),
|
||||
description: '',
|
||||
metadata: {
|
||||
size: binaryFileSize,
|
||||
author: 'User',
|
||||
tags: [],
|
||||
},
|
||||
}
|
||||
|
||||
const modelFilePath = await joinPath([
|
||||
modelFolderPath,
|
||||
JanModelExtension._modelMetadataFileName,
|
||||
])
|
||||
|
||||
await fs.writeFileSync(modelFilePath, JSON.stringify(model, null, 2))
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
async updateModelInfo(modelInfo: Partial<Model>): Promise<Model> {
|
||||
const modelId = modelInfo.id
|
||||
if (modelInfo.id == null) throw new Error('Model ID is required')
|
||||
|
||||
const janDataFolderPath = await getJanDataFolderPath()
|
||||
const jsonFilePath = await joinPath([
|
||||
janDataFolderPath,
|
||||
'models',
|
||||
modelId,
|
||||
JanModelExtension._modelMetadataFileName,
|
||||
])
|
||||
const model = JSON.parse(
|
||||
await this.readModelMetadata(jsonFilePath)
|
||||
) as Model
|
||||
|
||||
const updatedModel: Model = {
|
||||
...model,
|
||||
...modelInfo,
|
||||
metadata: {
|
||||
...model.metadata,
|
||||
tags: modelInfo.metadata?.tags ?? [],
|
||||
},
|
||||
}
|
||||
|
||||
await fs.writeFileSync(jsonFilePath, JSON.stringify(updatedModel, null, 2))
|
||||
return updatedModel
|
||||
}
|
||||
|
||||
private async importModel(
|
||||
model: ImportingModel,
|
||||
optionType: OptionType
|
||||
): Promise<Model> {
|
||||
const binaryName = extractFileName(model.path, '').replace(/\s/g, '')
|
||||
|
||||
let modelFolderName = binaryName
|
||||
if (binaryName.endsWith(JanModelExtension._supportedModelFormat)) {
|
||||
modelFolderName = binaryName.replace(
|
||||
JanModelExtension._supportedModelFormat,
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
const modelFolderPath = await this.getModelFolderName(modelFolderName)
|
||||
await fs.mkdirSync(modelFolderPath)
|
||||
|
||||
const uniqueFolderName = modelFolderPath.split('/').pop()
|
||||
const modelBinaryFile = binaryName.endsWith(
|
||||
JanModelExtension._supportedModelFormat
|
||||
)
|
||||
? binaryName
|
||||
: `${binaryName}${JanModelExtension._supportedModelFormat}`
|
||||
|
||||
const binaryPath = await joinPath([modelFolderPath, modelBinaryFile])
|
||||
|
||||
if (optionType === 'SYMLINK') {
|
||||
return this.importModelSymlink(
|
||||
model.path,
|
||||
uniqueFolderName,
|
||||
modelFolderPath
|
||||
)
|
||||
}
|
||||
|
||||
const srcStat = await fs.fileStat(model.path, true)
|
||||
|
||||
// interval getting the file size to calculate the percentage
|
||||
const interval = setInterval(async () => {
|
||||
const destStats = await fs.fileStat(binaryPath, true)
|
||||
const percentage = destStats.size / srcStat.size
|
||||
events.emit(LocalImportModelEvent.onLocalImportModelUpdate, {
|
||||
...model,
|
||||
percentage,
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
await fs.copyFile(model.path, binaryPath)
|
||||
|
||||
clearInterval(interval)
|
||||
|
||||
// generate model json
|
||||
return this.generateModelMetadata(uniqueFolderName)
|
||||
}
|
||||
|
||||
private async getModelFolderName(
|
||||
modelFolderName: string,
|
||||
count?: number
|
||||
): Promise<string> {
|
||||
const newModelFolderName = count
|
||||
? `${modelFolderName}-${count}`
|
||||
: modelFolderName
|
||||
|
||||
const janDataFolderPath = await getJanDataFolderPath()
|
||||
const modelFolderPath = await joinPath([
|
||||
janDataFolderPath,
|
||||
'models',
|
||||
newModelFolderName,
|
||||
])
|
||||
|
||||
const isFolderExist = await fs.existsSync(modelFolderPath)
|
||||
if (!isFolderExist) {
|
||||
return modelFolderPath
|
||||
} else {
|
||||
const newCount = (count ?? 0) + 1
|
||||
return this.getModelFolderName(modelFolderName, newCount)
|
||||
}
|
||||
}
|
||||
|
||||
async importModels(
|
||||
models: ImportingModel[],
|
||||
optionType: OptionType
|
||||
): Promise<void> {
|
||||
const importedModels: Model[] = []
|
||||
|
||||
for (const model of models) {
|
||||
events.emit(LocalImportModelEvent.onLocalImportModelUpdate, model)
|
||||
const importedModel = await this.importModel(model, optionType)
|
||||
|
||||
events.emit(LocalImportModelEvent.onLocalImportModelSuccess, {
|
||||
...model,
|
||||
modelId: importedModel.id,
|
||||
})
|
||||
importedModels.push(importedModel)
|
||||
}
|
||||
events.emit(
|
||||
LocalImportModelEvent.onLocalImportModelFinished,
|
||||
importedModels
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,7 +38,7 @@ export const s3 = (req: any, reply: any, done: any) => {
|
||||
reply.status(200).send(result)
|
||||
return
|
||||
} catch (ex) {
|
||||
console.log(ex)
|
||||
console.error(ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
66
uikit/src/circular-progress/styles.scss
Normal file
66
uikit/src/circular-progress/styles.scss
Normal file
@ -0,0 +1,66 @@
|
||||
/*
|
||||
* react-circular-progressbar styles
|
||||
* All of the styles in this file are configurable!
|
||||
*/
|
||||
|
||||
.CircularProgressbar {
|
||||
/*
|
||||
* This fixes an issue where the CircularProgressbar svg has
|
||||
* 0 width inside a "display: flex" container, and thus not visible.
|
||||
*/
|
||||
width: 100%;
|
||||
/*
|
||||
* This fixes a centering issue with CircularProgressbarWithChildren:
|
||||
* https://github.com/kevinsqi/react-circular-progressbar/issues/94
|
||||
*/
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.CircularProgressbar .CircularProgressbar-path {
|
||||
stroke: #3e98c7;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 0.5s ease 0s;
|
||||
}
|
||||
|
||||
.CircularProgressbar .CircularProgressbar-trail {
|
||||
stroke: #d6d6d6;
|
||||
/* Used when trail is not full diameter, i.e. when props.circleRatio is set */
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.CircularProgressbar .CircularProgressbar-text {
|
||||
fill: #3e98c7;
|
||||
font-size: 20px;
|
||||
dominant-baseline: middle;
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.CircularProgressbar .CircularProgressbar-background {
|
||||
fill: #d6d6d6;
|
||||
}
|
||||
|
||||
/*
|
||||
* Sample background styles. Use these with e.g.:
|
||||
*
|
||||
* <CircularProgressbar
|
||||
* className="CircularProgressbar-inverted"
|
||||
* background
|
||||
* percentage={50}
|
||||
* />
|
||||
*/
|
||||
.CircularProgressbar.CircularProgressbar-inverted
|
||||
.CircularProgressbar-background {
|
||||
fill: #3e98c7;
|
||||
}
|
||||
|
||||
.CircularProgressbar.CircularProgressbar-inverted .CircularProgressbar-text {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.CircularProgressbar.CircularProgressbar-inverted .CircularProgressbar-path {
|
||||
stroke: #fff;
|
||||
}
|
||||
|
||||
.CircularProgressbar.CircularProgressbar-inverted .CircularProgressbar-trail {
|
||||
stroke: transparent;
|
||||
}
|
||||
@ -17,6 +17,7 @@
|
||||
@import './select/styles.scss';
|
||||
@import './slider/styles.scss';
|
||||
@import './checkbox/styles.scss';
|
||||
@import './circular-progress/styles.scss';
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
|
||||
@ -19,7 +19,7 @@ const ModalOverlay = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ModalPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={twMerge(' modal-backdrop', className)}
|
||||
className={twMerge('modal-backdrop', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
8
web/.prettierrc
Normal file
8
web/.prettierrc
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"quoteProps": "consistent",
|
||||
"trailingComma": "es5",
|
||||
"endOfLine": "auto",
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
@ -1,19 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { useAtomValue } from 'jotai'
|
||||
|
||||
import BaseLayout from '@/containers/Layout'
|
||||
|
||||
import { MainViewState } from '@/constants/screens'
|
||||
|
||||
import { useMainViewState } from '@/hooks/useMainViewState'
|
||||
|
||||
import ChatScreen from '@/screens/Chat'
|
||||
import ExploreModelsScreen from '@/screens/ExploreModels'
|
||||
|
||||
import LocalServerScreen from '@/screens/LocalServer'
|
||||
import SettingsScreen from '@/screens/Settings'
|
||||
|
||||
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
|
||||
|
||||
export default function Page() {
|
||||
const { mainViewState } = useMainViewState()
|
||||
const mainViewState = useAtomValue(mainViewStateAtom)
|
||||
|
||||
let children = null
|
||||
switch (mainViewState) {
|
||||
|
||||
@ -38,7 +38,7 @@ export default function CardSidebar({
|
||||
const [menu, setMenu] = useState<HTMLDivElement | null>(null)
|
||||
const [toggle, setToggle] = useState<HTMLDivElement | null>(null)
|
||||
const activeThread = useAtomValue(activeThreadAtom)
|
||||
const { onReviewInFinder, onViewJson } = usePath()
|
||||
const { onRevealInFinder, onViewJson } = usePath()
|
||||
|
||||
useClickOutside(() => setMore(false), null, [menu, toggle])
|
||||
|
||||
@ -100,7 +100,7 @@ export default function CardSidebar({
|
||||
title === 'Model' ? 'items-start' : 'items-center'
|
||||
)}
|
||||
onClick={() => {
|
||||
onReviewInFinder && onReviewInFinder(title)
|
||||
onRevealInFinder && onRevealInFinder(title)
|
||||
setMore(false)
|
||||
}}
|
||||
>
|
||||
|
||||
@ -30,7 +30,6 @@ import { MainViewState } from '@/constants/screens'
|
||||
import { useActiveModel } from '@/hooks/useActiveModel'
|
||||
|
||||
import { useClipboard } from '@/hooks/useClipboard'
|
||||
import { useMainViewState } from '@/hooks/useMainViewState'
|
||||
|
||||
import useRecommendedModel from '@/hooks/useRecommendedModel'
|
||||
|
||||
@ -41,6 +40,7 @@ import { toGibibytes } from '@/utils/converter'
|
||||
import ModelLabel from '../ModelLabel'
|
||||
import OpenAiKeyInput from '../OpenAiKeyInput'
|
||||
|
||||
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
|
||||
import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom'
|
||||
|
||||
import {
|
||||
@ -64,11 +64,13 @@ const DropdownListSidebar = ({
|
||||
const [isTabActive, setIsTabActive] = useState(0)
|
||||
const { stateModel } = useActiveModel()
|
||||
const [serverEnabled, setServerEnabled] = useAtom(serverEnabledAtom)
|
||||
const { setMainViewState } = useMainViewState()
|
||||
|
||||
const setMainViewState = useSetAtom(mainViewStateAtom)
|
||||
const [loader, setLoader] = useState(0)
|
||||
const { recommendedModel, downloadedModels } = useRecommendedModel()
|
||||
const { updateModelParameter } = useUpdateModelParameters()
|
||||
const clipboard = useClipboard({ timeout: 1000 })
|
||||
|
||||
const [copyId, setCopyId] = useState('')
|
||||
|
||||
const localModel = downloadedModels.filter(
|
||||
|
||||
@ -0,0 +1,61 @@
|
||||
import { Fragment, useCallback } from 'react'
|
||||
|
||||
import { Progress } from '@janhq/uikit'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
|
||||
import { setImportModelStageAtom } from '@/hooks/useImportModel'
|
||||
|
||||
import { importingModelsAtom } from '@/helpers/atoms/Model.atom'
|
||||
|
||||
const ImportingModelState: React.FC = () => {
|
||||
const importingModels = useAtomValue(importingModelsAtom)
|
||||
const setImportModelStage = useSetAtom(setImportModelStageAtom)
|
||||
|
||||
const isImportingModels =
|
||||
importingModels.filter((m) => m.status === 'IMPORTING').length > 0
|
||||
|
||||
const finishedImportModelCount = importingModels.filter(
|
||||
(model) => model.status === 'IMPORTED' || model.status === 'FAILED'
|
||||
).length
|
||||
|
||||
let transferredSize = 0
|
||||
importingModels.forEach((model) => {
|
||||
transferredSize += (model.percentage ?? 0) * 100 * model.size
|
||||
})
|
||||
|
||||
const totalSize = importingModels.reduce((acc, model) => acc + model.size, 0)
|
||||
|
||||
const progress = totalSize === 0 ? 0 : transferredSize / totalSize
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
setImportModelStage('IMPORTING_MODEL')
|
||||
}, [setImportModelStage])
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{isImportingModels ? (
|
||||
<div
|
||||
className="flex cursor-pointer flex-row items-center space-x-2"
|
||||
onClick={onClick}
|
||||
>
|
||||
<p className="text-xs font-semibold text-[#09090B]">
|
||||
Importing model ({finishedImportModelCount}/{importingModels.length}
|
||||
)
|
||||
</p>
|
||||
|
||||
<div className="flex flex-row items-center justify-center space-x-2 rounded-md bg-[#F4F4F5] px-2 py-[2px]">
|
||||
<Progress
|
||||
className="h-2 w-24"
|
||||
value={transferredSize / totalSize}
|
||||
/>
|
||||
<span className="text-xs font-bold text-primary">
|
||||
{progress.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImportingModelState
|
||||
@ -25,8 +25,8 @@ const TableActiveModel = () => {
|
||||
const [serverEnabled, setServerEnabled] = useAtom(serverEnabledAtom)
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 m-4 mr-0 w-2/3">
|
||||
<div className="rounded-lg border border-border shadow-sm overflow-hidden">
|
||||
<div className="m-4 mr-0 w-2/3 flex-shrink-0">
|
||||
<div className="overflow-hidden rounded-lg border border-border shadow-sm">
|
||||
<table className="w-full px-8">
|
||||
<thead className="w-full border-b border-border bg-secondary">
|
||||
<tr>
|
||||
|
||||
@ -73,7 +73,7 @@ const SystemMonitor = () => {
|
||||
<div
|
||||
ref={setControl}
|
||||
className={twMerge(
|
||||
'flex items-center gap-x-2 cursor-pointer p-2 rounded-md hover:bg-secondary',
|
||||
'flex cursor-pointer items-center gap-x-2 rounded-md p-2 hover:bg-secondary',
|
||||
systemMonitorCollapse && 'bg-secondary'
|
||||
)}
|
||||
onClick={() => {
|
||||
@ -88,29 +88,29 @@ const SystemMonitor = () => {
|
||||
<div
|
||||
ref={setElementExpand}
|
||||
className={twMerge(
|
||||
'fixed left-16 bottom-12 bg-background w-[calc(100%-64px)] z-50 border-t border-border flex flex-col flex-shrink-0',
|
||||
'fixed bottom-12 left-16 z-50 flex w-[calc(100%-64px)] flex-shrink-0 flex-col border-t border-border bg-background',
|
||||
showFullScreen && 'h-[calc(100%-48px)]'
|
||||
)}
|
||||
>
|
||||
<div className="h-12 flex items-center border-b border-border px-4 justify-between flex-shrink-0">
|
||||
<div className="flex h-12 flex-shrink-0 items-center justify-between border-b border-border px-4">
|
||||
<h6 className="font-bold">Running Models</h6>
|
||||
<div className="flex items-center gap-x-2 unset-drag">
|
||||
<div className="unset-drag flex items-center gap-x-2">
|
||||
{showFullScreen ? (
|
||||
<ChevronDown
|
||||
size={20}
|
||||
className="text-muted-foreground cursor-pointer"
|
||||
className="cursor-pointer text-muted-foreground"
|
||||
onClick={() => setShowFullScreen(!showFullScreen)}
|
||||
/>
|
||||
) : (
|
||||
<ChevronUp
|
||||
size={20}
|
||||
className="text-muted-foreground cursor-pointer"
|
||||
className="cursor-pointer text-muted-foreground"
|
||||
onClick={() => setShowFullScreen(!showFullScreen)}
|
||||
/>
|
||||
)}
|
||||
<XIcon
|
||||
size={16}
|
||||
className="text-muted-foreground cursor-pointer"
|
||||
className="cursor-pointer text-muted-foreground"
|
||||
onClick={() => {
|
||||
setSystemMonitorCollapse(false)
|
||||
setShowFullScreen(false)
|
||||
@ -118,10 +118,10 @@ const SystemMonitor = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 h-full">
|
||||
<div className="flex h-full gap-4">
|
||||
<TableActiveModel />
|
||||
<div className="border-l border-border p-4 w-full">
|
||||
<div className="mb-4 pb-4 border-b border-border">
|
||||
<div className="w-full border-l border-border p-4">
|
||||
<div className="mb-4 border-b border-border pb-4">
|
||||
<h6 className="font-bold">CPU</h6>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Progress value={cpuUsage} className="h-2" />
|
||||
@ -130,7 +130,7 @@ const SystemMonitor = () => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4 pb-4 border-b border-border">
|
||||
<div className="mb-4 border-b border-border pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h6 className="font-bold">Memory</h6>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@ -148,7 +148,7 @@ const SystemMonitor = () => {
|
||||
</div>
|
||||
</div>
|
||||
{gpus.length > 0 && (
|
||||
<div className="mb-4 pb-4 border-b border-border">
|
||||
<div className="mb-4 border-b border-border pb-4">
|
||||
<h6 className="font-bold">GPU</h6>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Progress value={calculateUtilization()} className="h-2" />
|
||||
@ -159,9 +159,9 @@ const SystemMonitor = () => {
|
||||
{gpus.map((gpu, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start justify-between mt-4 gap-4"
|
||||
className="mt-4 flex items-start justify-between gap-4"
|
||||
>
|
||||
<span className="text-muted-foreground font-medium line-clamp-1 w-1/2">
|
||||
<span className="line-clamp-1 w-1/2 font-medium text-muted-foreground">
|
||||
{gpu.name}
|
||||
</span>
|
||||
<div className="flex gap-x-2">
|
||||
|
||||
@ -15,6 +15,7 @@ import ProgressBar from '@/containers/ProgressBar'
|
||||
|
||||
import { appDownloadProgress } from '@/containers/Providers/Jotai'
|
||||
|
||||
import ImportingModelState from './ImportingModelState'
|
||||
import SystemMonitor from './SystemMonitor'
|
||||
|
||||
const menuLinks = [
|
||||
@ -41,6 +42,7 @@ const BottomBar = () => {
|
||||
<ProgressBar total={100} used={progress} />
|
||||
) : null}
|
||||
</div>
|
||||
<ImportingModelState />
|
||||
<DownloadingState />
|
||||
</div>
|
||||
<div className="flex items-center gap-x-3">
|
||||
|
||||
@ -20,13 +20,12 @@ import LogoMark from '@/containers/Brand/Logo/Mark'
|
||||
|
||||
import { MainViewState } from '@/constants/screens'
|
||||
|
||||
import { useMainViewState } from '@/hooks/useMainViewState'
|
||||
|
||||
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
|
||||
import { editMessageAtom } from '@/helpers/atoms/ChatMessage.atom'
|
||||
import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom'
|
||||
|
||||
export default function RibbonNav() {
|
||||
const { mainViewState, setMainViewState } = useMainViewState()
|
||||
const [mainViewState, setMainViewState] = useAtom(mainViewStateAtom)
|
||||
const [serverEnabled] = useAtom(serverEnabledAtom)
|
||||
const setEditMessage = useSetAtom(editMessageAtom)
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
Badge,
|
||||
} from '@janhq/uikit'
|
||||
|
||||
import { useAtom, useAtomValue } from 'jotai'
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { DatabaseIcon, CpuIcon } from 'lucide-react'
|
||||
|
||||
import { showSelectModelModalAtom } from '@/containers/Providers/KeyListener'
|
||||
@ -19,13 +19,13 @@ import { showSelectModelModalAtom } from '@/containers/Providers/KeyListener'
|
||||
import { MainViewState } from '@/constants/screens'
|
||||
|
||||
import { useActiveModel } from '@/hooks/useActiveModel'
|
||||
import { useMainViewState } from '@/hooks/useMainViewState'
|
||||
|
||||
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
|
||||
import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom'
|
||||
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
|
||||
|
||||
export default function CommandListDownloadedModel() {
|
||||
const { setMainViewState } = useMainViewState()
|
||||
const setMainViewState = useSetAtom(mainViewStateAtom)
|
||||
const downloadedModels = useAtomValue(downloadedModelsAtom)
|
||||
const { activeModel, startModel, stopModel } = useActiveModel()
|
||||
const [serverEnabled] = useAtom(serverEnabledAtom)
|
||||
|
||||
@ -10,20 +10,15 @@ import {
|
||||
CommandList,
|
||||
} from '@janhq/uikit'
|
||||
|
||||
import { useAtom } from 'jotai'
|
||||
import {
|
||||
MessageCircleIcon,
|
||||
SettingsIcon,
|
||||
LayoutGridIcon,
|
||||
MonitorIcon,
|
||||
} from 'lucide-react'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { MessageCircleIcon, SettingsIcon, LayoutGridIcon } from 'lucide-react'
|
||||
|
||||
import { showCommandSearchModalAtom } from '@/containers/Providers/KeyListener'
|
||||
import ShortCut from '@/containers/Shortcut'
|
||||
|
||||
import { MainViewState } from '@/constants/screens'
|
||||
|
||||
import { useMainViewState } from '@/hooks/useMainViewState'
|
||||
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
|
||||
|
||||
const menus = [
|
||||
{
|
||||
@ -48,7 +43,7 @@ const menus = [
|
||||
]
|
||||
|
||||
export default function CommandSearch() {
|
||||
const { setMainViewState } = useMainViewState()
|
||||
const setMainViewState = useSetAtom(mainViewStateAtom)
|
||||
const [showCommandSearchModal, setShowCommandSearchModal] = useAtom(
|
||||
showCommandSearchModalAtom
|
||||
)
|
||||
|
||||
@ -20,7 +20,6 @@ import { MainViewState } from '@/constants/screens'
|
||||
|
||||
import { useClickOutside } from '@/hooks/useClickOutside'
|
||||
import { useCreateNewThread } from '@/hooks/useCreateNewThread'
|
||||
import { useMainViewState } from '@/hooks/useMainViewState'
|
||||
|
||||
import { usePath } from '@/hooks/usePath'
|
||||
|
||||
@ -28,18 +27,19 @@ import { showRightSideBarAtom } from '@/screens/Chat/Sidebar'
|
||||
|
||||
import { openFileTitle } from '@/utils/titleUtils'
|
||||
|
||||
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
|
||||
import { assistantsAtom } from '@/helpers/atoms/Assistant.atom'
|
||||
import { activeThreadAtom } from '@/helpers/atoms/Thread.atom'
|
||||
|
||||
const TopBar = () => {
|
||||
const activeThread = useAtomValue(activeThreadAtom)
|
||||
const { mainViewState } = useMainViewState()
|
||||
const mainViewState = useAtomValue(mainViewStateAtom)
|
||||
const { requestCreateNewThread } = useCreateNewThread()
|
||||
const assistants = useAtomValue(assistantsAtom)
|
||||
const [showRightSideBar, setShowRightSideBar] = useAtom(showRightSideBarAtom)
|
||||
const [showLeftSideBar, setShowLeftSideBar] = useAtom(showLeftSideBarAtom)
|
||||
const showing = useAtomValue(showRightSideBarAtom)
|
||||
const { onReviewInFinder, onViewJson } = usePath()
|
||||
const { onRevealInFinder, onViewJson } = usePath()
|
||||
const [more, setMore] = useState(false)
|
||||
const [menu, setMenu] = useState<HTMLDivElement | null>(null)
|
||||
const [toggle, setToggle] = useState<HTMLDivElement | null>(null)
|
||||
@ -151,7 +151,7 @@ const TopBar = () => {
|
||||
<div
|
||||
className="flex cursor-pointer items-center space-x-2 px-4 py-2 hover:bg-secondary"
|
||||
onClick={() => {
|
||||
onReviewInFinder('Thread')
|
||||
onRevealInFinder('Thread')
|
||||
setMore(false)
|
||||
}}
|
||||
>
|
||||
@ -195,7 +195,7 @@ const TopBar = () => {
|
||||
<div
|
||||
className="flex cursor-pointer items-center space-x-2 px-4 py-2 hover:bg-secondary"
|
||||
onClick={() => {
|
||||
onReviewInFinder('Model')
|
||||
onRevealInFinder('Model')
|
||||
setMore(false)
|
||||
}}
|
||||
>
|
||||
|
||||
@ -4,6 +4,8 @@ import { useTheme } from 'next-themes'
|
||||
|
||||
import { motion as m } from 'framer-motion'
|
||||
|
||||
import { useAtom, useAtomValue } from 'jotai'
|
||||
|
||||
import BottomBar from '@/containers/Layout/BottomBar'
|
||||
import RibbonNav from '@/containers/Layout/Ribbon'
|
||||
|
||||
@ -11,14 +13,21 @@ import TopBar from '@/containers/Layout/TopBar'
|
||||
|
||||
import { MainViewState } from '@/constants/screens'
|
||||
|
||||
import { useMainViewState } from '@/hooks/useMainViewState'
|
||||
import { getImportModelStageAtom } from '@/hooks/useImportModel'
|
||||
|
||||
import { SUCCESS_SET_NEW_DESTINATION } from '@/screens/Settings/Advanced/DataFolder'
|
||||
import CancelModelImportModal from '@/screens/Settings/CancelModelImportModal'
|
||||
import EditModelInfoModal from '@/screens/Settings/EditModelInfoModal'
|
||||
import ImportModelOptionModal from '@/screens/Settings/ImportModelOptionModal'
|
||||
import ImportingModelModal from '@/screens/Settings/ImportingModelModal'
|
||||
import SelectingModelModal from '@/screens/Settings/SelectingModelModal'
|
||||
|
||||
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
|
||||
|
||||
const BaseLayout = (props: PropsWithChildren) => {
|
||||
const { children } = props
|
||||
const { mainViewState, setMainViewState } = useMainViewState()
|
||||
|
||||
const [mainViewState, setMainViewState] = useAtom(mainViewStateAtom)
|
||||
const importModelStage = useAtomValue(getImportModelStageAtom)
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
@ -54,6 +63,11 @@ const BaseLayout = (props: PropsWithChildren) => {
|
||||
<BottomBar />
|
||||
</div>
|
||||
</div>
|
||||
{importModelStage === 'SELECTING_MODEL' && <SelectingModelModal />}
|
||||
{importModelStage === 'MODEL_SELECTED' && <ImportModelOptionModal />}
|
||||
{importModelStage === 'IMPORTING_MODEL' && <ImportingModelModal />}
|
||||
{importModelStage === 'EDIT_MODEL_INFO' && <EditModelInfoModal />}
|
||||
{importModelStage === 'CONFIRM_CANCEL' && <CancelModelImportModal />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
37
web/containers/Providers/AppUpdateListener.tsx
Normal file
37
web/containers/Providers/AppUpdateListener.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Fragment, PropsWithChildren, useEffect } from 'react'
|
||||
|
||||
import { useSetAtom } from 'jotai'
|
||||
|
||||
import { appDownloadProgress } from './Jotai'
|
||||
|
||||
const AppUpdateListener = ({ children }: PropsWithChildren) => {
|
||||
const setProgress = useSetAtom(appDownloadProgress)
|
||||
|
||||
useEffect(() => {
|
||||
if (window && window.electronAPI) {
|
||||
window.electronAPI.onAppUpdateDownloadUpdate(
|
||||
(_event: string, progress: any) => {
|
||||
setProgress(progress.percent)
|
||||
console.debug('app update progress:', progress.percent)
|
||||
}
|
||||
)
|
||||
|
||||
window.electronAPI.onAppUpdateDownloadError(
|
||||
(_event: string, callback: any) => {
|
||||
console.error('Download error', callback)
|
||||
setProgress(-1)
|
||||
}
|
||||
)
|
||||
|
||||
window.electronAPI.onAppUpdateDownloadSuccess(() => {
|
||||
setProgress(-1)
|
||||
})
|
||||
}
|
||||
return () => {}
|
||||
}, [setProgress])
|
||||
|
||||
return <Fragment>{children}</Fragment>
|
||||
}
|
||||
|
||||
export default AppUpdateListener
|
||||
@ -1,21 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { Fragment, ReactNode } from 'react'
|
||||
import { Fragment, ReactNode, useEffect } from 'react'
|
||||
|
||||
import { AppConfiguration } from '@janhq/core/.'
|
||||
import { useSetAtom } from 'jotai'
|
||||
|
||||
import useAssistants from '@/hooks/useAssistants'
|
||||
import useGetSystemResources from '@/hooks/useGetSystemResources'
|
||||
import useModels from '@/hooks/useModels'
|
||||
import useThreads from '@/hooks/useThreads'
|
||||
|
||||
import { janDataFolderPathAtom } from '@/helpers/atoms/AppConfig.atom'
|
||||
|
||||
type Props = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const DataLoader: React.FC<Props> = ({ children }) => {
|
||||
const setJanDataFolderPath = useSetAtom(janDataFolderPathAtom)
|
||||
|
||||
useModels()
|
||||
useThreads()
|
||||
useAssistants()
|
||||
useGetSystemResources()
|
||||
|
||||
useEffect(() => {
|
||||
window.core?.api
|
||||
?.getAppConfigurations()
|
||||
?.then((appConfig: AppConfiguration) => {
|
||||
setJanDataFolderPath(appConfig.data_folder)
|
||||
})
|
||||
}, [setJanDataFolderPath])
|
||||
|
||||
console.debug('Load Data...')
|
||||
|
||||
return <Fragment>{children}</Fragment>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { ReactNode, useCallback, useEffect, useRef } from 'react'
|
||||
import { Fragment, ReactNode, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import {
|
||||
ChatCompletionMessage,
|
||||
@ -302,5 +302,5 @@ export default function EventHandler({ children }: { children: ReactNode }) {
|
||||
events.off(MessageEvent.OnMessageUpdate, onMessageResponseUpdate)
|
||||
}
|
||||
}, [onNewMessageResponse, onMessageResponseUpdate])
|
||||
return <>{children}</>
|
||||
return <Fragment>{children}</Fragment>
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { PropsWithChildren, useCallback, useEffect } from 'react'
|
||||
|
||||
import React from 'react'
|
||||
@ -8,13 +7,13 @@ import { useSetAtom } from 'jotai'
|
||||
|
||||
import { setDownloadStateAtom } from '@/hooks/useDownloadState'
|
||||
|
||||
import AppUpdateListener from './AppUpdateListener'
|
||||
import EventHandler from './EventHandler'
|
||||
|
||||
import { appDownloadProgress } from './Jotai'
|
||||
import ModelImportListener from './ModelImportListener'
|
||||
|
||||
const EventListenerWrapper = ({ children }: PropsWithChildren) => {
|
||||
const setDownloadState = useSetAtom(setDownloadStateAtom)
|
||||
const setProgress = useSetAtom(appDownloadProgress)
|
||||
|
||||
const onFileDownloadUpdate = useCallback(
|
||||
async (state: DownloadState) => {
|
||||
@ -42,7 +41,6 @@ const EventListenerWrapper = ({ children }: PropsWithChildren) => {
|
||||
|
||||
useEffect(() => {
|
||||
console.debug('EventListenerWrapper: registering event listeners...')
|
||||
|
||||
events.on(DownloadEvent.onFileDownloadUpdate, onFileDownloadUpdate)
|
||||
events.on(DownloadEvent.onFileDownloadError, onFileDownloadError)
|
||||
events.on(DownloadEvent.onFileDownloadSuccess, onFileDownloadSuccess)
|
||||
@ -55,30 +53,13 @@ const EventListenerWrapper = ({ children }: PropsWithChildren) => {
|
||||
}
|
||||
}, [onFileDownloadUpdate, onFileDownloadError, onFileDownloadSuccess])
|
||||
|
||||
useEffect(() => {
|
||||
if (window && window.electronAPI) {
|
||||
window.electronAPI.onAppUpdateDownloadUpdate(
|
||||
(_event: string, progress: any) => {
|
||||
setProgress(progress.percent)
|
||||
console.debug('app update progress:', progress.percent)
|
||||
}
|
||||
)
|
||||
|
||||
window.electronAPI.onAppUpdateDownloadError(
|
||||
(_event: string, callback: any) => {
|
||||
console.error('Download error', callback)
|
||||
setProgress(-1)
|
||||
}
|
||||
)
|
||||
|
||||
window.electronAPI.onAppUpdateDownloadSuccess(() => {
|
||||
setProgress(-1)
|
||||
})
|
||||
}
|
||||
return () => {}
|
||||
}, [setDownloadState, setProgress])
|
||||
|
||||
return <EventHandler>{children}</EventHandler>
|
||||
return (
|
||||
<AppUpdateListener>
|
||||
<ModelImportListener>
|
||||
<EventHandler>{children}</EventHandler>
|
||||
</ModelImportListener>
|
||||
</AppUpdateListener>
|
||||
)
|
||||
}
|
||||
|
||||
export default EventListenerWrapper
|
||||
|
||||
@ -6,7 +6,7 @@ import { atom, useSetAtom } from 'jotai'
|
||||
|
||||
import { MainViewState } from '@/constants/screens'
|
||||
|
||||
import { useMainViewState } from '@/hooks/useMainViewState'
|
||||
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
|
||||
|
||||
type Props = {
|
||||
children: ReactNode
|
||||
@ -19,7 +19,7 @@ export const showCommandSearchModalAtom = atom<boolean>(false)
|
||||
export default function KeyListener({ children }: Props) {
|
||||
const setShowLeftSideBar = useSetAtom(showLeftSideBarAtom)
|
||||
const setShowSelectModelModal = useSetAtom(showSelectModelModalAtom)
|
||||
const { setMainViewState } = useMainViewState()
|
||||
const setMainViewState = useSetAtom(mainViewStateAtom)
|
||||
const showCommandSearchModal = useSetAtom(showCommandSearchModalAtom)
|
||||
|
||||
useEffect(() => {
|
||||
@ -48,8 +48,12 @@ export default function KeyListener({ children }: Props) {
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}, [
|
||||
setMainViewState,
|
||||
setShowLeftSideBar,
|
||||
setShowSelectModelModal,
|
||||
showCommandSearchModal,
|
||||
])
|
||||
|
||||
return <Fragment>{children}</Fragment>
|
||||
}
|
||||
|
||||
86
web/containers/Providers/ModelImportListener.tsx
Normal file
86
web/containers/Providers/ModelImportListener.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { Fragment, PropsWithChildren, useCallback, useEffect } from 'react'
|
||||
|
||||
import {
|
||||
ImportingModel,
|
||||
LocalImportModelEvent,
|
||||
Model,
|
||||
ModelEvent,
|
||||
events,
|
||||
} from '@janhq/core'
|
||||
import { useSetAtom } from 'jotai'
|
||||
|
||||
import { snackbar } from '../Toast'
|
||||
|
||||
import {
|
||||
setImportingModelSuccessAtom,
|
||||
updateImportingModelProgressAtom,
|
||||
} from '@/helpers/atoms/Model.atom'
|
||||
|
||||
const ModelImportListener = ({ children }: PropsWithChildren) => {
|
||||
const updateImportingModelProgress = useSetAtom(
|
||||
updateImportingModelProgressAtom
|
||||
)
|
||||
const setImportingModelSuccess = useSetAtom(setImportingModelSuccessAtom)
|
||||
|
||||
const onImportModelUpdate = useCallback(
|
||||
async (state: ImportingModel) => {
|
||||
if (!state.importId) return
|
||||
updateImportingModelProgress(state.importId, state.percentage ?? 0)
|
||||
},
|
||||
[updateImportingModelProgress]
|
||||
)
|
||||
|
||||
const onImportModelSuccess = useCallback(
|
||||
(state: ImportingModel) => {
|
||||
if (!state.modelId) return
|
||||
events.emit(ModelEvent.OnModelsUpdate, {})
|
||||
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
|
||||
)
|
||||
|
||||
return () => {
|
||||
console.debug('ModelImportListener: unregistering event listeners...')
|
||||
events.off(
|
||||
LocalImportModelEvent.onLocalImportModelUpdate,
|
||||
onImportModelUpdate
|
||||
)
|
||||
events.off(
|
||||
LocalImportModelEvent.onLocalImportModelSuccess,
|
||||
onImportModelSuccess
|
||||
)
|
||||
events.off(
|
||||
LocalImportModelEvent.onLocalImportModelFinished,
|
||||
onImportModelFinished
|
||||
)
|
||||
}
|
||||
}, [onImportModelUpdate, onImportModelSuccess, onImportModelFinished])
|
||||
|
||||
return <Fragment>{children}</Fragment>
|
||||
}
|
||||
|
||||
export default ModelImportListener
|
||||
@ -1,4 +1,4 @@
|
||||
version: "3.8"
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
web:
|
||||
@ -14,6 +14,6 @@ services:
|
||||
- /app/node_modules
|
||||
- /app/.next
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- '3000:3000'
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
|
||||
5
web/helpers/atoms/App.atom.ts
Normal file
5
web/helpers/atoms/App.atom.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { atom } from 'jotai'
|
||||
|
||||
import { MainViewState } from '@/constants/screens'
|
||||
|
||||
export const mainViewStateAtom = atom<MainViewState>(MainViewState.Thread)
|
||||
3
web/helpers/atoms/AppConfig.atom.ts
Normal file
3
web/helpers/atoms/AppConfig.atom.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { atom } from 'jotai'
|
||||
|
||||
export const janDataFolderPathAtom = atom('')
|
||||
@ -1,4 +1,4 @@
|
||||
import { Model } from '@janhq/core'
|
||||
import { ImportingModel, Model } from '@janhq/core'
|
||||
import { atom } from 'jotai'
|
||||
|
||||
export const stateModel = atom({ state: 'start', loading: false, model: '' })
|
||||
@ -32,4 +32,81 @@ export const removeDownloadingModelAtom = atom(
|
||||
|
||||
export const downloadedModelsAtom = atom<Model[]>([])
|
||||
|
||||
export const removeDownloadedModelAtom = atom(
|
||||
null,
|
||||
(get, set, modelId: string) => {
|
||||
const downloadedModels = get(downloadedModelsAtom)
|
||||
|
||||
set(
|
||||
downloadedModelsAtom,
|
||||
downloadedModels.filter((e) => e.id !== modelId)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export const configuredModelsAtom = atom<Model[]>([])
|
||||
|
||||
/// TODO: move this part to another atom
|
||||
// store the paths of the models that are being imported
|
||||
export const importingModelsAtom = atom<ImportingModel[]>([])
|
||||
|
||||
export const updateImportingModelProgressAtom = atom(
|
||||
null,
|
||||
(get, set, importId: string, percentage: number) => {
|
||||
const model = get(importingModelsAtom).find((x) => x.importId === importId)
|
||||
if (!model) return
|
||||
const newModel: ImportingModel = {
|
||||
...model,
|
||||
status: 'IMPORTING',
|
||||
percentage,
|
||||
}
|
||||
const newList = get(importingModelsAtom).map((x) =>
|
||||
x.importId === importId ? newModel : x
|
||||
)
|
||||
set(importingModelsAtom, newList)
|
||||
}
|
||||
)
|
||||
|
||||
export const setImportingModelSuccessAtom = atom(
|
||||
null,
|
||||
(get, set, importId: string, modelId: string) => {
|
||||
const model = get(importingModelsAtom).find((x) => x.importId === importId)
|
||||
if (!model) return
|
||||
const newModel: ImportingModel = {
|
||||
...model,
|
||||
modelId,
|
||||
status: 'IMPORTED',
|
||||
percentage: 1,
|
||||
}
|
||||
const newList = get(importingModelsAtom).map((x) =>
|
||||
x.importId === importId ? newModel : x
|
||||
)
|
||||
set(importingModelsAtom, newList)
|
||||
}
|
||||
)
|
||||
|
||||
export const updateImportingModelAtom = atom(
|
||||
null,
|
||||
(
|
||||
get,
|
||||
set,
|
||||
importId: string,
|
||||
name: string,
|
||||
description: string,
|
||||
tags: string[]
|
||||
) => {
|
||||
const model = get(importingModelsAtom).find((x) => x.importId === importId)
|
||||
if (!model) return
|
||||
const newModel: ImportingModel = {
|
||||
...model,
|
||||
name,
|
||||
importId,
|
||||
description,
|
||||
tags,
|
||||
}
|
||||
const newList = get(importingModelsAtom).map((x) =>
|
||||
x.importId === importId ? newModel : x
|
||||
)
|
||||
set(importingModelsAtom, newList)
|
||||
}
|
||||
)
|
||||
|
||||
@ -1,28 +1,32 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { ExtensionTypeEnum, ModelExtension, Model } from '@janhq/core'
|
||||
|
||||
import { useAtom } from 'jotai'
|
||||
import { useSetAtom } from 'jotai'
|
||||
|
||||
import { toaster } from '@/containers/Toast'
|
||||
|
||||
import { extensionManager } from '@/extension/ExtensionManager'
|
||||
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
|
||||
import { removeDownloadedModelAtom } from '@/helpers/atoms/Model.atom'
|
||||
|
||||
export default function useDeleteModel() {
|
||||
const [downloadedModels, setDownloadedModels] = useAtom(downloadedModelsAtom)
|
||||
const removeDownloadedModel = useSetAtom(removeDownloadedModelAtom)
|
||||
|
||||
const deleteModel = async (model: Model) => {
|
||||
await extensionManager
|
||||
.get<ModelExtension>(ExtensionTypeEnum.Model)
|
||||
?.deleteModel(model.id)
|
||||
|
||||
// reload models
|
||||
setDownloadedModels(downloadedModels.filter((e) => e.id !== model.id))
|
||||
toaster({
|
||||
title: 'Model Deletion Successful',
|
||||
description: `The model ${model.id} has been successfully deleted.`,
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
const deleteModel = useCallback(
|
||||
async (model: Model) => {
|
||||
await localDeleteModel(model.id)
|
||||
removeDownloadedModel(model.id)
|
||||
toaster({
|
||||
title: 'Model Deletion Successful',
|
||||
description: `Model ${model.name} has been successfully deleted.`,
|
||||
type: 'success',
|
||||
})
|
||||
},
|
||||
[removeDownloadedModel]
|
||||
)
|
||||
|
||||
return { deleteModel }
|
||||
}
|
||||
|
||||
const localDeleteModel = async (id: string) =>
|
||||
extensionManager.get<ModelExtension>(ExtensionTypeEnum.Model)?.deleteModel(id)
|
||||
|
||||
70
web/hooks/useImportModel.ts
Normal file
70
web/hooks/useImportModel.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import {
|
||||
ExtensionTypeEnum,
|
||||
ImportingModel,
|
||||
Model,
|
||||
ModelExtension,
|
||||
OptionType,
|
||||
} from '@janhq/core'
|
||||
|
||||
import { atom } from 'jotai'
|
||||
|
||||
import { extensionManager } from '@/extension'
|
||||
|
||||
export type ImportModelStage =
|
||||
| 'NONE'
|
||||
| 'SELECTING_MODEL'
|
||||
| 'MODEL_SELECTED'
|
||||
| 'IMPORTING_MODEL'
|
||||
| 'EDIT_MODEL_INFO'
|
||||
| 'CONFIRM_CANCEL'
|
||||
|
||||
const importModelStageAtom = atom<ImportModelStage>('NONE')
|
||||
|
||||
export const getImportModelStageAtom = atom((get) => get(importModelStageAtom))
|
||||
|
||||
export const setImportModelStageAtom = atom(
|
||||
null,
|
||||
(_get, set, stage: ImportModelStage) => {
|
||||
set(importModelStageAtom, stage)
|
||||
}
|
||||
)
|
||||
|
||||
export type ModelUpdate = {
|
||||
name: string
|
||||
description: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
const useImportModel = () => {
|
||||
const importModels = useCallback(
|
||||
(models: ImportingModel[], optionType: OptionType) =>
|
||||
localImportModels(models, optionType),
|
||||
[]
|
||||
)
|
||||
|
||||
const updateModelInfo = useCallback(
|
||||
async (modelInfo: Partial<Model>) => localUpdateModelInfo(modelInfo),
|
||||
[]
|
||||
)
|
||||
|
||||
return { importModels, updateModelInfo }
|
||||
}
|
||||
|
||||
const localImportModels = async (
|
||||
models: ImportingModel[],
|
||||
optionType: OptionType
|
||||
): Promise<void> =>
|
||||
extensionManager
|
||||
.get<ModelExtension>(ExtensionTypeEnum.Model)
|
||||
?.importModels(models, optionType)
|
||||
|
||||
const localUpdateModelInfo = async (
|
||||
modelInfo: Partial<Model>
|
||||
): Promise<Model | undefined> =>
|
||||
extensionManager
|
||||
.get<ModelExtension>(ExtensionTypeEnum.Model)
|
||||
?.updateModelInfo(modelInfo)
|
||||
|
||||
export default useImportModel
|
||||
@ -1,11 +0,0 @@
|
||||
import { atom, useAtom } from 'jotai'
|
||||
|
||||
import { MainViewState } from '@/constants/screens'
|
||||
|
||||
const currentMainViewState = atom<MainViewState>(MainViewState.Thread)
|
||||
|
||||
export function useMainViewState() {
|
||||
const [mainViewState, setMainViewState] = useAtom(currentMainViewState)
|
||||
const viewStateName = MainViewState[mainViewState]
|
||||
return { mainViewState, setMainViewState, viewStateName }
|
||||
}
|
||||
@ -9,7 +9,7 @@ export const usePath = () => {
|
||||
const activeThread = useAtomValue(activeThreadAtom)
|
||||
const selectedModel = useAtomValue(selectedModelAtom)
|
||||
|
||||
const onReviewInFinder = async (type: string) => {
|
||||
const onRevealInFinder = async (type: string) => {
|
||||
// TODO: this logic should be refactored.
|
||||
if (type !== 'Model' && !activeThread) return
|
||||
|
||||
@ -96,7 +96,7 @@ export const usePath = () => {
|
||||
}
|
||||
|
||||
return {
|
||||
onReviewInFinder,
|
||||
onRevealInFinder,
|
||||
onViewJson,
|
||||
onViewFile,
|
||||
onViewFileContainer,
|
||||
|
||||
@ -32,6 +32,7 @@
|
||||
"postcss": "8.4.31",
|
||||
"posthog-js": "^1.95.1",
|
||||
"react": "18.2.0",
|
||||
"react-circular-progressbar": "^2.1.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.47.0",
|
||||
|
||||
@ -4,27 +4,24 @@ import ScrollToBottom from 'react-scroll-to-bottom'
|
||||
|
||||
import { InferenceEngine, MessageStatus } from '@janhq/core'
|
||||
import { Button } from '@janhq/uikit'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
|
||||
import LogoMark from '@/containers/Brand/Logo/Mark'
|
||||
|
||||
import { MainViewState } from '@/constants/screens'
|
||||
|
||||
import { useMainViewState } from '@/hooks/useMainViewState'
|
||||
|
||||
import ChatItem from '../ChatItem'
|
||||
|
||||
import ErrorMessage from '../ErrorMessage'
|
||||
|
||||
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
|
||||
import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
|
||||
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
|
||||
|
||||
const ChatBody: React.FC = () => {
|
||||
const messages = useAtomValue(getCurrentChatMessagesAtom)
|
||||
|
||||
const downloadedModels = useAtomValue(downloadedModelsAtom)
|
||||
|
||||
const { setMainViewState } = useMainViewState()
|
||||
const setMainViewState = useSetAtom(mainViewStateAtom)
|
||||
|
||||
if (downloadedModels.length === 0)
|
||||
return (
|
||||
|
||||
@ -48,7 +48,7 @@ const ErrorMessage = ({ message }: { message: ThreadMessage }) => {
|
||||
{loadModelError === PORT_NOT_AVAILABLE ? (
|
||||
<div
|
||||
key={message.id}
|
||||
className="flex flex-col items-center text-center text-sm font-medium text-gray-500 w-full"
|
||||
className="flex w-full flex-col items-center text-center text-sm font-medium text-gray-500"
|
||||
>
|
||||
<p className="w-[90%]">
|
||||
Port 3928 is currently unavailable. Check for conflicting apps,
|
||||
|
||||
@ -2,19 +2,18 @@ import React, { Fragment, useCallback } from 'react'
|
||||
|
||||
import { Button } from '@janhq/uikit'
|
||||
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
|
||||
import LogoMark from '@/containers/Brand/Logo/Mark'
|
||||
|
||||
import { MainViewState } from '@/constants/screens'
|
||||
|
||||
import { useMainViewState } from '@/hooks/useMainViewState'
|
||||
|
||||
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
|
||||
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
|
||||
|
||||
const RequestDownloadModel: React.FC = () => {
|
||||
const downloadedModels = useAtomValue(downloadedModelsAtom)
|
||||
const { setMainViewState } = useMainViewState()
|
||||
const setMainViewState = useSetAtom(mainViewStateAtom)
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
setMainViewState(MainViewState.Hub)
|
||||
|
||||
@ -32,6 +32,8 @@ import { usePath } from '@/hooks/usePath'
|
||||
import { toGibibytes } from '@/utils/converter'
|
||||
import { displayDate } from '@/utils/datetime'
|
||||
|
||||
import { openFileTitle } from '@/utils/titleUtils'
|
||||
|
||||
import EditChatInput from '../EditChatInput'
|
||||
import Icon from '../FileUploadPreview/Icon'
|
||||
import MessageToolbar from '../MessageToolbar'
|
||||
@ -234,7 +236,7 @@ const SimpleTextMessage: React.FC<ThreadMessage> = (props) => {
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="top" className="max-w-[154px] px-3">
|
||||
<span>Show in finder</span>
|
||||
<span>{openFileTitle()}</span>
|
||||
<TooltipArrow />
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
@ -261,7 +263,7 @@ const SimpleTextMessage: React.FC<ThreadMessage> = (props) => {
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="top" className="max-w-[154px] px-3">
|
||||
<span>Show in finder</span>
|
||||
<span>{openFileTitle()}</span>
|
||||
<TooltipArrow />
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@janhq/uikit'
|
||||
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
|
||||
import { ChevronDownIcon } from 'lucide-react'
|
||||
|
||||
@ -24,10 +24,9 @@ import { MainViewState } from '@/constants/screens'
|
||||
import { useCreateNewThread } from '@/hooks/useCreateNewThread'
|
||||
import useDownloadModel from '@/hooks/useDownloadModel'
|
||||
|
||||
import { useMainViewState } from '@/hooks/useMainViewState'
|
||||
|
||||
import { toGibibytes } from '@/utils/converter'
|
||||
|
||||
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
|
||||
import { assistantsAtom } from '@/helpers/atoms/Assistant.atom'
|
||||
import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom'
|
||||
|
||||
@ -70,7 +69,7 @@ const ExploreModelItemHeader: React.FC<Props> = ({ model, onClick, open }) => {
|
||||
const totalRam = useAtomValue(totalRamAtom)
|
||||
|
||||
const nvidiaTotalVram = useAtomValue(nvidiaTotalVramAtom)
|
||||
const { setMainViewState } = useMainViewState()
|
||||
const setMainViewState = useSetAtom(mainViewStateAtom)
|
||||
|
||||
// Default nvidia returns vram in MB, need to convert to bytes to match the unit of totalRamW
|
||||
let ram = nvidiaTotalVram * 1024 * 1024
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
import { openExternalUrl } from '@janhq/core'
|
||||
import {
|
||||
Input,
|
||||
ScrollArea,
|
||||
@ -10,10 +9,13 @@ import {
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
Button,
|
||||
} from '@janhq/uikit'
|
||||
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { Plus, SearchIcon } from 'lucide-react'
|
||||
|
||||
import { setImportModelStageAtom } from '@/hooks/useImportModel'
|
||||
|
||||
import ExploreModelList from './ExploreModelList'
|
||||
import { HuggingFaceModal } from './HuggingFaceModal'
|
||||
@ -23,13 +25,16 @@ import {
|
||||
downloadedModelsAtom,
|
||||
} from '@/helpers/atoms/Model.atom'
|
||||
|
||||
const sortMenu = ['All Models', 'Recommended', 'Downloaded']
|
||||
|
||||
const ExploreModelsScreen = () => {
|
||||
const configuredModels = useAtomValue(configuredModelsAtom)
|
||||
const downloadedModels = useAtomValue(downloadedModelsAtom)
|
||||
const [searchValue, setsearchValue] = useState('')
|
||||
const [sortSelected, setSortSelected] = useState('All Models')
|
||||
const sortMenu = ['All Models', 'Recommended', 'Downloaded']
|
||||
|
||||
const [showHuggingFaceModal, setShowHuggingFaceModal] = useState(false)
|
||||
const setImportModelStage = useSetAtom(setImportModelStageAtom)
|
||||
|
||||
const filteredModels = configuredModels.filter((x) => {
|
||||
if (sortSelected === 'Downloaded') {
|
||||
@ -47,9 +52,9 @@ const ExploreModelsScreen = () => {
|
||||
}
|
||||
})
|
||||
|
||||
const onHowToImportModelClick = useCallback(() => {
|
||||
openExternalUrl('https://jan.ai/guides/using-models/import-manually/')
|
||||
}, [])
|
||||
const onImportModelClick = useCallback(() => {
|
||||
setImportModelStage('SELECTING_MODEL')
|
||||
}, [setImportModelStage])
|
||||
|
||||
const onHuggingFaceConverterClick = () => {
|
||||
setShowHuggingFaceModal(true)
|
||||
@ -73,30 +78,29 @@ const ExploreModelsScreen = () => {
|
||||
alt="Hub Banner"
|
||||
className="w-full object-cover"
|
||||
/>
|
||||
<div className="absolute left-1/2 top-1/2 w-1/3 -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="relative">
|
||||
<SearchIcon
|
||||
size={20}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search models"
|
||||
className="bg-white pl-9 dark:bg-background"
|
||||
onChange={(e) => {
|
||||
setsearchValue(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-center">
|
||||
<p
|
||||
onClick={onHowToImportModelClick}
|
||||
className="cursor-pointer font-semibold text-white underline"
|
||||
<div className="absolute left-1/2 top-1/2 w-1/3 -translate-x-1/2 -translate-y-1/2 space-y-2">
|
||||
<div className="flex flex-row space-x-2">
|
||||
<div className="relative">
|
||||
<SearchIcon
|
||||
size={20}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search models"
|
||||
className="bg-white pl-9 dark:bg-background"
|
||||
onChange={(e) => setsearchValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
themes={'primary'}
|
||||
className="space-x-2"
|
||||
onClick={onImportModelClick}
|
||||
>
|
||||
How to manually import models
|
||||
</p>
|
||||
<Plus className="h-3 w-3" />
|
||||
<p>Import Model</p>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-white">or</p>
|
||||
<p
|
||||
onClick={onHuggingFaceConverterClick}
|
||||
className="cursor-pointer font-semibold text-white underline"
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { Fragment, useCallback, useEffect, useState } from 'react'
|
||||
import { Fragment, useCallback, useState } from 'react'
|
||||
|
||||
import { fs, AppConfiguration, isSubdirectory } from '@janhq/core'
|
||||
import { Button, Input } from '@janhq/uikit'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { PencilIcon, FolderOpenIcon } from 'lucide-react'
|
||||
|
||||
import Loader from '@/containers/Loader'
|
||||
@ -21,22 +21,17 @@ import ModalErrorSetDestGlobal, {
|
||||
|
||||
import ModalSameDirectory, { showSamePathModalAtom } from './ModalSameDirectory'
|
||||
|
||||
import { janDataFolderPathAtom } from '@/helpers/atoms/AppConfig.atom'
|
||||
|
||||
const DataFolder = () => {
|
||||
const [janDataFolderPath, setJanDataFolderPath] = useState('')
|
||||
const [showLoader, setShowLoader] = useState(false)
|
||||
const setShowDirectoryConfirm = useSetAtom(showDirectoryConfirmModalAtom)
|
||||
const setShowSameDirectory = useSetAtom(showSamePathModalAtom)
|
||||
const setShowChangeFolderError = useSetAtom(showChangeFolderErrorAtom)
|
||||
const showDestNotEmptyConfirm = useSetAtom(showDestNotEmptyConfirmAtom)
|
||||
const [destinationPath, setDestinationPath] = useState(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
window.core?.api
|
||||
?.getAppConfigurations()
|
||||
?.then((appConfig: AppConfiguration) => {
|
||||
setJanDataFolderPath(appConfig.data_folder)
|
||||
})
|
||||
}, [])
|
||||
const [destinationPath, setDestinationPath] = useState(undefined)
|
||||
const janDataFolderPath = useAtomValue(janDataFolderPathAtom)
|
||||
|
||||
const onChangeFolderClick = useCallback(async () => {
|
||||
const destFolder = await window.core?.api?.selectDirectory()
|
||||
@ -56,8 +51,7 @@ const DataFolder = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const newDestChildren: any[] = await fs.readdirSync(destFolder)
|
||||
const newDestChildren: string[] = await fs.readdirSync(destFolder)
|
||||
const isNotEmpty =
|
||||
newDestChildren.filter((x) => x !== '.DS_Store').length > 0
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@ import React, { useCallback, useState } from 'react'
|
||||
|
||||
import {
|
||||
Modal,
|
||||
ModalPortal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
@ -33,7 +32,6 @@ const ModalConfirmReset = () => {
|
||||
open={modalValidation}
|
||||
onOpenChange={() => setModalValidation(false)}
|
||||
>
|
||||
<ModalPortal />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>
|
||||
|
||||
@ -26,6 +26,7 @@ import {
|
||||
TooltipArrow,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
ScrollArea,
|
||||
} from '@janhq/uikit'
|
||||
|
||||
import { AlertTriangleIcon, AlertCircleIcon } from 'lucide-react'
|
||||
@ -138,301 +139,312 @@ const Advanced = () => {
|
||||
gpuList.length > 0 ? 'Select GPU' : "You don't have any compatible GPU"
|
||||
|
||||
return (
|
||||
<div className="block w-full">
|
||||
{/* Keyboard shortcut */}
|
||||
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
|
||||
<div className="flex-shrink-0 space-y-1.5">
|
||||
<div className="flex gap-x-2">
|
||||
<h6 className="text-sm font-semibold capitalize">
|
||||
Keyboard Shortcuts
|
||||
</h6>
|
||||
</div>
|
||||
<p className="leading-relaxed">
|
||||
Shortcuts that you might find useful in Jan app.
|
||||
</p>
|
||||
</div>
|
||||
<ShortcutModal />
|
||||
</div>
|
||||
|
||||
{/* Experimental */}
|
||||
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
|
||||
<div className="flex-shrink-0 space-y-1.5">
|
||||
<div className="flex gap-x-2">
|
||||
<h6 className="text-sm font-semibold capitalize">
|
||||
Experimental Mode
|
||||
</h6>
|
||||
</div>
|
||||
<p className="leading-relaxed">
|
||||
Enable experimental features that may be unstable tested.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={experimentalFeature}
|
||||
onCheckedChange={setExperimentalFeature}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CPU / GPU switching */}
|
||||
{!isMac && (
|
||||
<div className="flex w-full flex-col items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
|
||||
<div className="flex items-start justify-between w-full">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex gap-x-2">
|
||||
<h6 className="text-sm font-semibold capitalize">
|
||||
GPU Acceleration
|
||||
</h6>
|
||||
</div>
|
||||
<p className="pr-8 leading-relaxed">
|
||||
Enable to enhance model performance by utilizing your GPU
|
||||
devices for acceleration. Read{' '}
|
||||
<span>
|
||||
{' '}
|
||||
<span
|
||||
className="cursor-pointer text-blue-600"
|
||||
onClick={() =>
|
||||
openExternalUrl(
|
||||
'https://jan.ai/guides/troubleshooting/gpu-not-used/'
|
||||
)
|
||||
}
|
||||
>
|
||||
troubleshooting guide
|
||||
</span>{' '}
|
||||
</span>{' '}
|
||||
for further assistance.
|
||||
</p>
|
||||
<ScrollArea className="px-4">
|
||||
<div className="block w-full">
|
||||
{/* Keyboard shortcut */}
|
||||
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-4 last:border-none">
|
||||
<div className="flex-shrink-0 space-y-1.5">
|
||||
<div className="flex gap-x-2">
|
||||
<h6 className="text-sm font-semibold capitalize">
|
||||
Keyboard Shortcuts
|
||||
</h6>
|
||||
</div>
|
||||
{gpuList.length > 0 && !gpuEnabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<AlertCircleIcon size={20} className="mr-2 text-yellow-600" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={10}
|
||||
className="max-w-[240px]"
|
||||
>
|
||||
<span>
|
||||
Disabling NVIDIA GPU Acceleration may result in reduced
|
||||
performance. It is recommended to keep this enabled for
|
||||
optimal user experience.
|
||||
</span>
|
||||
<TooltipArrow />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Switch
|
||||
disabled={gpuList.length === 0 || vulkanEnabled}
|
||||
checked={gpuEnabled}
|
||||
onCheckedChange={(e) => {
|
||||
if (e === true) {
|
||||
saveSettings({ runMode: 'gpu' })
|
||||
setGpuEnabled(true)
|
||||
setShowNotification(false)
|
||||
snackbar({
|
||||
description: 'Successfully turned on GPU Accelertion',
|
||||
type: 'success',
|
||||
})
|
||||
setTimeout(() => {
|
||||
validateSettings()
|
||||
}, 300)
|
||||
} else {
|
||||
saveSettings({ runMode: 'cpu' })
|
||||
setGpuEnabled(false)
|
||||
snackbar({
|
||||
description: 'Successfully turned off GPU Accelertion',
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
// Stop any running model to apply the changes
|
||||
if (e !== gpuEnabled) stopModel()
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
{gpuList.length === 0 && (
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={10}
|
||||
className="max-w-[240px]"
|
||||
>
|
||||
<span>
|
||||
Your current device does not have a compatible GPU for
|
||||
monitoring. To enable GPU monitoring, please ensure your
|
||||
device has a supported Nvidia or AMD GPU with updated
|
||||
drivers.
|
||||
</span>
|
||||
<TooltipArrow />
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="mt-2 w-full rounded-lg bg-secondary p-4">
|
||||
<label className="mb-1 inline-block font-medium">
|
||||
Choose device(s)
|
||||
</label>
|
||||
<Select
|
||||
disabled={gpuList.length === 0 || !gpuEnabled}
|
||||
value={selectedGpu.join()}
|
||||
>
|
||||
<SelectTrigger className="w-[340px] dark:bg-gray-500 bg-white">
|
||||
<SelectValue placeholder={gpuSelectionPlaceHolder}>
|
||||
<span className="line-clamp-1 w-full pr-8">
|
||||
{selectedGpu.join()}
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
<SelectContent className="w-[400px] px-1 pb-2">
|
||||
<SelectGroup>
|
||||
<SelectLabel>
|
||||
{vulkanEnabled ? 'Vulkan Supported GPUs' : 'Nvidia'}
|
||||
</SelectLabel>
|
||||
<div className="px-4 pb-2">
|
||||
<div className="rounded-lg bg-secondary p-3">
|
||||
{gpuList
|
||||
.filter((gpu) =>
|
||||
vulkanEnabled
|
||||
? gpu.name
|
||||
: gpu.name?.toLowerCase().includes('nvidia')
|
||||
)
|
||||
.map((gpu) => (
|
||||
<div
|
||||
key={gpu.id}
|
||||
className="my-1 flex items-center space-x-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={`gpu-${gpu.id}`}
|
||||
name="gpu-nvidia"
|
||||
className="bg-white"
|
||||
value={gpu.id}
|
||||
checked={gpusInUse.includes(gpu.id)}
|
||||
onCheckedChange={() => handleGPUChange(gpu.id)}
|
||||
/>
|
||||
<label
|
||||
className="flex w-full items-center justify-between"
|
||||
htmlFor={`gpu-${gpu.id}`}
|
||||
>
|
||||
<span>{gpu.name}</span>
|
||||
{!vulkanEnabled && (
|
||||
<span>{gpu.vram}MB VRAM</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Warning message */}
|
||||
{gpuEnabled && gpusInUse.length > 1 && (
|
||||
<div className="mt-2 flex items-start space-x-2 text-yellow-500">
|
||||
<AlertTriangleIcon
|
||||
size={16}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<p className="text-xs leading-relaxed">
|
||||
If multi-GPU is enabled with different GPU models or
|
||||
without NVLink, it could impact token speed.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SelectGroup>
|
||||
|
||||
{/* TODO enable this when we support AMD */}
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</Select>
|
||||
<p className="leading-relaxed">
|
||||
Shortcuts that you might find useful in Jan app.
|
||||
</p>
|
||||
</div>
|
||||
<ShortcutModal />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vulkan for AMD GPU/ APU and Intel Arc GPU */}
|
||||
{!isMac && experimentalFeature && (
|
||||
{/* Experimental */}
|
||||
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
|
||||
<div className="flex-shrink-0 space-y-1.5">
|
||||
<div className="flex gap-x-2">
|
||||
<h6 className="text-sm font-semibold capitalize">
|
||||
Vulkan Support
|
||||
Experimental Mode
|
||||
</h6>
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed">
|
||||
Enable Vulkan with AMD GPU/APU and Intel Arc GPU for better model
|
||||
performance (reload needed).
|
||||
<p className="leading-relaxed">
|
||||
Enable experimental features that may be unstable tested.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={vulkanEnabled}
|
||||
onCheckedChange={(e) => {
|
||||
toaster({
|
||||
title: 'Reload',
|
||||
description:
|
||||
'Vulkan settings updated. Reload now to apply the changes.',
|
||||
})
|
||||
stopModel()
|
||||
saveSettings({ vulkan: e, gpusInUse: [] })
|
||||
setVulkanEnabled(e)
|
||||
}}
|
||||
checked={experimentalFeature}
|
||||
onCheckedChange={setExperimentalFeature}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataFolder />
|
||||
{/* Proxy */}
|
||||
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
|
||||
<div className="flex-shrink-0 space-y-1.5 w-full">
|
||||
<div className="flex gap-x-2 justify-between w-full">
|
||||
<h6 className="text-sm font-semibold capitalize">HTTPS Proxy</h6>
|
||||
{/* CPU / GPU switching */}
|
||||
{!isMac && (
|
||||
<div className="flex w-full flex-col items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
|
||||
<div className="flex w-full items-start justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex gap-x-2">
|
||||
<h6 className="text-sm font-semibold capitalize">
|
||||
GPU Acceleration
|
||||
</h6>
|
||||
</div>
|
||||
<p className="pr-8 leading-relaxed">
|
||||
Enable to enhance model performance by utilizing your GPU
|
||||
devices for acceleration. Read{' '}
|
||||
<span>
|
||||
{' '}
|
||||
<span
|
||||
className="cursor-pointer text-blue-600"
|
||||
onClick={() =>
|
||||
openExternalUrl(
|
||||
'https://jan.ai/guides/troubleshooting/gpu-not-used/'
|
||||
)
|
||||
}
|
||||
>
|
||||
troubleshooting guide
|
||||
</span>{' '}
|
||||
</span>{' '}
|
||||
for further assistance.
|
||||
</p>
|
||||
</div>
|
||||
{gpuList.length > 0 && !gpuEnabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<AlertCircleIcon
|
||||
size={20}
|
||||
className="mr-2 text-yellow-600"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={10}
|
||||
className="max-w-[240px]"
|
||||
>
|
||||
<span>
|
||||
Disabling NVIDIA GPU Acceleration may result in reduced
|
||||
performance. It is recommended to keep this enabled for
|
||||
optimal user experience.
|
||||
</span>
|
||||
<TooltipArrow />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Switch
|
||||
disabled={gpuList.length === 0 || vulkanEnabled}
|
||||
checked={gpuEnabled}
|
||||
onCheckedChange={(e) => {
|
||||
if (e === true) {
|
||||
saveSettings({ runMode: 'gpu' })
|
||||
setGpuEnabled(true)
|
||||
setShowNotification(false)
|
||||
snackbar({
|
||||
description: 'Successfully turned on GPU Accelertion',
|
||||
type: 'success',
|
||||
})
|
||||
setTimeout(() => {
|
||||
validateSettings()
|
||||
}, 300)
|
||||
} else {
|
||||
saveSettings({ runMode: 'cpu' })
|
||||
setGpuEnabled(false)
|
||||
snackbar({
|
||||
description:
|
||||
'Successfully turned off GPU Accelertion',
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
// Stop any running model to apply the changes
|
||||
if (e !== gpuEnabled) stopModel()
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
{gpuList.length === 0 && (
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={10}
|
||||
className="max-w-[240px]"
|
||||
>
|
||||
<span>
|
||||
Your current device does not have a compatible GPU for
|
||||
monitoring. To enable GPU monitoring, please ensure your
|
||||
device has a supported Nvidia or AMD GPU with updated
|
||||
drivers.
|
||||
</span>
|
||||
<TooltipArrow />
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="mt-2 w-full rounded-lg bg-secondary p-4">
|
||||
<label className="mb-1 inline-block font-medium">
|
||||
Choose device(s)
|
||||
</label>
|
||||
<Select
|
||||
disabled={gpuList.length === 0 || !gpuEnabled}
|
||||
value={selectedGpu.join()}
|
||||
>
|
||||
<SelectTrigger className="w-[340px] bg-white dark:bg-gray-500">
|
||||
<SelectValue placeholder={gpuSelectionPlaceHolder}>
|
||||
<span className="line-clamp-1 w-full pr-8">
|
||||
{selectedGpu.join()}
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
<SelectContent className="w-[400px] px-1 pb-2">
|
||||
<SelectGroup>
|
||||
<SelectLabel>
|
||||
{vulkanEnabled ? 'Vulkan Supported GPUs' : 'Nvidia'}
|
||||
</SelectLabel>
|
||||
<div className="px-4 pb-2">
|
||||
<div className="rounded-lg bg-secondary p-3">
|
||||
{gpuList
|
||||
.filter((gpu) =>
|
||||
vulkanEnabled
|
||||
? gpu.name
|
||||
: gpu.name?.toLowerCase().includes('nvidia')
|
||||
)
|
||||
.map((gpu) => (
|
||||
<div
|
||||
key={gpu.id}
|
||||
className="my-1 flex items-center space-x-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={`gpu-${gpu.id}`}
|
||||
name="gpu-nvidia"
|
||||
className="bg-white"
|
||||
value={gpu.id}
|
||||
checked={gpusInUse.includes(gpu.id)}
|
||||
onCheckedChange={() =>
|
||||
handleGPUChange(gpu.id)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
className="flex w-full items-center justify-between"
|
||||
htmlFor={`gpu-${gpu.id}`}
|
||||
>
|
||||
<span>{gpu.name}</span>
|
||||
{!vulkanEnabled && (
|
||||
<span>{gpu.vram}MB VRAM</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Warning message */}
|
||||
{gpuEnabled && gpusInUse.length > 1 && (
|
||||
<div className="mt-2 flex items-start space-x-2 text-yellow-500">
|
||||
<AlertTriangleIcon
|
||||
size={16}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<p className="text-xs leading-relaxed">
|
||||
If multi-GPU is enabled with different GPU models
|
||||
or without NVLink, it could impact token speed.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SelectGroup>
|
||||
|
||||
{/* TODO enable this when we support AMD */}
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vulkan for AMD GPU/ APU and Intel Arc GPU */}
|
||||
{!isMac && experimentalFeature && (
|
||||
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
|
||||
<div className="flex-shrink-0 space-y-1.5">
|
||||
<div className="flex gap-x-2">
|
||||
<h6 className="text-sm font-semibold capitalize">
|
||||
Vulkan Support
|
||||
</h6>
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed">
|
||||
Enable Vulkan with AMD GPU/APU and Intel Arc GPU for better
|
||||
model performance (reload needed).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={proxyEnabled}
|
||||
onCheckedChange={(_) => setProxyEnabled(!proxyEnabled)}
|
||||
checked={vulkanEnabled}
|
||||
onCheckedChange={(e) => {
|
||||
toaster({
|
||||
title: 'Reload',
|
||||
description:
|
||||
'Vulkan settings updated. Reload now to apply the changes.',
|
||||
})
|
||||
stopModel()
|
||||
saveSettings({ vulkan: e, gpusInUse: [] })
|
||||
setVulkanEnabled(e)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="leading-relaxed">
|
||||
Specify the HTTPS proxy or leave blank (proxy auto-configuration and
|
||||
SOCKS not supported).
|
||||
</p>
|
||||
<Input
|
||||
placeholder={'http://<user>:<password>@<domain or IP>:<port>'}
|
||||
value={partialProxy}
|
||||
onChange={onProxyChange}
|
||||
className="w-2/3"
|
||||
)}
|
||||
|
||||
<DataFolder />
|
||||
{/* Proxy */}
|
||||
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
|
||||
<div className="w-full flex-shrink-0 space-y-1.5">
|
||||
<div className="flex w-full justify-between gap-x-2">
|
||||
<h6 className="text-sm font-semibold capitalize">HTTPS Proxy</h6>
|
||||
<Switch
|
||||
checked={proxyEnabled}
|
||||
onCheckedChange={() => setProxyEnabled(!proxyEnabled)}
|
||||
/>
|
||||
</div>
|
||||
<p className="leading-relaxed">
|
||||
Specify the HTTPS proxy or leave blank (proxy auto-configuration
|
||||
and SOCKS not supported).
|
||||
</p>
|
||||
<Input
|
||||
placeholder={'http://<user>:<password>@<domain or IP>:<port>'}
|
||||
value={partialProxy}
|
||||
onChange={onProxyChange}
|
||||
className="w-2/3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ignore SSL certificates */}
|
||||
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
|
||||
<div className="flex-shrink-0 space-y-1.5">
|
||||
<div className="flex gap-x-2">
|
||||
<h6 className="text-sm font-semibold capitalize">
|
||||
Ignore SSL certificates
|
||||
</h6>
|
||||
</div>
|
||||
<p className="leading-relaxed">
|
||||
Allow self-signed or unverified certificates - may be required for
|
||||
certain proxies.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={ignoreSSL}
|
||||
onCheckedChange={(e) => setIgnoreSSL(e)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ignore SSL certificates */}
|
||||
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
|
||||
<div className="flex-shrink-0 space-y-1.5">
|
||||
<div className="flex gap-x-2">
|
||||
<h6 className="text-sm font-semibold capitalize">
|
||||
Ignore SSL certificates
|
||||
</h6>
|
||||
{/* Clear log */}
|
||||
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
|
||||
<div className="flex-shrink-0 space-y-1.5">
|
||||
<div className="flex gap-x-2">
|
||||
<h6 className="text-sm font-semibold capitalize">Clear logs</h6>
|
||||
</div>
|
||||
<p className="leading-relaxed">Clear all logs from Jan app.</p>
|
||||
</div>
|
||||
<p className="leading-relaxed">
|
||||
Allow self-signed or unverified certificates - may be required for
|
||||
certain proxies.
|
||||
</p>
|
||||
<Button size="sm" themes="secondaryDanger" onClick={clearLogs}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<Switch checked={ignoreSSL} onCheckedChange={(e) => setIgnoreSSL(e)} />
|
||||
</div>
|
||||
|
||||
{/* Clear log */}
|
||||
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
|
||||
<div className="flex-shrink-0 space-y-1.5">
|
||||
<div className="flex gap-x-2">
|
||||
<h6 className="text-sm font-semibold capitalize">Clear logs</h6>
|
||||
</div>
|
||||
<p className="leading-relaxed">Clear all logs from Jan app.</p>
|
||||
</div>
|
||||
<Button size="sm" themes="secondaryDanger" onClick={clearLogs}>
|
||||
Clear
|
||||
</Button>
|
||||
{/* Factory Reset */}
|
||||
<FactoryReset />
|
||||
</div>
|
||||
|
||||
{/* Factory Reset */}
|
||||
<FactoryReset />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import ToggleTheme from '@/screens/Settings/Appearance/ToggleTheme'
|
||||
|
||||
export default function AppearanceOptions() {
|
||||
return (
|
||||
<div className="block w-full">
|
||||
<div className="m-4 block w-full">
|
||||
<div className="flex w-full items-center justify-between border-b border-border py-3 first:pt-0 last:border-none">
|
||||
<div className="flex-shrink-0 space-y-1">
|
||||
<h6 className="text-sm font-semibold capitalize">
|
||||
|
||||
61
web/screens/Settings/CancelModelImportModal/index.tsx
Normal file
61
web/screens/Settings/CancelModelImportModal/index.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import React from 'react'
|
||||
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalFooter,
|
||||
ModalClose,
|
||||
Button,
|
||||
} from '@janhq/uikit'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
|
||||
import {
|
||||
getImportModelStageAtom,
|
||||
setImportModelStageAtom,
|
||||
} from '@/hooks/useImportModel'
|
||||
|
||||
const CancelModelImportModal: React.FC = () => {
|
||||
const importModelStage = useAtomValue(getImportModelStageAtom)
|
||||
const setImportModelStage = useSetAtom(setImportModelStageAtom)
|
||||
|
||||
const onContinueClick = () => {
|
||||
setImportModelStage('IMPORTING_MODEL')
|
||||
}
|
||||
|
||||
const onCancelAllClick = () => {
|
||||
setImportModelStage('NONE')
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={importModelStage === 'CONFIRM_CANCEL'}>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>Cancel Model Import?</ModalTitle>
|
||||
</ModalHeader>
|
||||
|
||||
<p>
|
||||
The model import process is not complete. Are you sure you want to
|
||||
cancel all ongoing model imports? This action is irreversible and the
|
||||
progress will be lost.
|
||||
</p>
|
||||
|
||||
<ModalFooter>
|
||||
<div className="flex gap-x-2">
|
||||
<ModalClose asChild onClick={onContinueClick}>
|
||||
<Button themes="ghost">Continue</Button>
|
||||
</ModalClose>
|
||||
<ModalClose asChild>
|
||||
<Button autoFocus themes="danger" onClick={onCancelAllClick}>
|
||||
Cancel All
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(CancelModelImportModal)
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
|
||||
import { Button } from '@janhq/uikit'
|
||||
import { Button, ScrollArea } from '@janhq/uikit'
|
||||
|
||||
import { formatExtensionsName } from '@/utils/converter'
|
||||
|
||||
@ -68,58 +68,60 @@ const ExtensionCatalog = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="block w-full">
|
||||
{activeExtensions.map((item, i) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none"
|
||||
>
|
||||
<div className="w-4/5 flex-shrink-0 space-y-1.5">
|
||||
<div className="flex gap-x-2">
|
||||
<h6 className="text-sm font-semibold capitalize">
|
||||
{formatExtensionsName(item.name ?? item.description ?? '')}
|
||||
</h6>
|
||||
<p className="whitespace-pre-wrap font-semibold leading-relaxed ">
|
||||
v{item.version}
|
||||
<ScrollArea className="h-full w-full px-4">
|
||||
<div className="block w-full">
|
||||
{activeExtensions.map((item, i) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex w-full items-start justify-between border-b border-border py-4 first:pt-4 last:border-none"
|
||||
>
|
||||
<div className="w-4/5 flex-shrink-0 space-y-1.5">
|
||||
<div className="flex gap-x-2">
|
||||
<h6 className="text-sm font-semibold capitalize">
|
||||
{formatExtensionsName(item.name ?? item.description ?? '')}
|
||||
</h6>
|
||||
<p className="whitespace-pre-wrap font-semibold leading-relaxed ">
|
||||
v{item.version}
|
||||
</p>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap leading-relaxed ">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap leading-relaxed ">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Manual Installation */}
|
||||
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
|
||||
<div className="w-4/5 flex-shrink-0 space-y-1.5">
|
||||
<div className="flex gap-x-2">
|
||||
<h6 className="text-sm font-semibold capitalize">
|
||||
Manual Installation
|
||||
</h6>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap leading-relaxed ">
|
||||
Select a extension file to install (.tgz)
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Manual Installation */}
|
||||
<div className="flex w-full items-start justify-between border-b border-border py-4 first:pt-0 last:border-none">
|
||||
<div className="w-4/5 flex-shrink-0 space-y-1.5">
|
||||
<div className="flex gap-x-2">
|
||||
<h6 className="text-sm font-semibold capitalize">
|
||||
Manual Installation
|
||||
</h6>
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button
|
||||
themes="secondaryBlue"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap leading-relaxed ">
|
||||
Select a extension file to install (.tgz)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button
|
||||
themes="secondaryBlue"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
197
web/screens/Settings/EditModelInfoModal/index.tsx
Normal file
197
web/screens/Settings/EditModelInfoModal/index.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Model, ModelEvent, events, openFileExplorer } from '@janhq/core'
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalFooter,
|
||||
ModalClose,
|
||||
Button,
|
||||
Input,
|
||||
Textarea,
|
||||
} from '@janhq/uikit'
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
|
||||
import { Paperclip } from 'lucide-react'
|
||||
|
||||
import useImportModel, {
|
||||
getImportModelStageAtom,
|
||||
setImportModelStageAtom,
|
||||
} from '@/hooks/useImportModel'
|
||||
|
||||
import { toGibibytes } from '@/utils/converter'
|
||||
|
||||
import { openFileTitle } from '@/utils/titleUtils'
|
||||
|
||||
import { janDataFolderPathAtom } from '@/helpers/atoms/AppConfig.atom'
|
||||
import {
|
||||
importingModelsAtom,
|
||||
updateImportingModelAtom,
|
||||
} from '@/helpers/atoms/Model.atom'
|
||||
|
||||
export const editingModelIdAtom = atom<string | undefined>(undefined)
|
||||
|
||||
const EditModelInfoModal: React.FC = () => {
|
||||
const importModelStage = useAtomValue(getImportModelStageAtom)
|
||||
const importingModels = useAtomValue(importingModelsAtom)
|
||||
const setImportModelStage = useSetAtom(setImportModelStageAtom)
|
||||
const [editingModelId, setEditingModelId] = useAtom(editingModelIdAtom)
|
||||
|
||||
const [modelName, setModelName] = useState('')
|
||||
const [modelId, setModelId] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
|
||||
const janDataFolder = useAtomValue(janDataFolderPathAtom)
|
||||
const updateImportingModel = useSetAtom(updateImportingModelAtom)
|
||||
const { updateModelInfo } = useImportModel()
|
||||
|
||||
const editingModel = importingModels.find(
|
||||
(model) => model.importId === editingModelId
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (editingModel && editingModel.modelId != null) {
|
||||
setModelName(editingModel.name)
|
||||
setModelId(editingModel.modelId)
|
||||
setDescription(editingModel.description)
|
||||
setTags(editingModel.tags)
|
||||
}
|
||||
}, [editingModel])
|
||||
|
||||
const onCancelClick = () => {
|
||||
setImportModelStage('IMPORTING_MODEL')
|
||||
setEditingModelId(undefined)
|
||||
}
|
||||
|
||||
const onSaveClick = async () => {
|
||||
if (!editingModel || !editingModel.modelId) return
|
||||
|
||||
const modelInfo: Partial<Model> = {
|
||||
id: editingModel.modelId,
|
||||
name: modelName,
|
||||
description,
|
||||
metadata: {
|
||||
author: 'User',
|
||||
tags,
|
||||
size: 0,
|
||||
},
|
||||
}
|
||||
|
||||
await updateModelInfo(modelInfo)
|
||||
events.emit(ModelEvent.OnModelsUpdate, {})
|
||||
updateImportingModel(editingModel.importId, modelName, description, tags)
|
||||
|
||||
setImportModelStage('IMPORTING_MODEL')
|
||||
setEditingModelId(undefined)
|
||||
}
|
||||
|
||||
const modelFolderPath = useMemo(() => {
|
||||
return `${janDataFolder}/models/${editingModel?.modelId}`
|
||||
}, [janDataFolder, editingModel])
|
||||
|
||||
const onShowInFinderClick = useCallback(() => {
|
||||
openFileExplorer(modelFolderPath)
|
||||
}, [modelFolderPath])
|
||||
|
||||
if (!editingModel) {
|
||||
setImportModelStage('IMPORTING_MODEL')
|
||||
setEditingModelId(undefined)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={importModelStage === 'EDIT_MODEL_INFO'}>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>Edit Model Information</ModalTitle>
|
||||
</ModalHeader>
|
||||
|
||||
<div className="flex flex-row space-x-4 rounded-xl border p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-400">
|
||||
<Paperclip />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<p>{editingModel.name}</p>
|
||||
<div className="flex flex-row">
|
||||
<span className="mr-2 text-sm text-[#71717A]">
|
||||
{toGibibytes(editingModel.size)}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-[#71717A]">
|
||||
Format:{' '}
|
||||
</span>
|
||||
<span className="text-sm font-normal text-[#71717A]">
|
||||
{editingModel.format.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-row items-center space-x-2">
|
||||
<span className="line-clamp-1 text-xs font-normal text-[#71717A]">
|
||||
{modelFolderPath}
|
||||
</span>
|
||||
<Button themes="ghost" onClick={onShowInFinderClick}>
|
||||
{openFileTitle()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-col space-y-4">
|
||||
<div className="flex flex-col">
|
||||
<label className="mb-1">Model Name</label>
|
||||
<Input
|
||||
value={modelName}
|
||||
onChange={(e) => {
|
||||
e.preventDefault()
|
||||
setModelName(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<label className="mb-1">Model ID</label>
|
||||
<Input
|
||||
disabled
|
||||
value={modelId}
|
||||
onChange={(e) => {
|
||||
e.preventDefault()
|
||||
setModelId(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<label className="mb-1">Description</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
e.preventDefault()
|
||||
setDescription(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<label className="mb-1">Tags</label>
|
||||
<Input />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ModalFooter>
|
||||
<div className="flex gap-x-2">
|
||||
<ModalClose asChild onClick={onCancelClick}>
|
||||
<Button themes="ghost">Cancel</Button>
|
||||
</ModalClose>
|
||||
<ModalClose asChild>
|
||||
<Button autoFocus themes="primary" onClick={onSaveClick}>
|
||||
Save
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditModelInfoModal
|
||||
59
web/screens/Settings/ImportInProgressIcon/index.tsx
Normal file
59
web/screens/Settings/ImportInProgressIcon/index.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { CircularProgressbar } from 'react-circular-progressbar'
|
||||
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
type Props = {
|
||||
percentage: number
|
||||
onDeleteModelClick: () => void
|
||||
}
|
||||
|
||||
const ImportInProgressIcon: React.FC<Props> = ({
|
||||
percentage,
|
||||
onDeleteModelClick,
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
const onMouseOver = () => {
|
||||
setIsHovered(true)
|
||||
}
|
||||
|
||||
const onMouseOut = () => {
|
||||
setIsHovered(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div onMouseOver={onMouseOver} onMouseOut={onMouseOut}>
|
||||
{isHovered ? (
|
||||
<DeleteIcon onDeleteModelClick={onDeleteModelClick} />
|
||||
) : (
|
||||
<ProgressIcon percentage={percentage} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ProgressIcon: React.FC<Partial<Props>> = ({ percentage }) => (
|
||||
<div className="h-8 w-8 rounded-full">
|
||||
<CircularProgressbar value={(percentage ?? 0) * 100} />
|
||||
</div>
|
||||
)
|
||||
|
||||
const DeleteIcon: React.FC<Partial<Props>> = React.memo(
|
||||
({ onDeleteModelClick }) => {
|
||||
const onClick = useCallback(() => {
|
||||
onDeleteModelClick?.()
|
||||
}, [onDeleteModelClick])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-gray-100"
|
||||
onClick={onClick}
|
||||
>
|
||||
<X />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default ImportInProgressIcon
|
||||
@ -0,0 +1,29 @@
|
||||
import { ModelImportOption, OptionType } from '@janhq/core'
|
||||
|
||||
type Props = {
|
||||
option: ModelImportOption
|
||||
checked: boolean
|
||||
setSelectedOptionType: (type: OptionType) => void
|
||||
}
|
||||
|
||||
const ImportModelOptionSelection: React.FC<Props> = ({
|
||||
option,
|
||||
checked,
|
||||
setSelectedOptionType,
|
||||
}) => (
|
||||
<div
|
||||
className="flex cursor-pointer flex-row"
|
||||
onClick={() => setSelectedOptionType(option.type)}
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-full border border-[#2563EB]">
|
||||
{checked && <div className="h-2 w-2 rounded-full bg-primary" />}
|
||||
</div>
|
||||
|
||||
<div className="ml-2 flex-1">
|
||||
<p className="mb-2 text-sm font-medium">{option.title}</p>
|
||||
<p className="text-sm font-normal text-[#71717A]">{option.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default ImportModelOptionSelection
|
||||
105
web/screens/Settings/ImportModelOptionModal/index.tsx
Normal file
105
web/screens/Settings/ImportModelOptionModal/index.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
|
||||
import { ModelImportOption } from '@janhq/core'
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
} from '@janhq/uikit'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
|
||||
import useImportModel, {
|
||||
getImportModelStageAtom,
|
||||
setImportModelStageAtom,
|
||||
} from '@/hooks/useImportModel'
|
||||
|
||||
import ImportModelOptionSelection from './ImportModelOptionSelection'
|
||||
|
||||
import { importingModelsAtom } from '@/helpers/atoms/Model.atom'
|
||||
|
||||
const importOptions: ModelImportOption[] = [
|
||||
{
|
||||
type: 'SYMLINK',
|
||||
title: 'Keep Original Files & Symlink',
|
||||
description:
|
||||
'You maintain your model files outside of Jan. Keeping your files where they are, and Jan will create a smart link to them.',
|
||||
},
|
||||
{
|
||||
type: 'MOVE_BINARY_FILE',
|
||||
title: 'Move model binary file',
|
||||
description:
|
||||
'Jan will move your model binary file from your current folder into Jan Data Folder.',
|
||||
},
|
||||
]
|
||||
|
||||
const ImportModelOptionModal: React.FC = () => {
|
||||
const importingModels = useAtomValue(importingModelsAtom)
|
||||
const importStage = useAtomValue(getImportModelStageAtom)
|
||||
const setImportStage = useSetAtom(setImportModelStageAtom)
|
||||
const { importModels } = useImportModel()
|
||||
|
||||
const [importOption, setImportOption] = useState(importOptions[0])
|
||||
const destinationModal = useRef<'NONE' | 'IMPORTING_MODEL'>('NONE')
|
||||
|
||||
const onCancelClick = useCallback(() => {
|
||||
setImportStage('NONE')
|
||||
}, [setImportStage])
|
||||
|
||||
const onContinueClick = useCallback(() => {
|
||||
importModels(importingModels, importOption.type)
|
||||
setImportStage('IMPORTING_MODEL')
|
||||
}, [importingModels, importOption, setImportStage, importModels])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={importStage === 'MODEL_SELECTED'}
|
||||
onOpenChange={() => {
|
||||
if (destinationModal.current === 'NONE') {
|
||||
setImportStage('NONE')
|
||||
} else {
|
||||
onContinueClick()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>How would you like Jan to handle your models?</ModalTitle>
|
||||
</ModalHeader>
|
||||
|
||||
{importOptions.map((option) => (
|
||||
<ImportModelOptionSelection
|
||||
key={option.type}
|
||||
option={option}
|
||||
checked={importOption.type === option.type}
|
||||
setSelectedOptionType={() => setImportOption(option)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<ModalFooter>
|
||||
<div className="flex gap-x-2">
|
||||
<ModalClose asChild onClick={onCancelClick}>
|
||||
<Button themes="ghost">Cancel</Button>
|
||||
</ModalClose>
|
||||
<ModalClose asChild>
|
||||
<Button
|
||||
autoFocus
|
||||
themes="primary"
|
||||
onClick={() => {
|
||||
destinationModal.current = 'IMPORTING_MODEL'
|
||||
}}
|
||||
>
|
||||
Continue Importing
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImportModelOptionModal
|
||||
52
web/screens/Settings/ImportSuccessIcon/index.tsx
Normal file
52
web/screens/Settings/ImportSuccessIcon/index.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
|
||||
import { Check, Pencil } from 'lucide-react'
|
||||
|
||||
type Props = {
|
||||
onEditModelClick: () => void
|
||||
}
|
||||
|
||||
const ImportSuccessIcon: React.FC<Props> = ({ onEditModelClick }) => {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
const onMouseOver = () => {
|
||||
setIsHovered(true)
|
||||
}
|
||||
|
||||
const onMouseOut = () => {
|
||||
setIsHovered(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div onMouseOver={onMouseOver} onMouseOut={onMouseOut}>
|
||||
{isHovered ? (
|
||||
<EditIcon onEditModelClick={onEditModelClick} />
|
||||
) : (
|
||||
<SuccessIcon />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SuccessIcon: React.FC = React.memo(() => (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary">
|
||||
<Check color="#FFF" />
|
||||
</div>
|
||||
))
|
||||
|
||||
const EditIcon: React.FC<Props> = React.memo(({ onEditModelClick }) => {
|
||||
const onClick = useCallback(() => {
|
||||
onEditModelClick()
|
||||
}, [onEditModelClick])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-gray-100"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Pencil />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default ImportSuccessIcon
|
||||
@ -0,0 +1,45 @@
|
||||
import { ImportingModel } from '@janhq/core/.'
|
||||
import { useSetAtom } from 'jotai'
|
||||
|
||||
import { setImportModelStageAtom } from '@/hooks/useImportModel'
|
||||
|
||||
import { toGibibytes } from '@/utils/converter'
|
||||
|
||||
import { editingModelIdAtom } from '../EditModelInfoModal'
|
||||
import ImportInProgressIcon from '../ImportInProgressIcon'
|
||||
import ImportSuccessIcon from '../ImportSuccessIcon'
|
||||
|
||||
type Props = {
|
||||
model: ImportingModel
|
||||
}
|
||||
|
||||
const ImportingModelItem: React.FC<Props> = ({ model }) => {
|
||||
const setImportModelStage = useSetAtom(setImportModelStageAtom)
|
||||
const setEditingModelId = useSetAtom(editingModelIdAtom)
|
||||
const sizeInGb = toGibibytes(model.size)
|
||||
|
||||
const onEditModelInfoClick = () => {
|
||||
setEditingModelId(model.importId)
|
||||
setImportModelStage('EDIT_MODEL_INFO')
|
||||
}
|
||||
|
||||
const onDeleteModelClick = () => {}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-row items-center space-x-3 rounded-lg border px-4 py-3">
|
||||
<p className="line-clamp-1 flex-1">{model.name}</p>
|
||||
<p>{sizeInGb}</p>
|
||||
|
||||
{model.status === 'IMPORTED' || model.status === 'FAILED' ? (
|
||||
<ImportSuccessIcon onEditModelClick={onEditModelInfoClick} />
|
||||
) : (
|
||||
<ImportInProgressIcon
|
||||
percentage={model.percentage ?? 0}
|
||||
onDeleteModelClick={onDeleteModelClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImportingModelItem
|
||||
85
web/screens/Settings/ImportingModelModal/index.tsx
Normal file
85
web/screens/Settings/ImportingModelModal/index.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
import { openFileExplorer } from '@janhq/core'
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
} from '@janhq/uikit'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
|
||||
import {
|
||||
getImportModelStageAtom,
|
||||
setImportModelStageAtom,
|
||||
} from '@/hooks/useImportModel'
|
||||
|
||||
import { openFileTitle } from '@/utils/titleUtils'
|
||||
|
||||
import ImportingModelItem from './ImportingModelItem'
|
||||
|
||||
import { janDataFolderPathAtom } from '@/helpers/atoms/AppConfig.atom'
|
||||
import { importingModelsAtom } from '@/helpers/atoms/Model.atom'
|
||||
|
||||
const ImportingModelModal: React.FC = () => {
|
||||
const importingModels = useAtomValue(importingModelsAtom)
|
||||
const importModelStage = useAtomValue(getImportModelStageAtom)
|
||||
const setImportModelStage = useSetAtom(setImportModelStageAtom)
|
||||
const janDataFolder = useAtomValue(janDataFolderPathAtom)
|
||||
|
||||
const modelFolder = useMemo(() => `${janDataFolder}/models`, [janDataFolder])
|
||||
|
||||
const finishedImportModel = importingModels.filter(
|
||||
(model) => model.status === 'IMPORTED'
|
||||
).length
|
||||
|
||||
const onOpenModelFolderClick = useCallback(() => {
|
||||
openFileExplorer(modelFolder)
|
||||
}, [modelFolder])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={importModelStage === 'IMPORTING_MODEL'}
|
||||
onOpenChange={() => {
|
||||
setImportModelStage('NONE')
|
||||
}}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>
|
||||
Importing model ({finishedImportModel}/{importingModels.length})
|
||||
</ModalTitle>
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<label className="text-xs text-[#71717A]">{modelFolder}</label>
|
||||
<Button
|
||||
themes="ghost"
|
||||
className="text-blue-500"
|
||||
onClick={onOpenModelFolderClick}
|
||||
>
|
||||
{openFileTitle()}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
{importingModels.map((model) => (
|
||||
<ImportingModelItem key={model.importId} model={model} />
|
||||
))}
|
||||
</div>
|
||||
<ModalFooter className="mx-[-16px] mb-[-16px] flex flex-row rounded-b-lg bg-[#F4F4F5] px-2 py-2 ">
|
||||
<AlertCircle size={20} />
|
||||
<p className="text-sm font-semibold text-[#71717A]">
|
||||
Own your model configurations, use at your own risk.
|
||||
Misconfigurations may result in lower quality or unexpected outputs.{' '}
|
||||
</p>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImportingModelModal
|
||||
@ -1,66 +1,145 @@
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
import { Input } from '@janhq/uikit'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
import { ImportingModel } from '@janhq/core'
|
||||
import { Button, Input, ScrollArea } from '@janhq/uikit'
|
||||
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { Plus, SearchIcon, UploadCloudIcon } from 'lucide-react'
|
||||
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { setImportModelStageAtom } from '@/hooks/useImportModel'
|
||||
|
||||
import { getFileInfoFromFile } from '@/utils/file'
|
||||
|
||||
import RowModel from './Row'
|
||||
|
||||
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
|
||||
import {
|
||||
downloadedModelsAtom,
|
||||
importingModelsAtom,
|
||||
} from '@/helpers/atoms/Model.atom'
|
||||
|
||||
const Column = ['Name', 'Model ID', 'Size', 'Version', 'Status', '']
|
||||
|
||||
export default function Models() {
|
||||
const Models: React.FC = () => {
|
||||
const downloadedModels = useAtomValue(downloadedModelsAtom)
|
||||
const setImportModelStage = useSetAtom(setImportModelStageAtom)
|
||||
const setImportingModels = useSetAtom(importingModelsAtom)
|
||||
const [searchValue, setsearchValue] = useState('')
|
||||
|
||||
const filteredDownloadedModels = downloadedModels.filter((x) => {
|
||||
return x.name?.toLowerCase().includes(searchValue.toLowerCase())
|
||||
const filteredDownloadedModels = downloadedModels
|
||||
.filter((x) => x.name?.toLowerCase().includes(searchValue.toLowerCase()))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
const filePathWithSize = getFileInfoFromFile(acceptedFiles)
|
||||
|
||||
const importingModels: ImportingModel[] = filePathWithSize.map(
|
||||
(file) => ({
|
||||
importId: uuidv4(),
|
||||
modelId: undefined,
|
||||
name: file.name,
|
||||
description: '',
|
||||
path: file.path,
|
||||
tags: [],
|
||||
size: file.size,
|
||||
status: 'PREPARING',
|
||||
format: 'gguf',
|
||||
})
|
||||
)
|
||||
if (importingModels.length === 0) return
|
||||
|
||||
setImportingModels(importingModels)
|
||||
setImportModelStage('MODEL_SELECTED')
|
||||
},
|
||||
[setImportModelStage, setImportingModels]
|
||||
)
|
||||
|
||||
const { getRootProps, isDragActive } = useDropzone({
|
||||
noClick: true,
|
||||
multiple: true,
|
||||
onDrop,
|
||||
})
|
||||
|
||||
const onImportModelClick = useCallback(() => {
|
||||
setImportModelStage('SELECTING_MODEL')
|
||||
}, [setImportModelStage])
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border shadow-sm">
|
||||
<div className="px-6 py-5">
|
||||
<div className="relative w-1/3">
|
||||
<SearchIcon
|
||||
size={20}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search"
|
||||
className="pl-8"
|
||||
onChange={(e) => {
|
||||
setsearchValue(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<ScrollArea className="h-full w-full" {...getRootProps()}>
|
||||
{isDragActive && (
|
||||
<div className="absolute z-50 mx-auto h-full w-full bg-background/50 p-8 backdrop-blur-lg">
|
||||
<div
|
||||
className={twMerge(
|
||||
'flex h-full w-full items-center justify-center rounded-lg border border-dashed border-blue-500'
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto w-1/2 text-center">
|
||||
<div className="mx-auto inline-flex h-12 w-12 items-center justify-center rounded-full bg-blue-200">
|
||||
<UploadCloudIcon size={24} className="text-blue-600" />
|
||||
</div>
|
||||
<div className="mt-4 text-blue-600">
|
||||
<h6 className="font-bold">Drop file here</h6>
|
||||
<p className="mt-2">File (GGUF) or folder</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<table className="w-full px-8">
|
||||
)}
|
||||
<div className="m-4 rounded-xl border border-border shadow-sm">
|
||||
<div className="flex flex-row justify-between px-6 py-5">
|
||||
<div className="relative w-1/3">
|
||||
<SearchIcon
|
||||
size={20}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search"
|
||||
className="pl-8"
|
||||
onChange={(e) => {
|
||||
setsearchValue(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
themes={'outline'}
|
||||
className="space-x-2"
|
||||
onClick={onImportModelClick}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
<p>Import Model</p>
|
||||
</Button>
|
||||
</div>
|
||||
<table className="relative w-full px-8">
|
||||
<thead className="w-full border-b border-border bg-secondary">
|
||||
<tr>
|
||||
{Column.map((col, i) => {
|
||||
return (
|
||||
<th
|
||||
key={i}
|
||||
className="px-6 py-2 text-left font-normal last:text-center"
|
||||
>
|
||||
{col}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
{Column.map((col) => (
|
||||
<th
|
||||
key={col}
|
||||
className="px-6 py-2 text-left font-normal last:text-center"
|
||||
>
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredDownloadedModels
|
||||
? filteredDownloadedModels.map((x, i) => {
|
||||
return <RowModel key={i} data={x} />
|
||||
})
|
||||
? filteredDownloadedModels.map((x) => (
|
||||
<RowModel key={x.id} data={x} />
|
||||
))
|
||||
: null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
export default Models
|
||||
|
||||
147
web/screens/Settings/SelectingModelModal/index.tsx
Normal file
147
web/screens/Settings/SelectingModelModal/index.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
|
||||
import { ImportingModel, fs } from '@janhq/core'
|
||||
import { Modal, ModalContent, ModalHeader, ModalTitle } from '@janhq/uikit'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
|
||||
import { UploadCloudIcon } from 'lucide-react'
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import {
|
||||
getImportModelStageAtom,
|
||||
setImportModelStageAtom,
|
||||
} from '@/hooks/useImportModel'
|
||||
|
||||
import {
|
||||
FilePathWithSize,
|
||||
getFileInfoFromFile,
|
||||
getFileNameFromPath,
|
||||
} from '@/utils/file'
|
||||
|
||||
import { importingModelsAtom } from '@/helpers/atoms/Model.atom'
|
||||
|
||||
const SelectingModelModal: React.FC = () => {
|
||||
const setImportModelStage = useSetAtom(setImportModelStageAtom)
|
||||
const importModelStage = useAtomValue(getImportModelStageAtom)
|
||||
const setImportingModels = useSetAtom(importingModelsAtom)
|
||||
|
||||
const onSelectFileClick = useCallback(async () => {
|
||||
const filePaths = await window.core?.api?.selectModelFiles()
|
||||
if (!filePaths || filePaths.length === 0) return
|
||||
|
||||
const sanitizedFilePaths: FilePathWithSize[] = []
|
||||
for (const filePath of filePaths) {
|
||||
const fileStats = await fs.fileStat(filePath, true)
|
||||
if (!fileStats || fileStats.isDirectory) continue
|
||||
|
||||
const fileName = getFileNameFromPath(filePath)
|
||||
sanitizedFilePaths.push({
|
||||
path: filePath,
|
||||
name: fileName,
|
||||
size: fileStats.size,
|
||||
})
|
||||
}
|
||||
|
||||
const importingModels: ImportingModel[] = sanitizedFilePaths.map(
|
||||
({ path, name, size }: FilePathWithSize) => {
|
||||
return {
|
||||
importId: uuidv4(),
|
||||
modelId: undefined,
|
||||
name: name,
|
||||
description: '',
|
||||
path: path,
|
||||
tags: [],
|
||||
size: size,
|
||||
status: 'PREPARING',
|
||||
format: 'gguf',
|
||||
}
|
||||
}
|
||||
)
|
||||
if (importingModels.length === 0) return
|
||||
|
||||
setImportingModels(importingModels)
|
||||
setImportModelStage('MODEL_SELECTED')
|
||||
}, [setImportingModels, setImportModelStage])
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
const filePathWithSize = getFileInfoFromFile(acceptedFiles)
|
||||
|
||||
const importingModels: ImportingModel[] = filePathWithSize.map(
|
||||
(file) => ({
|
||||
importId: uuidv4(),
|
||||
modelId: undefined,
|
||||
name: file.name,
|
||||
description: '',
|
||||
path: file.path,
|
||||
tags: [],
|
||||
size: file.size,
|
||||
status: 'PREPARING',
|
||||
format: 'gguf',
|
||||
})
|
||||
)
|
||||
if (importingModels.length === 0) return
|
||||
|
||||
setImportingModels(importingModels)
|
||||
setImportModelStage('MODEL_SELECTED')
|
||||
},
|
||||
[setImportModelStage, setImportingModels]
|
||||
)
|
||||
|
||||
const { isDragActive, getRootProps } = useDropzone({
|
||||
noClick: true,
|
||||
multiple: true,
|
||||
onDrop,
|
||||
})
|
||||
|
||||
const borderColor = isDragActive ? 'border-primary' : 'border-[#F4F4F5]'
|
||||
const textColor = isDragActive ? 'text-primary' : 'text-[#71717A]'
|
||||
const dragAndDropBgColor = isDragActive ? 'bg-[#EFF6FF]' : 'bg-white'
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={importModelStage === 'SELECTING_MODEL'}
|
||||
onOpenChange={() => {
|
||||
setImportModelStage('NONE')
|
||||
}}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>Import Model</ModalTitle>
|
||||
|
||||
<p className="text-sm font-medium text-[#71717A]">
|
||||
Import any model file (GGUF) or folder. Your imported model will be
|
||||
private to you.
|
||||
</p>
|
||||
</ModalHeader>
|
||||
|
||||
<div
|
||||
className={`flex h-[172px] w-full items-center justify-center rounded-md border ${borderColor} ${dragAndDropBgColor}`}
|
||||
{...getRootProps()}
|
||||
onClick={onSelectFileClick}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="mx-auto inline-flex h-12 w-12 items-center justify-center rounded-full bg-blue-200">
|
||||
<UploadCloudIcon size={24} className="text-blue-600" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<span className="text-sm font-bold text-primary">
|
||||
Click to upload
|
||||
</span>
|
||||
<span className={`text-sm ${textColor} font-medium`}>
|
||||
{' '}
|
||||
or drag and drop
|
||||
</span>
|
||||
</div>
|
||||
<span className={`text-xs font-medium ${textColor}`}>(GGUF)</span>
|
||||
</div>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectingModelModal
|
||||
55
web/screens/Settings/SettingMenu/index.tsx
Normal file
55
web/screens/Settings/SettingMenu/index.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { ScrollArea } from '@janhq/uikit'
|
||||
import { motion as m } from 'framer-motion'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
type Props = {
|
||||
activeMenu: string
|
||||
onMenuClick: (menu: string) => void
|
||||
}
|
||||
|
||||
const SettingMenu: React.FC<Props> = ({ activeMenu, onMenuClick }) => {
|
||||
const [menus, setMenus] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setMenus([
|
||||
'My Models',
|
||||
'My Settings',
|
||||
'Advanced Settings',
|
||||
...(window.electronAPI ? ['Extensions'] : []),
|
||||
])
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-64 flex-shrink-0 flex-col overflow-y-auto border-r border-border">
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="flex-shrink-0 px-6 py-4 font-medium">
|
||||
{menus.map((menu) => {
|
||||
const isActive = activeMenu === menu
|
||||
return (
|
||||
<div
|
||||
key={menu}
|
||||
className="relative my-0.5 block cursor-pointer py-1.5"
|
||||
onClick={() => onMenuClick(menu)}
|
||||
>
|
||||
<span className={twMerge(isActive && 'relative z-10')}>
|
||||
{menu}
|
||||
</span>
|
||||
|
||||
{isActive && (
|
||||
<m.div
|
||||
className="absolute inset-0 -left-3 h-full w-[calc(100%+24px)] rounded-md bg-primary/50"
|
||||
layoutId="active-static-menu"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingMenu
|
||||
@ -1,12 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { ScrollArea } from '@janhq/uikit'
|
||||
import { motion as m } from 'framer-motion'
|
||||
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import Advanced from '@/screens/Settings/Advanced'
|
||||
import AppearanceOptions from '@/screens/Settings/Appearance'
|
||||
import ExtensionCatalog from '@/screens/Settings/CoreExtensions'
|
||||
@ -14,37 +7,26 @@ import ExtensionCatalog from '@/screens/Settings/CoreExtensions'
|
||||
import Models from '@/screens/Settings/Models'
|
||||
|
||||
import { SUCCESS_SET_NEW_DESTINATION } from './Advanced/DataFolder'
|
||||
import SettingMenu from './SettingMenu'
|
||||
|
||||
const SettingsScreen = () => {
|
||||
const [activeStaticMenu, setActiveStaticMenu] = useState('My Models')
|
||||
const [menus, setMenus] = useState<any[]>([])
|
||||
const handleShowOptions = (menu: string) => {
|
||||
switch (menu) {
|
||||
case 'Extensions':
|
||||
return <ExtensionCatalog />
|
||||
|
||||
useEffect(() => {
|
||||
const menu = ['My Models', 'My Settings', 'Advanced Settings']
|
||||
case 'My Settings':
|
||||
return <AppearanceOptions />
|
||||
|
||||
if (typeof window !== 'undefined' && window.electronAPI) {
|
||||
menu.push('Extensions')
|
||||
}
|
||||
setMenus(menu)
|
||||
}, [])
|
||||
case 'Advanced Settings':
|
||||
return <Advanced />
|
||||
|
||||
const [activePreferenceExtension, setActivePreferenceExtension] = useState('')
|
||||
|
||||
const handleShowOptions = (menu: string) => {
|
||||
switch (menu) {
|
||||
case 'Extensions':
|
||||
return <ExtensionCatalog />
|
||||
|
||||
case 'My Settings':
|
||||
return <AppearanceOptions />
|
||||
|
||||
case 'Advanced Settings':
|
||||
return <Advanced />
|
||||
|
||||
case 'My Models':
|
||||
return <Models />
|
||||
}
|
||||
case 'My Models':
|
||||
return <Models />
|
||||
}
|
||||
}
|
||||
|
||||
const SettingsScreen: React.FC = () => {
|
||||
const [activeStaticMenu, setActiveStaticMenu] = useState('My Models')
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem(SUCCESS_SET_NEW_DESTINATION) === 'true') {
|
||||
@ -58,48 +40,12 @@ const SettingsScreen = () => {
|
||||
className="flex h-full bg-background"
|
||||
data-testid="testid-setting-description"
|
||||
>
|
||||
<div className="flex h-full w-64 flex-shrink-0 flex-col overflow-y-auto border-r border-border">
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="font-medium">
|
||||
{menus.map((menu, i) => {
|
||||
const isActive = activeStaticMenu === menu
|
||||
return (
|
||||
<div key={i} className="relative my-0.5 block py-1.5">
|
||||
<div
|
||||
onClick={() => {
|
||||
setActiveStaticMenu(menu)
|
||||
setActivePreferenceExtension('')
|
||||
}}
|
||||
className="block w-full cursor-pointer"
|
||||
>
|
||||
<span className={twMerge(isActive && 'relative z-10')}>
|
||||
{menu}
|
||||
</span>
|
||||
</div>
|
||||
{isActive && (
|
||||
<m.div
|
||||
className="absolute inset-0 -left-3 h-full w-[calc(100%+24px)] rounded-md bg-primary/50"
|
||||
layoutId="active-static-menu"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<SettingMenu
|
||||
activeMenu={activeStaticMenu}
|
||||
onMenuClick={setActiveStaticMenu}
|
||||
/>
|
||||
|
||||
<div className="h-full w-full bg-background">
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="p-4">
|
||||
{handleShowOptions(activeStaticMenu || activePreferenceExtension)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
{handleShowOptions(activeStaticMenu)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
34
web/utils/file.ts
Normal file
34
web/utils/file.ts
Normal file
@ -0,0 +1,34 @@
|
||||
export type FilePathWithSize = {
|
||||
path: string
|
||||
name: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface FileWithPath extends File {
|
||||
path?: string
|
||||
}
|
||||
|
||||
export const getFileNameFromPath = (filePath: string): string => {
|
||||
let fileName = filePath.split('/').pop() ?? ''
|
||||
if (fileName.split('.').length > 1) {
|
||||
fileName = fileName.split('.').slice(0, -1).join('.')
|
||||
}
|
||||
|
||||
return fileName
|
||||
}
|
||||
|
||||
export const getFileInfoFromFile = (
|
||||
files: FileWithPath[]
|
||||
): FilePathWithSize[] => {
|
||||
const result: FilePathWithSize[] = []
|
||||
for (const file of files) {
|
||||
if (file.path && file.path.length > 0) {
|
||||
result.push({
|
||||
path: file.path,
|
||||
name: getFileNameFromPath(file.path),
|
||||
size: file.size,
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user