diff --git a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx new file mode 100644 index 00000000..a554cb8b --- /dev/null +++ b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx @@ -0,0 +1,369 @@ +import { + CloseOutlined, + CodeOutlined, + ExportOutlined, + MinusOutlined, + PushpinOutlined, + ReloadOutlined +} from '@ant-design/icons' +import { isMac, isWindows } from '@renderer/config/constant' +import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' +import { useBridge } from '@renderer/hooks/useBridge' +import { useMinappPopup } from '@renderer/hooks/useMinappPopup' +import { useMinapps } from '@renderer/hooks/useMinapps' +import { useRuntime } from '@renderer/hooks/useRuntime' +import { MinAppType } from '@renderer/types' +import { delay } from '@renderer/utils' +import { Avatar, Drawer, Tooltip } from 'antd' +import { WebviewTag } from 'electron' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import BeatLoader from 'react-spinners/BeatLoader' +import styled from 'styled-components' + +import WebviewContainer from './WebviewContainer' + +interface AppExtraInfo { + canPinned: boolean + isPinned: boolean + canOpenExternalLink: boolean +} + +type AppInfo = MinAppType & AppExtraInfo + +/** The main container for MinApp popup */ +const MinappPopupContainer: React.FC = () => { + const { openedKeepAliveMinapps, openedOneOffMinapp, currentMinappId, minappShow } = useRuntime() + const { closeMinapp, hideMinappPopup } = useMinappPopup() + const { pinned, updatePinnedMinapps } = useMinapps() + const { t } = useTranslation() + + /** control the drawer open or close */ + const [isPopupShow, setIsPopupShow] = useState(true) + /** whether the current minapp is ready */ + const [isReady, setIsReady] = useState(false) + + /** store the last minapp id and show status */ + const lastMinappId = useRef(null) + const lastMinappShow = useRef(false) + + /** store the webview refs, one of the key to make them keepalive */ + const webviewRefs = useRef>(new Map()) + /** indicate whether the webview has loaded */ + const webviewLoadedRefs = useRef>(new Map()) + + const isInDevelopment = process.env.NODE_ENV === 'development' + + useBridge() + + /** set the popup display status */ + useEffect(() => { + if (minappShow) { + setIsPopupShow(true) + + if (webviewLoadedRefs.current.get(currentMinappId)) { + setIsReady(true) + /** the case that open the minapp from sidebar */ + } else if (lastMinappId.current !== currentMinappId && lastMinappShow.current === minappShow) { + setIsReady(false) + } + } else { + setIsPopupShow(false) + setIsReady(false) + } + + return () => { + /** renew the last minapp id and show status */ + lastMinappId.current = currentMinappId + lastMinappShow.current = minappShow + } + }, [minappShow, currentMinappId]) + + useEffect(() => { + if (!webviewRefs.current) return + + /** set the webview display status + * DO NOT use the state to set the display status, + * to AVOID the re-render of the webview container + */ + webviewRefs.current.forEach((webviewRef, appid) => { + if (!webviewRef) return + webviewRef.style.display = appid === currentMinappId ? 'inline-flex' : 'none' + }) + + //delete the extra webviewLoadedRefs + webviewLoadedRefs.current.forEach((_, appid) => { + if (!webviewRefs.current.has(appid)) { + webviewLoadedRefs.current.delete(appid) + } + }) + }, [currentMinappId]) + + /** combine the openedKeepAliveMinapps and openedOneOffMinapp */ + const combinedApps = useMemo(() => { + return [...openedKeepAliveMinapps, ...(openedOneOffMinapp ? [openedOneOffMinapp] : [])] + }, [openedKeepAliveMinapps, openedOneOffMinapp]) + + /** get the extra info of the apps */ + const appsExtraInfo = useMemo(() => { + return combinedApps.reduce( + (acc, app) => ({ + ...acc, + [app.id]: { + canPinned: DEFAULT_MIN_APPS.some((item) => item.id === app.id), + isPinned: pinned.some((item) => item.id === app.id), + canOpenExternalLink: app.url.startsWith('http://') || app.url.startsWith('https://') + } + }), + {} as Record + ) + }, [combinedApps, pinned]) + + /** get the current app info with extra info */ + let currentAppInfo: AppInfo | null = null + if (currentMinappId) { + const currentApp = combinedApps.find((item) => item.id === currentMinappId) as MinAppType + currentAppInfo = { ...currentApp, ...appsExtraInfo[currentApp.id] } + } + + /** will close the popup and delete the webview */ + const handlePopupClose = async (appid: string) => { + setIsPopupShow(false) + await delay(0.3) + webviewLoadedRefs.current.delete(appid) + closeMinapp(appid) + } + + /** will hide the popup and remain the webviews */ + const handlePopupMinimize = async () => { + setIsPopupShow(false) + await delay(0.3) + hideMinappPopup() + } + + /** the callback function to set the webviews ref */ + const handleWebviewSetRef = (appid: string, element: WebviewTag | null) => { + webviewRefs.current.set(appid, element) + + if (!webviewRefs.current.has(appid)) { + webviewRefs.current.set(appid, null) + return + } + + if (element) { + webviewRefs.current.set(appid, element) + } else { + webviewRefs.current.delete(appid) + } + } + + /** the callback function to set the webviews loaded indicator */ + const handleWebviewLoaded = (appid: string) => { + webviewLoadedRefs.current.set(appid, true) + if (appid == currentMinappId) { + setTimeout(() => setIsReady(true), 200) + } + } + + /** will open the devtools of the minapp */ + const handleOpenDevTools = (appid: string) => { + const webview = webviewRefs.current.get(appid) + if (webview) { + webview.openDevTools() + } + } + + /** only reload the original url */ + const handleReload = (appid: string) => { + const webview = webviewRefs.current.get(appid) + if (webview) { + const url = combinedApps.find((item) => item.id === appid)?.url + if (url) { + webview.src = url + } + } + } + + /** only open the current url */ + const handleOpenLink = (appid: string) => { + const webview = webviewRefs.current.get(appid) + if (webview) { + window.api.openWebsite(webview.getURL()) + } + } + + /** toggle the pin status of the minapp */ + const handleTogglePin = (appid: string) => { + const app = combinedApps.find((item) => item.id === appid) + if (!app) return + + const newPinned = appsExtraInfo[appid].isPinned ? pinned.filter((item) => item.id !== appid) : [...pinned, app] + updatePinnedMinapps(newPinned) + } + + /** Title bar of the popup */ + const Title = ({ appInfo }: { appInfo: AppInfo | null }) => { + if (!appInfo) return null + return ( + + {appInfo.name} + + + + + {appInfo.canPinned && ( + + + + )} + {appInfo.canOpenExternalLink && ( + + + + )} + {isInDevelopment && ( + + + + )} + + + + + + + + + ) + } + + /** group the webview containers with Memo, one of the key to make them keepalive */ + const WebviewContainerGroup = useMemo(() => { + return combinedApps.map((app) => ( + + )) + + // because the combinedApps is enough + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [combinedApps]) + + return ( + } + placement="bottom" + onClose={handlePopupMinimize} + open={isPopupShow} + destroyOnClose={false} + mask={false} + rootClassName="minapp-drawer" + maskClassName="minapp-mask" + height={'100%'} + maskClosable={false} + closeIcon={null} + style={{ marginLeft: 'var(--sidebar-width)' }}> + {!isReady && ( + + + + + )} + {WebviewContainerGroup} + + ) +} + +const TitleContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + padding-left: ${isMac ? '20px' : '10px'}; + padding-right: 10px; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: transparent; +` + +const TitleText = styled.div` + font-weight: bold; + font-size: 14px; + color: var(--color-text-1); + margin-right: 10px; + user-select: none; +` +const ButtonsGroup = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 5px; + -webkit-app-region: no-drag; + &.windows { + margin-right: ${isWindows ? '130px' : 0}; + background-color: var(--color-background-mute); + border-radius: 50px; + padding: 0 3px; + overflow: hidden; + } +` + +const Button = styled.div` + cursor: pointer; + width: 30px; + height: 30px; + border-radius: 5px; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + color: var(--color-text-2); + transition: all 0.2s ease; + font-size: 14px; + &:hover { + color: var(--color-text-1); + background-color: var(--color-background-mute); + } + &.pinned { + color: var(--color-primary); + background-color: var(--color-primary-bg); + } +` + +const EmptyView = styled.div` + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + background-color: var(--color-background); +` + +export default MinappPopupContainer diff --git a/src/renderer/src/components/MinApp/TopViewMinappContainer.tsx b/src/renderer/src/components/MinApp/TopViewMinappContainer.tsx new file mode 100644 index 00000000..3cf05bf8 --- /dev/null +++ b/src/renderer/src/components/MinApp/TopViewMinappContainer.tsx @@ -0,0 +1,11 @@ +import MinappPopupContainer from '@renderer/components/MinApp/MinappPopupContainer' +import { useRuntime } from '@renderer/hooks/useRuntime' + +const TopViewMinappContainer = () => { + const { openedKeepAliveMinapps, openedOneOffMinapp } = useRuntime() + const isCreate = openedKeepAliveMinapps.length > 0 || openedOneOffMinapp !== null + + return <>{isCreate && } +} + +export default TopViewMinappContainer diff --git a/src/renderer/src/components/MinApp/WebviewContainer.tsx b/src/renderer/src/components/MinApp/WebviewContainer.tsx new file mode 100644 index 00000000..fe13a734 --- /dev/null +++ b/src/renderer/src/components/MinApp/WebviewContainer.tsx @@ -0,0 +1,84 @@ +import { WebviewTag } from 'electron' +import { memo, useEffect, useRef } from 'react' + +/** + * WebviewContainer is a component that renders a webview element. + * It is used in the MinAppPopupContainer component. + * The webcontent can be remain in memory + */ +const WebviewContainer = memo( + ({ + appid, + url, + onSetRefCallback, + onLoadedCallback + }: { + appid: string + url: string + onSetRefCallback: (appid: string, element: WebviewTag | null) => void + onLoadedCallback: (appid: string) => void + }) => { + const webviewRef = useRef(null) + + const setRef = (appid: string) => { + onSetRefCallback(appid, null) + + return (element: WebviewTag | null) => { + onSetRefCallback(appid, element) + if (element) { + webviewRef.current = element + } else { + webviewRef.current = null + } + } + } + + useEffect(() => { + if (!webviewRef.current) return + + const handleNewWindow = (event: any) => { + event.preventDefault() + if (webviewRef.current?.loadURL) { + webviewRef.current.loadURL(event.url) + } + } + + const handleLoaded = () => { + onLoadedCallback(appid) + } + + webviewRef.current.addEventListener('new-window', handleNewWindow) + webviewRef.current.addEventListener('did-finish-load', handleLoaded) + + // we set the url when the webview is ready + webviewRef.current.src = url + + return () => { + webviewRef.current?.removeEventListener('new-window', handleNewWindow) + webviewRef.current?.removeEventListener('did-finish-load', handleLoaded) + } + // because the appid and url are enough, no need to add onLoadedCallback + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [appid, url]) + + return ( + + ) + } +) + +const WebviewStyle: React.CSSProperties = { + width: 'calc(100vw - var(--sidebar-width))', + height: 'calc(100vh - var(--navbar-height))', + backgroundColor: 'white', + display: 'inline-flex' +} + +export default WebviewContainer diff --git a/src/renderer/src/components/MinApp/index.tsx b/src/renderer/src/components/MinApp/index.tsx deleted file mode 100644 index 78007e67..00000000 --- a/src/renderer/src/components/MinApp/index.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import { CloseOutlined, CodeOutlined, ExportOutlined, PushpinOutlined, ReloadOutlined } from '@ant-design/icons' -import { isMac, isWindows } from '@renderer/config/constant' -import { AppLogo } from '@renderer/config/env' -import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' -import { useBridge } from '@renderer/hooks/useBridge' -import { useMinapps } from '@renderer/hooks/useMinapps' -import store from '@renderer/store' -import { setMinappShow } from '@renderer/store/runtime' -import { MinAppType } from '@renderer/types' -import { delay } from '@renderer/utils' -import { Avatar, Drawer } from 'antd' -import { WebviewTag } from 'electron' -import { useEffect, useRef, useState } from 'react' -import BeatLoader from 'react-spinners/BeatLoader' -import styled from 'styled-components' - -import { TopView } from '../TopView' - -interface Props { - app: MinAppType - resolve: (data: any) => void -} - -const PopupContainer: React.FC = ({ app, resolve }) => { - const { pinned, updatePinnedMinapps } = useMinapps() - const isPinned = pinned.some((p) => p.id === app.id) - const [open, setOpen] = useState(true) - const [opened, setOpened] = useState(false) - const [isReady, setIsReady] = useState(false) - const webviewRef = useRef(null) - - useBridge() - - const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://') - const canPinned = DEFAULT_MIN_APPS.some((i) => i.id === app?.id) - - const onClose = async (_delay = 0.3) => { - setOpen(false) - await delay(_delay) - resolve({}) - } - - MinApp.onClose = onClose - const openDevTools = () => { - if (webviewRef.current) { - webviewRef.current.openDevTools() - } - } - const onReload = () => { - if (webviewRef.current) { - webviewRef.current.src = app.url - } - } - - const onOpenLink = () => { - if (webviewRef.current) { - const currentUrl = webviewRef.current.getURL() - window.api.openWebsite(currentUrl) - } - } - - const onTogglePin = () => { - const newPinned = isPinned ? pinned.filter((item) => item.id !== app.id) : [...pinned, app] - updatePinnedMinapps(newPinned) - } - - const isInDevelopment = process.env.NODE_ENV === 'development' - - const Title = () => { - return ( - - {app.name} - - - {canPinned && ( - - )} - {canOpenExternalLink && ( - - )} - {isInDevelopment && ( - - )} - - - - ) - } - - useEffect(() => { - const webview = webviewRef.current - - if (webview) { - const handleNewWindow = (event: any) => { - event.preventDefault() - if (webview.loadURL) { - webview.loadURL(event.url) - } - } - - const onLoaded = () => setIsReady(true) - - webview.addEventListener('new-window', handleNewWindow) - webview.addEventListener('did-finish-load', onLoaded) - - return () => { - webview.removeEventListener('new-window', handleNewWindow) - webview.removeEventListener('did-finish-load', onLoaded) - } - } - - return () => {} - }, [opened]) - - useEffect(() => { - setTimeout(() => setOpened(true), 350) - }, []) - - return ( - } - placement="bottom" - onClose={() => onClose()} - open={open} - mask={true} - rootClassName="minapp-drawer" - maskClassName="minapp-mask" - height={'100%'} - maskClosable={false} - closeIcon={null} - style={{ marginLeft: 'var(--sidebar-width)' }}> - {!isReady && ( - - - - - )} - {opened && ( - - )} - - ) -} - -const WebviewStyle: React.CSSProperties = { - width: 'calc(100vw - var(--sidebar-width))', - height: 'calc(100vh - var(--navbar-height))', - backgroundColor: 'white', - display: 'inline-flex' -} - -const TitleContainer = styled.div` - display: flex; - flex-direction: row; - align-items: center; - padding-left: ${isMac ? '20px' : '10px'}; - padding-right: 10px; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: transparent; -` - -const TitleText = styled.div` - font-weight: bold; - font-size: 14px; - color: var(--color-text-1); - margin-right: 10px; - user-select: none; -` - -const ButtonsGroup = styled.div` - display: flex; - flex-direction: row; - align-items: center; - gap: 5px; - -webkit-app-region: no-drag; - &.windows { - margin-right: ${isWindows ? '130px' : 0}; - background-color: var(--color-background-mute); - border-radius: 50px; - padding: 0 3px; - overflow: hidden; - } -` - -const Button = styled.div` - cursor: pointer; - width: 30px; - height: 30px; - border-radius: 5px; - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - color: var(--color-text-2); - transition: all 0.2s ease; - font-size: 14px; - &:hover { - color: var(--color-text-1); - background-color: var(--color-background-mute); - } - &.pinned { - color: var(--color-primary); - background-color: var(--color-primary-bg); - } -` - -const EmptyView = styled.div` - display: flex; - flex: 1; - flex-direction: column; - justify-content: center; - align-items: center; - width: 100%; - height: 100%; - background-color: var(--color-background); -` - -export default class MinApp { - static topviewId = 0 - static onClose = () => {} - static app: MinAppType | null = null - - static async start(app: MinAppType) { - if (app?.id && MinApp.app?.id === app?.id) { - return - } - - if (MinApp.app) { - // @ts-ignore delay params - await MinApp.onClose(0) - await delay(0) - } - - if (!app.logo) { - app.logo = AppLogo - } - - MinApp.app = app - store.dispatch(setMinappShow(true)) - - return new Promise((resolve) => { - TopView.show( - { - resolve(v) - this.close() - }} - />, - 'MinApp' - ) - }) - } - - static close() { - TopView.hide('MinApp') - store.dispatch(setMinappShow(false)) - MinApp.app = null - MinApp.onClose = () => {} - } -} diff --git a/src/renderer/src/components/TopView/index.tsx b/src/renderer/src/components/TopView/index.tsx index 868a1c39..4638e0db 100644 --- a/src/renderer/src/components/TopView/index.tsx +++ b/src/renderer/src/components/TopView/index.tsx @@ -1,3 +1,4 @@ +import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer' import { useAppInit } from '@renderer/hooks/useAppInit' import { message, Modal } from 'antd' import React, { PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react' @@ -76,6 +77,7 @@ const TopViewContainer: React.FC = ({ children }) => { {children} {messageContextHolder} {modalContextHolder} + {elements.map(({ element: Element, id }) => ( {typeof Element === 'function' ? : Element} diff --git a/src/renderer/src/components/app/Sidebar.tsx b/src/renderer/src/components/app/Sidebar.tsx index 9fc151e1..a7f7e324 100644 --- a/src/renderer/src/components/app/Sidebar.tsx +++ b/src/renderer/src/components/app/Sidebar.tsx @@ -9,35 +9,36 @@ import { isMac } from '@renderer/config/constant' import { AppLogo, UserAvatar } from '@renderer/config/env' import { useTheme } from '@renderer/context/ThemeProvider' import useAvatar from '@renderer/hooks/useAvatar' +import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { useMinapps } from '@renderer/hooks/useMinapps' import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor' import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' import { isEmoji } from '@renderer/utils' import type { MenuProps } from 'antd' -import { Tooltip } from 'antd' -import { Avatar } from 'antd' -import { Dropdown } from 'antd' -import { FC } from 'react' +import { Avatar, Dropdown, Tooltip } from 'antd' +import { FC, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useLocation, useNavigate } from 'react-router-dom' import styled from 'styled-components' import DragableList from '../DragableList' import MinAppIcon from '../Icons/MinAppIcon' -import MinApp from '../MinApp' import UserPopup from '../Popups/UserPopup' const Sidebar: FC = () => { - const { pathname } = useLocation() - const avatar = useAvatar() - const { minappShow } = useRuntime() - const { t } = useTranslation() - const navigate = useNavigate() + const { hideMinappPopup, openMinapp } = useMinappPopup() + const { minappShow, currentMinappId } = useRuntime() const { sidebarIcons } = useSettings() - const { theme, settingTheme, toggleTheme } = useTheme() const { pinned } = useMinapps() + const { pathname } = useLocation() + const navigate = useNavigate() + + const { theme, settingTheme, toggleTheme } = useTheme() + const avatar = useAvatar() + const { t } = useTranslation() + const onEditUser = () => UserPopup.show() const backgroundColor = useNavBackgroundColor() @@ -49,9 +50,10 @@ const Sidebar: FC = () => { navigate(path) } + const docsId = 'cherrystudio-docs' const onOpenDocs = () => { - MinApp.start({ - id: 'docs', + openMinapp({ + id: docsId, name: t('docs.title'), url: 'https://docs.cherry-ai.com/', logo: AppLogo @@ -66,9 +68,10 @@ const Sidebar: FC = () => { )} - + + {showPinnedApps && ( @@ -80,10 +83,7 @@ const Sidebar: FC = () => { - + @@ -102,7 +102,7 @@ const Sidebar: FC = () => { { - minappShow && (await MinApp.close()) + hideMinappPopup() await modelGenerating() await to('/settings/provider') }}> @@ -117,6 +117,7 @@ const Sidebar: FC = () => { } const MainMenus: FC = () => { + const { hideMinappPopup } = useMinappPopup() const { t } = useTranslation() const { pathname } = useLocation() const { sidebarIcons } = useSettings() @@ -155,7 +156,7 @@ const MainMenus: FC = () => { { - minappShow && (await MinApp.close()) + hideMinappPopup() await modelGenerating() navigate(path) }}> @@ -168,11 +169,99 @@ const MainMenus: FC = () => { }) } +/** Tabs of opened minapps in sidebar */ +const SidebarOpenedMinappTabs: FC = () => { + const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime() + const { openMinappKeepAlive, hideMinappPopup, closeMinapp, closeAllMinapps } = useMinappPopup() + const { theme } = useTheme() + const { t } = useTranslation() + + const handleOnClick = (app) => { + if (minappShow && currentMinappId === app.id) { + hideMinappPopup() + } else { + openMinappKeepAlive(app) + } + } + + // animation for minapp switch indicator + useEffect(() => { + //hacky way to get the height of the icon + const iconDefaultHeight = 40 + const iconDefaultOffset = 17 + const container = document.querySelector('.TabsContainer') as HTMLElement + const activeIcon = document.querySelector('.TabsContainer .opened-active') as HTMLElement + + let indicatorTop = 0, + indicatorRight = 0 + if (minappShow && activeIcon && container) { + indicatorTop = activeIcon.offsetTop + activeIcon.offsetHeight / 2 - 4 // 4 is half of the indicator's height (8px) + indicatorRight = 0 + } else { + indicatorTop = + ((openedKeepAliveMinapps.length > 0 ? openedKeepAliveMinapps.length : 1) / 2) * iconDefaultHeight + + iconDefaultOffset - + 4 + indicatorRight = -50 + } + container.style.setProperty('--indicator-top', `${indicatorTop}px`) + container.style.setProperty('--indicator-right', `${indicatorRight}px`) + }, [currentMinappId, openedKeepAliveMinapps, minappShow]) + + const isShowOpened = openedKeepAliveMinapps.length > 0 + if (!isShowOpened) return + + return ( + + + + + {openedKeepAliveMinapps.map((app) => { + const menuItems: MenuProps['items'] = [ + { + key: 'closeApp', + label: t('minapp.sidebar.close.title'), + onClick: () => { + closeMinapp(app.id) + } + }, + { + key: 'closeAllApp', + label: t('minapp.sidebar.closeall.title'), + onClick: () => { + closeAllMinapps() + } + } + ] + const isActive = minappShow && currentMinappId === app.id + + return ( + + + + handleOnClick(app)} + className={`${isActive ? 'opened-active' : ''}`}> + + + + + + ) + })} + + + + ) +} + const PinnedApps: FC = () => { const { pinned, updatePinnedMinapps } = useMinapps() const { t } = useTranslation() - const { minappShow } = useRuntime() + const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime() const { theme } = useTheme() + const { openMinappKeepAlive } = useMinappPopup() return ( @@ -187,12 +276,15 @@ const PinnedApps: FC = () => { } } ] - const isActive = minappShow && MinApp.app?.id === app.id + const isActive = minappShow && currentMinappId === app.id return ( - - MinApp.start(app)} className={isActive ? 'active' : ''}> + + openMinappKeepAlive(app)} + className={`${isActive ? 'active' : ''} ${openedKeepAliveMinapps.some((item) => item.id === app.id) ? 'opened-animation' : ''}`}> @@ -293,6 +385,23 @@ const Icon = styled.div<{ theme: string }>` color: var(--color-icon-white); } } + + @keyframes borderBreath { + 0% { + border-color: var(--color-primary-mute); + } + 50% { + border-color: var(--color-primary); + } + 100% { + border-color: var(--color-primary-mute); + } + } + + &.opened-animation { + border: 0.5px solid var(--color-primary); + animation: borderBreath 4s ease-in-out infinite; + } ` const StyledLink = styled.div` @@ -323,4 +432,37 @@ const Divider = styled.div` border-bottom: 0.5px solid var(--color-border); ` +const TabsContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + -webkit-app-region: none; + position: relative; + width: 100%; + + &::after { + content: ''; + position: absolute; + right: var(--indicator-right, 0); + top: var(--indicator-top, 0); + width: 4px; + height: 8px; + background-color: var(--color-primary); + transition: + top 0.3s cubic-bezier(0.4, 0, 0.2, 1), + right 0.3s ease-in-out; + border-radius: 2px; + } + + &::-webkit-scrollbar { + display: none; + } +` + +const TabsWrapper = styled.div` + background-color: rgba(128, 128, 128, 0.1); + border-radius: 20px; + overflow: hidden; +` + export default Sidebar diff --git a/src/renderer/src/config/minapps.ts b/src/renderer/src/config/minapps.ts index c966a1ec..cc267995 100644 --- a/src/renderer/src/config/minapps.ts +++ b/src/renderer/src/config/minapps.ts @@ -49,9 +49,7 @@ import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png import GroqProviderLogo from '@renderer/assets/images/providers/groq.png?url' import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png?url' import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png?url' -import MinApp from '@renderer/components/MinApp' import { MinAppType } from '@renderer/types' - export const DEFAULT_MIN_APPS: MinAppType[] = [ { id: 'openai', @@ -395,8 +393,3 @@ export const DEFAULT_MIN_APPS: MinAppType[] = [ bodered: true } ] - -export function startMinAppById(id: string) { - const app = DEFAULT_MIN_APPS.find((app) => app?.id === id) - app && MinApp.start(app) -} diff --git a/src/renderer/src/hooks/useMinappPopup.ts b/src/renderer/src/hooks/useMinappPopup.ts new file mode 100644 index 00000000..8c7bf2f3 --- /dev/null +++ b/src/renderer/src/hooks/useMinappPopup.ts @@ -0,0 +1,120 @@ +import { useRuntime } from '@renderer/hooks/useRuntime' +import { useAppDispatch } from '@renderer/store' +import { + setCurrentMinappId, + setMinappShow, + setOpenedKeepAliveMinapps, + setOpenedOneOffMinapp +} from '@renderer/store/runtime' +import { MinAppType } from '@renderer/types' + +/** The max number of keep alive minapps */ +const MINAPP_MAX_KEEPALIVE = 3 + +/** + * Usage: + * + * To control the minapp popup, you can use the following hooks: + * import { useMinappPopup } from '@renderer/hooks/useMinappPopup' + * + * in the component: + * const { openMinapp, openMinappKeepAlive, openMinappById, + * closeMinapp, hideMinappPopup, closeAllMinapps } = useMinappPopup() + * + * To use some key states of the minapp popup: + * import { useRuntime } from '@renderer/hooks/useRuntime' + * const { openedKeepAliveMinapps, openedOneOffMinapp, minappShow } = useRuntime() + */ +export const useMinappPopup = () => { + const { openedKeepAliveMinapps, openedOneOffMinapp, minappShow } = useRuntime() + const dispatch = useAppDispatch() + + /** Open a minapp (popup shows and minapp loaded) */ + const openMinapp = (app: MinAppType, keepAlive: boolean = false) => { + if (keepAlive) { + //if the minapp is already opened, do nothing + if (openedKeepAliveMinapps.some((item) => item.id === app.id)) { + dispatch(setCurrentMinappId(app.id)) + dispatch(setMinappShow(true)) + return + } + + //if the minapp is not opened, open it + //check if the keep alive minapps meet the max limit + if (openedKeepAliveMinapps.length < MINAPP_MAX_KEEPALIVE) { + //always put new minapp at the first + dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps])) + } else { + //pop the last one + dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps.slice(0, MINAPP_MAX_KEEPALIVE - 1)])) + } + + dispatch(setOpenedOneOffMinapp(null)) + dispatch(setCurrentMinappId(app.id)) + dispatch(setMinappShow(true)) + return + } + + //if the minapp is not keep alive, open it as one-off minapp + dispatch(setOpenedOneOffMinapp(app)) + dispatch(setCurrentMinappId(app.id)) + dispatch(setMinappShow(true)) + return + } + + /** a wrapper of openMinapp(app, true) */ + const openMinappKeepAlive = (app: MinAppType) => { + openMinapp(app, true) + } + + /** Open a minapp by id (look up the minapp in DEFAULT_MIN_APPS) */ + const openMinappById = (id: string, keepAlive: boolean = false) => { + import('@renderer/config/minapps').then(({ DEFAULT_MIN_APPS }) => { + const app = DEFAULT_MIN_APPS.find((app) => app?.id === id) + if (app) { + openMinapp(app, keepAlive) + } + }) + } + + /** Close a minapp immediately (popup hides and minapp unloaded) */ + const closeMinapp = (appid: string) => { + if (openedKeepAliveMinapps.some((item) => item.id === appid)) { + dispatch(setOpenedKeepAliveMinapps(openedKeepAliveMinapps.filter((item) => item.id !== appid))) + } else if (openedOneOffMinapp?.id === appid) { + dispatch(setOpenedOneOffMinapp(null)) + } + + dispatch(setCurrentMinappId('')) + dispatch(setMinappShow(false)) + return + } + + /** Close all minapps (popup hides and all minapps unloaded) */ + const closeAllMinapps = () => { + dispatch(setOpenedKeepAliveMinapps([])) + dispatch(setOpenedOneOffMinapp(null)) + dispatch(setCurrentMinappId('')) + dispatch(setMinappShow(false)) + } + + /** Hide the minapp popup (only one-off minapp unloaded) */ + const hideMinappPopup = () => { + if (!minappShow) return + + if (openedOneOffMinapp) { + dispatch(setOpenedOneOffMinapp(null)) + dispatch(setCurrentMinappId('')) + } + dispatch(setMinappShow(false)) + } + + return { + openMinapp, + openMinappKeepAlive, + openMinappById, + closeMinapp, + hideMinappPopup, + closeAllMinapps + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index b643f11a..4dd64a6a 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -542,10 +542,19 @@ "warn.siyuan.exporting": "Exporting to Siyuan Note, please do not request export repeatedly!" }, "minapp": { + "popup": { + "refresh": "Refresh", + "close": "Close MinApp", + "minimize": "Minimize MinApp", + "devtools": "Developer Tools", + "openExternal": "Open in Browser" + }, "sidebar.add.title": "Add to sidebar", "sidebar.remove.title": "Remove from sidebar", - "title": "MinApp", - "sidebar.hide.title": "Hide MinApp" + "sidebar.close.title": "Close", + "sidebar.closeall.title": "Close All", + "sidebar.hide.title": "Hide MinApp", + "title": "MinApp" }, "miniwindow": { "clipboard": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index c7ecf2dd..f43948ad 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -542,8 +542,17 @@ "error.yuque.no_config": "語雀のAPIアドレスまたはトークンが設定されていません" }, "minapp": { + "popup": { + "refresh": "更新", + "close": "ミニアプリを閉じる", + "minimize": "ミニアプリを最小化", + "devtools": "開発者ツール", + "openExternal": "ブラウザで開く" + }, "sidebar.add.title": "サイドバーに追加", "sidebar.remove.title": "サイドバーから削除", + "sidebar.close.title": "閉じる", + "sidebar.closeall.title": "すべて閉じる", "sidebar.hide.title": "ミニアプリを隠す", "title": "ミニアプリ" }, diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 5834edef..770a3a2c 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -542,8 +542,17 @@ "warn.siyuan.exporting": "Экспортируется в Siyuan, пожалуйста, не отправляйте повторные запросы!" }, "minapp": { + "popup": { + "refresh": "Обновить", + "close": "Закрыть встроенное приложение", + "minimize": "Свернуть встроенное приложение", + "devtools": "Инструменты разработчика", + "openExternal": "Открыть в браузере" + }, "sidebar.add.title": "Добавить в боковую панель", "sidebar.remove.title": "Удалить из боковой панели", + "sidebar.close.title": "Закрыть", + "sidebar.closeall.title": "Закрыть все", "sidebar.hide.title": "Скрыть приложение", "title": "Встроенные приложения" }, diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 51a43ebe..9ee6d033 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -542,8 +542,17 @@ "warn.siyuan.exporting": "正在导出到思源笔记,请勿重复请求导出!" }, "minapp": { + "popup": { + "refresh": "刷新", + "close": "关闭小程序", + "minimize": "最小化小程序", + "devtools": "开发者工具", + "openExternal": "在浏览器中打开" + }, "sidebar.add.title": "添加到侧边栏", "sidebar.remove.title": "从侧边栏移除", + "sidebar.close.title": "关闭", + "sidebar.closeall.title": "全部关闭", "sidebar.hide.title": "隐藏小程序", "title": "小程序" }, diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 21cec760..73f50223 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -542,8 +542,17 @@ "warn.siyuan.exporting": "正在導出到思源筆記,請勿重複請求導出!" }, "minapp": { + "popup": { + "refresh": "重新整理", + "close": "關閉小工具", + "minimize": "最小化小工具", + "devtools": "開發者工具", + "openExternal": "在瀏覽器中開啟" + }, "sidebar.add.title": "新增到側邊欄", "sidebar.remove.title": "從側邊欄移除", + "sidebar.close.title": "關閉", + "sidebar.closeall.title": "全部關閉", "sidebar.hide.title": "隱藏小工具", "title": "小工具" }, diff --git a/src/renderer/src/pages/apps/App.tsx b/src/renderer/src/pages/apps/App.tsx index 0e80f3e5..d9d46db4 100644 --- a/src/renderer/src/pages/apps/App.tsx +++ b/src/renderer/src/pages/apps/App.tsx @@ -1,5 +1,5 @@ import MinAppIcon from '@renderer/components/Icons/MinAppIcon' -import MinApp from '@renderer/components/MinApp' +import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { useMinapps } from '@renderer/hooks/useMinapps' import { MinAppType } from '@renderer/types' import type { MenuProps } from 'antd' @@ -15,13 +15,14 @@ interface Props { } const App: FC = ({ app, onClick, size = 60 }) => { + const { openMinappKeepAlive } = useMinappPopup() const { t } = useTranslation() const { minapps, pinned, disabled, updateMinapps, updateDisabledMinapps, updatePinnedMinapps } = useMinapps() const isPinned = pinned.some((p) => p.id === app.id) const isVisible = minapps.some((m) => m.id === app.id) const handleClick = () => { - MinApp.start(app) + openMinappKeepAlive(app) onClick?.() } diff --git a/src/renderer/src/pages/home/Markdown/Artifacts.tsx b/src/renderer/src/pages/home/Markdown/Artifacts.tsx index 19893b9d..09b8243d 100644 --- a/src/renderer/src/pages/home/Markdown/Artifacts.tsx +++ b/src/renderer/src/pages/home/Markdown/Artifacts.tsx @@ -1,6 +1,6 @@ import { DownloadOutlined, ExpandOutlined, LinkOutlined } from '@ant-design/icons' -import MinApp from '@renderer/components/MinApp' import { AppLogo } from '@renderer/config/env' +import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { extractTitle } from '@renderer/utils/formats' import { Button } from 'antd' import { FC } from 'react' @@ -14,6 +14,7 @@ interface Props { const Artifacts: FC = ({ html }) => { const { t } = useTranslation() const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview') + const { openMinapp } = useMinappPopup() /** * 在应用内打开 @@ -22,7 +23,8 @@ const Artifacts: FC = ({ html }) => { const path = await window.api.file.create('artifacts-preview.html') await window.api.file.write(path, html) const filePath = `file://${path}` - MinApp.start({ + openMinapp({ + id: 'artifacts-preview', name: title, logo: AppLogo, url: filePath diff --git a/src/renderer/src/pages/home/Messages/MessageHeader.tsx b/src/renderer/src/pages/home/Messages/MessageHeader.tsx index dc39f639..6645d701 100644 --- a/src/renderer/src/pages/home/Messages/MessageHeader.tsx +++ b/src/renderer/src/pages/home/Messages/MessageHeader.tsx @@ -1,9 +1,9 @@ import UserPopup from '@renderer/components/Popups/UserPopup' import { APP_NAME, AppLogo, isLocalAi } from '@renderer/config/env' -import { startMinAppById } from '@renderer/config/minapps' import { getModelLogo } from '@renderer/config/models' import { useTheme } from '@renderer/context/ThemeProvider' import useAvatar from '@renderer/hooks/useAvatar' +import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings' import { getMessageModelId } from '@renderer/services/MessagesService' import { getModelName } from '@renderer/services/ModelService' @@ -32,6 +32,7 @@ const MessageHeader: FC = memo(({ assistant, model, message }) => { const { userName, sidebarIcons } = useSettings() const { t } = useTranslation() const { isBubbleStyle } = useMessageStyle() + const { openMinappById } = useMinappPopup() const avatarSource = useMemo(() => getAvatarSource(isLocalAi, getMessageModelId(message)), [message]) @@ -54,7 +55,9 @@ const MessageHeader: FC = memo(({ assistant, model, message }) => { const username = useMemo(() => removeLeadingEmoji(getUserName()), [getUserName]) const showMiniApp = useCallback(() => { - showMinappIcon && model?.provider && startMinAppById(model.provider) + showMinappIcon && model?.provider && openMinappById(model.provider) + // because don't need openMinappById to be a dependency + // eslint-disable-next-line react-hooks/exhaustive-deps }, [model?.provider, showMinappIcon]) const avatarStyle: CSSProperties | undefined = isBubbleStyle diff --git a/src/renderer/src/pages/settings/AboutSettings.tsx b/src/renderer/src/pages/settings/AboutSettings.tsx index edd4eed4..fdea6e06 100644 --- a/src/renderer/src/pages/settings/AboutSettings.tsx +++ b/src/renderer/src/pages/settings/AboutSettings.tsx @@ -2,9 +2,9 @@ import { GithubOutlined } from '@ant-design/icons' import { FileProtectOutlined, GlobalOutlined, MailOutlined, SoundOutlined } from '@ant-design/icons' import IndicatorLight from '@renderer/components/IndicatorLight' import { HStack } from '@renderer/components/Layout' -import MinApp from '@renderer/components/MinApp' import { APP_NAME, AppLogo } from '@renderer/config/env' import { useTheme } from '@renderer/context/ThemeProvider' +import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { useRuntime } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' import { useAppDispatch } from '@renderer/store' @@ -29,6 +29,7 @@ const AboutSettings: FC = () => { const { theme } = useTheme() const dispatch = useAppDispatch() const { update } = useRuntime() + const { openMinapp } = useMinappPopup() const onCheckUpdate = debounce( async () => { @@ -70,7 +71,8 @@ const AboutSettings: FC = () => { const showLicense = async () => { const { appPath } = await window.api.getAppInfo() - MinApp.start({ + openMinapp({ + id: 'cherrystudio-license', name: t('settings.about.license.title'), url: `file://${appPath}/resources/cherry-studio/license.html`, logo: AppLogo @@ -79,7 +81,8 @@ const AboutSettings: FC = () => { const showReleases = async () => { const { appPath } = await window.api.getAppInfo() - MinApp.start({ + openMinapp({ + id: 'cherrystudio-releases', name: t('settings.about.releases.title'), url: `file://${appPath}/resources/cherry-studio/releases.html?theme=${theme === ThemeMode.dark ? 'dark' : 'light'}`, logo: AppLogo diff --git a/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx b/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx index 766316d1..322fdd88 100644 --- a/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx @@ -1,7 +1,7 @@ import { InfoCircleOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' -import MinApp from '@renderer/components/MinApp' import { useTheme } from '@renderer/context/ThemeProvider' +import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { RootState, useAppDispatch } from '@renderer/store' import { setJoplinToken, setJoplinUrl } from '@renderer/store/settings' import { Button, Tooltip } from 'antd' @@ -16,6 +16,7 @@ const JoplinSettings: FC = () => { const { t } = useTranslation() const { theme } = useTheme() const dispatch = useAppDispatch() + const { openMinapp } = useMinappPopup() const joplinToken = useSelector((state: RootState) => state.settings.joplinToken) const joplinUrl = useSelector((state: RootState) => state.settings.joplinUrl) @@ -64,7 +65,7 @@ const JoplinSettings: FC = () => { } const handleJoplinHelpClick = () => { - MinApp.start({ + openMinapp({ id: 'joplin-help', name: 'Joplin Help', url: 'https://joplinapp.org/help/apps/clipper' diff --git a/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx b/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx index bb15ce83..de670ad4 100644 --- a/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx @@ -1,8 +1,8 @@ import { InfoCircleOutlined } from '@ant-design/icons' import { Client } from '@notionhq/client' import { HStack } from '@renderer/components/Layout' -import MinApp from '@renderer/components/MinApp' import { useTheme } from '@renderer/context/ThemeProvider' +import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { RootState, useAppDispatch } from '@renderer/store' import { setNotionApiKey, @@ -22,6 +22,7 @@ const NotionSettings: FC = () => { const { t } = useTranslation() const { theme } = useTheme() const dispatch = useAppDispatch() + const { openMinapp } = useMinappPopup() const notionApiKey = useSelector((state: RootState) => state.settings.notionApiKey) const notionDatabaseID = useSelector((state: RootState) => state.settings.notionDatabaseID) @@ -68,7 +69,7 @@ const NotionSettings: FC = () => { } const handleNotionTitleClick = () => { - MinApp.start({ + openMinapp({ id: 'notion-help', name: 'Notion Help', url: 'https://docs.cherry-ai.com/advanced-basic/notion' diff --git a/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx b/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx index 6357ad29..3ba6673e 100644 --- a/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx @@ -1,7 +1,7 @@ import { InfoCircleOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' -import MinApp from '@renderer/components/MinApp' import { useTheme } from '@renderer/context/ThemeProvider' +import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { RootState, useAppDispatch } from '@renderer/store' import { setSiyuanApiUrl, setSiyuanBoxId, setSiyuanRootPath, setSiyuanToken } from '@renderer/store/settings' import { Button, Tooltip } from 'antd' @@ -13,6 +13,7 @@ import { useSelector } from 'react-redux' import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' const SiyuanSettings: FC = () => { + const { openMinapp } = useMinappPopup() const { t } = useTranslation() const { theme } = useTheme() const dispatch = useAppDispatch() @@ -39,7 +40,7 @@ const SiyuanSettings: FC = () => { } const handleSiyuanHelpClick = () => { - MinApp.start({ + openMinapp({ id: 'siyuan-help', name: 'Siyuan Help', url: 'https://docs.cherry-ai.com/advanced-basic/siyuan' diff --git a/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx b/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx index bee9f418..72a629e5 100644 --- a/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx @@ -1,7 +1,7 @@ import { InfoCircleOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' -import MinApp from '@renderer/components/MinApp' import { useTheme } from '@renderer/context/ThemeProvider' +import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { RootState, useAppDispatch } from '@renderer/store' import { setYuqueRepoId, setYuqueToken, setYuqueUrl } from '@renderer/store/settings' import { Button, Tooltip } from 'antd' @@ -16,6 +16,7 @@ const YuqueSettings: FC = () => { const { t } = useTranslation() const { theme } = useTheme() const dispatch = useAppDispatch() + const { openMinapp } = useMinappPopup() const yuqueToken = useSelector((state: RootState) => state.settings.yuqueToken) const yuqueUrl = useSelector((state: RootState) => state.settings.yuqueUrl) @@ -64,7 +65,7 @@ const YuqueSettings: FC = () => { } const handleYuqueHelpClick = () => { - MinApp.start({ + openMinapp({ id: 'yuque-help', name: 'Yuque Help', url: 'https://www.yuque.com/settings/tokens' diff --git a/src/renderer/src/pages/settings/ProviderSettings/GraphRAGSettings.tsx b/src/renderer/src/pages/settings/ProviderSettings/GraphRAGSettings.tsx index ff7b65c7..d3868eb1 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/GraphRAGSettings.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/GraphRAGSettings.tsx @@ -1,4 +1,4 @@ -import MinApp from '@renderer/components/MinApp' +import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { MinAppType, Provider } from '@renderer/types' import { Button } from 'antd' import { FC } from 'react' @@ -15,18 +15,20 @@ const GraphRAGSettings: FC = ({ provider }) => { const apiUrl = provider.apiHost const modalId = provider.models.filter((model) => model.id.includes('global'))[0]?.id const { t } = useTranslation() + const { openMinapp } = useMinappPopup() const onShowGraphRAG = async () => { const { appPath } = await window.api.getAppInfo() const url = `file://${appPath}/resources/graphrag.html?apiUrl=${apiUrl}&modelId=${modalId}` const app: MinAppType = { + id: 'graphrag', name: t('words.knowledgeGraph'), logo: '', url } - MinApp.start(app) + openMinapp(app) } if (!modalId) { diff --git a/src/renderer/src/store/runtime.ts b/src/renderer/src/store/runtime.ts index 727ee182..d3820ef8 100644 --- a/src/renderer/src/store/runtime.ts +++ b/src/renderer/src/store/runtime.ts @@ -1,7 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { AppLogo, UserAvatar } from '@renderer/config/env' +import type { MinAppType } from '@renderer/types' import type { UpdateInfo } from 'builder-util-runtime' - export interface UpdateState { info: UpdateInfo | null checking: boolean @@ -14,7 +14,14 @@ export interface UpdateState { export interface RuntimeState { avatar: string generating: boolean + /** whether the minapp popup is shown */ minappShow: boolean + /** the minapps that are opened and should be keep alive */ + openedKeepAliveMinapps: MinAppType[] + /** the minapp that is opened for one time */ + openedOneOffMinapp: MinAppType | null + /** the current minapp id */ + currentMinappId: string searching: boolean filesPath: string resourcesPath: string @@ -30,6 +37,9 @@ const initialState: RuntimeState = { avatar: UserAvatar, generating: false, minappShow: false, + openedKeepAliveMinapps: [], + openedOneOffMinapp: null, + currentMinappId: '', searching: false, filesPath: '', resourcesPath: '', @@ -59,6 +69,15 @@ const runtimeSlice = createSlice({ setMinappShow: (state, action: PayloadAction) => { state.minappShow = action.payload }, + setOpenedKeepAliveMinapps: (state, action: PayloadAction) => { + state.openedKeepAliveMinapps = action.payload + }, + setOpenedOneOffMinapp: (state, action: PayloadAction) => { + state.openedOneOffMinapp = action.payload + }, + setCurrentMinappId: (state, action: PayloadAction) => { + state.currentMinappId = action.payload + }, setSearching: (state, action: PayloadAction) => { state.searching = action.payload }, @@ -81,6 +100,9 @@ export const { setAvatar, setGenerating, setMinappShow, + setOpenedKeepAliveMinapps, + setOpenedOneOffMinapp, + setCurrentMinappId, setSearching, setFilesPath, setResourcesPath,