diff --git a/web/containers/Layout/RibbonPanel/index.tsx b/web/containers/Layout/RibbonPanel/index.tsx index b9b1434ae4..41ceea8e32 100644 --- a/web/containers/Layout/RibbonPanel/index.tsx +++ b/web/containers/Layout/RibbonPanel/index.tsx @@ -12,9 +12,12 @@ import { twMerge } from 'tailwind-merge' import { MainViewState } from '@/constants/screens' +import { localEngines } from '@/utils/modelEngine' + import { mainViewStateAtom, showLeftPanelAtom } from '@/helpers/atoms/App.atom' import { editMessageAtom } from '@/helpers/atoms/ChatMessage.atom' import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom' +import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom' import { reduceTransparentAtom, selectedSettingAtom, @@ -28,6 +31,7 @@ export default function RibbonPanel() { const matches = useMediaQuery('(max-width: 880px)') const reduceTransparent = useAtomValue(reduceTransparentAtom) const setSelectedSetting = useSetAtom(selectedSettingAtom) + const downloadedModels = useAtomValue(downloadedModelsAtom) const onMenuClick = (state: MainViewState) => { if (mainViewState === state) return @@ -37,6 +41,10 @@ export default function RibbonPanel() { setEditMessage('') } + const isDownloadALocalModel = downloadedModels.some((x) => + localEngines.includes(x.engine) + ) + const RibbonNavMenus = [ { name: 'Thread', @@ -77,7 +85,10 @@ export default function RibbonPanel() { 'border-none', !showLeftPanel && !reduceTransparent && 'border-none', matches && !reduceTransparent && 'border-none', - reduceTransparent && ' bg-[hsla(var(--ribbon-panel-bg))]' + reduceTransparent && ' bg-[hsla(var(--ribbon-panel-bg))]', + mainViewState === MainViewState.Thread && + !isDownloadALocalModel && + 'border-none' )} > {RibbonNavMenus.filter((menu) => !!menu).map((menu, i) => { diff --git a/web/screens/Thread/ThreadCenterPanel/ChatBody/EmptyModel/index.tsx b/web/screens/Thread/ThreadCenterPanel/ChatBody/EmptyModel/index.tsx index 77913c991b..2b179e7447 100644 --- a/web/screens/Thread/ThreadCenterPanel/ChatBody/EmptyModel/index.tsx +++ b/web/screens/Thread/ThreadCenterPanel/ChatBody/EmptyModel/index.tsx @@ -1,32 +1,317 @@ -import { memo } from 'react' +import React, { Fragment, useState } from 'react' -import { Button } from '@janhq/joi' -import { useSetAtom } from 'jotai' +import Image from 'next/image' + +import { InferenceEngine } from '@janhq/core/.' +import { Button, Input, Progress, ScrollArea } from '@janhq/joi' + +import { useAtomValue, useSetAtom } from 'jotai' +import { SearchIcon, DownloadCloudIcon } from 'lucide-react' + +import { twMerge } from 'tailwind-merge' import LogoMark from '@/containers/Brand/Logo/Mark' +import CenterPanelContainer from '@/containers/CenterPanelContainer' + +import ProgressCircle from '@/containers/Loader/ProgressCircle' + +import ModelLabel from '@/containers/ModelLabel' import { MainViewState } from '@/constants/screens' +import useDownloadModel from '@/hooks/useDownloadModel' + +import { modelDownloadStateAtom } from '@/hooks/useDownloadState' + +import { formatDownloadPercentage, toGibibytes } from '@/utils/converter' +import { + getLogoEngine, + getTitleByEngine, + localEngines, +} from '@/utils/modelEngine' + import { mainViewStateAtom } from '@/helpers/atoms/App.atom' +import { + configuredModelsAtom, + getDownloadingModelAtom, +} from '@/helpers/atoms/Model.atom' +import { selectedSettingAtom } from '@/helpers/atoms/Setting.atom' + +type Props = { + extensionHasSettings: { + name?: string + setting: string + apiKey: string + provider: string + }[] +} + +const OnDeviceStarterScreen = ({ extensionHasSettings }: Props) => { + const [searchValue, setSearchValue] = useState('') + const downloadingModels = useAtomValue(getDownloadingModelAtom) + const { downloadModel } = useDownloadModel() + const downloadStates = useAtomValue(modelDownloadStateAtom) + const setSelectedSetting = useSetAtom(selectedSettingAtom) -const EmptyModel = () => { + const configuredModels = useAtomValue(configuredModelsAtom) const setMainViewState = useSetAtom(mainViewStateAtom) + const featuredModel = configuredModels.filter((x) => + x.metadata.tags.includes('Featured') + ) + + const remoteModel = configuredModels.filter( + (x) => !localEngines.includes(x.engine) + ) + + const filteredModels = configuredModels.filter((model) => { + return ( + localEngines.includes(model.engine) && + model.name.toLowerCase().includes(searchValue.toLowerCase()) + ) + }) + + const remoteModelEngine = remoteModel.map((x) => x.engine) + const groupByEngine = remoteModelEngine.filter(function (item, index) { + if (remoteModelEngine.indexOf(item) === index) return item + }) + + const itemsPerRow = 5 + + const getRows = (array: string[], itemsPerRow: number) => { + const rows = [] + for (let i = 0; i < array.length; i += itemsPerRow) { + rows.push(array.slice(i, i + itemsPerRow)) + } + return rows + } + + const rows = getRows(groupByEngine, itemsPerRow) + + const [visibleRows, setVisibleRows] = useState(1) + return ( -
- -

Welcome!

-

- You need to download your first model -

- -
+ + +
+
+ +

Select a model to start

+
+ +
+ setSearchValue(e.target.value)} + placeholder="Search..." + prefixIcon={} + /> +
+ {!filteredModels.length ? ( +
+

+ No Result Found +

+
+ ) : ( + filteredModels.map((model) => { + const isDownloading = downloadingModels.some( + (md) => md.id === model.id + ) + return ( +
+
+

+ {model.name} +

+ +
+
+ + {toGibibytes(model.metadata.size)} + + {!isDownloading ? ( + downloadModel(model)} + /> + ) : ( + Object.values(downloadStates) + .filter((x) => x.modelId === model.id) + .map((item) => ( + + )) + )} +
+
+ ) + }) + )} +
+
+
+

+ On-device Models +

+

{ + setMainViewState(MainViewState.Hub) + }} + > + See All +

+
+ + {featuredModel.slice(0, 2).map((featModel) => { + const isDownloading = downloadingModels.some( + (md) => md.id === featModel.id + ) + return ( +
+
+
{featModel.name}
+

+ {featModel.metadata.author} +

+
+ + {isDownloading ? ( +
+ {Object.values(downloadStates).map((item, i) => ( +
+ +
+
+ + {formatDownloadPercentage(item?.percent)} + +
+
+
+ ))} +
+ ) : ( + + )} +
+ ) + })} + +
+

+ Cloud Models +

+
+ +
+ {rows.slice(0, visibleRows).map((row, rowIndex) => { + return ( +
+ {row.map((remoteEngine) => { + const engineLogo = getLogoEngine( + remoteEngine as InferenceEngine + ) + + return ( +
{ + setMainViewState(MainViewState.Settings) + setSelectedSetting( + extensionHasSettings.find((x) => + x.name?.toLowerCase().includes(remoteEngine) + )?.setting as string + ) + }} + > + {engineLogo && ( + Engine logo + )} + +

+ {getTitleByEngine( + remoteEngine as InferenceEngine + )} +

+
+ ) + })} +
+ ) + })} +
+ {visibleRows < rows.length && ( + + )} +
+
+
+
+
+
) } -export default memo(EmptyModel) +export default OnDeviceStarterScreen diff --git a/web/screens/Thread/ThreadCenterPanel/ChatBody/index.tsx b/web/screens/Thread/ThreadCenterPanel/ChatBody/index.tsx index 5b5218bb9a..6b3f4150a4 100644 --- a/web/screens/Thread/ThreadCenterPanel/ChatBody/index.tsx +++ b/web/screens/Thread/ThreadCenterPanel/ChatBody/index.tsx @@ -11,18 +11,15 @@ import ChatItem from '../ChatItem' import LoadModelError from '../LoadModelError' -import EmptyModel from './EmptyModel' import EmptyThread from './EmptyThread' import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' -import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom' const ChatBody = () => { const messages = useAtomValue(getCurrentChatMessagesAtom) - const downloadedModels = useAtomValue(downloadedModelsAtom) + const loadModelError = useAtomValue(loadModelErrorAtom) - if (!downloadedModels.length) return if (!messages.length) return return ( diff --git a/web/screens/Thread/index.tsx b/web/screens/Thread/index.tsx index ef125e924a..8b4db95ec6 100644 --- a/web/screens/Thread/index.tsx +++ b/web/screens/Thread/index.tsx @@ -1,17 +1,92 @@ +import { useEffect, useState } from 'react' + +import { useAtomValue } from 'jotai' + import ThreadLeftPanel from '@/screens/Thread/ThreadLeftPanel' +import { localEngines } from '@/utils/modelEngine' + import ThreadCenterPanel from './ThreadCenterPanel' +import OnDeviceStarterScreen from './ThreadCenterPanel/ChatBody/EmptyModel' import ModalCleanThread from './ThreadLeftPanel/ModalCleanThread' import ModalDeleteThread from './ThreadLeftPanel/ModalDeleteThread' import ModalEditTitleThread from './ThreadLeftPanel/ModalEditTitleThread' import ThreadRightPanel from './ThreadRightPanel' +import { extensionManager } from '@/extension' +import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom' +import { threadsAtom } from '@/helpers/atoms/Thread.atom' + const ThreadScreen = () => { + const downloadedModels = useAtomValue(downloadedModelsAtom) + const threads = useAtomValue(threadsAtom) + + const isDownloadALocalModel = downloadedModels.some((x) => + localEngines.includes(x.engine) + ) + + const [extensionHasSettings, setExtensionHasSettings] = useState< + { name?: string; setting: string; apiKey: string; provider: string }[] + >([]) + + useEffect(() => { + const getAllSettings = async () => { + const extensionsMenu: { + name?: string + setting: string + apiKey: string + provider: string + }[] = [] + const extensions = extensionManager.getAll() + + for (const extension of extensions) { + if (typeof extension.getSettings === 'function') { + const settings = await extension.getSettings() + + if ( + (settings && settings.length > 0) || + (await extension.installationState()) !== 'NotRequired' + ) { + extensionsMenu.push({ + name: extension.productName, + setting: extension.name, + apiKey: + 'apiKey' in extension && typeof extension.apiKey === 'string' + ? extension.apiKey + : '', + provider: + 'provider' in extension && + typeof extension.provider === 'string' + ? extension.provider + : '', + }) + } + } + } + setExtensionHasSettings(extensionsMenu) + } + getAllSettings() + }, []) + + const isAnyRemoteModelConfigured = extensionHasSettings.some( + (x) => x.apiKey.length > 1 + ) + return (
- - - + {!isAnyRemoteModelConfigured && + !isDownloadALocalModel && + !threads.length ? ( + <> + + + ) : ( + <> + + + + + )} {/* Showing variant modal action for thread screen */}