feat: Experimental Feature Toggle (#525)

* feat: Experimental Feature Toggle

* chore: add open app directory action

* chore: disable experimental feature test case
This commit is contained in:
Louis 2023-11-01 23:42:47 +07:00 committed by GitHub
parent 6f3b17bed8
commit 0d13756a86
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 224 additions and 116 deletions

View File

@ -212,6 +212,15 @@ function handleIPCs() {
return app.getVersion(); return app.getVersion();
}); });
/**
* Handles the "openAppDirectory" IPC message by opening the app's user data directory.
* The `shell.openPath` method is used to open the directory in the user's default file explorer.
* @param _event - The IPC event object.
*/
ipcMain.handle("openAppDirectory", async (_event) => {
shell.openPath(app.getPath("userData"));
});
/** /**
* Opens a URL in the user's default browser. * Opens a URL in the user's default browser.
* @param _event - The IPC event object. * @param _event - The IPC event object.

View File

@ -28,6 +28,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
relaunch: () => ipcRenderer.invoke("relaunch"), relaunch: () => ipcRenderer.invoke("relaunch"),
openAppDirectory: () => ipcRenderer.invoke("openAppDirectory"),
deleteFile: (filePath: string) => ipcRenderer.invoke("deleteFile", filePath), deleteFile: (filePath: string) => ipcRenderer.invoke("deleteFile", filePath),
installRemotePlugin: (pluginName: string) => installRemotePlugin: (pluginName: string) =>

View File

@ -40,8 +40,12 @@ test("renders left navigation panel", async () => {
expect(chatSection).toBe(false); expect(chatSection).toBe(false);
// Home actions // Home actions
const botBtn = await page.getByTestId("Bot").first().isEnabled(); /* Disable unstable feature tests
** const botBtn = await page.getByTestId("Bot").first().isEnabled();
** Enable back when it is whitelisted
*/
const myModelsBtn = await page.getByTestId("My Models").first().isEnabled(); const myModelsBtn = await page.getByTestId("My Models").first().isEnabled();
const settingsBtn = await page.getByTestId("Settings").first().isEnabled(); const settingsBtn = await page.getByTestId("Settings").first().isEnabled();
expect([botBtn, myModelsBtn, settingsBtn].filter((e) => !e).length).toBe(0); expect([myModelsBtn, settingsBtn].filter((e) => !e).length).toBe(0);
}); });

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import React from 'react' import React, { useContext } from 'react'
import SecondaryButton from '../SecondaryButton' import SecondaryButton from '../SecondaryButton'
import { useSetAtom, useAtomValue } from 'jotai' import { useSetAtom, useAtomValue } from 'jotai'
import { import {
@ -13,6 +13,9 @@ import { useGetDownloadedModels } from '@hooks/useGetDownloadedModels'
import { Button } from '@uikit' import { Button } from '@uikit'
import { activeAssistantModelAtom } from '@helpers/atoms/Model.atom' import { activeAssistantModelAtom } from '@helpers/atoms/Model.atom'
import { showingModalNoActiveModel } from '@helpers/atoms/Modal.atom' import { showingModalNoActiveModel } from '@helpers/atoms/Modal.atom'
import {
FeatureToggleContext,
} from '@helpers/FeatureToggleWrapper'
const LeftHeaderAction: React.FC = () => { const LeftHeaderAction: React.FC = () => {
const setMainView = useSetAtom(setMainViewStateAtom) const setMainView = useSetAtom(setMainViewStateAtom)
@ -20,6 +23,7 @@ const LeftHeaderAction: React.FC = () => {
const activeModel = useAtomValue(activeAssistantModelAtom) const activeModel = useAtomValue(activeAssistantModelAtom)
const { requestCreateConvo } = useCreateConversation() const { requestCreateConvo } = useCreateConversation()
const setShowModalNoActiveModel = useSetAtom(showingModalNoActiveModel) const setShowModalNoActiveModel = useSetAtom(showingModalNoActiveModel)
const { experimentalFeatureEnabed } = useContext(FeatureToggleContext)
const onExploreClick = () => { const onExploreClick = () => {
setMainView(MainViewState.ExploreModel) setMainView(MainViewState.ExploreModel)
@ -50,12 +54,14 @@ const LeftHeaderAction: React.FC = () => {
className="w-full flex-1" className="w-full flex-1"
icon={<MagnifyingGlassIcon width={16} height={16} />} icon={<MagnifyingGlassIcon width={16} height={16} />}
/> />
{experimentalFeatureEnabed && (
<SecondaryButton <SecondaryButton
title={'Create bot'} title={'Create bot'}
onClick={onCreateBotClicked} onClick={onCreateBotClicked}
className="w-full flex-1" className="w-full flex-1"
icon={<PlusIcon width={16} height={16} />} icon={<PlusIcon width={16} height={16} />}
/> />
)}
</div> </div>
<Button <Button
onClick={onNewConversationClick} onClick={onNewConversationClick}

View File

@ -15,6 +15,7 @@ import {
isCorePluginInstalled, isCorePluginInstalled,
setupBasePlugins, setupBasePlugins,
} from '@services/pluginService' } from '@services/pluginService'
import { FeatureToggleWrapper } from '@helpers/FeatureToggleWrapper'
const Providers = (props: PropsWithChildren) => { const Providers = (props: PropsWithChildren) => {
const [setupCore, setSetupCore] = useState(false) const [setupCore, setSetupCore] = useState(false)
@ -70,9 +71,11 @@ const Providers = (props: PropsWithChildren) => {
{setupCore && ( {setupCore && (
<ThemeWrapper> <ThemeWrapper>
{activated ? ( {activated ? (
<FeatureToggleWrapper>
<EventListenerWrapper> <EventListenerWrapper>
<ModalWrapper>{children}</ModalWrapper> <ModalWrapper>{children}</ModalWrapper>
</EventListenerWrapper> </EventListenerWrapper>
</FeatureToggleWrapper>
) : ( ) : (
<div className="flex h-screen w-screen items-center justify-center bg-background"> <div className="flex h-screen w-screen items-center justify-center bg-background">
<CompactLogo width={56} height={56} /> <CompactLogo width={56} height={56} />

View File

@ -1,4 +1,4 @@
import React from 'react' import React, { useContext } from 'react'
import { useAtomValue, useSetAtom } from 'jotai' import { useAtomValue, useSetAtom } from 'jotai'
import { import {
MainViewState, MainViewState,
@ -20,6 +20,9 @@ import { twMerge } from 'tailwind-merge'
import { showingBotListModalAtom } from '@helpers/atoms/Modal.atom' import { showingBotListModalAtom } from '@helpers/atoms/Modal.atom'
import useGetBots from '@hooks/useGetBots' import useGetBots from '@hooks/useGetBots'
import { useUserConfigs } from '@hooks/useUserConfigs' import { useUserConfigs } from '@hooks/useUserConfigs'
import {
FeatureToggleContext,
} from '@helpers/FeatureToggleWrapper'
export const SidebarLeft = () => { export const SidebarLeft = () => {
const [config] = useUserConfigs() const [config] = useUserConfigs()
@ -28,6 +31,7 @@ export const SidebarLeft = () => {
const setBotListModal = useSetAtom(showingBotListModalAtom) const setBotListModal = useSetAtom(showingBotListModalAtom)
const { downloadedModels } = useGetDownloadedModels() const { downloadedModels } = useGetDownloadedModels()
const { getAllBots } = useGetBots() const { getAllBots } = useGetBots()
const { experimentalFeatureEnabed } = useContext(FeatureToggleContext)
const onMenuClick = (mainViewState: MainViewState) => { const onMenuClick = (mainViewState: MainViewState) => {
if (currentState === mainViewState) return if (currentState === mainViewState) return
@ -88,18 +92,21 @@ export const SidebarLeft = () => {
icon: <LayoutGrid size={20} className="flex-shrink-0" />, icon: <LayoutGrid size={20} className="flex-shrink-0" />,
state: MainViewState.MyModel, state: MainViewState.MyModel,
}, },
...(experimentalFeatureEnabed
? [
{ {
name: 'Bot', name: 'Bot',
icon: <Bot size={20} className="flex-shrink-0" />, icon: <Bot size={20} className="flex-shrink-0" />,
state: MainViewState.CreateBot, state: MainViewState.CreateBot,
}, },
]
: []),
{ {
name: 'Settings', name: 'Settings',
icon: <Settings size={20} className="flex-shrink-0" />, icon: <Settings size={20} className="flex-shrink-0" />,
state: MainViewState.Setting, state: MainViewState.Setting,
}, },
] ]
return ( return (
<m.div <m.div
initial={false} initial={false}
@ -124,7 +131,9 @@ export const SidebarLeft = () => {
config.sidebarLeftExpand ? 'items-start' : 'items-center' config.sidebarLeftExpand ? 'items-start' : 'items-center'
)} )}
> >
{menus.map((menu, i) => { {menus
.filter((menu) => !!menu)
.map((menu, i) => {
const isActive = currentState === menu.state const isActive = currentState === menu.state
const isBotMenu = menu.name === 'Bot' const isBotMenu = menu.name === 'Bot'
return ( return (
@ -170,9 +179,7 @@ export const SidebarLeft = () => {
<div className="space-y-2 rounded-lg border border-border bg-background/50 p-3"> <div className="space-y-2 rounded-lg border border-border bg-background/50 p-3">
<button <button
onClick={() => onClick={() =>
window.coreAPI?.openExternalUrl( window.coreAPI?.openExternalUrl('https://discord.gg/AsJ8krTT3N')
'https://discord.gg/AsJ8krTT3N'
)
} }
className="block text-xs font-semibold text-muted-foreground" className="block text-xs font-semibold text-muted-foreground"
> >
@ -180,9 +187,7 @@ export const SidebarLeft = () => {
</button> </button>
<button <button
onClick={() => onClick={() =>
window.coreAPI?.openExternalUrl( window.coreAPI?.openExternalUrl('https://twitter.com/janhq_')
'https://twitter.com/janhq_'
)
} }
className="block text-xs font-semibold text-muted-foreground" className="block text-xs font-semibold text-muted-foreground"
> >

View File

@ -0,0 +1,43 @@
import React, { ReactNode, useEffect, useState } from 'react'
interface FeatureToggleContextType {
experimentalFeatureEnabed: boolean
setExperimentalFeatureEnabled: (on: boolean) => void
}
const initialContext: FeatureToggleContextType = {
experimentalFeatureEnabed: false,
setExperimentalFeatureEnabled: (boolean) => {},
}
export const FeatureToggleContext =
React.createContext<FeatureToggleContextType>(initialContext)
export function FeatureToggleWrapper({ children }: { children: ReactNode }) {
const EXPERIMENTAL_FEATURE_ENABLED = 'expermientalFeatureEnabled'
const [experimentalEnabed, setExperimentalEnabled] = useState<boolean>(false)
useEffect(() => {
// Global experimental feature is disabled
let globalFeatureEnabled = false
if (localStorage.getItem(EXPERIMENTAL_FEATURE_ENABLED) !== 'true') {
globalFeatureEnabled = true
}
}, [])
const setExperimentalFeature = (on: boolean) => {
localStorage.setItem(EXPERIMENTAL_FEATURE_ENABLED, on ? 'true' : 'false')
setExperimentalEnabled(on)
}
return (
<FeatureToggleContext.Provider
value={{
experimentalFeatureEnabed: experimentalEnabed,
setExperimentalFeatureEnabled: setExperimentalFeature,
}}
>
{children}
</FeatureToggleContext.Provider>
)
}

View File

@ -0,0 +1,63 @@
'use client'
import React, { useContext, useRef } from 'react'
import { Button, Switch } from '@uikit'
import { FeatureToggleContext } from '@helpers/FeatureToggleWrapper'
const Advanced = () => {
const { experimentalFeatureEnabed, setExperimentalFeatureEnabled } =
useContext(FeatureToggleContext)
const fileInputRef = useRef<HTMLInputElement | null>(null)
return (
<div className="block w-full">
<div className="flex w-full items-start justify-between border-b border-gray-200 py-4 first:pt-0 last:border-none dark:border-gray-800">
<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">
Experimental Mode
</h6>
</div>
<p className="whitespace-pre-wrap leading-relaxed text-gray-600 dark:text-gray-400">
Enable experimental features that may be unstable or not fully
tested.
</p>
</div>
<Switch
checked={experimentalFeatureEnabed}
onCheckedChange={(e) => {
if (e === true) {
setExperimentalFeatureEnabled(true)
} else {
setExperimentalFeatureEnabled(false)
}
}}
/>
</div>
{window.electronAPI && (
<div className="flex w-full items-start justify-between border-b border-gray-200 py-4 first:pt-0 last:border-none dark:border-gray-800">
<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">
Open App Directory
</h6>
</div>
<p className="whitespace-pre-wrap leading-relaxed text-gray-600 dark:text-gray-400">
Open the directory where the app data is located.
</p>
</div>
<div>
<Button
size="sm"
themes="outline"
onClick={() => window.electronAPI.openAppDirectory()}
>
Open
</Button>
</div>
</div>
)}
</div>
)
}
export default Advanced

View File

@ -1,24 +1,22 @@
'use client' 'use client'
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef, useContext } from 'react'
import { Button, Switch } from '@uikit' import { Button, Switch } from '@uikit'
import Loader from '@containers/Loader' import Loader from '@containers/Loader'
import { formatPluginsName } from '@utils/converter' import { formatPluginsName } from '@utils/converter'
import { plugins, extensionPoints } from '@plugin' import { plugins, extensionPoints } from '@plugin'
import { executeSerial } from '@services/pluginService'
import { DataService } from '@janhq/core'
import useGetAppVersion from '@hooks/useGetAppVersion' import useGetAppVersion from '@hooks/useGetAppVersion'
import { FeatureToggleContext } from '@helpers/FeatureToggleWrapper'
const PluginCatalog = () => { const PluginCatalog = () => {
// const [search, setSearch] = useState<string>('')
// const [fileName, setFileName] = useState('')
const [activePlugins, setActivePlugins] = useState<any[]>([]) const [activePlugins, setActivePlugins] = useState<any[]>([])
const [pluginCatalog, setPluginCatalog] = useState<any[]>([]) const [pluginCatalog, setPluginCatalog] = useState<any[]>([])
const [isLoading, setIsLoading] = useState<boolean>(false) const [isLoading, setIsLoading] = useState<boolean>(false)
const experimentRef = useRef(null) const experimentRef = useRef(null)
const fileInputRef = useRef<HTMLInputElement | null>(null) const fileInputRef = useRef<HTMLInputElement | null>(null)
const { version } = useGetAppVersion() const { version } = useGetAppVersion()
const { experimentalFeatureEnabed } = useContext(FeatureToggleContext)
/** /**
* Loads the plugin catalog module from a CDN and sets it as the plugin catalog state. * Loads the plugin catalog module from a CDN and sets it as the plugin catalog state.
*/ */
@ -28,33 +26,13 @@ const PluginCatalog = () => {
} }
if (!version) return if (!version) return
// Load plugin manifest from plugin if any // Get plugin manifest
if (extensionPoints.get(DataService.GetPluginManifest)) { import(/* webpackIgnore: true */ PLUGIN_CATALOG + `?t=${Date.now()}`).then(
executeSerial(DataService.GetPluginManifest).then((data: any) => { (data) => {
setPluginCatalog( if (Array.isArray(data.default) && experimentalFeatureEnabed)
data.filter( setPluginCatalog(data.default)
(e: any) =>
!e.requiredVersion ||
e.requiredVersion.replace(/[.^]/g, '') <=
version.replaceAll('.', '')
)
)
})
} else {
// Fallback to app default manifest
import(
/* webpackIgnore: true */ PLUGIN_CATALOG + `?t=${Date.now()}`
).then((data) =>
setPluginCatalog(
data.default.filter(
(e: any) =>
!e.requiredVersion ||
e.requiredVersion.replace(/[.^]/g, '') <=
version.replaceAll('.', '')
)
)
)
} }
)
}, [version]) }, [version])
/** /**
@ -67,7 +45,7 @@ const PluginCatalog = () => {
useEffect(() => { useEffect(() => {
const getActivePlugins = async () => { const getActivePlugins = async () => {
const plgs = await plugins.getActive() const plgs = await plugins.getActive()
setActivePlugins(plgs) if (Array.isArray(plgs)) setActivePlugins(plgs)
if (extensionPoints.get('experimentComponent')) { if (extensionPoints.get('experimentComponent')) {
const components = await Promise.all( const components = await Promise.all(
@ -111,20 +89,6 @@ const PluginCatalog = () => {
if (res) window.coreAPI?.relaunch() if (res) window.coreAPI?.relaunch()
} }
/**
* Updates a plugin by calling the `window.pluggableElectronIpc.update` function with the plugin name.
* If the update is successful, the application is relaunched using the `window.coreAPI.relaunch` function.
* TODO: should update using window.coreAPI rather than pluggableElectronIpc (Plugin Manager Facades)
* @param plugin - The name of the plugin to update.
*/
const update = async (plugin: string) => {
if (typeof window !== 'undefined') {
// @ts-ignore
await window.pluggableElectronIpc.update([plugin], true)
window.coreAPI?.relaunch()
}
}
/** /**
* Downloads a remote plugin tarball and installs it using the `plugins.install` function. * Downloads a remote plugin tarball and installs it using the `plugins.install` function.
* If the installation is successful, the application is relaunched using the `coreAPI.relaunch` function. * If the installation is successful, the application is relaunched using the `coreAPI.relaunch` function.
@ -152,9 +116,9 @@ const PluginCatalog = () => {
return ( return (
<div className="block w-full"> <div className="block w-full">
{(pluginCatalog ?? []) {pluginCatalog
.concat( .concat(
activePlugins?.filter( activePlugins.filter(
(e) => !(pluginCatalog ?? []).some((p) => p.name === e.name) (e) => !(pluginCatalog ?? []).some((p) => p.name === e.name)
) ?? [] ) ?? []
) )

View File

@ -14,17 +14,23 @@ import { twMerge } from 'tailwind-merge'
import { formatPluginsName } from '@utils/converter' import { formatPluginsName } from '@utils/converter'
import { extensionPoints } from '@plugin' import { extensionPoints } from '@plugin'
import Advanced from './Advanced'
const staticMenu = ['Appearance']
if (typeof window !== "undefined" && window.electronAPI) {
staticMenu.push('Core Plugins');
}
const SettingsScreen = () => { const SettingsScreen = () => {
const [activeStaticMenu, setActiveStaticMenu] = useState('Appearance') const [activeStaticMenu, setActiveStaticMenu] = useState('Appearance')
const [preferenceItems, setPreferenceItems] = useState<any[]>([]) const [preferenceItems, setPreferenceItems] = useState<any[]>([])
const [preferenceValues, setPreferenceValues] = useState<any[]>([]) const [preferenceValues, setPreferenceValues] = useState<any[]>([])
const [menus, setMenus] = useState<any[]>([])
useEffect(() => {
const menu = ['Appearance']
if (typeof window !== 'undefined' && window.electronAPI) {
menu.push('Core Plugins')
}
menu.push('Advanced')
setMenus(menu)
}, [])
/** /**
* Fetches the active plugins and their preferences from the `plugins` and `preferences` modules. * Fetches the active plugins and their preferences from the `plugins` and `preferences` modules.
@ -72,6 +78,9 @@ const SettingsScreen = () => {
case 'Appearance': case 'Appearance':
return <AppearanceOptions /> return <AppearanceOptions />
case 'Advanced':
return <Advanced />
default: default:
return ( return (
<PreferencePlugins <PreferencePlugins
@ -99,7 +108,7 @@ const SettingsScreen = () => {
Options Options
</label> </label>
<div className="mt-1 font-semibold"> <div className="mt-1 font-semibold">
{staticMenu.map((menu, i) => { {menus.map((menu, i) => {
const isActive = activeStaticMenu === menu const isActive = activeStaticMenu === menu
return ( return (
<div key={i} className="relative block py-2"> <div key={i} className="relative block py-2">