diff --git a/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.tsx b/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.tsx index 818007a26..c60a769e4 100644 --- a/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.tsx +++ b/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.tsx @@ -17,9 +17,9 @@ import Loading from '@/app/components/base/loading' import ProviderCard from '@/app/components/plugins/provider-card' import List from '@/app/components/plugins/marketplace/list' import type { Plugin } from '@/app/components/plugins/types' -import { MARKETPLACE_URL_PREFIX } from '@/config' import cn from '@/utils/classnames' import { getLocaleOnClient } from '@/i18n' +import { getMarketplaceUrl } from '@/utils/var' type InstallFromMarketplaceProps = { providers: ModelProvider[] @@ -55,7 +55,7 @@ const InstallFromMarketplace = ({
{t('common.modelProvider.discoverMore')} - + {t('plugin.marketplace.difyMarketplace')} diff --git a/web/app/components/plugins/card/index.tsx b/web/app/components/plugins/card/index.tsx index 1cc18ac24..bdb3705f6 100644 --- a/web/app/components/plugins/card/index.tsx +++ b/web/app/components/plugins/card/index.tsx @@ -15,6 +15,7 @@ import { renderI18nObject } from '@/i18n' import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks' import Partner from '../base/badges/partner' import Verified from '../base/badges/verified' +import { RiAlertFill } from '@remixicon/react' export type Props = { className?: string @@ -28,6 +29,7 @@ export type Props = { isLoading?: boolean loadingFileName?: string locale?: string + limitedInstall?: boolean } const Card = ({ @@ -42,6 +44,7 @@ const Card = ({ isLoading = false, loadingFileName, locale: localeFromProps, + limitedInstall = false, }: Props) => { const defaultLocale = useGetLanguage() const locale = localeFromProps ? getLanguage(localeFromProps) : defaultLocale @@ -54,7 +57,7 @@ const Card = ({ obj ? renderI18nObject(obj, locale) : '' const isPartner = badges.includes('partner') - const wrapClassName = 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) + const wrapClassName = cn('hover-bg-components-panel-on-panel-item-bg relative overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs', className) if (isLoading) { return ( - {!hideCornerMark && } - {/* Header */} -
- -
-
- - {isPartner && <Partner className='ml-0.5 h-4 w-4' text={t('plugin.marketplace.partnerTip')} />} - {verified && <Verified className='ml-0.5 h-4 w-4' text={t('plugin.marketplace.verifiedTip')} />} - {titleLeft} {/* This can be version badge */} + <div className={cn('p-4 pb-3', limitedInstall && 'pb-1')}> + {!hideCornerMark && <CornerMark text={cornerMark} />} + {/* Header */} + <div className="flex"> + <Icon src={icon} installed={installed} installFailed={installFailed} /> + <div className="ml-3 w-0 grow"> + <div className="flex h-5 items-center"> + <Title title={getLocalizedText(label)} /> + {isPartner && <Partner className='ml-0.5 h-4 w-4' text={t('plugin.marketplace.partnerTip')} />} + {verified && <Verified className='ml-0.5 h-4 w-4' text={t('plugin.marketplace.verifiedTip')} />} + {titleLeft} {/* This can be version badge */} + </div> + <OrgInfo + className="mt-0.5" + orgName={org} + packageName={name} + /> </div> - <OrgInfo - className="mt-0.5" - orgName={org} - packageName={name} - /> </div> + <Description + className="mt-3" + text={getLocalizedText(brief)} + descriptionLineRows={descriptionLineRows} + /> + {footer && <div>{footer}</div>} </div> - <Description - className="mt-3" - text={getLocalizedText(brief)} - descriptionLineRows={descriptionLineRows} - /> - {footer && <div>{footer}</div>} + {limitedInstall + && <div className='relative flex h-8 items-center gap-x-2 px-3 after:absolute after:bottom-0 after:left-0 after:right-0 after:top-0 after:bg-toast-warning-bg after:opacity-40'> + <RiAlertFill className='h-3 w-3 shrink-0 text-text-warning-secondary' /> + <p className='system-xs-regular z-10 grow text-text-secondary'> + {t('plugin.installModal.installWarning')} + </p> + </div>} </div> ) } diff --git a/web/app/components/plugins/install-plugin/hooks/use-install-plugin-limit.tsx b/web/app/components/plugins/install-plugin/hooks/use-install-plugin-limit.tsx new file mode 100644 index 000000000..d668cb3d1 --- /dev/null +++ b/web/app/components/plugins/install-plugin/hooks/use-install-plugin-limit.tsx @@ -0,0 +1,46 @@ +import { useGlobalPublicStore } from '@/context/global-public-context' +import type { SystemFeatures } from '@/types/feature' +import { InstallationScope } from '@/types/feature' +import type { Plugin, PluginManifestInMarket } from '../../types' + +type PluginProps = (Plugin | PluginManifestInMarket) & { from: 'github' | 'marketplace' | 'package' } + +export function pluginInstallLimit(plugin: PluginProps, systemFeatures: SystemFeatures) { + if (systemFeatures.plugin_installation_permission.restrict_to_marketplace_only) { + if (plugin.from === 'github' || plugin.from === 'package') + return { canInstall: false } + } + + if (systemFeatures.plugin_installation_permission.plugin_installation_scope === InstallationScope.ALL) { + return { + canInstall: true, + } + } + if (systemFeatures.plugin_installation_permission.plugin_installation_scope === InstallationScope.NONE) { + return { + canInstall: false, + } + } + const verification = plugin.verification || {} + if (!plugin.verification || !plugin.verification.authorized_category) + verification.authorized_category = 'langgenius' + + if (systemFeatures.plugin_installation_permission.plugin_installation_scope === InstallationScope.OFFICIAL_ONLY) { + return { + canInstall: verification.authorized_category === 'langgenius', + } + } + if (systemFeatures.plugin_installation_permission.plugin_installation_scope === InstallationScope.OFFICIAL_AND_PARTNER) { + return { + canInstall: verification.authorized_category === 'langgenius' || verification.authorized_category === 'partner', + } + } + return { + canInstall: true, + } +} + +export default function usePluginInstallLimit(plugin: PluginProps) { + const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + return pluginInstallLimit(plugin, systemFeatures) +} diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/github-item.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/github-item.tsx index 96abaa2e1..48f0bff3c 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/item/github-item.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/item/github-item.tsx @@ -39,7 +39,7 @@ const Item: FC<Props> = ({ plugin_id: data.unique_identifier, } onFetchedPayload(payload) - setPayload(payload) + setPayload({ ...payload, from: dependency.type }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [data]) diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/loaded-item.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/loaded-item.tsx index 5eb4c94ab..cf336ae68 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/item/loaded-item.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/item/loaded-item.tsx @@ -8,6 +8,7 @@ import useGetIcon from '../../base/use-get-icon' import { MARKETPLACE_API_PREFIX } from '@/config' import Version from '../../base/version' import type { VersionProps } from '../../../types' +import usePluginInstallLimit from '../../hooks/use-install-plugin-limit' type Props = { checked: boolean @@ -29,9 +30,11 @@ const LoadedItem: FC<Props> = ({ ...particleVersionInfo, toInstallVersion: payload.version, } + const { canInstall } = usePluginInstallLimit(payload) return ( <div className='flex items-center space-x-2'> <Checkbox + disabled={!canInstall} className='shrink-0' checked={checked} onCheck={() => onCheckedChange(payload)} @@ -43,6 +46,7 @@ const LoadedItem: FC<Props> = ({ icon: isFromMarketPlace ? `${MARKETPLACE_API_PREFIX}/plugins/${payload.org}/${payload.name}/icon` : getIconUrl(payload.icon), }} titleLeft={payload.version ? <Version {...versionInfo} /> : null} + limitedInstall={!canInstall} /> </div> ) diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/package-item.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/package-item.tsx index 101c8faca..eac03011a 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/item/package-item.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/item/package-item.tsx @@ -29,7 +29,7 @@ const PackageItem: FC<Props> = ({ const plugin = pluginManifestToCardPluginProps(payload.value.manifest) return ( <LoadedItem - payload={plugin} + payload={{ ...plugin, from: payload.type }} checked={checked} onCheckedChange={onCheckedChange} isFromMarketPlace={isFromMarketPlace} diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/install-multi.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/install-multi.tsx index 52824ba23..ee45520a6 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/install-multi.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/install-multi.tsx @@ -1,5 +1,6 @@ 'use client' -import type { FC } from 'react' +import type { ForwardRefRenderFunction } from 'react' +import { useImperativeHandle } from 'react' import React, { useCallback, useEffect, useMemo, useState } from 'react' import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '../../../types' import MarketplaceItem from '../item/marketplace-item' @@ -9,22 +10,34 @@ import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use import produce from 'immer' import PackageItem from '../item/package-item' import LoadingError from '../../base/loading-error' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { pluginInstallLimit } from '../../hooks/use-install-plugin-limit' type Props = { allPlugins: Dependency[] selectedPlugins: Plugin[] - onSelect: (plugin: Plugin, selectedIndex: number) => void + onSelect: (plugin: Plugin, selectedIndex: number, allCanInstallPluginsLength: number) => void + onSelectAll: (plugins: Plugin[], selectedIndexes: number[]) => void + onDeSelectAll: () => void onLoadedAllPlugin: (installedInfo: Record<string, VersionInfo>) => void isFromMarketPlace?: boolean } -const InstallByDSLList: FC<Props> = ({ +export type ExposeRefs = { + selectAllPlugins: () => void + deSelectAllPlugins: () => void +} + +const InstallByDSLList: ForwardRefRenderFunction<ExposeRefs, Props> = ({ allPlugins, selectedPlugins, onSelect, + onSelectAll, + onDeSelectAll, onLoadedAllPlugin, isFromMarketPlace, -}) => { +}, ref) => { + const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) // DSL has id, to get plugin info to show more info const { isLoading: isFetchingMarketplaceDataById, data: infoGetById, error: infoByIdError } = useFetchPluginsInMarketPlaceByInfo(allPlugins.filter(d => d.type === 'marketplace').map((d) => { const dependecy = (d as GitHubItemAndMarketPlaceDependency).value @@ -97,7 +110,8 @@ const InstallByDSLList: FC<Props> = ({ const sortedList = allPlugins.filter(d => d.type === 'marketplace').map((d) => { const p = d as GitHubItemAndMarketPlaceDependency const id = p.value.marketplace_plugin_unique_identifier?.split(':')[0] - return infoGetById.data.list.find(item => item.plugin.plugin_id === id)?.plugin + const retPluginInfo = infoGetById.data.list.find(item => item.plugin.plugin_id === id)?.plugin + return { ...retPluginInfo, from: d.type } as Plugin }) const payloads = sortedList const failedIndex: number[] = [] @@ -106,7 +120,7 @@ const InstallByDSLList: FC<Props> = ({ if (payloads[i]) { draft[index] = { ...payloads[i], - version: payloads[i].version || payloads[i].latest_version, + version: payloads[i]!.version || payloads[i]!.latest_version, } } else { failedIndex.push(index) } @@ -181,9 +195,35 @@ const InstallByDSLList: FC<Props> = ({ const handleSelect = useCallback((index: number) => { return () => { - onSelect(plugins[index]!, index) + const canSelectPlugins = plugins.filter((p) => { + const { canInstall } = pluginInstallLimit(p!, systemFeatures) + return canInstall + }) + onSelect(plugins[index]!, index, canSelectPlugins.length) } - }, [onSelect, plugins]) + }, [onSelect, plugins, systemFeatures]) + + useImperativeHandle(ref, () => ({ + selectAllPlugins: () => { + const selectedIndexes: number[] = [] + const selectedPlugins: Plugin[] = [] + allPlugins.forEach((d, index) => { + const p = plugins[index] + if (!p) + return + const { canInstall } = pluginInstallLimit(p, systemFeatures) + if (canInstall) { + selectedIndexes.push(index) + selectedPlugins.push(p) + } + }) + onSelectAll(selectedPlugins, selectedIndexes) + }, + deSelectAllPlugins: () => { + onDeSelectAll() + }, + })) + return ( <> {allPlugins.map((d, index) => { @@ -211,7 +251,7 @@ const InstallByDSLList: FC<Props> = ({ key={index} checked={!!selectedPlugins.find(p => p.plugin_id === plugins[index]?.plugin_id)} onCheckedChange={handleSelect(index)} - payload={plugin} + payload={{ ...plugin, from: d.type } as Plugin} version={(d as GitHubItemAndMarketPlaceDependency).value.version! || plugin?.version || ''} versionInfo={getVersionInfo(`${plugin?.org || plugin?.author}/${plugin?.name}`)} /> @@ -234,4 +274,4 @@ const InstallByDSLList: FC<Props> = ({ </> ) } -export default React.memo(InstallByDSLList) +export default React.forwardRef(InstallByDSLList) diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/install.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/install.tsx index db24bdd97..2d8bdcd3d 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/install.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/install.tsx @@ -1,15 +1,18 @@ 'use client' import type { FC } from 'react' +import { useRef } from 'react' import React, { useCallback, useState } from 'react' import type { Dependency, InstallStatusResponse, Plugin, VersionInfo } from '../../../types' import Button from '@/app/components/base/button' import { RiLoader2Line } from '@remixicon/react' import { useTranslation } from 'react-i18next' +import type { ExposeRefs } from './install-multi' import InstallMulti from './install-multi' import { useInstallOrUpdate } from '@/service/use-plugins' import useRefreshPluginList from '../../hooks/use-refresh-plugin-list' import { useCanInstallPluginFromMarketplace } from '@/app/components/plugins/plugin-page/use-permission' import { useMittContextSelector } from '@/context/mitt-context' +import Checkbox from '@/app/components/base/checkbox' const i18nPrefix = 'plugin.installModal' type Props = { @@ -34,18 +37,8 @@ const Install: FC<Props> = ({ const [selectedPlugins, setSelectedPlugins] = React.useState<Plugin[]>([]) const [selectedIndexes, setSelectedIndexes] = React.useState<number[]>([]) const selectedPluginsNum = selectedPlugins.length + const installMultiRef = useRef<ExposeRefs>(null) const { refreshPluginList } = useRefreshPluginList() - const handleSelect = (plugin: Plugin, selectedIndex: number) => { - const isSelected = !!selectedPlugins.find(p => p.plugin_id === plugin.plugin_id) - let nextSelectedPlugins - if (isSelected) - nextSelectedPlugins = selectedPlugins.filter(p => p.plugin_id !== plugin.plugin_id) - else - nextSelectedPlugins = [...selectedPlugins, plugin] - setSelectedPlugins(nextSelectedPlugins) - const nextSelectedIndexes = isSelected ? selectedIndexes.filter(i => i !== selectedIndex) : [...selectedIndexes, selectedIndex] - setSelectedIndexes(nextSelectedIndexes) - } const [canInstall, setCanInstall] = React.useState(false) const [installedInfo, setInstalledInfo] = useState<Record<string, VersionInfo> | undefined>(undefined) @@ -81,6 +74,51 @@ const Install: FC<Props> = ({ installedInfo: installedInfo!, }) } + const [isSelectAll, setIsSelectAll] = useState(false) + const [isIndeterminate, setIsIndeterminate] = useState(false) + const handleClickSelectAll = useCallback(() => { + if (isSelectAll) + installMultiRef.current?.deSelectAllPlugins() + else + installMultiRef.current?.selectAllPlugins() + }, [isSelectAll]) + const handleSelectAll = useCallback((plugins: Plugin[], selectedIndexes: number[]) => { + setSelectedPlugins(plugins) + setSelectedIndexes(selectedIndexes) + setIsSelectAll(true) + setIsIndeterminate(false) + }, []) + const handleDeSelectAll = useCallback(() => { + setSelectedPlugins([]) + setSelectedIndexes([]) + setIsSelectAll(false) + setIsIndeterminate(false) + }, []) + + const handleSelect = useCallback((plugin: Plugin, selectedIndex: number, allPluginsLength: number) => { + const isSelected = !!selectedPlugins.find(p => p.plugin_id === plugin.plugin_id) + let nextSelectedPlugins + if (isSelected) + nextSelectedPlugins = selectedPlugins.filter(p => p.plugin_id !== plugin.plugin_id) + else + nextSelectedPlugins = [...selectedPlugins, plugin] + setSelectedPlugins(nextSelectedPlugins) + const nextSelectedIndexes = isSelected ? selectedIndexes.filter(i => i !== selectedIndex) : [...selectedIndexes, selectedIndex] + setSelectedIndexes(nextSelectedIndexes) + if (nextSelectedPlugins.length === 0) { + setIsSelectAll(false) + setIsIndeterminate(false) + } + else if (nextSelectedPlugins.length === allPluginsLength) { + setIsSelectAll(true) + setIsIndeterminate(false) + } + else { + setIsIndeterminate(true) + setIsSelectAll(false) + } + }, [selectedPlugins, selectedIndexes]) + const { canInstallPluginFromMarketplace } = useCanInstallPluginFromMarketplace() return ( <> @@ -90,9 +128,12 @@ const Install: FC<Props> = ({ </div> <div className='w-full space-y-1 rounded-2xl bg-background-section-burn p-2'> <InstallMulti + ref={installMultiRef} allPlugins={allPlugins} selectedPlugins={selectedPlugins} onSelect={handleSelect} + onSelectAll={handleSelectAll} + onDeSelectAll={handleDeSelectAll} onLoadedAllPlugin={handleLoadedAllPlugin} isFromMarketPlace={isFromMarketPlace} /> @@ -100,21 +141,29 @@ const Install: FC<Props> = ({ </div> {/* Action Buttons */} {!isHideButton && ( - <div className='flex items-center justify-end gap-2 self-stretch p-6 pt-5'> - {!canInstall && ( - <Button variant='secondary' className='min-w-[72px]' onClick={onCancel}> - {t('common.operation.cancel')} + <div className='flex items-center justify-between gap-2 self-stretch p-6 pt-5'> + <div className='px-2'> + {canInstall && <div className='flex items-center gap-x-2' onClick={handleClickSelectAll}> + <Checkbox checked={isSelectAll} indeterminate={isIndeterminate} /> + <p className='system-sm-medium cursor-pointer text-text-secondary'>{isSelectAll ? t('common.operation.deSelectAll') : t('common.operation.selectAll')}</p> + </div>} + </div> + <div className='flex items-center justify-end gap-2 self-stretch'> + {!canInstall && ( + <Button variant='secondary' className='min-w-[72px]' onClick={onCancel}> + {t('common.operation.cancel')} + </Button> + )} + <Button + variant='primary' + className='flex min-w-[72px] space-x-0.5' + disabled={!canInstall || isInstalling || selectedPlugins.length === 0 || !canInstallPluginFromMarketplace} + onClick={handleInstall} + > + {isInstalling && <RiLoader2Line className='h-4 w-4 animate-spin-slow' />} + <span>{t(`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`)}</span> </Button> - )} - <Button - variant='primary' - className='flex min-w-[72px] space-x-0.5' - disabled={!canInstall || isInstalling || selectedPlugins.length === 0 || !canInstallPluginFromMarketplace} - onClick={handleInstall} - > - {isInstalling && <RiLoader2Line className='h-4 w-4 animate-spin-slow' />} - <span>{t(`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`)}</span> - </Button> + </div> </div> )} diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx index 412517283..1ddc52ced 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx @@ -124,7 +124,7 @@ const Installed: FC<Props> = ({ /> </p> {!isDifyVersionCompatible && ( - <p className='system-md-regular flex items-center gap-1 text-text-secondary text-text-warning'> + <p className='system-md-regular flex items-center gap-1 text-text-warning'> {t('plugin.difyVersionNotCompatible', { minimalDifyVersion: payload.meta.minimum_dify_version })} </p> )} diff --git a/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx b/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx index 53f1f40fb..dbc7c97d8 100644 --- a/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx +++ b/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx @@ -15,6 +15,7 @@ import Version from '../../base/version' import { usePluginTaskList } from '@/service/use-plugins' import { gte } from 'semver' import { useAppContext } from '@/context/app-context' +import useInstallPluginLimit from '../../hooks/use-install-plugin-limit' const i18nPrefix = 'plugin.installModal' @@ -124,15 +125,16 @@ const Installed: FC<Props> = ({ const isDifyVersionCompatible = useMemo(() => { if (!pluginDeclaration || !langeniusVersionInfo.current_version) return true return gte(langeniusVersionInfo.current_version, pluginDeclaration?.manifest.meta.minimum_dify_version ?? '0.0.0') - }, [langeniusVersionInfo.current_version, pluginDeclaration?.manifest.meta.minimum_dify_version]) + }, [langeniusVersionInfo.current_version, pluginDeclaration]) + const { canInstall } = useInstallPluginLimit({ ...payload, from: 'marketplace' }) return ( <> <div className='flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3'> <div className='system-md-regular text-text-secondary'> <p>{t(`${i18nPrefix}.readyToInstall`)}</p> {!isDifyVersionCompatible && ( - <p className='system-md-regular text-text-secondary text-text-warning'> + <p className='system-md-regular text-text-warning'> {t('plugin.difyVersionNotCompatible', { minimalDifyVersion: pluginDeclaration?.manifest.meta.minimum_dify_version })} </p> )} @@ -146,6 +148,7 @@ const Installed: FC<Props> = ({ installedVersion={installedVersion} toInstallVersion={toInstallVersion} />} + limitedInstall={!canInstall} /> </div> </div> @@ -159,7 +162,7 @@ const Installed: FC<Props> = ({ <Button variant='primary' className='flex min-w-[72px] space-x-0.5' - disabled={isInstalling || isLoading} + disabled={isInstalling || isLoading || !canInstall} onClick={handleInstall} > {isInstalling && <RiLoader2Line className='h-4 w-4 animate-spin-slow' />} diff --git a/web/app/components/plugins/install-plugin/utils.ts b/web/app/components/plugins/install-plugin/utils.ts index eff5e3a6d..43df10502 100644 --- a/web/app/components/plugins/install-plugin/utils.ts +++ b/web/app/components/plugins/install-plugin/utils.ts @@ -1,5 +1,6 @@ import type { Plugin, PluginDeclaration, PluginManifestInMarket } from '../types' import type { GitHubUrlInfo } from '@/app/components/plugins/types' +import { isEmpty } from 'lodash-es' export const pluginManifestToCardPluginProps = (pluginManifest: PluginDeclaration): Plugin => { return { @@ -47,6 +48,7 @@ export const pluginManifestInMarketToPluginProps = (pluginManifest: PluginManife }, tags: [], badges: pluginManifest.badges, + verification: isEmpty(pluginManifest.verification) ? { authorized_category: 'langgenius' } : pluginManifest.verification, } } diff --git a/web/app/components/plugins/marketplace/list/card-wrapper.tsx b/web/app/components/plugins/marketplace/list/card-wrapper.tsx index 27511559d..5e4b62179 100644 --- a/web/app/components/plugins/marketplace/list/card-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/card-wrapper.tsx @@ -56,7 +56,7 @@ const CardWrapper = ({ > {t('plugin.detailPanel.operation.install')} </Button> - <a href={`${getPluginLinkInMarketplace(plugin)}?language=${localeFromLocale}${theme ? `&theme=${theme}` : ''}`} target='_blank' className='block w-[calc(50%-4px)] flex-1 shrink-0'> + <a href={getPluginLinkInMarketplace(plugin, { language: localeFromLocale, theme })} target='_blank' className='block w-[calc(50%-4px)] flex-1 shrink-0'> <Button className='w-full gap-0.5' > diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index ef4822f84..c112a4aad 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -8,8 +8,8 @@ import type { } from '@/app/components/plugins/marketplace/types' import { MARKETPLACE_API_PREFIX, - MARKETPLACE_URL_PREFIX, } from '@/config' +import { getMarketplaceUrl } from '@/utils/var' export const getPluginIconInMarketplace = (plugin: Plugin) => { if (plugin.type === 'bundle') @@ -32,10 +32,10 @@ export const getFormattedPlugin = (bundle: any) => { } } -export const getPluginLinkInMarketplace = (plugin: Plugin) => { +export const getPluginLinkInMarketplace = (plugin: Plugin, params?: Record<string, string | undefined>) => { if (plugin.type === 'bundle') - return `${MARKETPLACE_URL_PREFIX}/bundles/${plugin.org}/${plugin.name}` - return `${MARKETPLACE_URL_PREFIX}/plugins/${plugin.org}/${plugin.name}` + return getMarketplaceUrl(`/bundles/${plugin.org}/${plugin.name}`, params) + return getMarketplaceUrl(`/plugins/${plugin.org}/${plugin.name}`, params) } export const getMarketplacePluginsByCollectionId = async (collectionId: string, query?: CollectionsAndPluginsSearchParams) => { 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 7548c90ac..0a5a8b87d 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header.tsx @@ -33,8 +33,9 @@ import { useGetLanguage } from '@/context/i18n' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import { useInvalidateAllToolProviders } from '@/service/use-tools' -import { API_PREFIX, MARKETPLACE_URL_PREFIX } from '@/config' +import { API_PREFIX } from '@/config' import cn from '@/utils/classnames' +import { getMarketplaceUrl } from '@/utils/var' const i18nPrefix = 'plugin.action' @@ -87,7 +88,7 @@ const DetailHeader = ({ if (isFromGitHub) return `https://github.com/${meta!.repo}` if (isFromMarketplace) - return `${MARKETPLACE_URL_PREFIX}/plugins/${author}/${name}${theme ? `?theme=${theme}` : ''}` + return getMarketplaceUrl(`/plugins/${author}/${name}`, { theme }) return '' }, [author, isFromGitHub, isFromMarketplace, meta, name, theme]) diff --git a/web/app/components/plugins/plugin-item/index.tsx b/web/app/components/plugins/plugin-item/index.tsx index b2d5f91ca..272a9bee8 100644 --- a/web/app/components/plugins/plugin-item/index.tsx +++ b/web/app/components/plugins/plugin-item/index.tsx @@ -21,13 +21,14 @@ import OrgInfo from '../card/base/org-info' import Title from '../card/base/title' import Action from './action' import cn from '@/utils/classnames' -import { API_PREFIX, MARKETPLACE_URL_PREFIX } from '@/config' +import { API_PREFIX } from '@/config' import { useSingleCategories } from '../hooks' import { useRenderI18nObject } from '@/hooks/use-i18n' import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list' import { useAppContext } from '@/context/app-context' import { gte } from 'semver' import Tooltip from '@/app/components/base/tooltip' +import { getMarketplaceUrl } from '@/utils/var' type Props = { className?: string @@ -166,7 +167,7 @@ const PluginItem: FC<Props> = ({ } {source === PluginSource.marketplace && <> - <a href={`${MARKETPLACE_URL_PREFIX}/plugins/${author}/${name}${theme ? `?theme=${theme}` : ''}`} target='_blank' className='flex items-center gap-0.5'> + <a href={getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })} target='_blank' className='flex items-center gap-0.5'> <div className='system-2xs-medium-uppercase text-text-tertiary'>{t('plugin.from')} <span className='text-text-secondary'>marketplace</span></div> <RiArrowRightUpLine className='h-3 w-3 text-text-tertiary' /> </a> diff --git a/web/app/components/plugins/plugin-page/empty/index.tsx b/web/app/components/plugins/plugin-page/empty/index.tsx index d17c4f420..a84427eca 100644 --- a/web/app/components/plugins/plugin-page/empty/index.tsx +++ b/web/app/components/plugins/plugin-page/empty/index.tsx @@ -1,4 +1,5 @@ -import React, { useMemo, useRef, useState } from 'react' +'use client' +import React, { useEffect, useMemo, useRef, useState } from 'react' import { MagicBox } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices' import { FileZip } from '@/app/components/base/icons/src/vender/solid/files' import { Github } from '@/app/components/base/icons/src/vender/solid/general' @@ -14,12 +15,18 @@ import { noop } from 'lodash-es' import { useGlobalPublicStore } from '@/context/global-public-context' import Button from '@/app/components/base/button' +type InstallMethod = { + icon: React.FC<{ className?: string }> + text: string + action: string +} + const Empty = () => { const { t } = useTranslation() const fileInputRef = useRef<HTMLInputElement>(null) const [selectedAction, setSelectedAction] = useState<string | null>(null) const [selectedFile, setSelectedFile] = useState<File | null>(null) - const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) + const { enable_marketplace, plugin_installation_permission } = useGlobalPublicStore(s => s.systemFeatures) const setActiveTab = usePluginPageContext(v => v.setActiveTab) const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { @@ -39,6 +46,22 @@ const Empty = () => { return t('plugin.list.notFound') }, [pluginList?.plugins.length, t, filters.categories.length, filters.tags.length, filters.searchQuery]) + const [installMethods, setInstallMethods] = useState<InstallMethod[]>([]) + useEffect(() => { + const methods = [] + if (enable_marketplace) + methods.push({ icon: MagicBox, text: t('plugin.source.marketplace'), action: 'marketplace' }) + + if (plugin_installation_permission.restrict_to_marketplace_only) { + setInstallMethods(methods) + } + else { + methods.push({ icon: Github, text: t('plugin.source.github'), action: 'github' }) + methods.push({ icon: FileZip, text: t('plugin.source.local'), action: 'local' }) + setInstallMethods(methods) + } + }, [plugin_installation_permission, enable_marketplace, t]) + return ( <div className='relative z-0 w-full grow'> {/* skeleton */} @@ -71,15 +94,7 @@ const Empty = () => { accept={SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS} /> <div className='flex w-full flex-col gap-y-1'> - {[ - ...( - (enable_marketplace) - ? [{ icon: MagicBox, text: t('plugin.list.source.marketplace'), action: 'marketplace' }] - : [] - ), - { icon: Github, text: t('plugin.list.source.github'), action: 'github' }, - { icon: FileZip, text: t('plugin.list.source.local'), action: 'local' }, - ].map(({ icon: Icon, text, action }) => ( + {installMethods.map(({ icon: Icon, text, action }) => ( <Button key={action} className='justify-start gap-x-0.5 px-3' diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx index 7e17459b3..1531e1789 100644 --- a/web/app/components/plugins/plugin-page/index.tsx +++ b/web/app/components/plugins/plugin-page/index.tsx @@ -136,7 +136,7 @@ const PluginPage = ({ const options = usePluginPageContext(v => v.options) const activeTab = usePluginPageContext(v => v.activeTab) const setActiveTab = usePluginPageContext(v => v.setActiveTab) - const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) + const { enable_marketplace, branding } = useGlobalPublicStore(s => s.systemFeatures) const isPluginsTab = useMemo(() => activeTab === PLUGIN_PAGE_TABS_MAP.plugins, [activeTab]) const isExploringMarketplace = useMemo(() => { @@ -225,7 +225,7 @@ const PluginPage = ({ ) } { - canSetPermissions && ( + canSetPermissions && !branding.enabled && ( <Tooltip popupContent={t('plugin.privilege.title')} > diff --git a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx index abe4f9cb6..be62adb31 100644 --- a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx +++ b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx @@ -1,6 +1,6 @@ 'use client' -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { RiAddLine, RiArrowDownSLine } from '@remixicon/react' import Button from '@/app/components/base/button' import { MagicBox } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices' @@ -22,6 +22,13 @@ import { useGlobalPublicStore } from '@/context/global-public-context' type Props = { onSwitchToMarketplaceTab: () => void } + +type InstallMethod = { + icon: React.FC<{ className?: string }> + text: string + action: string +} + const InstallPluginDropdown = ({ onSwitchToMarketplaceTab, }: Props) => { @@ -30,7 +37,7 @@ const InstallPluginDropdown = ({ const [isMenuOpen, setIsMenuOpen] = useState(false) const [selectedAction, setSelectedAction] = useState<string | null>(null) const [selectedFile, setSelectedFile] = useState<File | null>(null) - const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) + const { enable_marketplace, plugin_installation_permission } = useGlobalPublicStore(s => s.systemFeatures) const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0] @@ -54,6 +61,22 @@ const InstallPluginDropdown = ({ // console.log(res) // } + const [installMethods, setInstallMethods] = useState<InstallMethod[]>([]) + useEffect(() => { + const methods = [] + if (enable_marketplace) + methods.push({ icon: MagicBox, text: t('plugin.source.marketplace'), action: 'marketplace' }) + + if (plugin_installation_permission.restrict_to_marketplace_only) { + setInstallMethods(methods) + } + else { + methods.push({ icon: Github, text: t('plugin.source.github'), action: 'github' }) + methods.push({ icon: FileZip, text: t('plugin.source.local'), action: 'local' }) + setInstallMethods(methods) + } + }, [plugin_installation_permission, enable_marketplace, t]) + return ( <PortalToFollowElem open={isMenuOpen} @@ -84,15 +107,7 @@ const InstallPluginDropdown = ({ accept={SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS} /> <div className='w-full'> - {[ - ...( - (enable_marketplace) - ? [{ icon: MagicBox, text: t('plugin.source.marketplace'), action: 'marketplace' }] - : [] - ), - { icon: Github, text: t('plugin.source.github'), action: 'github' }, - { icon: FileZip, text: t('plugin.source.local'), action: 'local' }, - ].map(({ icon: Icon, text, action }) => ( + {installMethods.map(({ icon: Icon, text, action }) => ( <div key={action} className='flex w-full !cursor-pointer items-center gap-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover' diff --git a/web/app/components/plugins/types.ts b/web/app/components/plugins/types.ts index 231763aaa..ac62d7743 100644 --- a/web/app/components/plugins/types.ts +++ b/web/app/components/plugins/types.ts @@ -94,7 +94,11 @@ export type PluginManifestInMarket = { introduction: string verified: boolean install_count: number - badges: string[] + badges: string[], + verification: { + authorized_category: 'langgenius' | 'partner' | 'community' + }, + from: Dependency['type'] } export type PluginDetail = { @@ -145,7 +149,11 @@ export type Plugin = { settings: CredentialFormSchemaBase[] } tags: { name: string }[] - badges: string[] + badges: string[], + verification: { + authorized_category: 'langgenius' | 'partner' | 'community' + }, + from: Dependency['type'] } export enum PermissionType { diff --git a/web/app/components/tools/marketplace/index.tsx b/web/app/components/tools/marketplace/index.tsx index 8c805e1d5..2e42957a0 100644 --- a/web/app/components/tools/marketplace/index.tsx +++ b/web/app/components/tools/marketplace/index.tsx @@ -12,7 +12,7 @@ import { useMarketplace } from './hooks' import List from '@/app/components/plugins/marketplace/list' import Loading from '@/app/components/base/loading' import { getLocaleOnClient } from '@/i18n' -import { MARKETPLACE_URL_PREFIX } from '@/config' +import { getMarketplaceUrl } from '@/utils/var' type MarketplaceProps = { searchPluginText: string @@ -84,7 +84,7 @@ const Marketplace = ({ </span> {t('common.operation.in')} <a - href={`${MARKETPLACE_URL_PREFIX}?language=${locale}&q=${searchPluginText}&tags=${filterPluginTags.join(',')}${theme ? `&theme=${theme}` : ''}`} + href={getMarketplaceUrl('', { language: locale, q: searchPluginText, tags: filterPluginTags.join(','), theme })} className='system-sm-medium ml-1 flex items-center text-text-accent' target='_blank' > diff --git a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx index 9c3c69d60..4e6492958 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx @@ -12,9 +12,9 @@ import { PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' import cn from '@/utils/classnames' -import { MARKETPLACE_URL_PREFIX } from '@/config' import { useDownloadPlugin } from '@/service/use-plugins' import { downloadFile } from '@/utils/format' +import { getMarketplaceUrl } from '@/utils/var' type Props = { open: boolean @@ -80,7 +80,7 @@ const OperationDropdown: FC<Props> = ({ <PortalToFollowElemContent className='z-[9999]'> <div className='w-[112px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg'> <div onClick={handleDownload} className='system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'>{t('common.operation.download')}</div> - <a href={`${MARKETPLACE_URL_PREFIX}/plugins/${author}/${name}${theme ? `?theme=${theme}` : ''}`} target='_blank' className='system-md-regular block cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'>{t('common.operation.viewDetails')}</a> + <a href={getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })} target='_blank' className='system-md-regular block cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'>{t('common.operation.viewDetails')}</a> </div> </PortalToFollowElemContent> </PortalToFollowElem> diff --git a/web/app/signin/LoginLogo.tsx b/web/app/signin/LoginLogo.tsx deleted file mode 100644 index 73dfb8820..000000000 --- a/web/app/signin/LoginLogo.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client' -import type { FC } from 'react' -import classNames from '@/utils/classnames' -import { useGlobalPublicStore } from '@/context/global-public-context' -import { useTheme } from 'next-themes' - -type LoginLogoProps = { - className?: string -} - -const LoginLogo: FC<LoginLogoProps> = ({ - className, -}) => { - const { systemFeatures } = useGlobalPublicStore() - const { theme } = useTheme() - - let src = theme === 'light' ? '/logo/logo-site.png' : `/logo/logo-site-${theme}.png` - if (systemFeatures.branding.enabled) - src = systemFeatures.branding.login_page_logo - - return ( - <img - src={src} - className={classNames('block w-auto h-10', className)} - alt='logo' - /> - ) -} - -export default LoginLogo diff --git a/web/i18n/en-US/common.ts b/web/i18n/en-US/common.ts index 3c7630c15..f242ecd5a 100644 --- a/web/i18n/en-US/common.ts +++ b/web/i18n/en-US/common.ts @@ -64,6 +64,8 @@ const translation = { skip: 'Skip', format: 'Format', more: 'More', + selectAll: 'Select All', + deSelectAll: 'Deselect All', }, errorMsg: { fieldRequired: '{{field}} is required', diff --git a/web/i18n/en-US/plugin.ts b/web/i18n/en-US/plugin.ts index 00af85528..9fba3a471 100644 --- a/web/i18n/en-US/plugin.ts +++ b/web/i18n/en-US/plugin.ts @@ -154,6 +154,7 @@ const translation = { next: 'Next', pluginLoadError: 'Plugin load error', pluginLoadErrorDesc: 'This plugin will not be installed', + installWarning: 'This plugin is not allowed to be installed.', }, installFromGitHub: { installPlugin: 'Install plugin from GitHub', diff --git a/web/i18n/ja-JP/common.ts b/web/i18n/ja-JP/common.ts index f3120dcc2..0c33c8384 100644 --- a/web/i18n/ja-JP/common.ts +++ b/web/i18n/ja-JP/common.ts @@ -64,6 +64,8 @@ const translation = { in: '中', format: 'フォーマット', more: 'もっと', + selectAll: 'すべて選択', + deSelectAll: 'すべて選択解除', }, errorMsg: { fieldRequired: '{{field}}は必要です', diff --git a/web/i18n/ja-JP/plugin.ts b/web/i18n/ja-JP/plugin.ts index 6b0c9748b..b886c4955 100644 --- a/web/i18n/ja-JP/plugin.ts +++ b/web/i18n/ja-JP/plugin.ts @@ -137,6 +137,7 @@ const translation = { installPlugin: 'プラグインをインストールする', back: '戻る', uploadingPackage: '{{packageName}}をアップロード中...', + installWarning: 'このプラグインはインストールを許可されていません。', }, installFromGitHub: { installedSuccessfully: 'インストールに成功しました', diff --git a/web/i18n/zh-Hans/common.ts b/web/i18n/zh-Hans/common.ts index 91a1706cc..39964bb6b 100644 --- a/web/i18n/zh-Hans/common.ts +++ b/web/i18n/zh-Hans/common.ts @@ -64,6 +64,8 @@ const translation = { skip: '跳过', format: '格式化', more: '更多', + selectAll: '全选', + deSelectAll: '取消全选', }, errorMsg: { fieldRequired: '{{field}} 为必填项', diff --git a/web/i18n/zh-Hans/plugin.ts b/web/i18n/zh-Hans/plugin.ts index bc3e6b678..eddd11701 100644 --- a/web/i18n/zh-Hans/plugin.ts +++ b/web/i18n/zh-Hans/plugin.ts @@ -154,6 +154,7 @@ const translation = { next: '下一步', pluginLoadError: '插件加载错误', pluginLoadErrorDesc: '此插件将不会被安装', + installWarning: '此插件不允许安装。', }, installFromGitHub: { installPlugin: '从 GitHub 安装插件', diff --git a/web/types/feature.ts b/web/types/feature.ts index cc945754b..e12ea8e6d 100644 --- a/web/types/feature.ts +++ b/web/types/feature.ts @@ -13,12 +13,23 @@ export enum LicenseStatus { LOST = 'lost', } +export enum InstallationScope { + ALL = 'all', + NONE = 'none', + OFFICIAL_ONLY = 'official_only', + OFFICIAL_AND_PARTNER = 'official_and_specific_partners', +} + type License = { status: LicenseStatus expired_at: string | null } export type SystemFeatures = { + plugin_installation_permission: { + plugin_installation_scope: InstallationScope, + restrict_to_marketplace_only: boolean + }, sso_enforced_for_signin: boolean sso_enforced_for_signin_protocol: SSOProtocol | '' sso_enforced_for_web: boolean @@ -50,6 +61,10 @@ export type SystemFeatures = { } export const defaultSystemFeatures: SystemFeatures = { + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.ALL, + restrict_to_marketplace_only: false, + }, sso_enforced_for_signin: false, sso_enforced_for_signin_protocol: '', sso_enforced_for_web: false, diff --git a/web/utils/var.ts b/web/utils/var.ts index 06cb43c26..8ce7bdb85 100644 --- a/web/utils/var.ts +++ b/web/utils/var.ts @@ -1,4 +1,4 @@ -import { MAX_VAR_KEY_LENGTH, VAR_ITEM_TEMPLATE, VAR_ITEM_TEMPLATE_IN_WORKFLOW, getMaxVarNameLength } from '@/config' +import { MARKETPLACE_URL_PREFIX, MAX_VAR_KEY_LENGTH, VAR_ITEM_TEMPLATE, VAR_ITEM_TEMPLATE_IN_WORKFLOW, getMaxVarNameLength } from '@/config' import { CONTEXT_PLACEHOLDER_TEXT, HISTORY_PLACEHOLDER_TEXT, @@ -108,3 +108,15 @@ export const getVars = (value: string) => { // Set the value of basePath // example: /dify export const basePath = '' + +export function getMarketplaceUrl(path: string, params?: Record<string, string | undefined>) { + const searchParams = new URLSearchParams({ source: encodeURIComponent(window.location.origin) }) + if (params) { + Object.keys(params).forEach((key) => { + const value = params[key] + if (value !== undefined && value !== null) + searchParams.append(key, value) + }) + } + return `${MARKETPLACE_URL_PREFIX}${path}?${searchParams.toString()}` +}