From ad24cfad349eec866672582aa914109410dde56c Mon Sep 17 00:00:00 2001 From: James Date: Sun, 24 Mar 2024 12:03:28 +0700 Subject: [PATCH] fix: file explore on windows show empty when importing model Signed-off-by: James --- core/src/types/api/index.ts | 2 +- core/src/types/miscellaneous/index.ts | 3 +- core/src/types/miscellaneous/selectFiles.ts | 30 ++++++ electron/handlers/native.ts | 52 +++++++---- web/containers/Layout/index.tsx | 2 + web/hooks/useImportModel.ts | 86 +++++++++++++++++- .../ChooseWhatToImportModal/index.tsx | 65 +++++++++++++ .../Settings/ImportingModelModal/index.tsx | 4 +- .../Settings/SelectingModelModal/index.tsx | 91 ++++--------------- 9 files changed, 236 insertions(+), 99 deletions(-) create mode 100644 core/src/types/miscellaneous/selectFiles.ts create mode 100644 web/screens/Settings/ChooseWhatToImportModal/index.tsx diff --git a/core/src/types/api/index.ts b/core/src/types/api/index.ts index 91d6ae755a..d95d0474e1 100644 --- a/core/src/types/api/index.ts +++ b/core/src/types/api/index.ts @@ -7,7 +7,7 @@ export enum NativeRoute { openAppDirectory = 'openAppDirectory', openFileExplore = 'openFileExplorer', selectDirectory = 'selectDirectory', - selectModelFiles = 'selectModelFiles', + selectFiles = 'selectFiles', relaunch = 'relaunch', hideQuickAskWindow = 'hideQuickAskWindow', diff --git a/core/src/types/miscellaneous/index.ts b/core/src/types/miscellaneous/index.ts index b4ef68ab61..2693ffd8b1 100644 --- a/core/src/types/miscellaneous/index.ts +++ b/core/src/types/miscellaneous/index.ts @@ -2,4 +2,5 @@ export * from './systemResourceInfo' export * from './promptTemplate' export * from './appUpdate' export * from './fileDownloadRequest' -export * from './networkConfig' \ No newline at end of file +export * from './networkConfig' +export * from './selectFiles' diff --git a/core/src/types/miscellaneous/selectFiles.ts b/core/src/types/miscellaneous/selectFiles.ts new file mode 100644 index 0000000000..3120be24e3 --- /dev/null +++ b/core/src/types/miscellaneous/selectFiles.ts @@ -0,0 +1,30 @@ +export type SelectFileOption = { + /** + * The title of the dialog. + */ + title?: string + /** + * Whether the dialog allows multiple selection. + */ + allowMultiple?: boolean + + buttonLabel?: string + + selectDirectory?: boolean + + props?: SelectFileProp[] +} + +export const SelectFilePropTuple = [ + 'openFile', + 'openDirectory', + 'multiSelections', + 'showHiddenFiles', + 'createDirectory', + 'promptToCreate', + 'noResolveAliases', + 'treatPackageAsDirectory', + 'dontAddToRecent', +] as const + +export type SelectFileProp = (typeof SelectFilePropTuple)[number] diff --git a/electron/handlers/native.ts b/electron/handlers/native.ts index 06d9d2a6ad..34bfeffa3d 100644 --- a/electron/handlers/native.ts +++ b/electron/handlers/native.ts @@ -6,8 +6,11 @@ import { getJanDataFolderPath, getJanExtensionsPath, init, - AppEvent, NativeRoute, + AppEvent, + NativeRoute, + SelectFileProp, } from '@janhq/core/node' +import { SelectFileOption } from '@janhq/core/.' export function handleAppIPCs() { /** @@ -84,23 +87,38 @@ export function handleAppIPCs() { } }) - ipcMain.handle(NativeRoute.selectModelFiles, async () => { - const mainWindow = windowManager.mainWindow - if (!mainWindow) { - console.error('No main window found') - return - } - const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, { - title: 'Select model files', - buttonLabel: 'Select', - properties: ['openFile', 'openDirectory', 'multiSelections'], - }) - if (canceled) { - return - } + ipcMain.handle( + NativeRoute.selectFiles, + async (_event, option?: SelectFileOption) => { + const mainWindow = windowManager.mainWindow + if (!mainWindow) { + console.error('No main window found') + return + } - return filePaths - }) + const title = option?.title ?? 'Select files' + const buttonLabel = option?.buttonLabel ?? 'Select' + const props: SelectFileProp[] = ['openFile'] + + if (option?.allowMultiple) { + props.push('multiSelections') + } + + if (option?.selectDirectory) { + props.push('openDirectory') + } + console.debug(`Select files with props: ${props}`) + const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, { + title, + buttonLabel, + properties: props, + }) + + if (canceled) return + + return filePaths + } + ) ipcMain.handle( NativeRoute.hideQuickAskWindow, diff --git a/web/containers/Layout/index.tsx b/web/containers/Layout/index.tsx index fb08bc6acd..c87b6cacc9 100644 --- a/web/containers/Layout/index.tsx +++ b/web/containers/Layout/index.tsx @@ -17,6 +17,7 @@ import { getImportModelStageAtom } from '@/hooks/useImportModel' import { SUCCESS_SET_NEW_DESTINATION } from '@/screens/Settings/Advanced/DataFolder' import CancelModelImportModal from '@/screens/Settings/CancelModelImportModal' +import ChooseWhatToImportModal from '@/screens/Settings/ChooseWhatToImportModal' import EditModelInfoModal from '@/screens/Settings/EditModelInfoModal' import ImportModelOptionModal from '@/screens/Settings/ImportModelOptionModal' import ImportingModelModal from '@/screens/Settings/ImportingModelModal' @@ -70,6 +71,7 @@ const BaseLayout = (props: PropsWithChildren) => { {importModelStage === 'IMPORTING_MODEL' && } {importModelStage === 'EDIT_MODEL_INFO' && } {importModelStage === 'CONFIRM_CANCEL' && } + ) diff --git a/web/hooks/useImportModel.ts b/web/hooks/useImportModel.ts index d4b6f2919b..170f03b5ea 100644 --- a/web/hooks/useImportModel.ts +++ b/web/hooks/useImportModel.ts @@ -6,15 +6,26 @@ import { Model, ModelExtension, OptionType, + baseName, + fs, + joinPath, } from '@janhq/core' -import { atom } from 'jotai' +import { atom, useSetAtom } from 'jotai' + +import { v4 as uuidv4 } from 'uuid' + +import { snackbar } from '@/containers/Toast' + +import { FilePathWithSize } from '@/utils/file' import { extensionManager } from '@/extension' +import { importingModelsAtom } from '@/helpers/atoms/Model.atom' export type ImportModelStage = | 'NONE' | 'SELECTING_MODEL' + | 'CHOOSE_WHAT_TO_IMPORT' | 'MODEL_SELECTED' | 'IMPORTING_MODEL' | 'EDIT_MODEL_INFO' @@ -38,6 +49,9 @@ export type ModelUpdate = { } const useImportModel = () => { + const setImportModelStage = useSetAtom(setImportModelStageAtom) + const setImportingModels = useSetAtom(importingModelsAtom) + const importModels = useCallback( (models: ImportingModel[], optionType: OptionType) => localImportModels(models, optionType), @@ -49,7 +63,75 @@ const useImportModel = () => { [] ) - return { importModels, updateModelInfo } + const sanitizeFilePaths = useCallback( + async (filePaths: string[]) => { + if (!filePaths || filePaths.length === 0) return + + const sanitizedFilePaths: FilePathWithSize[] = [] + for (const filePath of filePaths) { + const fileStats = await fs.fileStat(filePath, true) + if (!fileStats) continue + + if (!fileStats.isDirectory) { + const fileName = await baseName(filePath) + sanitizedFilePaths.push({ + path: filePath, + name: fileName, + size: fileStats.size, + }) + } else { + // allowing only one level of directory + const files = await fs.readdirSync(filePath) + + for (const file of files) { + const fullPath = await joinPath([filePath, file]) + const fileStats = await fs.fileStat(fullPath, true) + if (!fileStats || fileStats.isDirectory) continue + + sanitizedFilePaths.push({ + path: fullPath, + name: file, + size: fileStats.size, + }) + } + } + } + + const unsupportedFiles = sanitizedFilePaths.filter( + (file) => !file.path.endsWith('.gguf') + ) + const supportedFiles = sanitizedFilePaths.filter((file) => + file.path.endsWith('.gguf') + ) + + const importingModels: ImportingModel[] = supportedFiles.map( + ({ path, name, size }: FilePathWithSize) => ({ + importId: uuidv4(), + modelId: undefined, + name: name.replace('.gguf', ''), + description: '', + path: path, + tags: [], + size: size, + status: 'PREPARING', + format: 'gguf', + }) + ) + if (unsupportedFiles.length > 0) { + snackbar({ + description: `Only files with .gguf extension can be imported.`, + type: 'error', + }) + } + if (importingModels.length === 0) return + + setImportingModels(importingModels) + setImportModelStage('MODEL_SELECTED') + }, + [setImportModelStage, setImportingModels] + ) + + return { importModels, updateModelInfo, sanitizeFilePaths } } const localImportModels = async ( diff --git a/web/screens/Settings/ChooseWhatToImportModal/index.tsx b/web/screens/Settings/ChooseWhatToImportModal/index.tsx new file mode 100644 index 0000000000..8aa4169920 --- /dev/null +++ b/web/screens/Settings/ChooseWhatToImportModal/index.tsx @@ -0,0 +1,65 @@ +import { useCallback } from 'react' + +import { SelectFileOption } from '@janhq/core' +import { + Button, + Modal, + ModalContent, + ModalHeader, + ModalTitle, +} from '@janhq/uikit' +import { useSetAtom, useAtomValue } from 'jotai' + +import useImportModel, { + setImportModelStageAtom, + getImportModelStageAtom, +} from '@/hooks/useImportModel' + +const ChooseWhatToImportModal: React.FC = () => { + const setImportModelStage = useSetAtom(setImportModelStageAtom) + const importModelStage = useAtomValue(getImportModelStageAtom) + const { sanitizeFilePaths } = useImportModel() + + const onImportFileClick = useCallback(async () => { + const options: SelectFileOption = { + title: 'Select model files', + buttonLabel: 'Select', + allowMultiple: true, + } + const filePaths = await window.core?.api?.selectFiles(options) + if (!filePaths || filePaths.length === 0) return + sanitizeFilePaths(filePaths) + }, [sanitizeFilePaths]) + + const onImportFolderClick = useCallback(async () => { + const options: SelectFileOption = { + title: 'Select model folders', + buttonLabel: 'Select', + allowMultiple: true, + selectDirectory: true, + } + const filePaths = await window.core?.api?.selectFiles(options) + if (!filePaths || filePaths.length === 0) return + sanitizeFilePaths(filePaths) + }, [sanitizeFilePaths]) + + return ( + setImportModelStage('SELECTING_MODEL')} + > + + + Choose what to import + + +
+ + +
+
+
+ ) +} + +export default ChooseWhatToImportModal diff --git a/web/screens/Settings/ImportingModelModal/index.tsx b/web/screens/Settings/ImportingModelModal/index.tsx index f621c2fb74..3bf9e4de27 100644 --- a/web/screens/Settings/ImportingModelModal/index.tsx +++ b/web/screens/Settings/ImportingModelModal/index.tsx @@ -52,9 +52,7 @@ const ImportingModelModal: React.FC = () => { return ( { - setImportModelStage('NONE') - }} + onOpenChange={() => setImportModelStage('NONE')} > diff --git a/web/screens/Settings/SelectingModelModal/index.tsx b/web/screens/Settings/SelectingModelModal/index.tsx index 7579e0c3c1..6bf28cd008 100644 --- a/web/screens/Settings/SelectingModelModal/index.tsx +++ b/web/screens/Settings/SelectingModelModal/index.tsx @@ -1,99 +1,40 @@ import { useCallback } from 'react' import { useDropzone } from 'react-dropzone' -import { ImportingModel, baseName, fs, joinPath } from '@janhq/core' +import { SelectFileOption, systemInformation } 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 { snackbar } from '@/containers/Toast' - import useDropModelBinaries from '@/hooks/useDropModelBinaries' -import { +import useImportModel, { getImportModelStageAtom, setImportModelStageAtom, } from '@/hooks/useImportModel' -import { FilePathWithSize } 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 { onDropModels } = useDropModelBinaries() + const { sanitizeFilePaths } = useImportModel() 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) continue - - if (!fileStats.isDirectory) { - const fileName = await baseName(filePath) - sanitizedFilePaths.push({ - path: filePath, - name: fileName, - size: fileStats.size, - }) - } else { - // allowing only one level of directory - const files = await fs.readdirSync(filePath) - - for (const file of files) { - const fullPath = await joinPath([filePath, file]) - const fileStats = await fs.fileStat(fullPath, true) - if (!fileStats || fileStats.isDirectory) continue - - sanitizedFilePaths.push({ - path: fullPath, - name: file, - size: fileStats.size, - }) - } - } + const platform = (await systemInformation()).osInfo?.platform + if (platform === 'win32') { + setImportModelStage('CHOOSE_WHAT_TO_IMPORT') + return } - - const unsupportedFiles = sanitizedFilePaths.filter( - (file) => !file.path.endsWith('.gguf') - ) - const supportedFiles = sanitizedFilePaths.filter((file) => - file.path.endsWith('.gguf') - ) - - const importingModels: ImportingModel[] = supportedFiles.map( - ({ path, name, size }: FilePathWithSize) => { - return { - importId: uuidv4(), - modelId: undefined, - name: name.replace('.gguf', ''), - description: '', - path: path, - tags: [], - size: size, - status: 'PREPARING', - format: 'gguf', - } - } - ) - if (unsupportedFiles.length > 0) { - snackbar({ - description: `Only files with .gguf extension can be imported.`, - type: 'error', - }) + const options: SelectFileOption = { + title: 'Select model folders', + buttonLabel: 'Select', + allowMultiple: true, + selectDirectory: true, } - if (importingModels.length === 0) return - - setImportingModels(importingModels) - setImportModelStage('MODEL_SELECTED') - }, [setImportingModels, setImportModelStage]) + const filePaths = await window.core?.api?.selectFiles(options) + if (!filePaths || filePaths.length === 0) return + sanitizeFilePaths(filePaths) + }, [sanitizeFilePaths, setImportModelStage]) const { isDragActive, getRootProps } = useDropzone({ noClick: true,