feat: add import model (#2104)

Signed-off-by: James <james@jan.ai>
Co-authored-by: James <james@jan.ai>
This commit is contained in:
NamH 2024-02-26 16:15:10 +07:00 committed by GitHub
parent 92edd85a12
commit 773963a456
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 2221 additions and 627 deletions

View File

@ -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),
]

View File

@ -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>
}

View File

@ -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,

View File

@ -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
})

View File

@ -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()
}
})
})
}
}

View File

@ -1,3 +1,4 @@
export * from './modelEntity'
export * from './modelInterface'
export * from './modelEvent'
export * from './modelImport'

View 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
}

View File

@ -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
}
})
}

View File

@ -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
}
}

View File

@ -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}`)
})
}
}

View File

@ -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'
}

View File

@ -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
)
}
}

View File

@ -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)
}
}
}

View 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;
}

View File

@ -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;

View File

@ -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
View File

@ -0,0 +1,8 @@
{
"semi": false,
"singleQuote": true,
"quoteProps": "consistent",
"trailingComma": "es5",
"endOfLine": "auto",
"plugins": ["prettier-plugin-tailwindcss"]
}

View File

@ -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) {

View File

@ -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)
}}
>

View File

@ -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(

View File

@ -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

View File

@ -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>

View File

@ -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">

View File

@ -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">

View File

@ -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)

View File

@ -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)

View File

@ -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
)

View File

@ -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)
}}
>

View File

@ -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>
)
}

View 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

View File

@ -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>

View File

@ -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>
}

View File

@ -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

View File

@ -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>
}

View 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

View File

@ -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

View File

@ -0,0 +1,5 @@
import { atom } from 'jotai'
import { MainViewState } from '@/constants/screens'
export const mainViewStateAtom = atom<MainViewState>(MainViewState.Thread)

View File

@ -0,0 +1,3 @@
import { atom } from 'jotai'
export const janDataFolderPathAtom = atom('')

View File

@ -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)
}
)

View File

@ -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)

View 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

View File

@ -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 }
}

View File

@ -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,

View File

@ -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",

View File

@ -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 (

View File

@ -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,

View File

@ -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)

View File

@ -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>

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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">

View 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)

View File

@ -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>
)
}

View 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

View 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

View File

@ -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

View 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

View 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

View File

@ -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

View 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

View File

@ -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

View 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

View 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

View File

@ -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
View 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
}