Co-authored-by: Wu Tianwei <30284043+WTW0313@users.noreply.github.com> Co-authored-by: twwu <twwu@dify.ai>tags/1.7.0
| @@ -32,6 +32,13 @@ class MarketplacePluginDeclaration(BaseModel): | |||
| latest_package_identifier: str = Field( | |||
| ..., description="Unique identifier for the latest package release of the plugin" | |||
| ) | |||
| status: str = Field(..., description="Indicate the status of marketplace plugin, enum from `active` `deleted`") | |||
| deprecated_reason: str = Field( | |||
| ..., description="Not empty when status='deleted', indicates the reason why this plugin is deleted(deprecated)" | |||
| ) | |||
| alternative_plugin_id: str = Field( | |||
| ..., description="Optional, indicates the alternative plugin for user to switch to" | |||
| ) | |||
| @model_validator(mode="before") | |||
| @classmethod | |||
| @@ -38,6 +38,9 @@ class PluginService: | |||
| plugin_id: str | |||
| version: str | |||
| unique_identifier: str | |||
| status: str | |||
| deprecated_reason: str | |||
| alternative_plugin_id: str | |||
| REDIS_KEY_PREFIX = "plugin_service:latest_plugin:" | |||
| REDIS_TTL = 60 * 5 # 5 minutes | |||
| @@ -71,6 +74,9 @@ class PluginService: | |||
| plugin_id=plugin_id, | |||
| version=manifest.latest_version, | |||
| unique_identifier=manifest.latest_package_identifier, | |||
| status=manifest.status, | |||
| deprecated_reason=manifest.deprecated_reason, | |||
| alternative_plugin_id=manifest.alternative_plugin_id, | |||
| ) | |||
| # Store in Redis | |||
| @@ -0,0 +1,104 @@ | |||
| import React, { useMemo } from 'react' | |||
| import type { FC } from 'react' | |||
| import Link from 'next/link' | |||
| import cn from '@/utils/classnames' | |||
| import { RiAlertFill } from '@remixicon/react' | |||
| import { Trans } from 'react-i18next' | |||
| import { snakeCase2CamelCase } from '@/utils/format' | |||
| import { useMixedTranslation } from '../marketplace/hooks' | |||
| type DeprecationNoticeProps = { | |||
| status: 'deleted' | 'active' | |||
| deprecatedReason: string | |||
| alternativePluginId: string | |||
| alternativePluginURL: string | |||
| locale?: string | |||
| className?: string | |||
| innerWrapperClassName?: string | |||
| iconWrapperClassName?: string | |||
| textClassName?: string | |||
| } | |||
| const i18nPrefix = 'plugin.detailPanel.deprecation' | |||
| const DeprecationNotice: FC<DeprecationNoticeProps> = ({ | |||
| status, | |||
| deprecatedReason, | |||
| alternativePluginId, | |||
| alternativePluginURL, | |||
| locale, | |||
| className, | |||
| innerWrapperClassName, | |||
| iconWrapperClassName, | |||
| textClassName, | |||
| }) => { | |||
| const { t } = useMixedTranslation(locale) | |||
| const deprecatedReasonKey = useMemo(() => { | |||
| if (!deprecatedReason) return '' | |||
| return snakeCase2CamelCase(deprecatedReason) | |||
| }, [deprecatedReason]) | |||
| // Check if the deprecatedReasonKey exists in i18n | |||
| const hasValidDeprecatedReason = useMemo(() => { | |||
| if (!deprecatedReason || !deprecatedReasonKey) return false | |||
| // Define valid reason keys that exist in i18n | |||
| const validReasonKeys = ['businessAdjustments', 'ownershipTransferred', 'noMaintainer'] | |||
| return validReasonKeys.includes(deprecatedReasonKey) | |||
| }, [deprecatedReason, deprecatedReasonKey]) | |||
| if (status !== 'deleted') | |||
| return null | |||
| return ( | |||
| <div className={cn('w-full', className)}> | |||
| <div className={cn( | |||
| 'relative flex items-start gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]', | |||
| innerWrapperClassName, | |||
| )}> | |||
| <div className='absolute left-0 top-0 -z-10 h-full w-full bg-toast-warning-bg opacity-40' /> | |||
| <div className={cn('flex size-6 shrink-0 items-center justify-center', iconWrapperClassName)}> | |||
| <RiAlertFill className='size-4 text-text-warning-secondary' /> | |||
| </div> | |||
| <div className={cn('system-xs-regular grow py-1 text-text-primary', textClassName)}> | |||
| { | |||
| hasValidDeprecatedReason && alternativePluginId && ( | |||
| <Trans | |||
| i18nKey={`${i18nPrefix}.fullMessage`} | |||
| components={{ | |||
| CustomLink: ( | |||
| <Link | |||
| href={alternativePluginURL} | |||
| target='_blank' | |||
| rel='noopener noreferrer' | |||
| className='underline' | |||
| /> | |||
| ), | |||
| }} | |||
| values={{ | |||
| deprecatedReason: t(`${i18nPrefix}.reason.${deprecatedReasonKey}`), | |||
| alternativePluginId, | |||
| }} | |||
| /> | |||
| ) | |||
| } | |||
| { | |||
| hasValidDeprecatedReason && !alternativePluginId && ( | |||
| <span> | |||
| {t(`${i18nPrefix}.onlyReason`, { deprecatedReason: t(`${i18nPrefix}.reason.${deprecatedReasonKey}`) })} | |||
| </span> | |||
| ) | |||
| } | |||
| { | |||
| !hasValidDeprecatedReason && ( | |||
| <span>{t(`${i18nPrefix}.noReason`)}</span> | |||
| ) | |||
| } | |||
| </div> | |||
| </div> | |||
| </div> | |||
| ) | |||
| } | |||
| export default React.memo(DeprecationNotice) | |||
| @@ -29,7 +29,7 @@ import Toast from '@/app/components/base/toast' | |||
| import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin' | |||
| import { Github } from '@/app/components/base/icons/src/public/common' | |||
| import { uninstallPlugin } from '@/service/plugins' | |||
| import { useGetLanguage } from '@/context/i18n' | |||
| import { useGetLanguage, useI18N } from '@/context/i18n' | |||
| import { useModalContext } from '@/context/modal-context' | |||
| import { useProviderContext } from '@/context/provider-context' | |||
| import { useInvalidateAllToolProviders } from '@/service/use-tools' | |||
| @@ -39,6 +39,7 @@ import { getMarketplaceUrl } from '@/utils/var' | |||
| import { PluginAuth } from '@/app/components/plugins/plugin-auth' | |||
| import { AuthCategory } from '@/app/components/plugins/plugin-auth' | |||
| import { useAllToolProviders } from '@/service/use-tools' | |||
| import DeprecationNotice from '../base/deprecation-notice' | |||
| const i18nPrefix = 'plugin.action' | |||
| @@ -56,6 +57,7 @@ const DetailHeader = ({ | |||
| const { t } = useTranslation() | |||
| const { theme } = useTheme() | |||
| const locale = useGetLanguage() | |||
| const { locale: currentLocale } = useI18N() | |||
| const { checkForUpdates, fetchReleases } = useGitHubReleases() | |||
| const { setShowUpdatePluginModal } = useModalContext() | |||
| const { refreshModelProviders } = useProviderContext() | |||
| @@ -70,6 +72,9 @@ const DetailHeader = ({ | |||
| latest_version, | |||
| meta, | |||
| plugin_id, | |||
| status, | |||
| deprecated_reason, | |||
| alternative_plugin_id, | |||
| } = detail | |||
| const { author, category, name, label, description, icon, verified, tool } = detail.declaration | |||
| const isTool = category === PluginType.tool | |||
| @@ -98,7 +103,7 @@ const DetailHeader = ({ | |||
| if (isFromGitHub) | |||
| return `https://github.com/${meta!.repo}` | |||
| if (isFromMarketplace) | |||
| return getMarketplaceUrl(`/plugins/${author}/${name}`, { theme }) | |||
| return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: currentLocale, theme }) | |||
| return '' | |||
| }, [author, isFromGitHub, isFromMarketplace, meta, name, theme]) | |||
| @@ -272,6 +277,15 @@ const DetailHeader = ({ | |||
| </ActionButton> | |||
| </div> | |||
| </div> | |||
| {isFromMarketplace && ( | |||
| <DeprecationNotice | |||
| status={status} | |||
| deprecatedReason={deprecated_reason} | |||
| alternativePluginId={alternative_plugin_id} | |||
| alternativePluginURL={getMarketplaceUrl(`/plugins/${alternative_plugin_id}`, { language: currentLocale, theme })} | |||
| className='mt-3' | |||
| /> | |||
| )} | |||
| <Description className='mb-2 mt-3 h-auto' text={description[locale]} descriptionLineRows={2}></Description> | |||
| { | |||
| category === PluginType.tool && ( | |||
| @@ -1,6 +1,6 @@ | |||
| 'use client' | |||
| import type { FC } from 'react' | |||
| import React, { useMemo } from 'react' | |||
| import React, { useCallback, useMemo } from 'react' | |||
| import { useTheme } from 'next-themes' | |||
| import { | |||
| RiArrowRightUpLine, | |||
| @@ -55,6 +55,8 @@ const PluginItem: FC<Props> = ({ | |||
| endpoints_active, | |||
| meta, | |||
| plugin_id, | |||
| status, | |||
| deprecated_reason, | |||
| } = plugin | |||
| const { category, author, name, label, description, icon, verified, meta: declarationMeta } = plugin.declaration | |||
| @@ -70,9 +72,14 @@ const PluginItem: FC<Props> = ({ | |||
| return gte(langGeniusVersionInfo.current_version, declarationMeta.minimum_dify_version ?? '0.0.0') | |||
| }, [declarationMeta.minimum_dify_version, langGeniusVersionInfo.current_version]) | |||
| const handleDelete = () => { | |||
| const isDeprecated = useMemo(() => { | |||
| return status === 'deleted' && !!deprecated_reason | |||
| }, [status, deprecated_reason]) | |||
| const handleDelete = useCallback(() => { | |||
| refreshPluginList({ category } as any) | |||
| } | |||
| }, [category, refreshPluginList]) | |||
| const getValueFromI18nObject = useRenderI18nObject() | |||
| const title = getValueFromI18nObject(label) | |||
| const descriptionText = getValueFromI18nObject(description) | |||
| @@ -81,7 +88,7 @@ const PluginItem: FC<Props> = ({ | |||
| return ( | |||
| <div | |||
| className={cn( | |||
| 'rounded-xl border-[1.5px] border-background-section-burn p-1', | |||
| 'relative overflow-hidden rounded-xl border-[1.5px] border-background-section-burn p-1', | |||
| currentPluginID === plugin_id && 'border-components-option-card-option-selected-border', | |||
| source === PluginSource.debugging | |||
| ? 'bg-[repeating-linear-gradient(-45deg,rgba(16,24,40,0.04),rgba(16,24,40,0.04)_5px,rgba(0,0,0,0.02)_5px,rgba(0,0,0,0.02)_10px)]' | |||
| @@ -91,10 +98,10 @@ const PluginItem: FC<Props> = ({ | |||
| setCurrentPluginID(plugin.plugin_id) | |||
| }} | |||
| > | |||
| <div className={cn('hover-bg-components-panel-on-panel-item-bg relative rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 pb-3 shadow-xs', className)}> | |||
| <div className={cn('hover-bg-components-panel-on-panel-item-bg relative z-10 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 pb-3 shadow-xs', className)}> | |||
| <CornerMark text={categoriesMap[category].label} /> | |||
| {/* Header */} | |||
| <div className="flex"> | |||
| <div className='flex'> | |||
| <div className='flex h-10 w-10 items-center justify-center overflow-hidden rounded-xl border-[1px] border-components-panel-border-subtle'> | |||
| <img | |||
| className='h-full w-full' | |||
| @@ -102,13 +109,13 @@ const PluginItem: FC<Props> = ({ | |||
| alt={`plugin-${plugin_unique_identifier}-logo`} | |||
| /> | |||
| </div> | |||
| <div className="ml-3 w-0 grow"> | |||
| <div className="flex h-5 items-center"> | |||
| <div className='ml-3 w-0 grow'> | |||
| <div className='flex h-5 items-center'> | |||
| <Title title={title} /> | |||
| {verified && <RiVerifiedBadgeLine className="ml-0.5 h-4 w-4 shrink-0 text-text-accent" />} | |||
| {verified && <RiVerifiedBadgeLine className='ml-0.5 h-4 w-4 shrink-0 text-text-accent' />} | |||
| {!isDifyVersionCompatible && <Tooltip popupContent={ | |||
| t('plugin.difyVersionNotCompatible', { minimalDifyVersion: declarationMeta.minimum_dify_version }) | |||
| }><RiErrorWarningLine color='red' className="ml-0.5 h-4 w-4 shrink-0 text-text-accent" /></Tooltip>} | |||
| }><RiErrorWarningLine color='red' className='ml-0.5 h-4 w-4 shrink-0 text-text-accent' /></Tooltip>} | |||
| <Badge className='ml-1 shrink-0' | |||
| text={source === PluginSource.github ? plugin.meta!.version : plugin.version} | |||
| hasRedCornerMark={(source === PluginSource.marketplace) && !!plugin.latest_version && plugin.latest_version !== plugin.version} | |||
| @@ -135,10 +142,11 @@ const PluginItem: FC<Props> = ({ | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div className='mb-1 mt-1.5 flex h-4 items-center justify-between px-4'> | |||
| <div className='flex items-center'> | |||
| <div className='mb-1 mt-1.5 flex h-4 items-center gap-x-2 px-4'> | |||
| {/* Organization & Name */} | |||
| <div className='flex grow items-center overflow-hidden'> | |||
| <OrgInfo | |||
| className="mt-0.5" | |||
| className='mt-0.5' | |||
| orgName={orgName} | |||
| packageName={name} | |||
| packageNameClassName='w-auto max-w-[150px]' | |||
| @@ -146,15 +154,20 @@ const PluginItem: FC<Props> = ({ | |||
| {category === PluginType.extension && ( | |||
| <> | |||
| <div className='system-xs-regular mx-2 text-text-quaternary'>·</div> | |||
| <div className='system-xs-regular flex space-x-1 text-text-tertiary'> | |||
| <RiLoginCircleLine className='h-4 w-4' /> | |||
| <span>{t('plugin.endpointsEnabled', { num: endpoints_active })}</span> | |||
| <div className='system-xs-regular flex space-x-1 overflow-hidden text-text-tertiary'> | |||
| <RiLoginCircleLine className='h-4 w-4 shrink-0' /> | |||
| <span | |||
| className='truncate' | |||
| title={t('plugin.endpointsEnabled', { num: endpoints_active })} | |||
| > | |||
| {t('plugin.endpointsEnabled', { num: endpoints_active })} | |||
| </span> | |||
| </div> | |||
| </> | |||
| )} | |||
| </div> | |||
| <div className='flex items-center'> | |||
| {/* Source */} | |||
| <div className='flex shrink-0 items-center'> | |||
| {source === PluginSource.github | |||
| && <> | |||
| <a href={`https://github.com/${meta!.repo}`} target='_blank' className='flex items-center gap-1'> | |||
| @@ -192,7 +205,20 @@ const PluginItem: FC<Props> = ({ | |||
| </> | |||
| } | |||
| </div> | |||
| {/* Deprecated */} | |||
| {source === PluginSource.marketplace && enable_marketplace && isDeprecated && ( | |||
| <div className='system-2xs-medium-uppercase flex shrink-0 items-center gap-x-2'> | |||
| <span className='text-text-tertiary'>·</span> | |||
| <span className='text-text-warning'> | |||
| {t('plugin.deprecated')} | |||
| </span> | |||
| </div> | |||
| )} | |||
| </div> | |||
| {/* BG Effect for Deprecated Plugin */} | |||
| {source === PluginSource.marketplace && enable_marketplace && isDeprecated && ( | |||
| <div className='absolute bottom-[-71px] right-[-45px] z-0 size-40 bg-components-badge-status-light-warning-halo opacity-60 blur-[120px]' /> | |||
| )} | |||
| </div> | |||
| ) | |||
| } | |||
| @@ -36,6 +36,9 @@ const PluginsPanel = () => { | |||
| ...plugin, | |||
| latest_version: installedLatestVersion?.versions[plugin.plugin_id]?.version ?? '', | |||
| latest_unique_identifier: installedLatestVersion?.versions[plugin.plugin_id]?.unique_identifier ?? '', | |||
| status: installedLatestVersion?.versions[plugin.plugin_id]?.status ?? 'active', | |||
| deprecated_reason: installedLatestVersion?.versions[plugin.plugin_id]?.deprecated_reason ?? '', | |||
| alternative_plugin_id: installedLatestVersion?.versions[plugin.plugin_id]?.alternative_plugin_id ?? '', | |||
| })) || [] | |||
| }, [pluginList, installedLatestVersion]) | |||
| @@ -66,20 +69,25 @@ const PluginsPanel = () => { | |||
| onFilterChange={handleFilterChange} | |||
| /> | |||
| </div> | |||
| {isPluginListLoading ? <Loading type='app' /> : (filteredList?.length ?? 0) > 0 ? ( | |||
| <div className='flex grow flex-wrap content-start items-start justify-center gap-2 self-stretch px-12'> | |||
| <div className='w-full'> | |||
| <List pluginList={filteredList || []} /> | |||
| </div> | |||
| {!isLastPage && !isFetching && ( | |||
| <Button onClick={loadNextPage}> | |||
| {t('workflow.common.loadMore')} | |||
| </Button> | |||
| {isPluginListLoading && <Loading type='app' />} | |||
| {!isPluginListLoading && ( | |||
| <> | |||
| {(filteredList?.length ?? 0) > 0 ? ( | |||
| <div className='flex grow flex-wrap content-start items-start justify-center gap-2 self-stretch px-12'> | |||
| <div className='w-full'> | |||
| <List pluginList={filteredList || []} /> | |||
| </div> | |||
| {!isLastPage && !isFetching && ( | |||
| <Button onClick={loadNextPage}> | |||
| {t('workflow.common.loadMore')} | |||
| </Button> | |||
| )} | |||
| {isFetching && <div className='system-md-semibold text-text-secondary'>{t('appLog.detail.loading')}</div>} | |||
| </div> | |||
| ) : ( | |||
| <Empty /> | |||
| )} | |||
| {isFetching && <div className='system-md-semibold text-text-secondary'>{t('appLog.detail.loading')}</div>} | |||
| </div> | |||
| ) : ( | |||
| <Empty /> | |||
| </> | |||
| )} | |||
| <PluginDetailPanel | |||
| detail={currentPluginDetail} | |||
| @@ -118,6 +118,9 @@ export type PluginDetail = { | |||
| latest_unique_identifier: string | |||
| source: PluginSource | |||
| meta?: MetaData | |||
| status: 'active' | 'deleted' | |||
| deprecated_reason: string | |||
| alternative_plugin_id: string | |||
| } | |||
| export type PluginInfoFromMarketPlace = { | |||
| @@ -343,6 +346,9 @@ export type InstalledLatestVersionResponse = { | |||
| [plugin_id: string]: { | |||
| unique_identifier: string | |||
| version: string | |||
| status: 'active' | 'deleted' | |||
| deprecated_reason: string | |||
| alternative_plugin_id: string | |||
| } | null | |||
| } | |||
| } | |||
| @@ -29,6 +29,7 @@ const translation = { | |||
| searchTools: 'Search tools...', | |||
| installPlugin: 'Install plugin', | |||
| installFrom: 'INSTALL FROM', | |||
| deprecated: 'Deprecated', | |||
| list: { | |||
| noInstalled: 'No plugins installed', | |||
| notFound: 'No plugins found', | |||
| @@ -99,6 +100,16 @@ const translation = { | |||
| configureApp: 'Configure App', | |||
| configureModel: 'Configure model', | |||
| configureTool: 'Configure tool', | |||
| deprecation: { | |||
| fullMessage: 'This plugin has been deprecated due to {{deprecatedReason}}, and will no longer be updated. Please use <CustomLink href=\'https://example.com/\'>{{-alternativePluginId}}</CustomLink> instead.', | |||
| onlyReason: 'This plugin has been deprecated due to {{deprecatedReason}} and will no longer be updated.', | |||
| noReason: 'This plugin has been deprecated and will no longer be updated.', | |||
| reason: { | |||
| businessAdjustments: 'business adjustments', | |||
| ownershipTransferred: 'ownership transferred', | |||
| noMaintainer: 'no maintainer', | |||
| }, | |||
| }, | |||
| }, | |||
| install: '{{num}} installs', | |||
| installAction: 'Install', | |||
| @@ -84,6 +84,16 @@ const translation = { | |||
| actionNum: '{{num}} {{action}} が含まれています', | |||
| endpointsDocLink: 'ドキュメントを表示する', | |||
| switchVersion: 'バージョンの切り替え', | |||
| deprecation: { | |||
| fullMessage: 'このプラグインは{{deprecatedReason}}のため非推奨となり、新しいバージョンはリリースされません。代わりに<CustomLink href=\'https://example.com/\'>{{-alternativePluginId}}</CustomLink>をご利用ください。', | |||
| onlyReason: 'このプラグインは{{deprecatedReason}}のため非推奨となり、新しいバージョンはリリースされません。', | |||
| noReason: 'このプラグインは廃止されており、今後更新されることはありません。', | |||
| reason: { | |||
| businessAdjustments: '事業調整', | |||
| ownershipTransferred: '所有権移転', | |||
| noMaintainer: 'メンテナーの不足', | |||
| }, | |||
| }, | |||
| }, | |||
| debugInfo: { | |||
| title: 'デバッグ', | |||
| @@ -198,6 +208,7 @@ const translation = { | |||
| install: '{{num}} インストール', | |||
| installAction: 'インストール', | |||
| installFrom: 'インストール元', | |||
| deprecated: '非推奨', | |||
| searchPlugins: '検索プラグイン', | |||
| search: '検索', | |||
| endpointsEnabled: '{{num}} セットのエンドポイントが有効になりました', | |||
| @@ -29,6 +29,7 @@ const translation = { | |||
| searchTools: '搜索工具...', | |||
| installPlugin: '安装插件', | |||
| installFrom: '安装源', | |||
| deprecated: '已弃用', | |||
| list: { | |||
| noInstalled: '无已安装的插件', | |||
| notFound: '未找到插件', | |||
| @@ -99,6 +100,16 @@ const translation = { | |||
| configureApp: '应用设置', | |||
| configureModel: '模型设置', | |||
| configureTool: '工具设置', | |||
| deprecation: { | |||
| fullMessage: '由于{{deprecatedReason}},此插件已被弃用,将不再发布新版本。请使用<CustomLink href=\'https://example.com/\'>{{-alternativePluginId}}</CustomLink>替代。', | |||
| onlyReason: '由于{{deprecatedReason}},此插件已被弃用,将不再发布新版本。', | |||
| noReason: '此插件已被弃用,将不再发布新版本。', | |||
| reason: { | |||
| businessAdjustments: '业务调整', | |||
| ownershipTransferred: '所有权转移', | |||
| noMaintainer: '无人维护', | |||
| }, | |||
| }, | |||
| }, | |||
| install: '{{num}} 次安装', | |||
| installAction: '安装', | |||
| @@ -56,3 +56,7 @@ export const downloadFile = ({ data, fileName }: { data: Blob; fileName: string | |||
| a.remove() | |||
| window.URL.revokeObjectURL(url) | |||
| } | |||
| export const snakeCase2CamelCase = (input: string): string => { | |||
| return input.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) | |||
| } | |||