diff --git a/api/core/plugin/entities/marketplace.py b/api/core/plugin/entities/marketplace.py index a19a44aa3c6077..1c13a621d40bac 100644 --- a/api/core/plugin/entities/marketplace.py +++ b/api/core/plugin/entities/marketplace.py @@ -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 diff --git a/api/services/plugin/plugin_service.py b/api/services/plugin/plugin_service.py index 0a5bc44b64eda8..9005f0669becae 100644 --- a/api/services/plugin/plugin_service.py +++ b/api/services/plugin/plugin_service.py @@ -38,6 +38,9 @@ class LatestPluginCache(BaseModel): 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 @@ def fetch_latest_plugin_version(plugin_ids: Sequence[str]) -> Mapping[str, Optio 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 diff --git a/web/app/components/plugins/base/deprecation-notice.tsx b/web/app/components/plugins/base/deprecation-notice.tsx new file mode 100644 index 00000000000000..36dc73fc202bc1 --- /dev/null +++ b/web/app/components/plugins/base/deprecation-notice.tsx @@ -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 = ({ + 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 ( +
+
+
+
+ +
+
+ { + hasValidDeprecatedReason && alternativePluginId && ( + + ), + }} + values={{ + deprecatedReason: t(`${i18nPrefix}.reason.${deprecatedReasonKey}`), + alternativePluginId, + }} + /> + ) + } + { + hasValidDeprecatedReason && !alternativePluginId && ( + + {t(`${i18nPrefix}.onlyReason`, { deprecatedReason: t(`${i18nPrefix}.reason.${deprecatedReasonKey}`) })} + + ) + } + { + !hasValidDeprecatedReason && ( + {t(`${i18nPrefix}.noReason`)} + ) + } +
+
+
+ ) +} + +export default React.memo(DeprecationNotice) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header.tsx index 124e133c2bfb38..9c31a0b8e13bd6 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header.tsx @@ -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 = ({
+ {isFromMarketplace && ( + + )} { category === PluginType.tool && ( diff --git a/web/app/components/plugins/plugin-item/index.tsx b/web/app/components/plugins/plugin-item/index.tsx index 058f1783f2bd84..2b584b3af1bf51 100644 --- a/web/app/components/plugins/plugin-item/index.tsx +++ b/web/app/components/plugins/plugin-item/index.tsx @@ -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 = ({ 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 = ({ return gte(langeniusVersionInfo.current_version, declarationMeta.minimum_dify_version ?? '0.0.0') }, [declarationMeta.minimum_dify_version, langeniusVersionInfo.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 = ({ return (
= ({ setCurrentPluginID(plugin.plugin_id) }} > -
+
{/* Header */} -
+
= ({ alt={`plugin-${plugin_unique_identifier}-logo`} />
-
-
+
+
- {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> ) } diff --git a/web/app/components/plugins/plugin-page/plugins-panel.tsx b/web/app/components/plugins/plugin-page/plugins-panel.tsx index a5f411c37e0745..ef4911e5238afd 100644 --- a/web/app/components/plugins/plugin-page/plugins-panel.tsx +++ b/web/app/components/plugins/plugin-page/plugins-panel.tsx @@ -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} diff --git a/web/app/components/plugins/types.ts b/web/app/components/plugins/types.ts index cd4d5e9ece07a5..1cd0409b1e8cdf 100644 --- a/web/app/components/plugins/types.ts +++ b/web/app/components/plugins/types.ts @@ -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 } } diff --git a/web/i18n/en-US/plugin.ts b/web/i18n/en-US/plugin.ts index 2984f5f77cfc3a..3258144132e484 100644 --- a/web/i18n/en-US/plugin.ts +++ b/web/i18n/en-US/plugin.ts @@ -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', diff --git a/web/i18n/ja-JP/plugin.ts b/web/i18n/ja-JP/plugin.ts index a12de17a160625..a80cde7e38151d 100644 --- a/web/i18n/ja-JP/plugin.ts +++ b/web/i18n/ja-JP/plugin.ts @@ -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}} セットのエンドポイントが有効になりました', diff --git a/web/i18n/zh-Hans/plugin.ts b/web/i18n/zh-Hans/plugin.ts index 5f8f641b721b7f..4c9510159206a9 100644 --- a/web/i18n/zh-Hans/plugin.ts +++ b/web/i18n/zh-Hans/plugin.ts @@ -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: '安装', diff --git a/web/utils/format.ts b/web/utils/format.ts index 720c8f67622225..781ec908153777 100644 --- a/web/utils/format.ts +++ b/web/utils/format.ts @@ -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()) +}