diff --git a/web/app/components/header/account-setting/model-provider-page/hooks.spec.ts b/web/app/components/header/account-setting/model-provider-page/hooks.spec.ts
new file mode 100644
index 0000000000..4d6941ddc6
--- /dev/null
+++ b/web/app/components/header/account-setting/model-provider-page/hooks.spec.ts
@@ -0,0 +1,90 @@
+import { renderHook } from '@testing-library/react'
+import { useLanguage } from './hooks'
+import { useContext } from 'use-context-selector'
+import { after } from 'node:test'
+
+jest.mock('swr', () => ({
+ __esModule: true,
+ default: jest.fn(), // mock useSWR
+ useSWRConfig: jest.fn(),
+}))
+
+// mock use-context-selector
+jest.mock('use-context-selector', () => ({
+ useContext: jest.fn(),
+}))
+
+// mock service/common functions
+jest.mock('@/service/common', () => ({
+ fetchDefaultModal: jest.fn(),
+ fetchModelList: jest.fn(),
+ fetchModelProviderCredentials: jest.fn(),
+ fetchModelProviders: jest.fn(),
+ getPayUrl: jest.fn(),
+}))
+
+// mock context hooks
+jest.mock('@/context/i18n', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}))
+
+jest.mock('@/context/provider-context', () => ({
+ useProviderContext: jest.fn(),
+}))
+
+jest.mock('@/context/modal-context', () => ({
+ useModalContextSelector: jest.fn(),
+}))
+
+jest.mock('@/context/event-emitter', () => ({
+ useEventEmitterContextContext: jest.fn(),
+}))
+
+// mock plugins
+jest.mock('@/app/components/plugins/marketplace/hooks', () => ({
+ useMarketplacePlugins: jest.fn(),
+}))
+
+jest.mock('@/app/components/plugins/marketplace/utils', () => ({
+ getMarketplacePluginsByCollectionId: jest.fn(),
+}))
+
+jest.mock('./provider-added-card', () => {
+ // eslint-disable-next-line no-labels, ts/no-unused-expressions
+ UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST: []
+})
+
+after(() => {
+ jest.resetModules()
+ jest.clearAllMocks()
+})
+
+describe('useLanguage', () => {
+ it('should replace hyphen with underscore in locale', () => {
+ (useContext as jest.Mock).mockReturnValue({
+ locale: 'en-US',
+ })
+ const { result } = renderHook(() => useLanguage())
+ expect(result.current).toBe('en_US')
+ })
+
+ it('should return locale as is if no hyphen exists', () => {
+ (useContext as jest.Mock).mockReturnValue({
+ locale: 'enUS',
+ })
+
+ const { result } = renderHook(() => useLanguage())
+ expect(result.current).toBe('enUS')
+ })
+
+ it('should handle multiple hyphens', () => {
+ // Mock the I18n context return value
+ (useContext as jest.Mock).mockReturnValue({
+ locale: 'zh-Hans-CN',
+ })
+
+ const { result } = renderHook(() => useLanguage())
+ expect(result.current).toBe('zh_Hans-CN')
+ })
+})
diff --git a/web/app/components/header/account-setting/model-provider-page/model-icon/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-icon/index.tsx
index c2fbe7930e..9d1846cdf0 100644
--- a/web/app/components/header/account-setting/model-provider-page/model-icon/index.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/model-icon/index.tsx
@@ -7,7 +7,7 @@ import { useLanguage } from '../hooks'
import { Group } from '@/app/components/base/icons/src/vender/other'
import { OpenaiBlue, OpenaiViolet } from '@/app/components/base/icons/src/public/llm'
import cn from '@/utils/classnames'
-import { renderI18nObject } from '@/hooks/use-i18n'
+import { renderI18nObject } from '@/i18n'
type ModelIconProps = {
provider?: Model | ModelProvider
diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx
index 4adab6d2e0..bd1bb6ced9 100644
--- a/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx
@@ -270,8 +270,7 @@ const ModelModal: FC
= ({
}
const renderTitlePrefix = () => {
- const prefix = configurateMethod === ConfigurationMethodEnum.customizableModel ? t('common.operation.add') : t('common.operation.setup')
-
+ const prefix = isEditMode ? t('common.operation.setup') : t('common.operation.add')
return `${prefix} ${provider.label[language] || provider.label.en_US}`
}
diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger.tsx
index 8a9c6bbf88..7c96c9a0af 100644
--- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger.tsx
@@ -47,7 +47,7 @@ const Trigger: FC = ({
'relative flex h-8 cursor-pointer items-center rounded-lg px-2',
!isInWorkflow && 'border ring-inset hover:ring-[0.5px]',
!isInWorkflow && (disabled ? 'border-text-warning bg-state-warning-hover ring-text-warning' : 'border-util-colors-indigo-indigo-600 bg-state-accent-hover ring-util-colors-indigo-indigo-600'),
- isInWorkflow && 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg pr-[30px] hover:border-gray-200',
+ isInWorkflow && 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg pr-[30px] hover:border-components-input-border-active',
)}
>
{
diff --git a/web/app/components/header/account-setting/model-provider-page/provider-icon/index.tsx b/web/app/components/header/account-setting/model-provider-page/provider-icon/index.tsx
index 1eb579a7a0..253269d920 100644
--- a/web/app/components/header/account-setting/model-provider-page/provider-icon/index.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/provider-icon/index.tsx
@@ -3,7 +3,7 @@ import type { ModelProvider } from '../declarations'
import { useLanguage } from '../hooks'
import { Openai } from '@/app/components/base/icons/src/vender/other'
import { AnthropicDark, AnthropicLight } from '@/app/components/base/icons/src/public/llm'
-import { renderI18nObject } from '@/hooks/use-i18n'
+import { renderI18nObject } from '@/i18n'
import { Theme } from '@/types/app'
import cn from '@/utils/classnames'
import useTheme from '@/hooks/use-theme'
diff --git a/web/app/components/plugins/card/index.tsx b/web/app/components/plugins/card/index.tsx
index f4878a433c..1cc18ac24f 100644
--- a/web/app/components/plugins/card/index.tsx
+++ b/web/app/components/plugins/card/index.tsx
@@ -11,7 +11,7 @@ import cn from '@/utils/classnames'
import { useGetLanguage } from '@/context/i18n'
import { getLanguage } from '@/i18n/language'
import { useSingleCategories } from '../hooks'
-import { renderI18nObject } from '@/hooks/use-i18n'
+import { renderI18nObject } from '@/i18n'
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
import Partner from '../base/badges/partner'
import Verified from '../base/badges/verified'
diff --git a/web/app/components/plugins/hooks.ts b/web/app/components/plugins/hooks.ts
index f4b81d98c1..0349c46f9e 100644
--- a/web/app/components/plugins/hooks.ts
+++ b/web/app/components/plugins/hooks.ts
@@ -92,3 +92,17 @@ export const useSingleCategories = (translateFromOut?: TFunction) => {
categoriesMap,
}
}
+
+export const PLUGIN_PAGE_TABS_MAP = {
+ plugins: 'plugins',
+ marketplace: 'discover',
+}
+
+export const usePluginPageTabs = () => {
+ const { t } = useTranslation()
+ const tabs = [
+ { value: PLUGIN_PAGE_TABS_MAP.plugins, text: t('common.menus.plugins') },
+ { value: PLUGIN_PAGE_TABS_MAP.marketplace, text: t('common.menus.exploreMarketplace') },
+ ]
+ return tabs
+}
diff --git a/web/app/components/plugins/marketplace/context.tsx b/web/app/components/plugins/marketplace/context.tsx
index 53f57c0252..91621afaf8 100644
--- a/web/app/components/plugins/marketplace/context.tsx
+++ b/web/app/components/plugins/marketplace/context.tsx
@@ -35,9 +35,10 @@ import {
import {
getMarketplaceListCondition,
getMarketplaceListFilterType,
+ updateSearchParams,
} from './utils'
import { useInstalledPluginList } from '@/service/use-plugins'
-import { noop } from 'lodash-es'
+import { debounce, noop } from 'lodash-es'
export type MarketplaceContextValue = {
intersected: boolean
@@ -96,6 +97,7 @@ type MarketplaceContextProviderProps = {
searchParams?: SearchParams
shouldExclude?: boolean
scrollContainerId?: string
+ showSearchParams?: boolean
}
export function useMarketplaceContext(selector: (value: MarketplaceContextValue) => any) {
@@ -107,6 +109,7 @@ export const MarketplaceContextProvider = ({
searchParams,
shouldExclude,
scrollContainerId,
+ showSearchParams,
}: MarketplaceContextProviderProps) => {
const { data, isSuccess } = useInstalledPluginList(!shouldExclude)
const exclude = useMemo(() => {
@@ -159,7 +162,10 @@ export const MarketplaceContextProvider = ({
type: getMarketplaceListFilterType(activePluginTypeRef.current),
page: pageRef.current,
})
- history.pushState({}, '', `/${searchParams?.language ? `?language=${searchParams?.language}` : ''}`)
+ const url = new URL(window.location.href)
+ if (searchParams?.language)
+ url.searchParams.set('language', searchParams?.language)
+ history.replaceState({}, '', url)
}
else {
if (shouldExclude && isSuccess) {
@@ -182,7 +188,31 @@ export const MarketplaceContextProvider = ({
resetPlugins()
}, [exclude, queryMarketplaceCollectionsAndPlugins, resetPlugins])
+ const debouncedUpdateSearchParams = useMemo(() => debounce(() => {
+ updateSearchParams({
+ query: searchPluginTextRef.current,
+ category: activePluginTypeRef.current,
+ tags: filterPluginTagsRef.current,
+ })
+ }, 500), [])
+
+ const handleUpdateSearchParams = useCallback((debounced?: boolean) => {
+ if (!showSearchParams)
+ return
+ if (debounced) {
+ debouncedUpdateSearchParams()
+ }
+ else {
+ updateSearchParams({
+ query: searchPluginTextRef.current,
+ category: activePluginTypeRef.current,
+ tags: filterPluginTagsRef.current,
+ })
+ }
+ }, [debouncedUpdateSearchParams, showSearchParams])
+
const handleQueryPlugins = useCallback((debounced?: boolean) => {
+ handleUpdateSearchParams(debounced)
if (debounced) {
queryPluginsWithDebounced({
query: searchPluginTextRef.current,
@@ -207,17 +237,18 @@ export const MarketplaceContextProvider = ({
page: pageRef.current,
})
}
- }, [exclude, queryPluginsWithDebounced, queryPlugins])
+ }, [exclude, queryPluginsWithDebounced, queryPlugins, handleUpdateSearchParams])
const handleQuery = useCallback((debounced?: boolean) => {
if (!searchPluginTextRef.current && !filterPluginTagsRef.current.length) {
+ handleUpdateSearchParams(debounced)
cancelQueryPluginsWithDebounced()
handleQueryMarketplaceCollectionsAndPlugins()
return
}
handleQueryPlugins(debounced)
- }, [handleQueryMarketplaceCollectionsAndPlugins, handleQueryPlugins, cancelQueryPluginsWithDebounced])
+ }, [handleQueryMarketplaceCollectionsAndPlugins, handleQueryPlugins, cancelQueryPluginsWithDebounced, handleUpdateSearchParams])
const handleSearchPluginTextChange = useCallback((text: string) => {
setSearchPluginText(text)
@@ -242,11 +273,9 @@ export const MarketplaceContextProvider = ({
activePluginTypeRef.current = type
setPage(1)
pageRef.current = 1
- }, [])
- useEffect(() => {
handleQuery()
- }, [activePluginType, handleQuery])
+ }, [handleQuery])
const handleSortChange = useCallback((sort: PluginsSort) => {
setSort(sort)
diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx
index 5e6fbeec97..7a29556bda 100644
--- a/web/app/components/plugins/marketplace/index.tsx
+++ b/web/app/components/plugins/marketplace/index.tsx
@@ -17,6 +17,7 @@ type MarketplaceProps = {
pluginTypeSwitchClassName?: string
intersectionContainerId?: string
scrollContainerId?: string
+ showSearchParams?: boolean
}
const Marketplace = async ({
locale,
@@ -27,6 +28,7 @@ const Marketplace = async ({
pluginTypeSwitchClassName,
intersectionContainerId,
scrollContainerId,
+ showSearchParams = true,
}: MarketplaceProps) => {
let marketplaceCollections: any = []
let marketplaceCollectionPluginsMap = {}
@@ -42,6 +44,7 @@ const Marketplace = async ({
searchParams={searchParams}
shouldExclude={shouldExclude}
scrollContainerId={scrollContainerId}
+ showSearchParams={showSearchParams}
>
@@ -53,6 +56,7 @@ const Marketplace = async ({
locale={locale}
className={pluginTypeSwitchClassName}
searchBoxAutoAnimate={searchBoxAutoAnimate}
+ showSearchParams={showSearchParams}
/>
{
const { t } = useMixedTranslation(locale)
const activePluginType = useMarketplaceContext(s => s.activePluginType)
@@ -70,6 +73,23 @@ const PluginTypeSwitch = ({
},
]
+ const handlePopState = useCallback(() => {
+ if (!showSearchParams)
+ return
+ const url = new URL(window.location.href)
+ const category = url.searchParams.get('category') || PLUGIN_TYPE_SEARCH_MAP.all
+ handleActivePluginTypeChange(category)
+ }, [showSearchParams, handleActivePluginTypeChange])
+
+ useEffect(() => {
+ window.addEventListener('popstate', () => {
+ handlePopState()
+ })
+ return () => {
+ window.removeEventListener('popstate', handlePopState)
+ }
+ }, [handlePopState])
+
return (
{
return 'plugin'
}
+
+export const updateSearchParams = (pluginsSearchParams: PluginsSearchParams) => {
+ const { query, category, tags } = pluginsSearchParams
+ const url = new URL(window.location.href)
+ const categoryChanged = url.searchParams.get('category') !== category
+ if (query)
+ url.searchParams.set('q', query)
+ else
+ url.searchParams.delete('q')
+ if (category)
+ url.searchParams.set('category', category)
+ else
+ url.searchParams.delete('category')
+ if (tags && tags.length)
+ url.searchParams.set('tags', tags.join(','))
+ else
+ url.searchParams.delete('tags')
+ history[`${categoryChanged ? 'pushState' : 'replaceState'}`]({}, '', url)
+}
diff --git a/web/app/components/plugins/plugin-page/context.tsx b/web/app/components/plugins/plugin-page/context.tsx
index cf26cd4e08..ae1ad7d053 100644
--- a/web/app/components/plugins/plugin-page/context.tsx
+++ b/web/app/components/plugins/plugin-page/context.tsx
@@ -12,9 +12,9 @@ import {
} from 'use-context-selector'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import type { FilterState } from './filter-management'
-import { useTranslation } from 'react-i18next'
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
import { noop } from 'lodash-es'
+import { PLUGIN_PAGE_TABS_MAP, usePluginPageTabs } from '../hooks'
export type PluginPageContextValue = {
containerRef: React.RefObject
@@ -53,7 +53,6 @@ export function usePluginPageContext(selector: (value: PluginPageContextValue) =
export const PluginPageContextProvider = ({
children,
}: PluginPageContextProviderProps) => {
- const { t } = useTranslation()
const containerRef = useRef(null)
const [filters, setFilters] = useState({
categories: [],
@@ -63,16 +62,10 @@ export const PluginPageContextProvider = ({
const [currentPluginID, setCurrentPluginID] = useState()
const { enable_marketplace } = useAppContextSelector(s => s.systemFeatures)
+ const tabs = usePluginPageTabs()
const options = useMemo(() => {
- return [
- { value: 'plugins', text: t('common.menus.plugins') },
- ...(
- enable_marketplace
- ? [{ value: 'discover', text: t('common.menus.exploreMarketplace') }]
- : []
- ),
- ]
- }, [t, enable_marketplace])
+ return enable_marketplace ? tabs : tabs.filter(tab => tab.value !== PLUGIN_PAGE_TABS_MAP.marketplace)
+ }, [tabs, enable_marketplace])
const [activeTab, setActiveTab] = useTabSearchParams({
defaultTab: options[0].value,
})
diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx
index 801eaf6607..072b8ee22f 100644
--- a/web/app/components/plugins/plugin-page/index.tsx
+++ b/web/app/components/plugins/plugin-page/index.tsx
@@ -40,6 +40,8 @@ import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
import { LanguagesSupported } from '@/i18n/language'
import I18n from '@/context/i18n'
import { noop } from 'lodash-es'
+import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/plugin-type-switch'
+import { PLUGIN_PAGE_TABS_MAP } from '../hooks'
const PACKAGE_IDS_KEY = 'package-ids'
const BUNDLE_INFO_KEY = 'bundle-info'
@@ -136,40 +138,45 @@ const PluginPage = ({
const setActiveTab = usePluginPageContext(v => v.setActiveTab)
const { enable_marketplace } = useAppContextSelector(s => s.systemFeatures)
+ const isPluginsTab = useMemo(() => activeTab === PLUGIN_PAGE_TABS_MAP.plugins, [activeTab])
+ const isExploringMarketplace = useMemo(() => {
+ const values = Object.values(PLUGIN_TYPE_SEARCH_MAP)
+ return activeTab === PLUGIN_PAGE_TABS_MAP.marketplace || values.includes(activeTab)
+ }, [activeTab])
+
const uploaderProps = useUploader({
onFileChange: setCurrentFile,
containerRef,
- enabled: activeTab === 'plugins',
+ enabled: isPluginsTab,
})
const { dragging, fileUploader, fileChangeHandle, removeFile } = uploaderProps
-
return (
{
- activeTab === 'discover' && (
+ isExploringMarketplace && (
<>
- {activeTab === 'plugins' && (
+ {isPluginsTab && (
<>
{plugins}
{dragging && (
@@ -246,7 +253,7 @@ const PluginPage = ({
>
)}
{
- activeTab === 'discover' && enable_marketplace && marketplace
+ isExploringMarketplace && enable_marketplace && marketplace
}
{showPluginSettingModal && (
diff --git a/web/app/components/plugins/plugin-page/plugins-panel.tsx b/web/app/components/plugins/plugin-page/plugins-panel.tsx
index 063cec8721..125e6f0a70 100644
--- a/web/app/components/plugins/plugin-page/plugins-panel.tsx
+++ b/web/app/components/plugins/plugin-page/plugins-panel.tsx
@@ -3,17 +3,23 @@ import { useMemo } from 'react'
import type { FilterState } from './filter-management'
import FilterManagement from './filter-management'
import List from './list'
-import { useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins'
+import { useInstalledLatestVersion, useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins'
import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel'
import { usePluginPageContext } from './context'
import { useDebounceFn } from 'ahooks'
import Empty from './empty'
import Loading from '../../base/loading'
+import { PluginSource } from '../types'
const PluginsPanel = () => {
const filters = usePluginPageContext(v => v.filters) as FilterState
const setFilters = usePluginPageContext(v => v.setFilters)
const { data: pluginList, isLoading: isPluginListLoading } = useInstalledPluginList()
+ const { data: installedLatestVersion } = useInstalledLatestVersion(
+ pluginList?.plugins
+ .filter(plugin => plugin.source === PluginSource.marketplace)
+ .map(plugin => plugin.plugin_id) ?? [],
+ )
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
const currentPluginID = usePluginPageContext(v => v.currentPluginID)
const setCurrentPluginID = usePluginPageContext(v => v.setCurrentPluginID)
@@ -22,9 +28,17 @@ const PluginsPanel = () => {
setFilters(filters)
}, { wait: 500 })
+ const pluginListWithLatestVersion = useMemo(() => {
+ return pluginList?.plugins.map(plugin => ({
+ ...plugin,
+ latest_version: installedLatestVersion?.versions[plugin.plugin_id]?.version ?? '',
+ latest_unique_identifier: installedLatestVersion?.versions[plugin.plugin_id]?.unique_identifier ?? '',
+ })) || []
+ }, [pluginList, installedLatestVersion])
+
const filteredList = useMemo(() => {
const { categories, searchQuery, tags } = filters
- const filteredList = pluginList?.plugins.filter((plugin) => {
+ const filteredList = pluginListWithLatestVersion.filter((plugin) => {
return (
(categories.length === 0 || categories.includes(plugin.declaration.category))
&& (tags.length === 0 || tags.some(tag => plugin.declaration.tags.includes(tag)))
@@ -32,12 +46,12 @@ const PluginsPanel = () => {
)
})
return filteredList
- }, [pluginList, filters])
+ }, [pluginListWithLatestVersion, filters])
const currentPluginDetail = useMemo(() => {
- const detail = pluginList?.plugins.find(plugin => plugin.plugin_id === currentPluginID)
+ const detail = pluginListWithLatestVersion.find(plugin => plugin.plugin_id === currentPluginID)
return detail
- }, [currentPluginID, pluginList?.plugins])
+ }, [currentPluginID, pluginListWithLatestVersion])
const handleHide = () => setCurrentPluginID(undefined)
diff --git a/web/app/components/plugins/types.ts b/web/app/components/plugins/types.ts
index 1ed379511b..64f15a08a9 100644
--- a/web/app/components/plugins/types.ts
+++ b/web/app/components/plugins/types.ts
@@ -318,6 +318,15 @@ export type InstalledPluginListResponse = {
plugins: PluginDetail[]
}
+export type InstalledLatestVersionResponse = {
+ versions: {
+ [plugin_id: string]: {
+ unique_identifier: string
+ version: string
+ } | null
+ }
+}
+
export type UninstallPluginResponse = {
success: boolean
}
diff --git a/web/app/components/share/text-generation/run-once/index.tsx b/web/app/components/share/text-generation/run-once/index.tsx
index f31c5d5e85..e413bd53ac 100644
--- a/web/app/components/share/text-generation/run-once/index.tsx
+++ b/web/app/components/share/text-generation/run-once/index.tsx
@@ -1,4 +1,4 @@
-import type { FC, FormEvent } from 'react'
+import type { ChangeEvent, FC, FormEvent } from 'react'
import { useEffect } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
@@ -66,75 +66,73 @@ const RunOnce: FC
= ({
newInputs[item.key] = ''
})
onInputsChange(newInputs)
- }, [promptConfig.prompt_variables])
-
- if (inputs === null || inputs === undefined || Object.keys(inputs).length === 0)
- return null
+ }, [promptConfig.prompt_variables, onInputsChange])
return (