Introduce Plugins (#13836)

Signed-off-by: yihong0618 <zouzou0208@gmail.com>
Signed-off-by: -LAN- <laipz8200@outlook.com>
Signed-off-by: xhe <xw897002528@gmail.com>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: takatost <takatost@gmail.com>
Co-authored-by: kurokobo <kuro664@gmail.com>
Co-authored-by: Novice Lee <novicelee@NoviPro.local>
Co-authored-by: zxhlyh <jasonapring2015@outlook.com>
Co-authored-by: AkaraChen <akarachen@outlook.com>
Co-authored-by: Yi <yxiaoisme@gmail.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: JzoNg <jzongcode@gmail.com>
Co-authored-by: twwu <twwu@dify.ai>
Co-authored-by: Hiroshi Fujita <fujita-h@users.noreply.github.com>
Co-authored-by: AkaraChen <85140972+AkaraChen@users.noreply.github.com>
Co-authored-by: NFish <douxc512@gmail.com>
Co-authored-by: Wu Tianwei <30284043+WTW0313@users.noreply.github.com>
Co-authored-by: 非法操作 <hjlarry@163.com>
Co-authored-by: Novice <857526207@qq.com>
Co-authored-by: Hiroki Nagai <82458324+nagaihiroki-git@users.noreply.github.com>
Co-authored-by: Gen Sato <52241300+halogen22@users.noreply.github.com>
Co-authored-by: eux <euxuuu@gmail.com>
Co-authored-by: huangzhuo1949 <167434202+huangzhuo1949@users.noreply.github.com>
Co-authored-by: huangzhuo <huangzhuo1@xiaomi.com>
Co-authored-by: lotsik <lotsik@mail.ru>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: nite-knite <nkCoding@gmail.com>
Co-authored-by: Jyong <76649700+JohnJyong@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: gakkiyomi <gakkiyomi@aliyun.com>
Co-authored-by: CN-P5 <heibai2006@gmail.com>
Co-authored-by: CN-P5 <heibai2006@qq.com>
Co-authored-by: Chuehnone <1897025+chuehnone@users.noreply.github.com>
Co-authored-by: yihong <zouzou0208@gmail.com>
Co-authored-by: Kevin9703 <51311316+Kevin9703@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: Boris Feld <lothiraldan@gmail.com>
Co-authored-by: mbo <himabo@gmail.com>
Co-authored-by: mabo <mabo@aeyes.ai>
Co-authored-by: Warren Chen <warren.chen830@gmail.com>
Co-authored-by: JzoNgKVO <27049666+JzoNgKVO@users.noreply.github.com>
Co-authored-by: jiandanfeng <chenjh3@wangsu.com>
Co-authored-by: zhu-an <70234959+xhdd123321@users.noreply.github.com>
Co-authored-by: zhaoqingyu.1075 <zhaoqingyu.1075@bytedance.com>
Co-authored-by: 海狸大師 <86974027+yenslife@users.noreply.github.com>
Co-authored-by: Xu Song <xusong.vip@gmail.com>
Co-authored-by: rayshaw001 <396301947@163.com>
Co-authored-by: Ding Jiatong <dingjiatong@gmail.com>
Co-authored-by: Bowen Liang <liangbowen@gf.com.cn>
Co-authored-by: JasonVV <jasonwangiii@outlook.com>
Co-authored-by: le0zh <newlight@qq.com>
Co-authored-by: zhuxinliang <zhuxinliang@didiglobal.com>
Co-authored-by: k-zaku <zaku99@outlook.jp>
Co-authored-by: luckylhb90 <luckylhb90@gmail.com>
Co-authored-by: hobo.l <hobo.l@binance.com>
Co-authored-by: jiangbo721 <365065261@qq.com>
Co-authored-by: 刘江波 <jiangbo721@163.com>
Co-authored-by: Shun Miyazawa <34241526+miya@users.noreply.github.com>
Co-authored-by: EricPan <30651140+Egfly@users.noreply.github.com>
Co-authored-by: crazywoola <427733928@qq.com>
Co-authored-by: sino <sino2322@gmail.com>
Co-authored-by: Jhvcc <37662342+Jhvcc@users.noreply.github.com>
Co-authored-by: lowell <lowell.hu@zkteco.in>
Co-authored-by: Boris Polonsky <BorisPolonsky@users.noreply.github.com>
Co-authored-by: Ademílson Tonato <ademilsonft@outlook.com>
Co-authored-by: Ademílson Tonato <ademilson.tonato@refurbed.com>
Co-authored-by: IWAI, Masaharu <iwaim.sub@gmail.com>
Co-authored-by: Yueh-Po Peng (Yabi) <94939112+y10ab1@users.noreply.github.com>
Co-authored-by: Jason <ggbbddjm@gmail.com>
Co-authored-by: Xin Zhang <sjhpzx@gmail.com>
Co-authored-by: yjc980121 <3898524+yjc980121@users.noreply.github.com>
Co-authored-by: heyszt <36215648+hieheihei@users.noreply.github.com>
Co-authored-by: Abdullah AlOsaimi <osaimiacc@gmail.com>
Co-authored-by: Abdullah AlOsaimi <189027247+osaimi@users.noreply.github.com>
Co-authored-by: Yingchun Lai <laiyingchun@apache.org>
Co-authored-by: Hash Brown <hi@xzd.me>
Co-authored-by: zuodongxu <192560071+zuodongxu@users.noreply.github.com>
Co-authored-by: Masashi Tomooka <tmokmss@users.noreply.github.com>
Co-authored-by: aplio <ryo.091219@gmail.com>
Co-authored-by: Obada Khalili <54270856+obadakhalili@users.noreply.github.com>
Co-authored-by: Nam Vu <zuzoovn@gmail.com>
Co-authored-by: Kei YAMAZAKI <1715090+kei-yamazaki@users.noreply.github.com>
Co-authored-by: TechnoHouse <13776377+deephbz@users.noreply.github.com>
Co-authored-by: Riddhimaan-Senapati <114703025+Riddhimaan-Senapati@users.noreply.github.com>
Co-authored-by: MaFee921 <31881301+2284730142@users.noreply.github.com>
Co-authored-by: te-chan <t-nakanome@sakura-is.co.jp>
Co-authored-by: HQidea <HQidea@users.noreply.github.com>
Co-authored-by: Joshbly <36315710+Joshbly@users.noreply.github.com>
Co-authored-by: xhe <xw897002528@gmail.com>
Co-authored-by: weiwenyan-dev <154779315+weiwenyan-dev@users.noreply.github.com>
Co-authored-by: ex_wenyan.wei <ex_wenyan.wei@tcl.com>
Co-authored-by: engchina <12236799+engchina@users.noreply.github.com>
Co-authored-by: engchina <atjapan2015@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: 呆萌闷油瓶 <253605712@qq.com>
Co-authored-by: Kemal <kemalmeler@outlook.com>
Co-authored-by: Lazy_Frog <4590648+lazyFrogLOL@users.noreply.github.com>
Co-authored-by: Yi Xiao <54782454+YIXIAO0@users.noreply.github.com>
Co-authored-by: Steven sun <98230804+Tuyohai@users.noreply.github.com>
Co-authored-by: steven <sunzwj@digitalchina.com>
Co-authored-by: Kalo Chin <91766386+fdb02983rhy@users.noreply.github.com>
Co-authored-by: Katy Tao <34019945+KatyTao@users.noreply.github.com>
Co-authored-by: depy <42985524+h4ckdepy@users.noreply.github.com>
Co-authored-by: 胡春东 <gycm520@gmail.com>
Co-authored-by: Junjie.M <118170653@qq.com>
Co-authored-by: MuYu <mr.muzea@gmail.com>
Co-authored-by: Naoki Takashima <39912547+takatea@users.noreply.github.com>
Co-authored-by: Summer-Gu <37869445+gubinjie@users.noreply.github.com>
Co-authored-by: Fei He <droxer.he@gmail.com>
Co-authored-by: ybalbert001 <120714773+ybalbert001@users.noreply.github.com>
Co-authored-by: Yuanbo Li <ybalbert@amazon.com>
Co-authored-by: douxc <7553076+douxc@users.noreply.github.com>
Co-authored-by: liuzhenghua <1090179900@qq.com>
Co-authored-by: Wu Jiayang <62842862+Wu-Jiayang@users.noreply.github.com>
Co-authored-by: Your Name <you@example.com>
Co-authored-by: kimjion <45935338+kimjion@users.noreply.github.com>
Co-authored-by: AugNSo <song.tiankai@icloud.com>
Co-authored-by: llinvokerl <38915183+llinvokerl@users.noreply.github.com>
Co-authored-by: liusurong.lsr <liusurong.lsr@alibaba-inc.com>
Co-authored-by: Vasu Negi <vasu-negi@users.noreply.github.com>
Co-authored-by: Hundredwz <1808096180@qq.com>
Co-authored-by: Xiyuan Chen <52963600+GareArc@users.noreply.github.com>
This commit is contained in:
Yeuoly
2025-02-17 17:05:13 +08:00
committed by GitHub
parent 222df44d21
commit 403e2d58b9
3272 changed files with 66339 additions and 281594 deletions

View File

@@ -0,0 +1,4 @@
export const DEFAULT_SORT = {
sortBy: 'install_count',
sortOrder: 'DESC',
}

View File

@@ -0,0 +1,316 @@
'use client'
import type {
ReactNode,
} from 'react'
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {
createContext,
useContextSelector,
} from 'use-context-selector'
import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch'
import type { Plugin } from '../types'
import {
getValidCategoryKeys,
getValidTagKeys,
} from '../utils'
import type {
MarketplaceCollection,
PluginsSort,
SearchParams,
SearchParamsFromCollection,
} from './types'
import { DEFAULT_SORT } from './constants'
import {
useMarketplaceCollectionsAndPlugins,
useMarketplaceContainerScroll,
useMarketplacePlugins,
} from './hooks'
import {
getMarketplaceListCondition,
getMarketplaceListFilterType,
} from './utils'
import { useInstalledPluginList } from '@/service/use-plugins'
export type MarketplaceContextValue = {
intersected: boolean
setIntersected: (intersected: boolean) => void
searchPluginText: string
handleSearchPluginTextChange: (text: string) => void
filterPluginTags: string[]
handleFilterPluginTagsChange: (tags: string[]) => void
activePluginType: string
handleActivePluginTypeChange: (type: string) => void
page: number
handlePageChange: (page: number) => void
plugins?: Plugin[]
pluginsTotal?: number
resetPlugins: () => void
sort: PluginsSort
handleSortChange: (sort: PluginsSort) => void
handleQueryPlugins: () => void
handleMoreClick: (searchParams: SearchParamsFromCollection) => void
marketplaceCollectionsFromClient?: MarketplaceCollection[]
setMarketplaceCollectionsFromClient: (collections: MarketplaceCollection[]) => void
marketplaceCollectionPluginsMapFromClient?: Record<string, Plugin[]>
setMarketplaceCollectionPluginsMapFromClient: (map: Record<string, Plugin[]>) => void
isLoading: boolean
isSuccessCollections: boolean
}
export const MarketplaceContext = createContext<MarketplaceContextValue>({
intersected: true,
setIntersected: () => {},
searchPluginText: '',
handleSearchPluginTextChange: () => {},
filterPluginTags: [],
handleFilterPluginTagsChange: () => {},
activePluginType: 'all',
handleActivePluginTypeChange: () => {},
page: 1,
handlePageChange: () => {},
plugins: undefined,
pluginsTotal: 0,
resetPlugins: () => {},
sort: DEFAULT_SORT,
handleSortChange: () => {},
handleQueryPlugins: () => {},
handleMoreClick: () => {},
marketplaceCollectionsFromClient: [],
setMarketplaceCollectionsFromClient: () => {},
marketplaceCollectionPluginsMapFromClient: {},
setMarketplaceCollectionPluginsMapFromClient: () => {},
isLoading: false,
isSuccessCollections: false,
})
type MarketplaceContextProviderProps = {
children: ReactNode
searchParams?: SearchParams
shouldExclude?: boolean
scrollContainerId?: string
}
export function useMarketplaceContext(selector: (value: MarketplaceContextValue) => any) {
return useContextSelector(MarketplaceContext, selector)
}
export const MarketplaceContextProvider = ({
children,
searchParams,
shouldExclude,
scrollContainerId,
}: MarketplaceContextProviderProps) => {
const { data, isSuccess } = useInstalledPluginList(!shouldExclude)
const exclude = useMemo(() => {
if (shouldExclude)
return data?.plugins.map(plugin => plugin.plugin_id)
}, [data?.plugins, shouldExclude])
const queryFromSearchParams = searchParams?.q || ''
const tagsFromSearchParams = searchParams?.tags ? getValidTagKeys(searchParams.tags.split(',')) : []
const hasValidTags = !!tagsFromSearchParams.length
const hasValidCategory = getValidCategoryKeys(searchParams?.category)
const categoryFromSearchParams = hasValidCategory || PLUGIN_TYPE_SEARCH_MAP.all
const [intersected, setIntersected] = useState(true)
const [searchPluginText, setSearchPluginText] = useState(queryFromSearchParams)
const searchPluginTextRef = useRef(searchPluginText)
const [filterPluginTags, setFilterPluginTags] = useState<string[]>(tagsFromSearchParams)
const filterPluginTagsRef = useRef(filterPluginTags)
const [activePluginType, setActivePluginType] = useState(categoryFromSearchParams)
const activePluginTypeRef = useRef(activePluginType)
const [page, setPage] = useState(1)
const pageRef = useRef(page)
const [sort, setSort] = useState(DEFAULT_SORT)
const sortRef = useRef(sort)
const {
marketplaceCollections: marketplaceCollectionsFromClient,
setMarketplaceCollections: setMarketplaceCollectionsFromClient,
marketplaceCollectionPluginsMap: marketplaceCollectionPluginsMapFromClient,
setMarketplaceCollectionPluginsMap: setMarketplaceCollectionPluginsMapFromClient,
queryMarketplaceCollectionsAndPlugins,
isLoading,
isSuccess: isSuccessCollections,
} = useMarketplaceCollectionsAndPlugins()
const {
plugins,
total: pluginsTotal,
resetPlugins,
queryPlugins,
queryPluginsWithDebounced,
isLoading: isPluginsLoading,
} = useMarketplacePlugins()
useEffect(() => {
if (queryFromSearchParams || hasValidTags || hasValidCategory) {
queryPlugins({
query: queryFromSearchParams,
category: hasValidCategory,
tags: hasValidTags ? tagsFromSearchParams : [],
sortBy: sortRef.current.sortBy,
sortOrder: sortRef.current.sortOrder,
type: getMarketplaceListFilterType(activePluginTypeRef.current),
page: pageRef.current,
})
history.pushState({}, '', `/${searchParams?.language ? `?language=${searchParams?.language}` : ''}`)
}
else {
if (shouldExclude && isSuccess) {
queryMarketplaceCollectionsAndPlugins({
exclude,
type: getMarketplaceListFilterType(activePluginTypeRef.current),
})
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryPlugins, queryMarketplaceCollectionsAndPlugins, isSuccess, exclude])
const handleQueryMarketplaceCollectionsAndPlugins = useCallback(() => {
queryMarketplaceCollectionsAndPlugins({
category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current,
condition: getMarketplaceListCondition(activePluginTypeRef.current),
exclude,
type: getMarketplaceListFilterType(activePluginTypeRef.current),
})
resetPlugins()
}, [exclude, queryMarketplaceCollectionsAndPlugins, resetPlugins])
const handleQueryPlugins = useCallback((debounced?: boolean) => {
if (debounced) {
queryPluginsWithDebounced({
query: searchPluginTextRef.current,
category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current,
tags: filterPluginTagsRef.current,
sortBy: sortRef.current.sortBy,
sortOrder: sortRef.current.sortOrder,
exclude,
type: getMarketplaceListFilterType(activePluginTypeRef.current),
page: pageRef.current,
})
}
else {
queryPlugins({
query: searchPluginTextRef.current,
category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current,
tags: filterPluginTagsRef.current,
sortBy: sortRef.current.sortBy,
sortOrder: sortRef.current.sortOrder,
exclude,
type: getMarketplaceListFilterType(activePluginTypeRef.current),
page: pageRef.current,
})
}
}, [exclude, queryPluginsWithDebounced, queryPlugins])
const handleQuery = useCallback((debounced?: boolean) => {
if (!searchPluginTextRef.current && !filterPluginTagsRef.current.length) {
handleQueryMarketplaceCollectionsAndPlugins()
return
}
handleQueryPlugins(debounced)
}, [handleQueryMarketplaceCollectionsAndPlugins, handleQueryPlugins])
const handleSearchPluginTextChange = useCallback((text: string) => {
setSearchPluginText(text)
searchPluginTextRef.current = text
setPage(1)
pageRef.current = 1
handleQuery(true)
}, [handleQuery])
const handleFilterPluginTagsChange = useCallback((tags: string[]) => {
setFilterPluginTags(tags)
filterPluginTagsRef.current = tags
setPage(1)
pageRef.current = 1
handleQuery()
}, [handleQuery])
const handleActivePluginTypeChange = useCallback((type: string) => {
setActivePluginType(type)
activePluginTypeRef.current = type
setPage(1)
pageRef.current = 1
}, [])
useEffect(() => {
handleQuery()
}, [activePluginType, handleQuery])
const handleSortChange = useCallback((sort: PluginsSort) => {
setSort(sort)
sortRef.current = sort
setPage(1)
pageRef.current = 1
handleQueryPlugins()
}, [handleQueryPlugins])
const handlePageChange = useCallback(() => {
if (pluginsTotal && plugins && pluginsTotal > plugins.length) {
setPage(pageRef.current + 1)
pageRef.current++
handleQueryPlugins()
}
}, [handleQueryPlugins, plugins, pluginsTotal])
const handleMoreClick = useCallback((searchParams: SearchParamsFromCollection) => {
setSearchPluginText(searchParams?.query || '')
searchPluginTextRef.current = searchParams?.query || ''
setSort({
sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy,
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
})
sortRef.current = {
sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy,
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
}
setPage(1)
pageRef.current = 1
handleQueryPlugins()
}, [handleQueryPlugins])
useMarketplaceContainerScroll(handlePageChange, scrollContainerId)
return (
<MarketplaceContext.Provider
value={{
intersected,
setIntersected,
searchPluginText,
handleSearchPluginTextChange,
filterPluginTags,
handleFilterPluginTagsChange,
activePluginType,
handleActivePluginTypeChange,
page,
handlePageChange,
plugins,
pluginsTotal,
resetPlugins,
sort,
handleSortChange,
handleQueryPlugins,
handleMoreClick,
marketplaceCollectionsFromClient,
setMarketplaceCollectionsFromClient,
marketplaceCollectionPluginsMapFromClient,
setMarketplaceCollectionPluginsMapFromClient,
isLoading: isLoading || isPluginsLoading,
isSuccessCollections,
}}
>
{children}
</MarketplaceContext.Provider>
)
}

View File

@@ -0,0 +1,71 @@
import {
getLocaleOnServer,
useTranslation as translate,
} from '@/i18n/server'
type DescriptionProps = {
locale?: string
}
const Description = async ({
locale: localeFromProps,
}: DescriptionProps) => {
const localeDefault = getLocaleOnServer()
const { t } = await translate(localeFromProps || localeDefault, 'plugin')
const { t: tCommon } = await translate(localeFromProps || localeDefault, 'common')
const isZhHans = localeFromProps === 'zh-Hans'
return (
<>
<h1 className='shrink-0 mb-2 text-center title-4xl-semi-bold text-text-primary'>
{t('marketplace.empower')}
</h1>
<h2 className='shrink-0 flex justify-center items-center text-center body-md-regular text-text-tertiary'>
{
isZhHans && (
<>
<span className='mr-1'>{tCommon('operation.in')}</span>
{t('marketplace.difyMarketplace')}
{t('marketplace.discover')}
</>
)
}
{
!isZhHans && (
<>
{t('marketplace.discover')}
</>
)
}
<span className="relative ml-1 body-md-medium text-text-secondary after:content-[''] after:absolute after:left-0 after:bottom-[1.5px] after:w-full after:h-2 after:bg-text-text-selected z-[1]">
<span className='relative z-[2] lowercase'>{t('category.models')}</span>
</span>
,
<span className="relative ml-1 body-md-medium text-text-secondary after:content-[''] after:absolute after:left-0 after:bottom-[1.5px] after:w-full after:h-2 after:bg-text-text-selected z-[1]">
<span className='relative z-[2] lowercase'>{t('category.tools')}</span>
</span>
,
<span className="relative ml-1 body-md-medium text-text-secondary after:content-[''] after:absolute after:left-0 after:bottom-[1.5px] after:w-full after:h-2 after:bg-text-text-selected z-[1]">
<span className='relative z-[2] lowercase'>{t('category.agents')}</span>
</span>
,
<span className="relative ml-1 mr-1 body-md-medium text-text-secondary after:content-[''] after:absolute after:left-0 after:bottom-[1.5px] after:w-full after:h-2 after:bg-text-text-selected z-[1]">
<span className='relative z-[2] lowercase'>{t('category.extensions')}</span>
</span>
{t('marketplace.and')}
<span className="relative ml-1 mr-1 body-md-medium text-text-secondary after:content-[''] after:absolute after:left-0 after:bottom-[1.5px] after:w-full after:h-2 after:bg-text-text-selected z-[1]">
<span className='relative z-[2] lowercase'>{t('category.bundles')}</span>
</span>
{
!isZhHans && (
<>
<span className='mr-1'>{tCommon('operation.in')}</span>
{t('marketplace.difyMarketplace')}
</>
)
}
</h2>
</>
)
}
export default Description

View File

@@ -0,0 +1,63 @@
'use client'
import { Group } from '@/app/components/base/icons/src/vender/other'
import Line from './line'
import cn from '@/utils/classnames'
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
type Props = {
text?: string
lightCard?: boolean
className?: string
locale?: string
}
const Empty = ({
text,
lightCard,
className,
locale,
}: Props) => {
const { t } = useMixedTranslation(locale)
return (
<div
className={cn('grow relative h-0 flex flex-wrap p-2 overflow-hidden', className)}
>
{
Array.from({ length: 16 }).map((_, index) => (
<div
key={index}
className={cn(
'mr-3 mb-3 h-[144px] w-[calc((100%-36px)/4)] rounded-xl bg-background-section-burn',
index % 4 === 3 && 'mr-0',
index > 11 && 'mb-0',
lightCard && 'bg-background-default-lighter opacity-75',
)}
>
</div>
))
}
{
!lightCard && (
<div
className='absolute inset-0 bg-marketplace-plugin-empty z-[1]'
></div>
)
}
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[2] flex flex-col items-center'>
<div className='relative flex items-center justify-center mb-3 w-14 h-14 rounded-xl border border-dashed border-divider-deep bg-components-card-bg shadow-lg'>
<Group className='w-5 h-5' />
<Line className='absolute right-[-1px] top-1/2 -translate-y-1/2' />
<Line className='absolute left-[-1px] top-1/2 -translate-y-1/2' />
<Line className='absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 rotate-90' />
<Line className='absolute top-full left-1/2 -translate-x-1/2 -translate-y-1/2 rotate-90' />
</div>
<div className='text-center system-md-regular text-text-tertiary'>
{text || t('plugin.marketplace.noPluginFound')}
</div>
</div>
</div>
)
}
export default Empty

View File

@@ -0,0 +1,21 @@
type LineProps = {
className?: string
}
const Line = ({
className,
}: LineProps) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="2" height="241" viewBox="0 0 2 241" fill="none" className={className}>
<path d="M1 0.5L1 240.5" stroke="url(#paint0_linear_1989_74474)"/>
<defs>
<linearGradient id="paint0_linear_1989_74474" x1="-7.99584" y1="240.5" x2="-7.88094" y2="0.50004" gradientUnits="userSpaceOnUse">
<stop stopColor="white" stopOpacity="0.01"/>
<stop offset="0.503965" stopColor="#101828" stopOpacity="0.08"/>
<stop offset="1" stopColor="white" stopOpacity="0.01"/>
</linearGradient>
</defs>
</svg>
)
}
export default Line

View File

@@ -0,0 +1,176 @@
import {
useCallback,
useEffect,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useDebounceFn } from 'ahooks'
import type {
Plugin,
} from '../types'
import type {
CollectionsAndPluginsSearchParams,
MarketplaceCollection,
PluginsSearchParams,
} from './types'
import {
getFormattedPlugin,
getMarketplaceCollectionsAndPlugins,
} from './utils'
import i18n from '@/i18n/i18next-config'
import {
useMutationPluginsFromMarketplace,
} from '@/service/use-plugins'
export const useMarketplaceCollectionsAndPlugins = () => {
const [isLoading, setIsLoading] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [marketplaceCollections, setMarketplaceCollections] = useState<MarketplaceCollection[]>()
const [marketplaceCollectionPluginsMap, setMarketplaceCollectionPluginsMap] = useState<Record<string, Plugin[]>>()
const queryMarketplaceCollectionsAndPlugins = useCallback(async (query?: CollectionsAndPluginsSearchParams) => {
try {
setIsLoading(true)
setIsSuccess(false)
const { marketplaceCollections, marketplaceCollectionPluginsMap } = await getMarketplaceCollectionsAndPlugins(query)
setIsLoading(false)
setIsSuccess(true)
setMarketplaceCollections(marketplaceCollections)
setMarketplaceCollectionPluginsMap(marketplaceCollectionPluginsMap)
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
setIsLoading(false)
setIsSuccess(false)
}
}, [])
return {
marketplaceCollections,
setMarketplaceCollections,
marketplaceCollectionPluginsMap,
setMarketplaceCollectionPluginsMap,
queryMarketplaceCollectionsAndPlugins,
isLoading,
isSuccess,
}
}
export const useMarketplacePlugins = () => {
const {
data,
mutateAsync,
reset,
isPending,
} = useMutationPluginsFromMarketplace()
const [prevPlugins, setPrevPlugins] = useState<Plugin[] | undefined>()
const resetPlugins = useCallback(() => {
reset()
setPrevPlugins(undefined)
}, [reset])
const handleUpdatePlugins = useCallback((pluginsSearchParams: PluginsSearchParams) => {
mutateAsync(pluginsSearchParams).then((res) => {
const currentPage = pluginsSearchParams.page || 1
const resPlugins = res.data.bundles || res.data.plugins
if (currentPage > 1) {
setPrevPlugins(prevPlugins => [...(prevPlugins || []), ...resPlugins.map((plugin) => {
return getFormattedPlugin(plugin)
})])
}
else {
setPrevPlugins(resPlugins.map((plugin) => {
return getFormattedPlugin(plugin)
}))
}
})
}, [mutateAsync])
const queryPlugins = useCallback((pluginsSearchParams: PluginsSearchParams) => {
handleUpdatePlugins(pluginsSearchParams)
}, [handleUpdatePlugins])
const { run: queryPluginsWithDebounced } = useDebounceFn((pluginsSearchParams: PluginsSearchParams) => {
handleUpdatePlugins(pluginsSearchParams)
}, {
wait: 500,
})
return {
plugins: prevPlugins,
total: data?.data?.total,
resetPlugins,
queryPlugins,
queryPluginsWithDebounced,
isLoading: isPending,
}
}
export const useMixedTranslation = (localeFromOuter?: string) => {
let t = useTranslation().t
if (localeFromOuter)
t = i18n.getFixedT(localeFromOuter)
return {
t,
}
}
export const useMarketplaceContainerScroll = (
callback: () => void,
scrollContainerId = 'marketplace-container',
) => {
const container = document.getElementById(scrollContainerId)
const handleScroll = useCallback((e: Event) => {
const target = e.target as HTMLDivElement
const {
scrollTop,
scrollHeight,
clientHeight,
} = target
if (scrollTop + clientHeight >= scrollHeight - 5 && scrollTop > 0)
callback()
}, [callback])
useEffect(() => {
if (container)
container.addEventListener('scroll', handleScroll)
return () => {
if (container)
container.removeEventListener('scroll', handleScroll)
}
}, [container, handleScroll])
}
export const useSearchBoxAutoAnimate = (searchBoxAutoAnimate?: boolean) => {
const [searchBoxCanAnimate, setSearchBoxCanAnimate] = useState(true)
const handleSearchBoxCanAnimateChange = useCallback(() => {
if (!searchBoxAutoAnimate) {
const clientWidth = document.documentElement.clientWidth
if (clientWidth < 1400)
setSearchBoxCanAnimate(false)
else
setSearchBoxCanAnimate(true)
}
}, [searchBoxAutoAnimate])
useEffect(() => {
handleSearchBoxCanAnimateChange()
}, [handleSearchBoxCanAnimateChange])
useEffect(() => {
window.addEventListener('resize', handleSearchBoxCanAnimateChange)
return () => {
window.removeEventListener('resize', handleSearchBoxCanAnimateChange)
}
}, [handleSearchBoxCanAnimateChange])
return {
searchBoxCanAnimate,
}
}

View File

@@ -0,0 +1,68 @@
import { MarketplaceContextProvider } from './context'
import Description from './description'
import IntersectionLine from './intersection-line'
import SearchBoxWrapper from './search-box/search-box-wrapper'
import PluginTypeSwitch from './plugin-type-switch'
import ListWrapper from './list/list-wrapper'
import type { SearchParams } from './types'
import { getMarketplaceCollectionsAndPlugins } from './utils'
import { TanstackQueryIniter } from '@/context/query-client'
type MarketplaceProps = {
locale: string
searchBoxAutoAnimate?: boolean
showInstallButton?: boolean
shouldExclude?: boolean
searchParams?: SearchParams
pluginTypeSwitchClassName?: string
intersectionContainerId?: string
scrollContainerId?: string
}
const Marketplace = async ({
locale,
searchBoxAutoAnimate = true,
showInstallButton = true,
shouldExclude,
searchParams,
pluginTypeSwitchClassName,
intersectionContainerId,
scrollContainerId,
}: MarketplaceProps) => {
let marketplaceCollections: any = []
let marketplaceCollectionPluginsMap = {}
if (!shouldExclude) {
const marketplaceCollectionsAndPluginsData = await getMarketplaceCollectionsAndPlugins()
marketplaceCollections = marketplaceCollectionsAndPluginsData.marketplaceCollections
marketplaceCollectionPluginsMap = marketplaceCollectionsAndPluginsData.marketplaceCollectionPluginsMap
}
return (
<TanstackQueryIniter>
<MarketplaceContextProvider
searchParams={searchParams}
shouldExclude={shouldExclude}
scrollContainerId={scrollContainerId}
>
<Description locale={locale} />
<IntersectionLine intersectionContainerId={intersectionContainerId} />
<SearchBoxWrapper
locale={locale}
searchBoxAutoAnimate={searchBoxAutoAnimate}
/>
<PluginTypeSwitch
locale={locale}
className={pluginTypeSwitchClassName}
searchBoxAutoAnimate={searchBoxAutoAnimate}
/>
<ListWrapper
locale={locale}
marketplaceCollections={marketplaceCollections}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
showInstallButton={showInstallButton}
/>
</MarketplaceContextProvider>
</TanstackQueryIniter>
)
}
export default Marketplace

View File

@@ -0,0 +1,30 @@
import { useEffect } from 'react'
import { useMarketplaceContext } from '@/app/components/plugins/marketplace/context'
export const useScrollIntersection = (
anchorRef: React.RefObject<HTMLDivElement>,
intersectionContainerId = 'marketplace-container',
) => {
const intersected = useMarketplaceContext(v => v.intersected)
const setIntersected = useMarketplaceContext(v => v.setIntersected)
useEffect(() => {
const container = document.getElementById(intersectionContainerId)
let observer: IntersectionObserver | undefined
if (container && anchorRef.current) {
observer = new IntersectionObserver((entries) => {
const isIntersecting = entries[0].isIntersecting
if (isIntersecting && !intersected)
setIntersected(true)
if (!isIntersecting && intersected)
setIntersected(false)
}, {
root: container,
})
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [anchorRef, intersected, setIntersected, intersectionContainerId])
}

View File

@@ -0,0 +1,21 @@
'use client'
import { useRef } from 'react'
import { useScrollIntersection } from './hooks'
type IntersectionLineProps = {
intersectionContainerId?: string
}
const IntersectionLine = ({
intersectionContainerId,
}: IntersectionLineProps) => {
const ref = useRef<HTMLDivElement>(null)
useScrollIntersection(ref, intersectionContainerId)
return (
<div ref={ref} className='shrink-0 mb-4 h-[1px] bg-transparent'></div>
)
}
export default IntersectionLine

View File

@@ -0,0 +1,103 @@
'use client'
import { RiArrowRightUpLine } from '@remixicon/react'
import { getPluginLinkInMarketplace } from '../utils'
import Card from '@/app/components/plugins/card'
import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
import type { Plugin } from '@/app/components/plugins/types'
import Button from '@/app/components/base/button'
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import { useBoolean } from 'ahooks'
import { useI18N } from '@/context/i18n'
import { useTags } from '@/app/components/plugins/hooks'
type CardWrapperProps = {
plugin: Plugin
showInstallButton?: boolean
locale?: string
}
const CardWrapper = ({
plugin,
showInstallButton,
locale,
}: CardWrapperProps) => {
const { t } = useMixedTranslation(locale)
const [isShowInstallFromMarketplace, {
setTrue: showInstallFromMarketplace,
setFalse: hideInstallFromMarketplace,
}] = useBoolean(false)
const { locale: localeFromLocale } = useI18N()
const { tagsMap } = useTags(t)
if (showInstallButton) {
return (
<div
className='group relative rounded-xl cursor-pointer hover:bg-components-panel-on-panel-item-bg-hover'
>
<Card
key={plugin.name}
payload={plugin}
locale={locale}
footer={
<CardMoreInfo
downloadCount={plugin.install_count}
tags={plugin.tags.map(tag => tagsMap[tag.name].label)}
/>
}
/>
{
showInstallButton && (
<div className='hidden absolute bottom-0 group-hover:flex items-center space-x-2 px-4 pt-8 pb-4 w-full bg-gradient-to-tr from-components-panel-on-panel-item-bg to-background-gradient-mask-transparent rounded-b-xl'>
<Button
variant='primary'
className='w-[calc(50%-4px)]'
onClick={showInstallFromMarketplace}
>
{t('plugin.detailPanel.operation.install')}
</Button>
<a href={`${getPluginLinkInMarketplace(plugin)}?language=${localeFromLocale}`} target='_blank' className='block flex-1 shrink-0 w-[calc(50%-4px)]'>
<Button
className='w-full gap-0.5'
>
{t('plugin.detailPanel.operation.detail')}
<RiArrowRightUpLine className='ml-1 w-4 h-4' />
</Button>
</a>
</div>
)
}
{
isShowInstallFromMarketplace && (
<InstallFromMarketplace
manifest={plugin as any}
uniqueIdentifier={plugin.latest_package_identifier}
onClose={hideInstallFromMarketplace}
onSuccess={hideInstallFromMarketplace}
/>
)
}
</div>
)
}
return (
<a
className='group inline-block relative rounded-xl cursor-pointer'
href={getPluginLinkInMarketplace(plugin)}
>
<Card
key={plugin.name}
payload={plugin}
locale={locale}
footer={
<CardMoreInfo
downloadCount={plugin.install_count}
tags={plugin.tags.map(tag => tagsMap[tag.name].label)}
/>
}
/>
</a>
)
}
export default CardWrapper

View File

@@ -0,0 +1,79 @@
'use client'
import type { Plugin } from '../../types'
import type { MarketplaceCollection } from '../types'
import ListWithCollection from './list-with-collection'
import CardWrapper from './card-wrapper'
import Empty from '../empty'
import cn from '@/utils/classnames'
type ListProps = {
marketplaceCollections: MarketplaceCollection[]
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
plugins?: Plugin[]
showInstallButton?: boolean
locale: string
cardContainerClassName?: string
cardRender?: (plugin: Plugin) => JSX.Element | null
onMoreClick?: () => void
emptyClassName?: string
}
const List = ({
marketplaceCollections,
marketplaceCollectionPluginsMap,
plugins,
showInstallButton,
locale,
cardContainerClassName,
cardRender,
onMoreClick,
emptyClassName,
}: ListProps) => {
return (
<>
{
!plugins && (
<ListWithCollection
marketplaceCollections={marketplaceCollections}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
showInstallButton={showInstallButton}
locale={locale}
cardContainerClassName={cardContainerClassName}
cardRender={cardRender}
onMoreClick={onMoreClick}
/>
)
}
{
plugins && !!plugins.length && (
<div className={cn(
'grid grid-cols-4 gap-3',
cardContainerClassName,
)}>
{
plugins.map((plugin) => {
if (cardRender)
return cardRender(plugin)
return (
<CardWrapper
key={plugin.name}
plugin={plugin}
showInstallButton={showInstallButton}
locale={locale}
/>
)
})
}
</div>
)
}
{
plugins && !plugins.length && (
<Empty className={emptyClassName} locale={locale} />
)
}
</>
)
}
export default List

View File

@@ -0,0 +1,84 @@
'use client'
import { RiArrowRightSLine } from '@remixicon/react'
import type { MarketplaceCollection } from '../types'
import CardWrapper from './card-wrapper'
import type { Plugin } from '@/app/components/plugins/types'
import { getLanguage } from '@/i18n/language'
import cn from '@/utils/classnames'
import type { SearchParamsFromCollection } from '@/app/components/plugins/marketplace/types'
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
type ListWithCollectionProps = {
marketplaceCollections: MarketplaceCollection[]
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
showInstallButton?: boolean
locale: string
cardContainerClassName?: string
cardRender?: (plugin: Plugin) => JSX.Element | null
onMoreClick?: (searchParams?: SearchParamsFromCollection) => void
}
const ListWithCollection = ({
marketplaceCollections,
marketplaceCollectionPluginsMap,
showInstallButton,
locale,
cardContainerClassName,
cardRender,
onMoreClick,
}: ListWithCollectionProps) => {
const { t } = useMixedTranslation(locale)
return (
<>
{
marketplaceCollections.map(collection => (
<div
key={collection.name}
className='py-3'
>
<div className='flex justify-between items-end'>
<div>
<div className='title-xl-semi-bold text-text-primary'>{collection.label[getLanguage(locale)]}</div>
<div className='system-xs-regular text-text-tertiary'>{collection.description[getLanguage(locale)]}</div>
</div>
{
collection.searchable && onMoreClick && (
<div
className='flex items-center system-xs-medium text-text-accent cursor-pointer '
onClick={() => onMoreClick?.(collection.search_params)}
>
{t('plugin.marketplace.viewMore')}
<RiArrowRightSLine className='w-4 h-4' />
</div>
)
}
</div>
<div className={cn(
'grid grid-cols-4 gap-3 mt-2',
cardContainerClassName,
)}>
{
marketplaceCollectionPluginsMap[collection.name].map((plugin) => {
if (cardRender)
return cardRender(plugin)
return (
<CardWrapper
key={plugin.name}
plugin={plugin}
showInstallButton={showInstallButton}
locale={locale}
/>
)
})
}
</div>
</div>
))
}
</>
)
}
export default ListWithCollection

View File

@@ -0,0 +1,73 @@
'use client'
import { useEffect } from 'react'
import type { Plugin } from '../../types'
import type { MarketplaceCollection } from '../types'
import { useMarketplaceContext } from '../context'
import List from './index'
import SortDropdown from '../sort-dropdown'
import Loading from '@/app/components/base/loading'
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
type ListWrapperProps = {
marketplaceCollections: MarketplaceCollection[]
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
showInstallButton?: boolean
locale: string
}
const ListWrapper = ({
marketplaceCollections,
marketplaceCollectionPluginsMap,
showInstallButton,
locale,
}: ListWrapperProps) => {
const { t } = useMixedTranslation(locale)
const plugins = useMarketplaceContext(v => v.plugins)
const pluginsTotal = useMarketplaceContext(v => v.pluginsTotal)
const marketplaceCollectionsFromClient = useMarketplaceContext(v => v.marketplaceCollectionsFromClient)
const marketplaceCollectionPluginsMapFromClient = useMarketplaceContext(v => v.marketplaceCollectionPluginsMapFromClient)
const isLoading = useMarketplaceContext(v => v.isLoading)
const isSuccessCollections = useMarketplaceContext(v => v.isSuccessCollections)
const handleQueryPlugins = useMarketplaceContext(v => v.handleQueryPlugins)
const page = useMarketplaceContext(v => v.page)
const handleMoreClick = useMarketplaceContext(v => v.handleMoreClick)
useEffect(() => {
if (!marketplaceCollectionsFromClient?.length && isSuccessCollections)
handleQueryPlugins()
}, [handleQueryPlugins, marketplaceCollections, marketplaceCollectionsFromClient, isSuccessCollections])
return (
<div className='relative flex flex-col grow px-12 py-2 bg-background-default-subtle'>
{
plugins && (
<div className='flex items-center mb-4 pt-3'>
<div className='title-xl-semi-bold text-text-primary'>{t('plugin.marketplace.pluginsResult', { num: pluginsTotal })}</div>
<div className='mx-3 w-[1px] h-3.5 bg-divider-regular'></div>
<SortDropdown locale={locale} />
</div>
)
}
{
isLoading && page === 1 && (
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
<Loading />
</div>
)
}
{
(!isLoading || page > 1) && (
<List
marketplaceCollections={marketplaceCollectionsFromClient || marketplaceCollections}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMapFromClient || marketplaceCollectionPluginsMap}
plugins={plugins}
showInstallButton={showInstallButton}
locale={locale}
onMoreClick={handleMoreClick}
/>
)
}
</div>
)
}
export default ListWrapper

View File

@@ -0,0 +1,100 @@
'use client'
import {
RiArchive2Line,
RiBrain2Line,
RiHammerLine,
RiPuzzle2Line,
RiSpeakAiLine,
} from '@remixicon/react'
import { PluginType } from '../types'
import { useMarketplaceContext } from './context'
import {
useMixedTranslation,
useSearchBoxAutoAnimate,
} from './hooks'
import cn from '@/utils/classnames'
export const PLUGIN_TYPE_SEARCH_MAP = {
all: 'all',
model: PluginType.model,
tool: PluginType.tool,
agent: PluginType.agent,
extension: PluginType.extension,
bundle: 'bundle',
}
type PluginTypeSwitchProps = {
locale?: string
className?: string
searchBoxAutoAnimate?: boolean
}
const PluginTypeSwitch = ({
locale,
className,
searchBoxAutoAnimate,
}: PluginTypeSwitchProps) => {
const { t } = useMixedTranslation(locale)
const activePluginType = useMarketplaceContext(s => s.activePluginType)
const handleActivePluginTypeChange = useMarketplaceContext(s => s.handleActivePluginTypeChange)
const { searchBoxCanAnimate } = useSearchBoxAutoAnimate(searchBoxAutoAnimate)
const options = [
{
value: PLUGIN_TYPE_SEARCH_MAP.all,
text: t('plugin.category.all'),
icon: null,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.model,
text: t('plugin.category.models'),
icon: <RiBrain2Line className='mr-1.5 w-4 h-4' />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.tool,
text: t('plugin.category.tools'),
icon: <RiHammerLine className='mr-1.5 w-4 h-4' />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.agent,
text: t('plugin.category.agents'),
icon: <RiSpeakAiLine className='mr-1.5 w-4 h-4' />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.extension,
text: t('plugin.category.extensions'),
icon: <RiPuzzle2Line className='mr-1.5 w-4 h-4' />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.bundle,
text: t('plugin.category.bundles'),
icon: <RiArchive2Line className='mr-1.5 w-4 h-4' />,
},
]
return (
<div className={cn(
'shrink-0 flex items-center justify-center py-3 bg-background-body space-x-2',
searchBoxCanAnimate && 'sticky top-[56px] z-10',
className,
)}>
{
options.map(option => (
<div
key={option.value}
className={cn(
'flex items-center px-3 h-8 border border-transparent rounded-xl cursor-pointer hover:bg-state-base-hover hover:text-text-secondary system-md-medium text-text-tertiary',
activePluginType === option.value && 'border-components-main-nav-nav-button-border !bg-components-main-nav-nav-button-bg-active !text-components-main-nav-nav-button-text-active shadow-xs',
)}
onClick={() => {
handleActivePluginTypeChange(option.value)
}}
>
{option.icon}
{option.text}
</div>
))
}
</div>
)
}
export default PluginTypeSwitch

View File

@@ -0,0 +1,70 @@
'use client'
import { RiCloseLine } from '@remixicon/react'
import TagsFilter from './tags-filter'
import ActionButton from '@/app/components/base/action-button'
import cn from '@/utils/classnames'
type SearchBoxProps = {
search: string
onSearchChange: (search: string) => void
inputClassName?: string
tags: string[]
onTagsChange: (tags: string[]) => void
size?: 'small' | 'large'
placeholder?: string
locale?: string
}
const SearchBox = ({
search,
onSearchChange,
inputClassName,
tags,
onTagsChange,
size = 'small',
placeholder = '',
locale,
}: SearchBoxProps) => {
return (
<div
className={cn(
'flex items-center z-[11]',
size === 'large' && 'p-1.5 bg-components-panel-bg-blur rounded-xl shadow-md border border-components-chat-input-border',
size === 'small' && 'p-0.5 bg-components-input-bg-normal rounded-lg',
inputClassName,
)}
>
<TagsFilter
tags={tags}
onTagsChange={onTagsChange}
size={size}
locale={locale}
/>
<div className='mx-1 w-[1px] h-3.5 bg-divider-regular'></div>
<div className='relative grow flex items-center p-1 pl-2'>
<div className='flex items-center mr-2 w-full'>
<input
className={cn(
'grow block outline-none appearance-none body-md-medium text-text-secondary bg-transparent',
)}
value={search}
onChange={(e) => {
onSearchChange(e.target.value)
}}
placeholder={placeholder}
/>
{
search && (
<div className='absolute right-2 top-1/2 -translate-y-1/2'>
<ActionButton onClick={() => onSearchChange('')}>
<RiCloseLine className='w-4 h-4' />
</ActionButton>
</div>
)
}
</div>
</div>
</div>
)
}
export default SearchBox

View File

@@ -0,0 +1,45 @@
'use client'
import { useMarketplaceContext } from '../context'
import {
useMixedTranslation,
useSearchBoxAutoAnimate,
} from '../hooks'
import SearchBox from './index'
import cn from '@/utils/classnames'
type SearchBoxWrapperProps = {
locale?: string
searchBoxAutoAnimate?: boolean
}
const SearchBoxWrapper = ({
locale,
searchBoxAutoAnimate,
}: SearchBoxWrapperProps) => {
const { t } = useMixedTranslation(locale)
const intersected = useMarketplaceContext(v => v.intersected)
const searchPluginText = useMarketplaceContext(v => v.searchPluginText)
const handleSearchPluginTextChange = useMarketplaceContext(v => v.handleSearchPluginTextChange)
const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags)
const handleFilterPluginTagsChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange)
const { searchBoxCanAnimate } = useSearchBoxAutoAnimate(searchBoxAutoAnimate)
return (
<SearchBox
inputClassName={cn(
'mx-auto w-[640px] shrink-0 z-[0]',
searchBoxCanAnimate && 'sticky top-3 z-[11]',
!intersected && searchBoxCanAnimate && 'w-[508px] transition-[width] duration-300',
)}
search={searchPluginText}
onSearchChange={handleSearchPluginTextChange}
tags={filterPluginTags}
onTagsChange={handleFilterPluginTagsChange}
size='large'
locale={locale}
placeholder={t('plugin.searchPlugins')}
/>
)
}
export default SearchBoxWrapper

View File

@@ -0,0 +1,138 @@
'use client'
import { useState } from 'react'
import {
RiArrowDownSLine,
RiCloseCircleFill,
RiFilter3Line,
} from '@remixicon/react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Checkbox from '@/app/components/base/checkbox'
import cn from '@/utils/classnames'
import Input from '@/app/components/base/input'
import { useTags } from '@/app/components/plugins/hooks'
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
type TagsFilterProps = {
tags: string[]
onTagsChange: (tags: string[]) => void
size: 'small' | 'large'
locale?: string
}
const TagsFilter = ({
tags,
onTagsChange,
size,
locale,
}: TagsFilterProps) => {
const { t } = useMixedTranslation(locale)
const [open, setOpen] = useState(false)
const [searchText, setSearchText] = useState('')
const { tags: options, tagsMap } = useTags(t)
const filteredOptions = options.filter(option => option.label.toLowerCase().includes(searchText.toLowerCase()))
const handleCheck = (id: string) => {
if (tags.includes(id))
onTagsChange(tags.filter((tag: string) => tag !== id))
else
onTagsChange([...tags, id])
}
const selectedTagsLength = tags.length
return (
<PortalToFollowElem
placement='bottom-start'
offset={{
mainAxis: 4,
crossAxis: -6,
}}
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger
className='shrink-0'
onClick={() => setOpen(v => !v)}
>
<div className={cn(
'flex items-center text-text-tertiary rounded-lg hover:bg-state-base-hover cursor-pointer',
size === 'large' && 'px-2 py-1 h-8',
size === 'small' && 'pr-1.5 py-0.5 h-7 pl-1 ',
selectedTagsLength && 'text-text-secondary',
open && 'bg-state-base-hover',
)}>
<div className='p-0.5'>
<RiFilter3Line className='w-4 h-4' />
</div>
<div className={cn(
'flex items-center p-1 system-sm-medium',
size === 'large' && 'p-1',
size === 'small' && 'px-0.5 py-1',
)}>
{
!selectedTagsLength && t('pluginTags.allTags')
}
{
!!selectedTagsLength && tags.map(tag => tagsMap[tag].label).slice(0, 2).join(',')
}
{
selectedTagsLength > 2 && (
<div className='ml-1 system-xs-medium text-text-tertiary'>
+{selectedTagsLength - 2}
</div>
)
}
</div>
{
!!selectedTagsLength && (
<RiCloseCircleFill
className='w-4 h-4 text-text-quaternary cursor-pointer'
onClick={() => onTagsChange([])}
/>
)
}
{
!selectedTagsLength && (
<RiArrowDownSLine className='w-4 h-4' />
)
}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className='w-[240px] border-[0.5px] border-components-panel-border bg-components-panel-bg-blur rounded-xl shadow-lg'>
<div className='p-2 pb-1'>
<Input
showLeftIcon
value={searchText}
onChange={e => setSearchText(e.target.value)}
placeholder={t('pluginTags.searchTags') || ''}
/>
</div>
<div className='p-1 max-h-[448px] overflow-y-auto'>
{
filteredOptions.map(option => (
<div
key={option.name}
className='flex items-center px-2 py-1.5 h-7 rounded-lg cursor-pointer hover:bg-state-base-hover'
onClick={() => handleCheck(option.name)}
>
<Checkbox
className='mr-1'
checked={tags.includes(option.name)}
/>
<div className='px-1 system-sm-medium text-text-secondary'>
{option.label}
</div>
</div>
))
}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default TagsFilter

View File

@@ -0,0 +1,94 @@
'use client'
import { useState } from 'react'
import {
RiArrowDownSLine,
RiCheckLine,
} from '@remixicon/react'
import { useMarketplaceContext } from '../context'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
type SortDropdownProps = {
locale?: string
}
const SortDropdown = ({
locale,
}: SortDropdownProps) => {
const { t } = useMixedTranslation(locale)
const options = [
{
value: 'install_count',
order: 'DESC',
text: t('plugin.marketplace.sortOption.mostPopular'),
},
{
value: 'version_updated_at',
order: 'DESC',
text: t('plugin.marketplace.sortOption.recentlyUpdated'),
},
{
value: 'created_at',
order: 'DESC',
text: t('plugin.marketplace.sortOption.newlyReleased'),
},
{
value: 'created_at',
order: 'ASC',
text: t('plugin.marketplace.sortOption.firstReleased'),
},
]
const sort = useMarketplaceContext(v => v.sort)
const handleSortChange = useMarketplaceContext(v => v.handleSortChange)
const [open, setOpen] = useState(false)
const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder)!
return (
<PortalToFollowElem
placement='bottom-start'
offset={{
mainAxis: 4,
crossAxis: 0,
}}
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<div className='flex items-center px-2 pr-3 h-8 rounded-lg bg-state-base-hover-alt cursor-pointer'>
<span className='mr-1 system-sm-regular text-text-secondary'>
{t('plugin.marketplace.sortBy')}
</span>
<span className='mr-1 system-sm-medium text-text-primary'>
{selectedOption.text}
</span>
<RiArrowDownSLine className='w-4 h-4 text-text-tertiary' />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent>
<div className='p-1 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur backdrop-blur-sm shadow-lg'>
{
options.map(option => (
<div
key={`${option.value}-${option.order}`}
className='flex items-center justify-between px-3 pr-2 h-8 cursor-pointer system-md-regular text-text-primary rounded-lg hover:bg-components-panel-on-panel-item-bg-hover'
onClick={() => handleSortChange({ sortBy: option.value, sortOrder: option.order })}
>
{option.text}
{
sort.sortBy === option.value && sort.sortOrder === option.order && (
<RiCheckLine className='ml-2 w-4 h-4 text-text-accent' />
)
}
</div>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default SortDropdown

View File

@@ -0,0 +1,59 @@
import type { Plugin } from '../types'
export type SearchParamsFromCollection = {
query?: string
sort_by?: string
sort_order?: string
}
export type MarketplaceCollection = {
name: string
label: Record<string, string>
description: Record<string, string>
rule: string
created_at: string
updated_at: string
searchable?: boolean
search_params?: SearchParamsFromCollection
}
export type MarketplaceCollectionsResponse = {
collections: MarketplaceCollection[]
total: number
}
export type MarketplaceCollectionPluginsResponse = {
plugins: Plugin[]
total: number
}
export type PluginsSearchParams = {
query: string
page?: number
pageSize?: number
sortBy?: string
sortOrder?: string
category?: string
tags?: string[]
exclude?: string[]
type?: 'plugin' | 'bundle'
}
export type PluginsSort = {
sortBy: string
sortOrder: string
}
export type CollectionsAndPluginsSearchParams = {
category?: string
condition?: string
exclude?: string[]
type?: 'plugin' | 'bundle'
}
export type SearchParams = {
language?: string
q?: string
tags?: string
category?: string
}

View File

@@ -0,0 +1,127 @@
import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch'
import type { Plugin } from '@/app/components/plugins/types'
import { PluginType } from '@/app/components/plugins/types'
import type {
CollectionsAndPluginsSearchParams,
MarketplaceCollection,
} from '@/app/components/plugins/marketplace/types'
import {
MARKETPLACE_API_PREFIX,
MARKETPLACE_URL_PREFIX,
} from '@/config'
export const getPluginIconInMarketplace = (plugin: Plugin) => {
if (plugin.type === 'bundle')
return `${MARKETPLACE_API_PREFIX}/bundles/${plugin.org}/${plugin.name}/icon`
return `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon`
}
export const getFormattedPlugin = (bundle: any) => {
if (bundle.type === 'bundle') {
return {
...bundle,
icon: getPluginIconInMarketplace(bundle),
brief: bundle.description,
label: bundle.labels,
}
}
return {
...bundle,
icon: getPluginIconInMarketplace(bundle),
}
}
export const getPluginLinkInMarketplace = (plugin: Plugin) => {
if (plugin.type === 'bundle')
return `${MARKETPLACE_URL_PREFIX}/bundles/${plugin.org}/${plugin.name}`
return `${MARKETPLACE_URL_PREFIX}/plugins/${plugin.org}/${plugin.name}`
}
export const getMarketplacePluginsByCollectionId = async (collectionId: string, query?: CollectionsAndPluginsSearchParams) => {
let plugins = [] as Plugin[]
try {
const url = `${MARKETPLACE_API_PREFIX}/collections/${collectionId}/plugins`
const marketplaceCollectionPluginsData = await globalThis.fetch(
url,
{
cache: 'no-store',
method: 'POST',
body: JSON.stringify({
category: query?.category,
exclude: query?.exclude,
type: query?.type,
}),
},
)
const marketplaceCollectionPluginsDataJson = await marketplaceCollectionPluginsData.json()
plugins = marketplaceCollectionPluginsDataJson.data.plugins.map((plugin: Plugin) => {
return getFormattedPlugin(plugin)
})
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
plugins = []
}
return plugins
}
export const getMarketplaceCollectionsAndPlugins = async (query?: CollectionsAndPluginsSearchParams) => {
let marketplaceCollections = [] as MarketplaceCollection[]
let marketplaceCollectionPluginsMap = {} as Record<string, Plugin[]>
try {
let marketplaceUrl = `${MARKETPLACE_API_PREFIX}/collections?page=1&page_size=100`
if (query?.condition)
marketplaceUrl += `&condition=${query.condition}`
if (query?.type)
marketplaceUrl += `&type=${query.type}`
const marketplaceCollectionsData = await globalThis.fetch(marketplaceUrl, { cache: 'no-store' })
const marketplaceCollectionsDataJson = await marketplaceCollectionsData.json()
marketplaceCollections = marketplaceCollectionsDataJson.data.collections
await Promise.all(marketplaceCollections.map(async (collection: MarketplaceCollection) => {
const plugins = await getMarketplacePluginsByCollectionId(collection.name, query)
marketplaceCollectionPluginsMap[collection.name] = plugins
}))
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
marketplaceCollections = []
marketplaceCollectionPluginsMap = {}
}
return {
marketplaceCollections,
marketplaceCollectionPluginsMap,
}
}
export const getMarketplaceListCondition = (pluginType: string) => {
if (pluginType === PluginType.tool)
return 'category=tool'
if (pluginType === PluginType.agent)
return 'category=agent-strategy'
if (pluginType === PluginType.model)
return 'category=model'
if (pluginType === PluginType.extension)
return 'category=endpoint'
if (pluginType === 'bundle')
return 'type=bundle'
return ''
}
export const getMarketplaceListFilterType = (category: string) => {
if (category === PLUGIN_TYPE_SEARCH_MAP.all)
return undefined
if (category === PLUGIN_TYPE_SEARCH_MAP.bundle)
return 'bundle'
return 'plugin'
}