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:
parent
6f3b17bed8
commit
0d13756a86
@ -212,6 +212,15 @@ function handleIPCs() {
|
||||
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.
|
||||
* @param _event - The IPC event object.
|
||||
|
||||
@ -28,6 +28,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
|
||||
relaunch: () => ipcRenderer.invoke("relaunch"),
|
||||
|
||||
openAppDirectory: () => ipcRenderer.invoke("openAppDirectory"),
|
||||
|
||||
deleteFile: (filePath: string) => ipcRenderer.invoke("deleteFile", filePath),
|
||||
|
||||
installRemotePlugin: (pluginName: string) =>
|
||||
|
||||
@ -40,8 +40,12 @@ test("renders left navigation panel", async () => {
|
||||
expect(chatSection).toBe(false);
|
||||
|
||||
// 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 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);
|
||||
});
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import React, { useContext } from 'react'
|
||||
import SecondaryButton from '../SecondaryButton'
|
||||
import { useSetAtom, useAtomValue } from 'jotai'
|
||||
import {
|
||||
@ -13,6 +13,9 @@ import { useGetDownloadedModels } from '@hooks/useGetDownloadedModels'
|
||||
import { Button } from '@uikit'
|
||||
import { activeAssistantModelAtom } from '@helpers/atoms/Model.atom'
|
||||
import { showingModalNoActiveModel } from '@helpers/atoms/Modal.atom'
|
||||
import {
|
||||
FeatureToggleContext,
|
||||
} from '@helpers/FeatureToggleWrapper'
|
||||
|
||||
const LeftHeaderAction: React.FC = () => {
|
||||
const setMainView = useSetAtom(setMainViewStateAtom)
|
||||
@ -20,6 +23,7 @@ const LeftHeaderAction: React.FC = () => {
|
||||
const activeModel = useAtomValue(activeAssistantModelAtom)
|
||||
const { requestCreateConvo } = useCreateConversation()
|
||||
const setShowModalNoActiveModel = useSetAtom(showingModalNoActiveModel)
|
||||
const { experimentalFeatureEnabed } = useContext(FeatureToggleContext)
|
||||
|
||||
const onExploreClick = () => {
|
||||
setMainView(MainViewState.ExploreModel)
|
||||
@ -50,12 +54,14 @@ const LeftHeaderAction: React.FC = () => {
|
||||
className="w-full flex-1"
|
||||
icon={<MagnifyingGlassIcon width={16} height={16} />}
|
||||
/>
|
||||
<SecondaryButton
|
||||
title={'Create bot'}
|
||||
onClick={onCreateBotClicked}
|
||||
className="w-full flex-1"
|
||||
icon={<PlusIcon width={16} height={16} />}
|
||||
/>
|
||||
{experimentalFeatureEnabed && (
|
||||
<SecondaryButton
|
||||
title={'Create bot'}
|
||||
onClick={onCreateBotClicked}
|
||||
className="w-full flex-1"
|
||||
icon={<PlusIcon width={16} height={16} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={onNewConversationClick}
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
isCorePluginInstalled,
|
||||
setupBasePlugins,
|
||||
} from '@services/pluginService'
|
||||
import { FeatureToggleWrapper } from '@helpers/FeatureToggleWrapper'
|
||||
|
||||
const Providers = (props: PropsWithChildren) => {
|
||||
const [setupCore, setSetupCore] = useState(false)
|
||||
@ -70,9 +71,11 @@ const Providers = (props: PropsWithChildren) => {
|
||||
{setupCore && (
|
||||
<ThemeWrapper>
|
||||
{activated ? (
|
||||
<EventListenerWrapper>
|
||||
<ModalWrapper>{children}</ModalWrapper>
|
||||
</EventListenerWrapper>
|
||||
<FeatureToggleWrapper>
|
||||
<EventListenerWrapper>
|
||||
<ModalWrapper>{children}</ModalWrapper>
|
||||
</EventListenerWrapper>
|
||||
</FeatureToggleWrapper>
|
||||
) : (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-background">
|
||||
<CompactLogo width={56} height={56} />
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React, { useContext } from 'react'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import {
|
||||
MainViewState,
|
||||
@ -20,6 +20,9 @@ import { twMerge } from 'tailwind-merge'
|
||||
import { showingBotListModalAtom } from '@helpers/atoms/Modal.atom'
|
||||
import useGetBots from '@hooks/useGetBots'
|
||||
import { useUserConfigs } from '@hooks/useUserConfigs'
|
||||
import {
|
||||
FeatureToggleContext,
|
||||
} from '@helpers/FeatureToggleWrapper'
|
||||
|
||||
export const SidebarLeft = () => {
|
||||
const [config] = useUserConfigs()
|
||||
@ -28,6 +31,7 @@ export const SidebarLeft = () => {
|
||||
const setBotListModal = useSetAtom(showingBotListModalAtom)
|
||||
const { downloadedModels } = useGetDownloadedModels()
|
||||
const { getAllBots } = useGetBots()
|
||||
const { experimentalFeatureEnabed } = useContext(FeatureToggleContext)
|
||||
|
||||
const onMenuClick = (mainViewState: MainViewState) => {
|
||||
if (currentState === mainViewState) return
|
||||
@ -88,18 +92,21 @@ export const SidebarLeft = () => {
|
||||
icon: <LayoutGrid size={20} className="flex-shrink-0" />,
|
||||
state: MainViewState.MyModel,
|
||||
},
|
||||
{
|
||||
name: 'Bot',
|
||||
icon: <Bot size={20} className="flex-shrink-0" />,
|
||||
state: MainViewState.CreateBot,
|
||||
},
|
||||
...(experimentalFeatureEnabed
|
||||
? [
|
||||
{
|
||||
name: 'Bot',
|
||||
icon: <Bot size={20} className="flex-shrink-0" />,
|
||||
state: MainViewState.CreateBot,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Settings',
|
||||
icon: <Settings size={20} className="flex-shrink-0" />,
|
||||
state: MainViewState.Setting,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={false}
|
||||
@ -124,42 +131,44 @@ export const SidebarLeft = () => {
|
||||
config.sidebarLeftExpand ? 'items-start' : 'items-center'
|
||||
)}
|
||||
>
|
||||
{menus.map((menu, i) => {
|
||||
const isActive = currentState === menu.state
|
||||
const isBotMenu = menu.name === 'Bot'
|
||||
return (
|
||||
<div className="relative w-full px-4 py-2" key={i}>
|
||||
<button
|
||||
data-testid={menu.name}
|
||||
className={twMerge(
|
||||
'flex w-full flex-shrink-0 items-center gap-x-2',
|
||||
config.sidebarLeftExpand
|
||||
? 'justify-start'
|
||||
: 'justify-center'
|
||||
)}
|
||||
onClick={() =>
|
||||
isBotMenu ? onBotListClick() : onMenuClick(menu.state)
|
||||
}
|
||||
>
|
||||
{menu.icon}
|
||||
<m.span
|
||||
initial={false}
|
||||
variants={variant}
|
||||
animate={config.sidebarLeftExpand ? 'show' : 'hide'}
|
||||
className="text-xs font-semibold text-muted-foreground"
|
||||
{menus
|
||||
.filter((menu) => !!menu)
|
||||
.map((menu, i) => {
|
||||
const isActive = currentState === menu.state
|
||||
const isBotMenu = menu.name === 'Bot'
|
||||
return (
|
||||
<div className="relative w-full px-4 py-2" key={i}>
|
||||
<button
|
||||
data-testid={menu.name}
|
||||
className={twMerge(
|
||||
'flex w-full flex-shrink-0 items-center gap-x-2',
|
||||
config.sidebarLeftExpand
|
||||
? 'justify-start'
|
||||
: 'justify-center'
|
||||
)}
|
||||
onClick={() =>
|
||||
isBotMenu ? onBotListClick() : onMenuClick(menu.state)
|
||||
}
|
||||
>
|
||||
{menu.name}
|
||||
</m.span>
|
||||
</button>
|
||||
{isActive ? (
|
||||
<m.div
|
||||
className="absolute inset-0 left-2 -z-10 h-full w-[calc(100%-16px)] rounded-md bg-accent/20 p-2 backdrop-blur-lg"
|
||||
layoutId="active-state"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{menu.icon}
|
||||
<m.span
|
||||
initial={false}
|
||||
variants={variant}
|
||||
animate={config.sidebarLeftExpand ? 'show' : 'hide'}
|
||||
className="text-xs font-semibold text-muted-foreground"
|
||||
>
|
||||
{menu.name}
|
||||
</m.span>
|
||||
</button>
|
||||
{isActive ? (
|
||||
<m.div
|
||||
className="absolute inset-0 left-2 -z-10 h-full w-[calc(100%-16px)] rounded-md bg-accent/20 p-2 backdrop-blur-lg"
|
||||
layoutId="active-state"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<m.div
|
||||
initial={false}
|
||||
@ -170,9 +179,7 @@ export const SidebarLeft = () => {
|
||||
<div className="space-y-2 rounded-lg border border-border bg-background/50 p-3">
|
||||
<button
|
||||
onClick={() =>
|
||||
window.coreAPI?.openExternalUrl(
|
||||
'https://discord.gg/AsJ8krTT3N'
|
||||
)
|
||||
window.coreAPI?.openExternalUrl('https://discord.gg/AsJ8krTT3N')
|
||||
}
|
||||
className="block text-xs font-semibold text-muted-foreground"
|
||||
>
|
||||
@ -180,9 +187,7 @@ export const SidebarLeft = () => {
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
window.coreAPI?.openExternalUrl(
|
||||
'https://twitter.com/janhq_'
|
||||
)
|
||||
window.coreAPI?.openExternalUrl('https://twitter.com/janhq_')
|
||||
}
|
||||
className="block text-xs font-semibold text-muted-foreground"
|
||||
>
|
||||
|
||||
43
web/helpers/FeatureToggleWrapper.tsx
Normal file
43
web/helpers/FeatureToggleWrapper.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
web/screens/Settings/Advanced/index.tsx
Normal file
63
web/screens/Settings/Advanced/index.tsx
Normal 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
|
||||
@ -1,24 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import React, { useState, useEffect, useRef, useContext } from 'react'
|
||||
import { Button, Switch } from '@uikit'
|
||||
import Loader from '@containers/Loader'
|
||||
import { formatPluginsName } from '@utils/converter'
|
||||
|
||||
import { plugins, extensionPoints } from '@plugin'
|
||||
import { executeSerial } from '@services/pluginService'
|
||||
import { DataService } from '@janhq/core'
|
||||
import useGetAppVersion from '@hooks/useGetAppVersion'
|
||||
import { FeatureToggleContext } from '@helpers/FeatureToggleWrapper'
|
||||
|
||||
const PluginCatalog = () => {
|
||||
// const [search, setSearch] = useState<string>('')
|
||||
// const [fileName, setFileName] = useState('')
|
||||
const [activePlugins, setActivePlugins] = useState<any[]>([])
|
||||
const [pluginCatalog, setPluginCatalog] = useState<any[]>([])
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const experimentRef = useRef(null)
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const { version } = useGetAppVersion()
|
||||
const { experimentalFeatureEnabed } = useContext(FeatureToggleContext)
|
||||
/**
|
||||
* 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
|
||||
|
||||
// Load plugin manifest from plugin if any
|
||||
if (extensionPoints.get(DataService.GetPluginManifest)) {
|
||||
executeSerial(DataService.GetPluginManifest).then((data: any) => {
|
||||
setPluginCatalog(
|
||||
data.filter(
|
||||
(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('.', '')
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
// Get plugin manifest
|
||||
import(/* webpackIgnore: true */ PLUGIN_CATALOG + `?t=${Date.now()}`).then(
|
||||
(data) => {
|
||||
if (Array.isArray(data.default) && experimentalFeatureEnabed)
|
||||
setPluginCatalog(data.default)
|
||||
}
|
||||
)
|
||||
}, [version])
|
||||
|
||||
/**
|
||||
@ -67,7 +45,7 @@ const PluginCatalog = () => {
|
||||
useEffect(() => {
|
||||
const getActivePlugins = async () => {
|
||||
const plgs = await plugins.getActive()
|
||||
setActivePlugins(plgs)
|
||||
if (Array.isArray(plgs)) setActivePlugins(plgs)
|
||||
|
||||
if (extensionPoints.get('experimentComponent')) {
|
||||
const components = await Promise.all(
|
||||
@ -111,20 +89,6 @@ const PluginCatalog = () => {
|
||||
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.
|
||||
* If the installation is successful, the application is relaunched using the `coreAPI.relaunch` function.
|
||||
@ -152,9 +116,9 @@ const PluginCatalog = () => {
|
||||
|
||||
return (
|
||||
<div className="block w-full">
|
||||
{(pluginCatalog ?? [])
|
||||
{pluginCatalog
|
||||
.concat(
|
||||
activePlugins?.filter(
|
||||
activePlugins.filter(
|
||||
(e) => !(pluginCatalog ?? []).some((p) => p.name === e.name)
|
||||
) ?? []
|
||||
)
|
||||
|
||||
@ -14,17 +14,23 @@ import { twMerge } from 'tailwind-merge'
|
||||
import { formatPluginsName } from '@utils/converter'
|
||||
|
||||
import { extensionPoints } from '@plugin'
|
||||
|
||||
const staticMenu = ['Appearance']
|
||||
|
||||
if (typeof window !== "undefined" && window.electronAPI) {
|
||||
staticMenu.push('Core Plugins');
|
||||
}
|
||||
import Advanced from './Advanced'
|
||||
|
||||
const SettingsScreen = () => {
|
||||
const [activeStaticMenu, setActiveStaticMenu] = useState('Appearance')
|
||||
const [preferenceItems, setPreferenceItems] = 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.
|
||||
@ -72,6 +78,9 @@ const SettingsScreen = () => {
|
||||
case 'Appearance':
|
||||
return <AppearanceOptions />
|
||||
|
||||
case 'Advanced':
|
||||
return <Advanced />
|
||||
|
||||
default:
|
||||
return (
|
||||
<PreferencePlugins
|
||||
@ -99,7 +108,7 @@ const SettingsScreen = () => {
|
||||
Options
|
||||
</label>
|
||||
<div className="mt-1 font-semibold">
|
||||
{staticMenu.map((menu, i) => {
|
||||
{menus.map((menu, i) => {
|
||||
const isActive = activeStaticMenu === menu
|
||||
return (
|
||||
<div key={i} className="relative block py-2">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user