diff --git a/web/app/(commonLayout)/plugins/page.tsx b/web/app/(commonLayout)/plugins/page.tsx index f366200cf9..3171031767 100644 --- a/web/app/(commonLayout)/plugins/page.tsx +++ b/web/app/(commonLayout)/plugins/page.tsx @@ -6,7 +6,7 @@ const PluginList = () => { return ( } - marketplace={} + marketplace={} /> ) } diff --git a/web/app/components/plugins/card/base/download-count.tsx b/web/app/components/plugins/card/base/download-count.tsx index 91541cb931..35ff0aa656 100644 --- a/web/app/components/plugins/card/base/download-count.tsx +++ b/web/app/components/plugins/card/base/download-count.tsx @@ -1,4 +1,4 @@ -import { RiInstallLine } from '@remixicon/react' +import { useTranslation } from '#i18n' import * as React from 'react' import { formatNumber } from '@/utils/format' @@ -9,10 +9,13 @@ type Props = { const DownloadCountComponent = ({ downloadCount, }: Props) => { + const { t } = useTranslation('plugin') + return ( -
- -
{formatNumber(downloadCount)}
+
+ {formatNumber(downloadCount)} + {' '} + {t('marketplace.installs')}
) } diff --git a/web/app/components/plugins/card/base/org-info.tsx b/web/app/components/plugins/card/base/org-info.tsx index 475b877198..70ad2ffe8b 100644 --- a/web/app/components/plugins/card/base/org-info.tsx +++ b/web/app/components/plugins/card/base/org-info.tsx @@ -1,10 +1,12 @@ import { cn } from '@/utils/classnames' +import DownloadCount from './download-count' type Props = { className?: string orgName?: string - packageName: string + packageName?: string packageNameClassName?: string + downloadCount?: number } const OrgInfo = ({ @@ -12,7 +14,25 @@ const OrgInfo = ({ orgName, packageName, packageNameClassName, + downloadCount, }: Props) => { + // New format: "by {orgName} · {downloadCount} installs" (for marketplace cards) + if (downloadCount !== undefined) { + return ( +
+ {orgName && ( + + by + {orgName} + + )} + · + +
+ ) + } + + // Legacy format: "{orgName} / {packageName}" (for plugin detail panels) return (
{orgName && ( @@ -21,9 +41,11 @@ const OrgInfo = ({ / )} - - {packageName} - + {packageName && ( + + {packageName} + + )}
) } diff --git a/web/app/components/plugins/card/card-more-info.tsx b/web/app/components/plugins/card/card-more-info.tsx index 33f819f31a..1f9cde9a22 100644 --- a/web/app/components/plugins/card/card-more-info.tsx +++ b/web/app/components/plugins/card/card-more-info.tsx @@ -1,34 +1,28 @@ +import { RiPriceTag3Line } from '@remixicon/react' import * as React from 'react' -import DownloadCount from './base/download-count' type Props = { - downloadCount?: number tags: string[] } const CardMoreInfoComponent = ({ - downloadCount, tags, }: Props) => { return ( -
- {downloadCount !== undefined && } - {downloadCount !== undefined && tags && tags.length > 0 &&
·
} +
{tags && tags.length > 0 && ( - <> -
- {tags.map(tag => ( -
- # - {tag} -
- ))} -
- +
+ {tags.slice(0, 2).map(tag => ( + + + {tag.toUpperCase()} + + ))} +
)}
) diff --git a/web/app/components/plugins/card/index.tsx b/web/app/components/plugins/card/index.tsx index 0fc5e37241..8c4f35718f 100644 --- a/web/app/components/plugins/card/index.tsx +++ b/web/app/components/plugins/card/index.tsx @@ -50,7 +50,7 @@ const Card = ({ const locale = useGetLanguage() const { t } = useTranslation() const { categoriesMap } = useCategories(true) - const { category, type, name, org, label, brief, icon, icon_dark, verified, badges = [] } = payload + const { category, type, org, label, brief, icon, icon_dark, verified, badges = [], install_count } = payload const { theme } = useTheme() const iconSrc = theme === Theme.dark && icon_dark ? icon_dark : icon const getLocalizedText = (obj: Record | undefined) => @@ -86,7 +86,7 @@ const Card = ({
diff --git a/web/app/components/plugins/marketplace/description/hero-illustration.tsx b/web/app/components/plugins/marketplace/description/hero-illustration.tsx new file mode 100644 index 0000000000..8707c3b772 --- /dev/null +++ b/web/app/components/plugins/marketplace/description/hero-illustration.tsx @@ -0,0 +1,83 @@ +'use client' + +// todo: update the illustration +const HeroIllustration = () => { + return ( + + {/* Large circle - top right */} + + {/* Medium circle - middle */} + + {/* Small circle - bottom */} + + {/* Decorative dots */} + + + + + {/* Abstract shapes */} + + + {/* Gradient definitions */} + + + + + + + + + + + + + + + + ) +} + +export default HeroIllustration diff --git a/web/app/components/plugins/marketplace/description/index.tsx b/web/app/components/plugins/marketplace/description/index.tsx index 30ccbdb76e..107f99448b 100644 --- a/web/app/components/plugins/marketplace/description/index.tsx +++ b/web/app/components/plugins/marketplace/description/index.tsx @@ -1,72 +1,36 @@ -import { useLocale, useTranslation } from '#i18n' +'use client' -const Description = () => { - const { t } = useTranslation('plugin') - const { t: tCommon } = useTranslation('common') - const locale = useLocale() +import { useTranslation } from '#i18n' +import { cn } from '@/utils/classnames' +import PluginTypeSwitch from '../plugin-type-switch' +import HeroIllustration from './hero-illustration' - const isZhHans = locale === 'zh-Hans' - - return ( - <> -

- {t('marketplace.empower')} -

-

- { - isZhHans && ( - <> - {tCommon('operation.in')} - {t('marketplace.difyMarketplace')} - {t('marketplace.discover')} - - ) - } - { - !isZhHans && ( - <> - {t('marketplace.discover')} - - ) - } - - {t('category.models')} - - , - - {t('category.tools')} - - , - - {t('category.datasources')} - - , - - {t('category.triggers')} - - , - - {t('category.agents')} - - , - - {t('category.extensions')} - - {t('marketplace.and')} - - {t('category.bundles')} - - { - !isZhHans && ( - <> - {tCommon('operation.in')} - {t('marketplace.difyMarketplace')} - - ) - } -

- - ) +type DescriptionProps = { + className?: string } -export default Description +export const Description = ({ className }: DescriptionProps) => { + const { t } = useTranslation('plugin') + + return ( +
+ {/* Background illustration */} + + + {/* Content */} +
+

+ {t('marketplace.heroTitle')} +

+

+ {t('marketplace.heroSubtitle')} +

+ + {/* Plugin type switch tabs */} +
+ +
+
+
+ ) +} diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index 0eb2488cef..8d358708a2 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -1,13 +1,11 @@ import type { SearchParams } from 'nuqs' import { TanstackQueryInitializer } from '@/context/query-client' -import Description from './description' +import { Description } from './description' import { HydrateQueryClient } from './hydration-server' import ListWrapper from './list/list-wrapper' -import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' type MarketplaceProps = { showInstallButton?: boolean - pluginTypeSwitchClassName?: string /** * Pass the search params from the request to prefetch data on the server. */ @@ -16,16 +14,12 @@ type MarketplaceProps = { const Marketplace = async ({ showInstallButton = true, - pluginTypeSwitchClassName, searchParams, }: MarketplaceProps) => { return ( - - + diff --git a/web/app/components/plugins/marketplace/list/card-wrapper.tsx b/web/app/components/plugins/marketplace/list/card-wrapper.tsx index 42a5fec5c5..ffeb6c1716 100644 --- a/web/app/components/plugins/marketplace/list/card-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/card-wrapper.tsx @@ -50,7 +50,6 @@ const CardWrapperComponent = ({ payload={plugin} footer={( )} @@ -88,7 +87,7 @@ const CardWrapperComponent = ({ return ( )} diff --git a/web/app/components/plugins/marketplace/list/carousel.tsx b/web/app/components/plugins/marketplace/list/carousel.tsx new file mode 100644 index 0000000000..397768765e --- /dev/null +++ b/web/app/components/plugins/marketplace/list/carousel.tsx @@ -0,0 +1,238 @@ +'use client' + +import type { RemixiconComponentType } from '@remixicon/react' +import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react' +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' +import { cn } from '@/utils/classnames' + +type CarouselProps = { + children: React.ReactNode + className?: string + itemWidth?: number + gap?: number + showNavigation?: boolean + showPagination?: boolean + autoPlay?: boolean + autoPlayInterval?: number +} + +type ScrollState = { + canScrollLeft: boolean + canScrollRight: boolean + currentPage: number + totalPages: number +} + +const defaultScrollState: ScrollState = { + canScrollLeft: false, + canScrollRight: false, + currentPage: 0, + totalPages: 0, +} + +type NavButtonProps = { + direction: 'left' | 'right' + disabled: boolean + onClick: () => void + Icon: RemixiconComponentType +} + +const NavButton = ({ direction, disabled, onClick, Icon }: NavButtonProps) => ( + +) + +const Carousel = ({ + children, + className, + itemWidth = 280, + gap = 12, + showNavigation = true, + showPagination = true, + autoPlay = false, + autoPlayInterval = 5000, +}: CarouselProps) => { + const containerRef = useRef(null) + const scrollStateRef = useRef(defaultScrollState) + const [isHovered, setIsHovered] = useState(false) + + const calculateScrollState = useCallback((container: HTMLDivElement): ScrollState => { + const { scrollLeft, scrollWidth, clientWidth } = container + const canScrollLeft = scrollLeft > 0 + const canScrollRight = scrollLeft < scrollWidth - clientWidth - 1 + + // Calculate total pages based on actual scroll range + const maxScrollLeft = scrollWidth - clientWidth + const itemsPerPage = Math.floor(clientWidth / (itemWidth + gap)) + const totalItems = container.children.length + const pages = Math.max(1, Math.ceil(totalItems / itemsPerPage)) + + // Calculate current page based on scroll position ratio + let currentPage = 0 + if (maxScrollLeft > 0) { + const scrollRatio = scrollLeft / maxScrollLeft + currentPage = Math.round(scrollRatio * (pages - 1)) + } + + return { + canScrollLeft, + canScrollRight, + totalPages: pages, + currentPage: Math.min(Math.max(0, currentPage), pages - 1), + } + }, [itemWidth, gap]) + + const subscribe = useCallback((onStoreChange: () => void) => { + const container = containerRef.current + if (!container) + return () => { } + + const handleChange = () => { + scrollStateRef.current = calculateScrollState(container) + onStoreChange() + } + + // Initial calculation + handleChange() + + const resizeObserver = new ResizeObserver(handleChange) + resizeObserver.observe(container) + container.addEventListener('scroll', handleChange) + + return () => { + resizeObserver.disconnect() + container.removeEventListener('scroll', handleChange) + } + }, [calculateScrollState]) + + const getSnapshot = useCallback(() => scrollStateRef.current, []) + + const scrollState = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + + // Re-subscribe when children change + useEffect(() => { + const container = containerRef.current + if (container) + scrollStateRef.current = calculateScrollState(container) + }, [children, calculateScrollState]) + + const scroll = useCallback((direction: 'left' | 'right') => { + const container = containerRef.current + if (!container) + return + + const scrollAmount = container.clientWidth - (itemWidth / 2) + const newScrollLeft = direction === 'left' + ? container.scrollLeft - scrollAmount + : container.scrollLeft + scrollAmount + + container.scrollTo({ + left: newScrollLeft, + behavior: 'smooth', + }) + }, [itemWidth]) + + const scrollToPage = useCallback((pageIndex: number) => { + const container = containerRef.current + if (!container) + return + + const itemsPerPage = Math.floor(container.clientWidth / (itemWidth + gap)) + const scrollLeft = pageIndex * itemsPerPage * (itemWidth + gap) + + container.scrollTo({ + left: scrollLeft, + behavior: 'smooth', + }) + }, [itemWidth, gap]) + + // Auto-play functionality + useEffect(() => { + if (!autoPlay || isHovered || scrollState.totalPages <= 1) + return + + const interval = setInterval(() => { + const nextPage = scrollState.canScrollRight + ? scrollState.currentPage + 1 + : 0 // Loop back to first page + scrollToPage(nextPage) + }, autoPlayInterval) + + return () => clearInterval(interval) + }, [autoPlay, autoPlayInterval, isHovered, scrollState.totalPages, scrollState.canScrollRight, scrollState.currentPage, scrollToPage]) + + const handleMouseEnter = useCallback(() => setIsHovered(true), []) + const handleMouseLeave = useCallback(() => setIsHovered(false), []) + + return ( +
+ {/* Navigation arrows */} + {showNavigation && ( +
+ {/* Pagination dots */} + {showPagination && scrollState.totalPages > 1 && ( +
+ {Array.from({ length: scrollState.totalPages }).map((_, index) => ( +
+ )} + +
+ scroll('left')} + Icon={RiArrowLeftSLine} + /> + scroll('right')} + Icon={RiArrowRightSLine} + /> +
+
+ )} + + {/* Scrollable container */} +
+ {children} +
+
+ ) +} + +export default Carousel diff --git a/web/app/components/plugins/marketplace/list/list-with-collection.tsx b/web/app/components/plugins/marketplace/list/list-with-collection.tsx index 264227b666..2eaa96127f 100644 --- a/web/app/components/plugins/marketplace/list/list-with-collection.tsx +++ b/web/app/components/plugins/marketplace/list/list-with-collection.tsx @@ -5,9 +5,9 @@ import type { Plugin } from '@/app/components/plugins/types' import { useLocale, useTranslation } from '#i18n' import { RiArrowRightSLine } from '@remixicon/react' import { getLanguage } from '@/i18n-config/language' -import { cn } from '@/utils/classnames' import { useMarketplaceMoreClick } from '../atoms' import CardWrapper from './card-wrapper' +import Carousel from './carousel' type ListWithCollectionProps = { marketplaceCollections: MarketplaceCollection[] @@ -16,6 +16,10 @@ type ListWithCollectionProps = { cardContainerClassName?: string cardRender?: (plugin: Plugin) => React.JSX.Element | null } + +const PARTNERS_COLLECTION_NAME = 'partners' +const GRID_DISPLAY_LIMIT = 8 // 2 rows × 4 columns + const ListWithCollection = ({ marketplaceCollections, marketplaceCollectionPluginsMap, @@ -27,55 +31,102 @@ const ListWithCollection = ({ const locale = useLocale() const onMoreClick = useMarketplaceMoreClick() + const renderPluginCard = (plugin: Plugin) => { + if (cardRender) + return cardRender(plugin) + + return ( + + ) + } + + const renderPartnersCarousel = (collection: MarketplaceCollection, plugins: Plugin[]) => { + // Partners collection: 2-row carousel with auto-play + const rows: Plugin[][] = [] + for (let i = 0; i < plugins.length; i += 2) { + // Group plugins in pairs (2 per column) + rows.push(plugins.slice(i, i + 2)) + } + + return ( + 8} + showPagination={plugins.length > 8} + autoPlay={plugins.length > 8} + autoPlayInterval={5000} + > + {rows.map(columnPlugins => ( +
+ {columnPlugins.map(plugin => ( +
+ {renderPluginCard(plugin)} +
+ ))} +
+ ))} +
+ ) + } + + const renderGridCollection = (collection: MarketplaceCollection, plugins: Plugin[]) => { + // Other collections: Fixed 2 rows × 4 columns grid + const displayPlugins = plugins.slice(0, GRID_DISPLAY_LIMIT) + + return ( +
+ {displayPlugins.map(plugin => ( +
+ {renderPluginCard(plugin)} +
+ ))} +
+ ) + } + return ( <> { marketplaceCollections.filter((collection) => { return marketplaceCollectionPluginsMap[collection.name]?.length - }).map(collection => ( -
-
-
-
{collection.label[getLanguage(locale)]}
-
{collection.description[getLanguage(locale)]}
-
- { - collection.searchable && ( + }).map((collection) => { + const plugins = marketplaceCollectionPluginsMap[collection.name] + const isPartnersCollection = collection.name === PARTNERS_COLLECTION_NAME + const showViewMore = collection.searchable && (isPartnersCollection || plugins.length > GRID_DISPLAY_LIMIT) + + return ( +
+
+
+
{collection.label[getLanguage(locale)]}
+
{collection.description[getLanguage(locale)]}
+
+ {showViewMore && (
onMoreClick(collection.search_params)} > {t('marketplace.viewMore', { ns: 'plugin' })}
- ) - } + )} +
+ {isPartnersCollection + ? renderPartnersCarousel(collection, plugins) + : renderGridCollection(collection, plugins)}
-
- { - marketplaceCollectionPluginsMap[collection.name].map((plugin) => { - if (cardRender) - return cardRender(plugin) - - return ( - - ) - }) - } -
-
- )) + ) + }) } ) diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index 6e56a288d8..916d290638 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -2,6 +2,7 @@ import type { ActivePluginType } from './constants' import { useTranslation } from '#i18n' import { + RiApps2Line, RiArchive2Line, RiBrain2Line, RiDatabase2Line, @@ -17,14 +18,18 @@ import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './cons type PluginTypeSwitchProps = { className?: string + variant?: 'default' | 'hero' } const PluginTypeSwitch = ({ className, + variant = 'default', }: PluginTypeSwitchProps) => { const { t } = useTranslation() const [activePluginType, handleActivePluginTypeChange] = useActivePluginType() const setSearchMode = useSetAtom(searchModeAtom) + const isHeroVariant = variant === 'hero' + const options: Array<{ value: ActivePluginType text: string @@ -32,8 +37,8 @@ const PluginTypeSwitch = ({ }> = [ { value: PLUGIN_TYPE_SEARCH_MAP.all, - text: t('category.all', { ns: 'plugin' }), - icon: null, + text: isHeroVariant ? t('category.allTypes', { ns: 'plugin' }) : t('category.all', { ns: 'plugin' }), + icon: isHeroVariant ? : null, }, { value: PLUGIN_TYPE_SEARCH_MAP.model, @@ -72,9 +77,25 @@ const PluginTypeSwitch = ({ }, ] + const getItemClassName = (isActive: boolean) => { + if (isHeroVariant) { + return cn( + 'system-md-medium flex h-8 cursor-pointer items-center rounded-lg px-3 text-text-primary-on-surface transition-all', + isActive + ? 'bg-components-button-secondary-bg text-saas-dify-blue-inverted' + : 'hover:bg-state-base-hover', + ) + } + return cn( + 'system-md-medium flex h-8 cursor-pointer items-center rounded-xl border border-transparent px-3 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', + isActive && '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', + ) + } + return (
@@ -82,10 +103,7 @@ const PluginTypeSwitch = ({ options.map(option => (
{ handleActivePluginTypeChange(option.value) if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) { diff --git a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx index 9957e9bc42..39f2f1bdc6 100644 --- a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx +++ b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx @@ -1,18 +1,26 @@ 'use client' import { useTranslation } from '#i18n' +import { cn } from '@/utils/classnames' import { useFilterPluginTags, useSearchPluginText } from '../atoms' import SearchBox from './index' -const SearchBoxWrapper = () => { +type SearchBoxWrapperProps = { + wrapperClassName?: string + inputClassName?: string +} +const SearchBoxWrapper = ({ + wrapperClassName, + inputClassName, +}: SearchBoxWrapperProps) => { const { t } = useTranslation() const [searchPluginText, handleSearchPluginTextChange] = useSearchPluginText() const [filterPluginTags, handleFilterPluginTagsChange] = useFilterPluginTags() return ( -
) } diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx index efb665197a..30ece18ac3 100644 --- a/web/app/components/plugins/plugin-page/index.tsx +++ b/web/app/components/plugins/plugin-page/index.tsx @@ -27,6 +27,7 @@ import { PLUGIN_PAGE_TABS_MAP } from '../hooks' import InstallFromLocalPackage from '../install-plugin/install-from-local-package' import InstallFromMarketplace from '../install-plugin/install-from-marketplace' import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/constants' +import SearchBoxWrapper from '../marketplace/search-box/search-box-wrapper' import { PluginPageContextProvider, usePluginPageContext, @@ -140,23 +141,17 @@ const PluginPage = ({ id="marketplace-container" ref={containerRef} style={{ scrollbarGutter: 'stable' }} - className={cn('relative flex grow flex-col overflow-y-auto border-t border-divider-subtle', isPluginsTab - ? 'rounded-t-xl bg-components-panel-bg' - : 'bg-background-body')} + className="relative flex grow flex-col overflow-y-auto rounded-t-xl border-t border-divider-subtle bg-components-panel-bg" > -
+
-
+
+
{ diff --git a/web/i18n/en-US/plugin.json b/web/i18n/en-US/plugin.json index c7f091a442..c7e4eb4a05 100644 --- a/web/i18n/en-US/plugin.json +++ b/web/i18n/en-US/plugin.json @@ -65,6 +65,7 @@ "autoUpdate.upgradeModePlaceholder.partial": "Only selected plugins will auto-update. No plugins are currently selected, so no plugins will auto-update.", "category.agents": "Agent Strategies", "category.all": "All", + "category.allTypes": "All types", "category.bundles": "Bundles", "category.datasources": "Data Sources", "category.extensions": "Extensions", @@ -194,7 +195,12 @@ "marketplace.difyMarketplace": "Dify Marketplace", "marketplace.discover": "Discover", "marketplace.empower": "Empower your AI development", + "marketplace.featured": "Featured", + "marketplace.heroSubtitle": "Use community-built plugins to power your AI development.", + "marketplace.heroTitle": "Discover. Extend. Build.", + "marketplace.installs": "installs", "marketplace.moreFrom": "More from Marketplace", + "marketplace.ourTopPicks": "Our top picks to get you started", "marketplace.noPluginFound": "No plugin found", "marketplace.partnerTip": "Verified by a Dify partner", "marketplace.pluginsResult": "{{num}} results", diff --git a/web/i18n/zh-Hans/plugin.json b/web/i18n/zh-Hans/plugin.json index 703bd4e6ea..e2b625dc8a 100644 --- a/web/i18n/zh-Hans/plugin.json +++ b/web/i18n/zh-Hans/plugin.json @@ -65,6 +65,7 @@ "autoUpdate.upgradeModePlaceholder.partial": "仅选定的插件将自动更新。目前未选择任何插件,因此不会自动更新任何插件。", "category.agents": "Agent 策略", "category.all": "全部", + "category.allTypes": "所有类型", "category.bundles": "插件集", "category.datasources": "数据源", "category.extensions": "扩展", @@ -194,7 +195,12 @@ "marketplace.difyMarketplace": "Dify 市场", "marketplace.discover": "探索", "marketplace.empower": "助力您的 AI 开发", + "marketplace.featured": "精选", + "marketplace.heroSubtitle": "使用社区构建的插件为您的 AI 开发提供动力。", + "marketplace.heroTitle": "探索。扩展。构建。", + "marketplace.installs": "次安装", "marketplace.moreFrom": "更多来自市场", + "marketplace.ourTopPicks": "我们精选推荐", "marketplace.noPluginFound": "未找到插件", "marketplace.partnerTip": "此插件由 Dify 合作伙伴认证", "marketplace.pluginsResult": "{{num}} 个插件结果",