dify
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import React, { type FC } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Theme } from '@/types/app'
|
||||
|
||||
type IconWithTooltipProps = {
|
||||
className?: string
|
||||
popupContent?: string
|
||||
theme: Theme
|
||||
BadgeIconLight: React.ElementType
|
||||
BadgeIconDark: React.ElementType
|
||||
}
|
||||
|
||||
const IconWithTooltip: FC<IconWithTooltipProps> = ({
|
||||
className,
|
||||
theme,
|
||||
popupContent,
|
||||
BadgeIconLight,
|
||||
BadgeIconDark,
|
||||
}) => {
|
||||
const isDark = theme === Theme.dark
|
||||
const iconClassName = cn('h-5 w-5', className)
|
||||
const Icon = isDark ? BadgeIconDark : BadgeIconLight
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupClassName='p-1.5 border-[0.5px] border-[0.5px] border-components-panel-border bg-components-tooltip-bg text-text-secondary system-xs-medium'
|
||||
popupContent={popupContent}
|
||||
>
|
||||
<div className='flex shrink-0 items-center justify-center'>
|
||||
<Icon className={iconClassName} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(IconWithTooltip)
|
||||
29
dify/web/app/components/plugins/base/badges/partner.tsx
Normal file
29
dify/web/app/components/plugins/base/badges/partner.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { FC } from 'react'
|
||||
import IconWithTooltip from './icon-with-tooltip'
|
||||
import PartnerDark from '@/app/components/base/icons/src/public/plugins/PartnerDark'
|
||||
import PartnerLight from '@/app/components/base/icons/src/public/plugins/PartnerLight'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
|
||||
type PartnerProps = {
|
||||
className?: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const Partner: FC<PartnerProps> = ({
|
||||
className,
|
||||
text,
|
||||
}) => {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<IconWithTooltip
|
||||
className={className}
|
||||
theme={theme}
|
||||
BadgeIconLight={PartnerLight}
|
||||
BadgeIconDark={PartnerDark}
|
||||
popupContent={text}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Partner
|
||||
29
dify/web/app/components/plugins/base/badges/verified.tsx
Normal file
29
dify/web/app/components/plugins/base/badges/verified.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { FC } from 'react'
|
||||
import IconWithTooltip from './icon-with-tooltip'
|
||||
import VerifiedDark from '@/app/components/base/icons/src/public/plugins/VerifiedDark'
|
||||
import VerifiedLight from '@/app/components/base/icons/src/public/plugins/VerifiedLight'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
|
||||
type VerifiedProps = {
|
||||
className?: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const Verified: FC<VerifiedProps> = ({
|
||||
className,
|
||||
text,
|
||||
}) => {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<IconWithTooltip
|
||||
className={className}
|
||||
theme={theme}
|
||||
BadgeIconLight={VerifiedLight}
|
||||
BadgeIconDark={VerifiedDark}
|
||||
popupContent={text}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Verified
|
||||
105
dify/web/app/components/plugins/base/deprecation-notice.tsx
Normal file
105
dify/web/app/components/plugins/base/deprecation-notice.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
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 { useMixedTranslation } from '../marketplace/hooks'
|
||||
import { camelCase } from 'lodash-es'
|
||||
|
||||
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 camelCase(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
|
||||
t={t}
|
||||
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)
|
||||
66
dify/web/app/components/plugins/base/key-value-item.tsx
Normal file
66
dify/web/app/components/plugins/base/key-value-item.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import {
|
||||
RiClipboardLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CopyCheck } from '../../base/icons/src/vender/line/files'
|
||||
import Tooltip from '../../base/tooltip'
|
||||
import cn from '@/utils/classnames'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
|
||||
type Props = {
|
||||
label: string
|
||||
labelWidthClassName?: string
|
||||
value: string
|
||||
maskedValue?: string
|
||||
valueMaxWidthClassName?: string
|
||||
}
|
||||
|
||||
const KeyValueItem: FC<Props> = ({
|
||||
label,
|
||||
labelWidthClassName = 'w-10',
|
||||
value,
|
||||
maskedValue,
|
||||
valueMaxWidthClassName = 'max-w-[162px]',
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const handleCopy = useCallback(() => {
|
||||
copy(value)
|
||||
setIsCopied(true)
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCopied) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsCopied(false)
|
||||
}, 2000)
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [isCopied])
|
||||
|
||||
const CopyIcon = isCopied ? CopyCheck : RiClipboardLine
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-1'>
|
||||
<span className={cn('system-xs-medium flex flex-col items-start justify-center text-text-tertiary', labelWidthClassName)}>{label}</span>
|
||||
<div className='flex items-center justify-center gap-0.5'>
|
||||
<span className={cn(valueMaxWidthClassName, ' system-xs-medium truncate text-text-secondary')}>
|
||||
{maskedValue || value}
|
||||
</span>
|
||||
<Tooltip popupContent={t(`common.operation.${isCopied ? 'copied' : 'copy'}`)} position='top'>
|
||||
<ActionButton onClick={handleCopy}>
|
||||
<CopyIcon className='h-3.5 w-3.5 shrink-0 text-text-tertiary' />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(KeyValueItem)
|
||||
69
dify/web/app/components/plugins/card/base/card-icon.tsx
Normal file
69
dify/web/app/components/plugins/card/base/card-icon.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { RiCheckLine, RiCloseLine } from '@remixicon/react'
|
||||
import { Mcp } from '@/app/components/base/icons/src/vender/other'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import cn from '@/utils/classnames'
|
||||
import { shouldUseMcpIcon } from '@/utils/mcp'
|
||||
|
||||
const iconSizeMap = {
|
||||
xs: 'w-4 h-4 text-base',
|
||||
tiny: 'w-6 h-6 text-base',
|
||||
small: 'w-8 h-8',
|
||||
medium: 'w-9 h-9',
|
||||
large: 'w-10 h-10',
|
||||
}
|
||||
const Icon = ({
|
||||
className,
|
||||
src,
|
||||
installed = false,
|
||||
installFailed = false,
|
||||
size = 'large',
|
||||
}: {
|
||||
className?: string
|
||||
src: string | {
|
||||
content: string
|
||||
background: string
|
||||
}
|
||||
installed?: boolean
|
||||
installFailed?: boolean
|
||||
size?: 'xs' | 'tiny' | 'small' | 'medium' | 'large'
|
||||
}) => {
|
||||
const iconClassName = 'flex justify-center items-center gap-2 absolute bottom-[-4px] right-[-4px] w-[18px] h-[18px] rounded-full border-2 border-components-panel-bg'
|
||||
if (typeof src === 'object') {
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<AppIcon
|
||||
size={size}
|
||||
iconType={'emoji'}
|
||||
icon={src.content}
|
||||
background={src.background}
|
||||
className='rounded-md'
|
||||
innerIcon={shouldUseMcpIcon(src) ? <Mcp className='h-8 w-8 text-text-primary-on-surface' /> : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative shrink-0 rounded-md bg-contain bg-center bg-no-repeat', iconSizeMap[size], className)}
|
||||
style={{
|
||||
backgroundImage: `url(${src})`,
|
||||
}}
|
||||
>
|
||||
{
|
||||
installed
|
||||
&& <div className={cn(iconClassName, 'bg-state-success-solid')}>
|
||||
<RiCheckLine className='h-3 w-3 text-text-primary-on-surface' />
|
||||
</div>
|
||||
}
|
||||
{
|
||||
installFailed
|
||||
&& <div className={cn(iconClassName, 'bg-state-destructive-solid')}>
|
||||
<RiCloseLine className='h-3 w-3 text-text-primary-on-surface' />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Icon
|
||||
12
dify/web/app/components/plugins/card/base/corner-mark.tsx
Normal file
12
dify/web/app/components/plugins/card/base/corner-mark.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { LeftCorner } from '../../../base/icons/src/vender/plugin'
|
||||
|
||||
const CornerMark = ({ text }: { text: string }) => {
|
||||
return (
|
||||
<div className='absolute right-0 top-0 flex pl-[13px] '>
|
||||
<LeftCorner className="text-background-section" />
|
||||
<div className="system-2xs-medium-uppercase h-5 rounded-tr-xl bg-background-section pr-2 leading-5 text-text-tertiary">{text}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CornerMark
|
||||
31
dify/web/app/components/plugins/card/base/description.tsx
Normal file
31
dify/web/app/components/plugins/card/base/description.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { FC } from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
text: string
|
||||
descriptionLineRows: number
|
||||
}
|
||||
|
||||
const Description: FC<Props> = ({
|
||||
className,
|
||||
text,
|
||||
descriptionLineRows,
|
||||
}) => {
|
||||
const lineClassName = useMemo(() => {
|
||||
if (descriptionLineRows === 1)
|
||||
return 'h-4 truncate'
|
||||
else if (descriptionLineRows === 2)
|
||||
return 'h-8 line-clamp-2'
|
||||
else
|
||||
return 'h-12 line-clamp-3'
|
||||
}, [descriptionLineRows])
|
||||
return (
|
||||
<div className={cn('system-xs-regular text-text-tertiary', lineClassName, className)}>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Description
|
||||
19
dify/web/app/components/plugins/card/base/download-count.tsx
Normal file
19
dify/web/app/components/plugins/card/base/download-count.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { RiInstallLine } from '@remixicon/react'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
type Props = {
|
||||
downloadCount: number
|
||||
}
|
||||
|
||||
const DownloadCount = ({
|
||||
downloadCount,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex items-center space-x-1 text-text-tertiary">
|
||||
<RiInstallLine className="h-3 w-3 shrink-0" />
|
||||
<div className="system-xs-regular">{formatNumber(downloadCount)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DownloadCount
|
||||
30
dify/web/app/components/plugins/card/base/org-info.tsx
Normal file
30
dify/web/app/components/plugins/card/base/org-info.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import cn from '@/utils/classnames'
|
||||
type Props = {
|
||||
className?: string
|
||||
orgName?: string
|
||||
packageName: string
|
||||
packageNameClassName?: string
|
||||
}
|
||||
|
||||
const OrgInfo = ({
|
||||
className,
|
||||
orgName,
|
||||
packageName,
|
||||
packageNameClassName,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className={cn('flex h-4 items-center space-x-0.5', className)}>
|
||||
{orgName && (
|
||||
<>
|
||||
<span className='system-xs-regular shrink-0 text-text-tertiary'>{orgName}</span>
|
||||
<span className='system-xs-regular shrink-0 text-text-quaternary'>/</span>
|
||||
</>
|
||||
)}
|
||||
<span className={cn('system-xs-regular w-0 shrink-0 grow truncate text-text-tertiary', packageNameClassName)}>
|
||||
{packageName}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrgInfo
|
||||
51
dify/web/app/components/plugins/card/base/placeholder.tsx
Normal file
51
dify/web/app/components/plugins/card/base/placeholder.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Group } from '../../../base/icons/src/vender/other'
|
||||
import Title from './title'
|
||||
import { SkeletonContainer, SkeletonPoint, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
wrapClassName: string
|
||||
loadingFileName?: string
|
||||
}
|
||||
|
||||
export const LoadingPlaceholder = ({ className }: { className?: string }) => (
|
||||
<div className={cn('h-2 rounded-sm bg-text-quaternary opacity-20', className)} />
|
||||
)
|
||||
|
||||
const Placeholder = ({
|
||||
wrapClassName,
|
||||
loadingFileName,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className={wrapClassName}>
|
||||
<SkeletonRow>
|
||||
<div
|
||||
className='flex h-10 w-10 items-center justify-center gap-2 rounded-[10px] border-[0.5px]
|
||||
border-components-panel-border bg-background-default p-1 backdrop-blur-sm'>
|
||||
<div className='flex h-5 w-5 items-center justify-center'>
|
||||
<Group className='text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow">
|
||||
<SkeletonContainer>
|
||||
<div className="flex h-5 items-center">
|
||||
{loadingFileName ? (
|
||||
<Title title={loadingFileName} />
|
||||
) : (
|
||||
<SkeletonRectangle className="w-[260px]" />
|
||||
)}
|
||||
</div>
|
||||
<SkeletonRow className="h-4">
|
||||
<SkeletonRectangle className="w-[41px]" />
|
||||
<SkeletonPoint />
|
||||
<SkeletonRectangle className="w-[180px]" />
|
||||
</SkeletonRow>
|
||||
</SkeletonContainer>
|
||||
</div>
|
||||
</SkeletonRow>
|
||||
<SkeletonRectangle className="mt-3 w-[420px]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Placeholder
|
||||
13
dify/web/app/components/plugins/card/base/title.tsx
Normal file
13
dify/web/app/components/plugins/card/base/title.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
const Title = ({
|
||||
title,
|
||||
}: {
|
||||
title: string
|
||||
}) => {
|
||||
return (
|
||||
<div className='system-md-semibold truncate text-text-secondary'>
|
||||
{title}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Title
|
||||
36
dify/web/app/components/plugins/card/card-more-info.tsx
Normal file
36
dify/web/app/components/plugins/card/card-more-info.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import DownloadCount from './base/download-count'
|
||||
|
||||
type Props = {
|
||||
downloadCount?: number
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
const CardMoreInfo = ({
|
||||
downloadCount,
|
||||
tags,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex h-5 items-center">
|
||||
{downloadCount !== undefined && <DownloadCount downloadCount={downloadCount} />}
|
||||
{downloadCount !== undefined && tags && tags.length > 0 && <div className="system-xs-regular mx-2 text-text-quaternary">·</div>}
|
||||
{tags && tags.length > 0 && (
|
||||
<>
|
||||
<div className="flex h-4 flex-wrap space-x-2 overflow-hidden">
|
||||
{tags.map(tag => (
|
||||
<div
|
||||
key={tag}
|
||||
className="system-xs-regular flex max-w-[120px] space-x-1 overflow-hidden"
|
||||
title={`# ${tag}`}
|
||||
>
|
||||
<span className="text-text-quaternary">#</span>
|
||||
<span className="truncate text-text-tertiary">{tag}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CardMoreInfo
|
||||
107
dify/web/app/components/plugins/card/index.tsx
Normal file
107
dify/web/app/components/plugins/card/index.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import { renderI18nObject } from '@/i18n-config'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiAlertFill } from '@remixicon/react'
|
||||
import React from 'react'
|
||||
import Partner from '../base/badges/partner'
|
||||
import Verified from '../base/badges/verified'
|
||||
import Icon from '../card/base/card-icon'
|
||||
import { useCategories } from '../hooks'
|
||||
import type { Plugin } from '../types'
|
||||
import CornerMark from './base/corner-mark'
|
||||
import Description from './base/description'
|
||||
import OrgInfo from './base/org-info'
|
||||
import Placeholder from './base/placeholder'
|
||||
import Title from './base/title'
|
||||
|
||||
export type Props = {
|
||||
className?: string
|
||||
payload: Plugin
|
||||
titleLeft?: React.ReactNode
|
||||
installed?: boolean
|
||||
installFailed?: boolean
|
||||
hideCornerMark?: boolean
|
||||
descriptionLineRows?: number
|
||||
footer?: React.ReactNode
|
||||
isLoading?: boolean
|
||||
loadingFileName?: string
|
||||
locale?: string
|
||||
limitedInstall?: boolean
|
||||
}
|
||||
|
||||
const Card = ({
|
||||
className,
|
||||
payload,
|
||||
titleLeft,
|
||||
installed,
|
||||
installFailed,
|
||||
hideCornerMark,
|
||||
descriptionLineRows = 2,
|
||||
footer,
|
||||
isLoading = false,
|
||||
loadingFileName,
|
||||
locale: localeFromProps,
|
||||
limitedInstall = false,
|
||||
}: Props) => {
|
||||
const defaultLocale = useGetLanguage()
|
||||
const locale = localeFromProps ? getLanguage(localeFromProps) : defaultLocale
|
||||
const { t } = useMixedTranslation(localeFromProps)
|
||||
const { categoriesMap } = useCategories(t, true)
|
||||
const { category, type, name, org, label, brief, icon, verified, badges = [] } = payload
|
||||
const getLocalizedText = (obj: Record<string, string> | undefined) =>
|
||||
obj ? renderI18nObject(obj, locale) : ''
|
||||
const isPartner = badges.includes('partner')
|
||||
|
||||
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 (
|
||||
<Placeholder
|
||||
wrapClassName={wrapClassName}
|
||||
loadingFileName={loadingFileName!}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={wrapClassName}>
|
||||
<div className={cn('p-4 pb-3', limitedInstall && 'pb-1')}>
|
||||
{!hideCornerMark && <CornerMark text={categoriesMap[type === 'bundle' ? type : category]?.label} />}
|
||||
{/* 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>
|
||||
</div>
|
||||
<Description
|
||||
className="mt-3"
|
||||
text={getLocalizedText(brief)}
|
||||
descriptionLineRows={descriptionLineRows}
|
||||
/>
|
||||
{footer && <div>{footer}</div>}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Card)
|
||||
32
dify/web/app/components/plugins/constants.ts
Normal file
32
dify/web/app/components/plugins/constants.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { PluginCategoryEnum } from './types'
|
||||
|
||||
export const tagKeys = [
|
||||
'agent',
|
||||
'rag',
|
||||
'search',
|
||||
'image',
|
||||
'videos',
|
||||
'weather',
|
||||
'finance',
|
||||
'design',
|
||||
'travel',
|
||||
'social',
|
||||
'news',
|
||||
'medical',
|
||||
'productivity',
|
||||
'education',
|
||||
'business',
|
||||
'entertainment',
|
||||
'utilities',
|
||||
'other',
|
||||
]
|
||||
|
||||
export const categoryKeys = [
|
||||
PluginCategoryEnum.model,
|
||||
PluginCategoryEnum.tool,
|
||||
PluginCategoryEnum.datasource,
|
||||
PluginCategoryEnum.agent,
|
||||
PluginCategoryEnum.extension,
|
||||
'bundle',
|
||||
PluginCategoryEnum.trigger,
|
||||
]
|
||||
99
dify/web/app/components/plugins/hooks.ts
Normal file
99
dify/web/app/components/plugins/hooks.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
categoryKeys,
|
||||
tagKeys,
|
||||
} from './constants'
|
||||
import { PluginCategoryEnum } from './types'
|
||||
|
||||
export type Tag = {
|
||||
name: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export const useTags = (translateFromOut?: TFunction) => {
|
||||
const { t: translation } = useTranslation()
|
||||
const t = translateFromOut || translation
|
||||
|
||||
const tags = useMemo(() => {
|
||||
return tagKeys.map((tag) => {
|
||||
return {
|
||||
name: tag,
|
||||
label: t(`pluginTags.tags.${tag}`),
|
||||
}
|
||||
})
|
||||
}, [t])
|
||||
|
||||
const tagsMap = useMemo(() => {
|
||||
return tags.reduce((acc, tag) => {
|
||||
acc[tag.name] = tag
|
||||
return acc
|
||||
}, {} as Record<string, Tag>)
|
||||
}, [tags])
|
||||
|
||||
const getTagLabel = useMemo(() => {
|
||||
return (name: string) => {
|
||||
if (!tagsMap[name])
|
||||
return name
|
||||
return tagsMap[name].label
|
||||
}
|
||||
}, [tagsMap])
|
||||
|
||||
return {
|
||||
tags,
|
||||
tagsMap,
|
||||
getTagLabel,
|
||||
}
|
||||
}
|
||||
|
||||
type Category = {
|
||||
name: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export const useCategories = (translateFromOut?: TFunction, isSingle?: boolean) => {
|
||||
const { t: translation } = useTranslation()
|
||||
const t = translateFromOut || translation
|
||||
|
||||
const categories = useMemo(() => {
|
||||
return categoryKeys.map((category) => {
|
||||
if (category === PluginCategoryEnum.agent) {
|
||||
return {
|
||||
name: PluginCategoryEnum.agent,
|
||||
label: isSingle ? t('plugin.categorySingle.agent') : t('plugin.category.agents'),
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: category,
|
||||
label: isSingle ? t(`plugin.categorySingle.${category}`) : t(`plugin.category.${category}s`),
|
||||
}
|
||||
})
|
||||
}, [t, isSingle])
|
||||
|
||||
const categoriesMap = useMemo(() => {
|
||||
return categories.reduce((acc, category) => {
|
||||
acc[category.name] = category
|
||||
return acc
|
||||
}, {} as Record<string, Category>)
|
||||
}, [categories])
|
||||
|
||||
return {
|
||||
categories,
|
||||
categoriesMap,
|
||||
}
|
||||
}
|
||||
|
||||
export const PLUGIN_PAGE_TABS_MAP = {
|
||||
plugins: 'plugins',
|
||||
marketplace: 'discover',
|
||||
}
|
||||
|
||||
export const usePluginPageTabs = () => {
|
||||
const { t } = useTranslation()
|
||||
const tabs = [
|
||||
{ value: PLUGIN_PAGE_TABS_MAP.plugins, text: t('common.menus.plugins') },
|
||||
{ value: PLUGIN_PAGE_TABS_MAP.marketplace, text: t('common.menus.exploreMarketplace') },
|
||||
]
|
||||
return tabs
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { checkTaskStatus as fetchCheckTaskStatus } from '@/service/plugins'
|
||||
import type { PluginStatus } from '../../types'
|
||||
import { TaskStatus } from '../../types'
|
||||
import { sleep } from '@/utils'
|
||||
|
||||
const INTERVAL = 10 * 1000 // 10 seconds
|
||||
|
||||
type Params = {
|
||||
taskId: string
|
||||
pluginUniqueIdentifier: string
|
||||
}
|
||||
|
||||
function checkTaskStatus() {
|
||||
let nextStatus = TaskStatus.running
|
||||
let isStop = false
|
||||
|
||||
const doCheckStatus = async ({
|
||||
taskId,
|
||||
pluginUniqueIdentifier,
|
||||
}: Params) => {
|
||||
if (isStop) {
|
||||
return {
|
||||
status: TaskStatus.success,
|
||||
}
|
||||
}
|
||||
const res = await fetchCheckTaskStatus(taskId)
|
||||
const { plugins } = res.task
|
||||
const plugin = plugins.find((p: PluginStatus) => p.plugin_unique_identifier === pluginUniqueIdentifier)
|
||||
if (!plugin) {
|
||||
nextStatus = TaskStatus.failed
|
||||
return {
|
||||
status: TaskStatus.failed,
|
||||
error: 'Plugin package not found',
|
||||
}
|
||||
}
|
||||
nextStatus = plugin.status
|
||||
if (nextStatus === TaskStatus.running) {
|
||||
await sleep(INTERVAL)
|
||||
return await doCheckStatus({
|
||||
taskId,
|
||||
pluginUniqueIdentifier,
|
||||
})
|
||||
}
|
||||
if (nextStatus === TaskStatus.failed) {
|
||||
return {
|
||||
status: TaskStatus.failed,
|
||||
error: plugin.message,
|
||||
}
|
||||
}
|
||||
return ({
|
||||
status: TaskStatus.success,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
check: doCheckStatus,
|
||||
stop: () => {
|
||||
isStop = true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default checkTaskStatus
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Card from '../../card'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { Plugin, PluginDeclaration, PluginManifestInMarket } from '../../types'
|
||||
import { pluginManifestInMarketToPluginProps, pluginManifestToCardPluginProps } from '../utils'
|
||||
import Badge, { BadgeState } from '@/app/components/base/badge/index'
|
||||
|
||||
type Props = {
|
||||
payload?: Plugin | PluginDeclaration | PluginManifestInMarket | null
|
||||
isMarketPayload?: boolean
|
||||
isFailed: boolean
|
||||
errMsg?: string | null
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const Installed: FC<Props> = ({
|
||||
payload,
|
||||
isMarketPayload,
|
||||
isFailed,
|
||||
errMsg,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleClose = () => {
|
||||
onCancel()
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3'>
|
||||
<p className='system-md-regular text-text-secondary'>{(isFailed && errMsg) ? errMsg : t(`plugin.installModal.${isFailed ? 'installFailedDesc' : 'installedSuccessfullyDesc'}`)}</p>
|
||||
{payload && (
|
||||
<div className='flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2'>
|
||||
<Card
|
||||
className='w-full'
|
||||
payload={isMarketPayload ? pluginManifestInMarketToPluginProps(payload as PluginManifestInMarket) : pluginManifestToCardPluginProps(payload as PluginDeclaration)}
|
||||
installed={!isFailed}
|
||||
installFailed={isFailed}
|
||||
titleLeft={<Badge className='mx-1' size="s" state={BadgeState.Default}>{(payload as PluginDeclaration).version || (payload as PluginManifestInMarket).latest_version}</Badge>}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Action Buttons */}
|
||||
<div className='flex items-center justify-end gap-2 self-stretch p-6 pt-5'>
|
||||
<Button
|
||||
variant='primary'
|
||||
className='min-w-[72px]'
|
||||
onClick={handleClose}
|
||||
>
|
||||
{t('common.operation.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default React.memo(Installed)
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { Group } from '../../../base/icons/src/vender/other'
|
||||
import { LoadingPlaceholder } from '@/app/components/plugins/card/base/placeholder'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const LoadingError: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Checkbox
|
||||
className='shrink-0'
|
||||
checked={false}
|
||||
disabled
|
||||
/>
|
||||
<div className='hover-bg-components-panel-on-panel-item-bg relative grow rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 pb-3 shadow-xs'>
|
||||
<div className="flex">
|
||||
<div
|
||||
className='relative flex h-10 w-10 items-center justify-center gap-2 rounded-[10px] border-[0.5px]
|
||||
border-state-destructive-border bg-state-destructive-hover p-1 backdrop-blur-sm'>
|
||||
<div className='flex h-5 w-5 items-center justify-center'>
|
||||
<Group className='text-text-quaternary' />
|
||||
</div>
|
||||
<div className='absolute bottom-[-4px] right-[-4px] rounded-full border-[2px] border-components-panel-bg bg-state-destructive-solid'>
|
||||
<RiCloseLine className='h-3 w-3 text-text-primary-on-surface' />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-3 grow">
|
||||
<div className="system-md-semibold flex h-5 items-center text-text-destructive">
|
||||
{t('plugin.installModal.pluginLoadError')}
|
||||
</div>
|
||||
<div className='system-xs-regular mt-0.5 text-text-tertiary'>
|
||||
{t('plugin.installModal.pluginLoadErrorDesc')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LoadingPlaceholder className="mt-3 w-[420px]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(LoadingError)
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import Placeholder from '../../card/base/placeholder'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Checkbox
|
||||
className='shrink-0'
|
||||
checked={false}
|
||||
disabled
|
||||
/>
|
||||
<div className='hover-bg-components-panel-on-panel-item-bg relative grow rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 pb-3 shadow-xs'>
|
||||
<Placeholder
|
||||
wrapClassName='w-full'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Loading)
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useCallback } from 'react'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { useSelector } from '@/context/app-context'
|
||||
|
||||
const useGetIcon = () => {
|
||||
const currentWorkspace = useSelector(s => s.currentWorkspace)
|
||||
const getIconUrl = useCallback((fileName: string) => {
|
||||
return `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${currentWorkspace.id}&filename=${fileName}`
|
||||
}, [currentWorkspace.id])
|
||||
|
||||
return {
|
||||
getIconUrl,
|
||||
}
|
||||
}
|
||||
|
||||
export default useGetIcon
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import Badge, { BadgeState } from '@/app/components/base/badge/index'
|
||||
import type { VersionProps } from '../../types'
|
||||
|
||||
const Version: FC<VersionProps> = ({
|
||||
hasInstalled,
|
||||
installedVersion,
|
||||
toInstallVersion,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{
|
||||
!hasInstalled
|
||||
? (
|
||||
<Badge className='mx-1' size="s" state={BadgeState.Default}>{toInstallVersion}</Badge>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Badge className='mx-1' size="s" state={BadgeState.Warning}>
|
||||
{`${installedVersion} -> ${toInstallVersion}`}
|
||||
</Badge>
|
||||
{/* <div className='flex px-0.5 justify-center items-center gap-0.5'>
|
||||
<div className='text-text-warning system-xs-medium'>Used in 3 apps</div>
|
||||
<RiInformation2Line className='w-4 h-4 text-text-tertiary' />
|
||||
</div> */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default React.memo(Version)
|
||||
107
dify/web/app/components/plugins/install-plugin/hooks.ts
Normal file
107
dify/web/app/components/plugins/install-plugin/hooks.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import Toast, { type IToastProps } from '@/app/components/base/toast'
|
||||
import { uploadGitHub } from '@/service/plugins'
|
||||
import { compareVersion, getLatestVersion } from '@/utils/semver'
|
||||
import type { GitHubRepoReleaseResponse } from '../types'
|
||||
import { GITHUB_ACCESS_TOKEN } from '@/config'
|
||||
|
||||
const formatReleases = (releases: any) => {
|
||||
return releases.map((release: any) => ({
|
||||
tag_name: release.tag_name,
|
||||
assets: release.assets.map((asset: any) => ({
|
||||
browser_download_url: asset.browser_download_url,
|
||||
name: asset.name,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
export const useGitHubReleases = () => {
|
||||
const fetchReleases = async (owner: string, repo: string) => {
|
||||
try {
|
||||
if (!GITHUB_ACCESS_TOKEN) {
|
||||
// Fetch releases without authentication from client
|
||||
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases`)
|
||||
if (!res.ok) throw new Error('Failed to fetch repository releases')
|
||||
const data = await res.json()
|
||||
return formatReleases(data)
|
||||
}
|
||||
else {
|
||||
// Fetch releases with authentication from server
|
||||
const res = await fetch(`/repos/${owner}/${repo}/releases`)
|
||||
const bodyJson = await res.json()
|
||||
if (bodyJson.status !== 200) throw new Error(bodyJson.data.message)
|
||||
return formatReleases(bodyJson.data)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof Error) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
})
|
||||
}
|
||||
else {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'Failed to fetch repository releases',
|
||||
})
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const checkForUpdates = (fetchedReleases: GitHubRepoReleaseResponse[], currentVersion: string) => {
|
||||
let needUpdate = false
|
||||
const toastProps: IToastProps = {
|
||||
type: 'info',
|
||||
message: 'No new version available',
|
||||
}
|
||||
if (fetchedReleases.length === 0) {
|
||||
toastProps.type = 'error'
|
||||
toastProps.message = 'Input releases is empty'
|
||||
return { needUpdate, toastProps }
|
||||
}
|
||||
const versions = fetchedReleases.map(release => release.tag_name)
|
||||
const latestVersion = getLatestVersion(versions)
|
||||
try {
|
||||
needUpdate = compareVersion(latestVersion, currentVersion) === 1
|
||||
if (needUpdate)
|
||||
toastProps.message = `New version available: ${latestVersion}`
|
||||
}
|
||||
catch {
|
||||
needUpdate = false
|
||||
toastProps.type = 'error'
|
||||
toastProps.message = 'Fail to compare versions, please check the version format'
|
||||
}
|
||||
return { needUpdate, toastProps }
|
||||
}
|
||||
|
||||
return { fetchReleases, checkForUpdates }
|
||||
}
|
||||
|
||||
export const useGitHubUpload = () => {
|
||||
const handleUpload = async (
|
||||
repoUrl: string,
|
||||
selectedVersion: string,
|
||||
selectedPackage: string,
|
||||
onSuccess?: (GitHubPackage: { manifest: any; unique_identifier: string }) => void,
|
||||
) => {
|
||||
try {
|
||||
const response = await uploadGitHub(repoUrl, selectedVersion, selectedPackage)
|
||||
const GitHubPackage = {
|
||||
manifest: response.manifest,
|
||||
unique_identifier: response.unique_identifier,
|
||||
}
|
||||
if (onSuccess) onSuccess(GitHubPackage)
|
||||
return GitHubPackage
|
||||
}
|
||||
catch (error) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'Error uploading package',
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return { handleUpload }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useCheckInstalled as useDoCheckInstalled } from '@/service/use-plugins'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { VersionInfo } from '../../types'
|
||||
type Props = {
|
||||
pluginIds: string[],
|
||||
enabled: boolean
|
||||
}
|
||||
const useCheckInstalled = (props: Props) => {
|
||||
const { data, isLoading, error } = useDoCheckInstalled(props)
|
||||
|
||||
const installedInfo = useMemo(() => {
|
||||
if (!data)
|
||||
return undefined
|
||||
|
||||
const res: Record<string, VersionInfo> = {}
|
||||
data?.plugins.forEach((plugin) => {
|
||||
res[plugin.plugin_id] = {
|
||||
installedId: plugin.id,
|
||||
installedVersion: plugin.declaration.version,
|
||||
uniqueIdentifier: plugin.plugin_unique_identifier,
|
||||
}
|
||||
})
|
||||
return res
|
||||
}, [data])
|
||||
return {
|
||||
installedInfo,
|
||||
isLoading,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
export default useCheckInstalled
|
||||
@@ -0,0 +1,57 @@
|
||||
import { sleep } from '@/utils'
|
||||
|
||||
const animTime = 750
|
||||
const modalClassName = 'install-modal'
|
||||
const COUNT_DOWN_TIME = 15000 // 15s
|
||||
|
||||
function getElemCenter(elem: HTMLElement) {
|
||||
const rect = elem.getBoundingClientRect()
|
||||
return {
|
||||
x: rect.left + rect.width / 2 + window.scrollX,
|
||||
y: rect.top + rect.height / 2 + window.scrollY,
|
||||
}
|
||||
}
|
||||
|
||||
const useFoldAnimInto = (onClose: () => void) => {
|
||||
let countDownRunId: number
|
||||
const clearCountDown = () => {
|
||||
clearTimeout(countDownRunId)
|
||||
}
|
||||
// modalElem fold into plugin install task btn
|
||||
const foldIntoAnim = async () => {
|
||||
clearCountDown()
|
||||
const modalElem = document.querySelector(`.${modalClassName}`) as HTMLElement
|
||||
const pluginTaskTriggerElem = document.getElementById('plugin-task-trigger') || document.querySelector('.plugins-nav-button')
|
||||
|
||||
if (!modalElem || !pluginTaskTriggerElem) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
const modelCenter = getElemCenter(modalElem)
|
||||
const modalElemRect = modalElem.getBoundingClientRect()
|
||||
const pluginTaskTriggerCenter = getElemCenter(pluginTaskTriggerElem)
|
||||
const xDiff = pluginTaskTriggerCenter.x - modelCenter.x
|
||||
const yDiff = pluginTaskTriggerCenter.y - modelCenter.y
|
||||
const scale = 1 / Math.max(modalElemRect.width, modalElemRect.height)
|
||||
modalElem.style.transition = `all cubic-bezier(0.4, 0, 0.2, 1) ${animTime}ms`
|
||||
modalElem.style.transform = `translate(${xDiff}px, ${yDiff}px) scale(${scale})`
|
||||
await sleep(animTime)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const countDownFoldIntoAnim = async () => {
|
||||
countDownRunId = window.setTimeout(() => {
|
||||
foldIntoAnim()
|
||||
}, COUNT_DOWN_TIME)
|
||||
}
|
||||
|
||||
return {
|
||||
modalClassName,
|
||||
foldIntoAnim,
|
||||
clearCountDown,
|
||||
countDownFoldIntoAnim,
|
||||
}
|
||||
}
|
||||
|
||||
export default useFoldAnimInto
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import useFoldAnimInto from './use-fold-anim-into'
|
||||
|
||||
const useHideLogic = (onClose: () => void) => {
|
||||
const {
|
||||
modalClassName,
|
||||
foldIntoAnim: doFoldAnimInto,
|
||||
clearCountDown,
|
||||
countDownFoldIntoAnim,
|
||||
} = useFoldAnimInto(onClose)
|
||||
|
||||
const [isInstalling, doSetIsInstalling] = useState(false)
|
||||
const setIsInstalling = useCallback((isInstalling: boolean) => {
|
||||
if (!isInstalling)
|
||||
clearCountDown()
|
||||
doSetIsInstalling(isInstalling)
|
||||
}, [clearCountDown])
|
||||
|
||||
const foldAnimInto = useCallback(() => {
|
||||
if (isInstalling) {
|
||||
doFoldAnimInto()
|
||||
return
|
||||
}
|
||||
onClose()
|
||||
}, [doFoldAnimInto, isInstalling, onClose])
|
||||
|
||||
const handleStartToInstall = useCallback(() => {
|
||||
setIsInstalling(true)
|
||||
countDownFoldIntoAnim()
|
||||
}, [countDownFoldIntoAnim, setIsInstalling])
|
||||
|
||||
return {
|
||||
modalClassName,
|
||||
foldAnimInto,
|
||||
setIsInstalling,
|
||||
handleStartToInstall,
|
||||
}
|
||||
}
|
||||
|
||||
export default useHideLogic
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
|
||||
import { useInvalidateAllBuiltInTools, useInvalidateAllToolProviders, useInvalidateRAGRecommendedPlugins } from '@/service/use-tools'
|
||||
import { useInvalidateStrategyProviders } from '@/service/use-strategy'
|
||||
import type { Plugin, PluginDeclaration, PluginManifestInMarket } from '../../types'
|
||||
import { PluginCategoryEnum } from '../../types'
|
||||
import { useInvalidDataSourceList } from '@/service/use-pipeline'
|
||||
import { useInvalidDataSourceListAuth } from '@/service/use-datasource'
|
||||
import { useInvalidateAllTriggerPlugins } from '@/service/use-triggers'
|
||||
|
||||
const useRefreshPluginList = () => {
|
||||
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
|
||||
const { mutate: refetchLLMModelList } = useModelList(ModelTypeEnum.textGeneration)
|
||||
const { mutate: refetchEmbeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
|
||||
const { mutate: refetchRerankModelList } = useModelList(ModelTypeEnum.rerank)
|
||||
const { refreshModelProviders } = useProviderContext()
|
||||
|
||||
const invalidateAllToolProviders = useInvalidateAllToolProviders()
|
||||
const invalidateAllBuiltInTools = useInvalidateAllBuiltInTools()
|
||||
const invalidateAllDataSources = useInvalidDataSourceList()
|
||||
|
||||
const invalidateDataSourceListAuth = useInvalidDataSourceListAuth()
|
||||
|
||||
const invalidateStrategyProviders = useInvalidateStrategyProviders()
|
||||
|
||||
const invalidateAllTriggerPlugins = useInvalidateAllTriggerPlugins()
|
||||
|
||||
const invalidateRAGRecommendedPlugins = useInvalidateRAGRecommendedPlugins()
|
||||
return {
|
||||
refreshPluginList: (manifest?: PluginManifestInMarket | Plugin | PluginDeclaration | null, refreshAllType?: boolean) => {
|
||||
// installed list
|
||||
invalidateInstalledPluginList()
|
||||
|
||||
// tool page, tool select
|
||||
if ((manifest && PluginCategoryEnum.tool.includes(manifest.category)) || refreshAllType) {
|
||||
invalidateAllToolProviders()
|
||||
invalidateAllBuiltInTools()
|
||||
invalidateRAGRecommendedPlugins()
|
||||
// TODO: update suggested tools. It's a function in hook useMarketplacePlugins,handleUpdatePlugins
|
||||
}
|
||||
|
||||
if ((manifest && PluginCategoryEnum.trigger.includes(manifest.category)) || refreshAllType)
|
||||
invalidateAllTriggerPlugins()
|
||||
|
||||
if ((manifest && PluginCategoryEnum.datasource.includes(manifest.category)) || refreshAllType) {
|
||||
invalidateAllDataSources()
|
||||
invalidateDataSourceListAuth()
|
||||
}
|
||||
|
||||
// model select
|
||||
if ((manifest && PluginCategoryEnum.model.includes(manifest.category)) || refreshAllType) {
|
||||
refreshModelProviders()
|
||||
refetchLLMModelList()
|
||||
refetchEmbeddingModelList()
|
||||
refetchRerankModelList()
|
||||
}
|
||||
|
||||
// agent select
|
||||
if ((manifest && PluginCategoryEnum.agent.includes(manifest.category)) || refreshAllType)
|
||||
invalidateStrategyProviders()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default useRefreshPluginList
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { InstallStep } from '../../types'
|
||||
import type { Dependency } from '../../types'
|
||||
import ReadyToInstall from './ready-to-install'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useHideLogic from '../hooks/use-hide-logic'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
const i18nPrefix = 'plugin.installModal'
|
||||
|
||||
export enum InstallType {
|
||||
fromLocal = 'fromLocal',
|
||||
fromMarketplace = 'fromMarketplace',
|
||||
fromDSL = 'fromDSL',
|
||||
}
|
||||
|
||||
type Props = {
|
||||
installType?: InstallType
|
||||
fromDSLPayload: Dependency[]
|
||||
// plugins?: PluginDeclaration[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const InstallBundle: FC<Props> = ({
|
||||
installType = InstallType.fromMarketplace,
|
||||
fromDSLPayload,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [step, setStep] = useState<InstallStep>(installType === InstallType.fromMarketplace ? InstallStep.readyToInstall : InstallStep.uploading)
|
||||
|
||||
const {
|
||||
modalClassName,
|
||||
foldAnimInto,
|
||||
setIsInstalling,
|
||||
handleStartToInstall,
|
||||
} = useHideLogic(onClose)
|
||||
|
||||
const getTitle = useCallback(() => {
|
||||
if (step === InstallStep.uploadFailed)
|
||||
return t(`${i18nPrefix}.uploadFailed`)
|
||||
if (step === InstallStep.installed)
|
||||
return t(`${i18nPrefix}.installComplete`)
|
||||
|
||||
return t(`${i18nPrefix}.installPlugin`)
|
||||
}, [step, t])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={true}
|
||||
onClose={foldAnimInto}
|
||||
className={cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0')}
|
||||
closable
|
||||
>
|
||||
<div className='flex items-start gap-2 self-stretch pb-3 pl-6 pr-14 pt-6'>
|
||||
<div className='title-2xl-semi-bold self-stretch text-text-primary'>
|
||||
{getTitle()}
|
||||
</div>
|
||||
</div>
|
||||
<ReadyToInstall
|
||||
step={step}
|
||||
onStepChange={setStep}
|
||||
onStartToInstall={handleStartToInstall}
|
||||
setIsInstalling={setIsInstalling}
|
||||
allPlugins={fromDSLPayload}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(InstallBundle)
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import type { GitHubItemAndMarketPlaceDependency, Plugin } from '../../../types'
|
||||
import { pluginManifestToCardPluginProps } from '../../utils'
|
||||
import { useUploadGitHub } from '@/service/use-plugins'
|
||||
import Loading from '../../base/loading'
|
||||
import LoadedItem from './loaded-item'
|
||||
import type { VersionProps } from '@/app/components/plugins/types'
|
||||
|
||||
type Props = {
|
||||
checked: boolean
|
||||
onCheckedChange: (plugin: Plugin) => void
|
||||
dependency: GitHubItemAndMarketPlaceDependency
|
||||
versionInfo: VersionProps
|
||||
onFetchedPayload: (payload: Plugin) => void
|
||||
onFetchError: () => void
|
||||
}
|
||||
|
||||
const Item: FC<Props> = ({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
dependency,
|
||||
versionInfo,
|
||||
onFetchedPayload,
|
||||
onFetchError,
|
||||
}) => {
|
||||
const info = dependency.value
|
||||
const { data, error } = useUploadGitHub({
|
||||
repo: info.repo!,
|
||||
version: info.release! || info.version!,
|
||||
package: info.packages! || info.package!,
|
||||
})
|
||||
const [payload, setPayload] = React.useState<Plugin | null>(null)
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const payload = {
|
||||
...pluginManifestToCardPluginProps(data.manifest),
|
||||
plugin_id: data.unique_identifier,
|
||||
}
|
||||
onFetchedPayload(payload)
|
||||
setPayload({ ...payload, from: dependency.type })
|
||||
}
|
||||
}, [data])
|
||||
useEffect(() => {
|
||||
if (error)
|
||||
onFetchError()
|
||||
}, [error])
|
||||
if (!payload) return <Loading />
|
||||
return (
|
||||
<LoadedItem
|
||||
payload={payload}
|
||||
versionInfo={versionInfo}
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default React.memo(Item)
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { Plugin } from '../../../types'
|
||||
import Card from '../../../card'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
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
|
||||
onCheckedChange: (plugin: Plugin) => void
|
||||
payload: Plugin
|
||||
isFromMarketPlace?: boolean
|
||||
versionInfo: VersionProps
|
||||
}
|
||||
|
||||
const LoadedItem: FC<Props> = ({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
payload,
|
||||
isFromMarketPlace,
|
||||
versionInfo: particleVersionInfo,
|
||||
}) => {
|
||||
const { getIconUrl } = useGetIcon()
|
||||
const versionInfo = {
|
||||
...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)}
|
||||
/>
|
||||
<Card
|
||||
className='grow'
|
||||
payload={{
|
||||
...payload,
|
||||
icon: isFromMarketPlace ? `${MARKETPLACE_API_PREFIX}/plugins/${payload.org}/${payload.name}/icon` : getIconUrl(payload.icon),
|
||||
}}
|
||||
titleLeft={payload.version ? <Version {...versionInfo} /> : null}
|
||||
limitedInstall={!canInstall}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(LoadedItem)
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { Plugin } from '../../../types'
|
||||
import Loading from '../../base/loading'
|
||||
import LoadedItem from './loaded-item'
|
||||
import type { VersionProps } from '@/app/components/plugins/types'
|
||||
|
||||
type Props = {
|
||||
checked: boolean
|
||||
onCheckedChange: (plugin: Plugin) => void
|
||||
payload?: Plugin
|
||||
version: string
|
||||
versionInfo: VersionProps
|
||||
}
|
||||
|
||||
const MarketPlaceItem: FC<Props> = ({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
payload,
|
||||
version,
|
||||
versionInfo,
|
||||
}) => {
|
||||
if (!payload) return <Loading />
|
||||
return (
|
||||
<LoadedItem
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
payload={{ ...payload, version }}
|
||||
isFromMarketPlace
|
||||
versionInfo={versionInfo}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(MarketPlaceItem)
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { Plugin } from '../../../types'
|
||||
import type { PackageDependency } from '../../../types'
|
||||
import { pluginManifestToCardPluginProps } from '../../utils'
|
||||
import LoadedItem from './loaded-item'
|
||||
import LoadingError from '../../base/loading-error'
|
||||
import type { VersionProps } from '@/app/components/plugins/types'
|
||||
|
||||
type Props = {
|
||||
checked: boolean
|
||||
onCheckedChange: (plugin: Plugin) => void
|
||||
payload: PackageDependency
|
||||
isFromMarketPlace?: boolean
|
||||
versionInfo: VersionProps
|
||||
}
|
||||
|
||||
const PackageItem: FC<Props> = ({
|
||||
payload,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
isFromMarketPlace,
|
||||
versionInfo,
|
||||
}) => {
|
||||
if (!payload.value?.manifest)
|
||||
return <LoadingError />
|
||||
|
||||
const plugin = pluginManifestToCardPluginProps(payload.value.manifest)
|
||||
return (
|
||||
<LoadedItem
|
||||
payload={{ ...plugin, from: payload.type }}
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
isFromMarketPlace={isFromMarketPlace}
|
||||
versionInfo={versionInfo}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(PackageItem)
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { InstallStep } from '../../types'
|
||||
import Install from './steps/install'
|
||||
import Installed from './steps/installed'
|
||||
import type { Dependency, InstallStatus, Plugin } from '../../types'
|
||||
|
||||
type Props = {
|
||||
step: InstallStep
|
||||
onStepChange: (step: InstallStep) => void,
|
||||
onStartToInstall: () => void
|
||||
setIsInstalling: (isInstalling: boolean) => void
|
||||
allPlugins: Dependency[]
|
||||
onClose: () => void
|
||||
isFromMarketPlace?: boolean
|
||||
}
|
||||
|
||||
const ReadyToInstall: FC<Props> = ({
|
||||
step,
|
||||
onStepChange,
|
||||
onStartToInstall,
|
||||
setIsInstalling,
|
||||
allPlugins,
|
||||
onClose,
|
||||
isFromMarketPlace,
|
||||
}) => {
|
||||
const [installedPlugins, setInstalledPlugins] = useState<Plugin[]>([])
|
||||
const [installStatus, setInstallStatus] = useState<InstallStatus[]>([])
|
||||
const handleInstalled = useCallback((plugins: Plugin[], installStatus: InstallStatus[]) => {
|
||||
setInstallStatus(installStatus)
|
||||
setInstalledPlugins(plugins)
|
||||
onStepChange(InstallStep.installed)
|
||||
setIsInstalling(false)
|
||||
}, [onStepChange, setIsInstalling])
|
||||
return (
|
||||
<>
|
||||
{step === InstallStep.readyToInstall && (
|
||||
<Install
|
||||
allPlugins={allPlugins}
|
||||
onCancel={onClose}
|
||||
onStartToInstall={onStartToInstall}
|
||||
onInstalled={handleInstalled}
|
||||
isFromMarketPlace={isFromMarketPlace}
|
||||
/>
|
||||
)}
|
||||
{step === InstallStep.installed && (
|
||||
<Installed
|
||||
list={installedPlugins}
|
||||
installStatus={installStatus}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default React.memo(ReadyToInstall)
|
||||
@@ -0,0 +1,272 @@
|
||||
'use client'
|
||||
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'
|
||||
import GithubItem from '../item/github-item'
|
||||
import { useFetchPluginsInMarketPlaceByInfo } from '@/service/use-plugins'
|
||||
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
|
||||
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, allCanInstallPluginsLength: number) => void
|
||||
onSelectAll: (plugins: Plugin[], selectedIndexes: number[]) => void
|
||||
onDeSelectAll: () => void
|
||||
onLoadedAllPlugin: (installedInfo: Record<string, VersionInfo>) => void
|
||||
isFromMarketPlace?: boolean
|
||||
ref?: React.Ref<ExposeRefs>
|
||||
}
|
||||
|
||||
export type ExposeRefs = {
|
||||
selectAllPlugins: () => void
|
||||
deSelectAllPlugins: () => void
|
||||
}
|
||||
|
||||
const InstallByDSLList = ({
|
||||
allPlugins,
|
||||
selectedPlugins,
|
||||
onSelect,
|
||||
onSelectAll,
|
||||
onDeSelectAll,
|
||||
onLoadedAllPlugin,
|
||||
isFromMarketPlace,
|
||||
ref,
|
||||
}: Props) => {
|
||||
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
|
||||
// split org, name, version by / and :
|
||||
// and remove @ and its suffix
|
||||
const [orgPart, nameAndVersionPart] = dependecy.marketplace_plugin_unique_identifier!.split('@')[0].split('/')
|
||||
const [name, version] = nameAndVersionPart.split(':')
|
||||
return {
|
||||
organization: orgPart,
|
||||
plugin: name,
|
||||
version,
|
||||
}
|
||||
}))
|
||||
// has meta(org,name,version), to get id
|
||||
const { isLoading: isFetchingDataByMeta, data: infoByMeta, error: infoByMetaError } = useFetchPluginsInMarketPlaceByInfo(allPlugins.filter(d => d.type === 'marketplace').map(d => (d as GitHubItemAndMarketPlaceDependency).value!))
|
||||
|
||||
const [plugins, doSetPlugins] = useState<(Plugin | undefined)[]>((() => {
|
||||
const hasLocalPackage = allPlugins.some(d => d.type === 'package')
|
||||
if (!hasLocalPackage)
|
||||
return []
|
||||
|
||||
const _plugins = allPlugins.map((d) => {
|
||||
if (d.type === 'package') {
|
||||
return {
|
||||
...(d as any).value.manifest,
|
||||
plugin_id: (d as any).value.unique_identifier,
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
return _plugins
|
||||
})())
|
||||
|
||||
const pluginsRef = React.useRef<(Plugin | undefined)[]>(plugins)
|
||||
|
||||
const setPlugins = useCallback((p: (Plugin | undefined)[]) => {
|
||||
doSetPlugins(p)
|
||||
pluginsRef.current = p
|
||||
}, [])
|
||||
|
||||
const [errorIndexes, setErrorIndexes] = useState<number[]>([])
|
||||
|
||||
const handleGitHubPluginFetched = useCallback((index: number) => {
|
||||
return (p: Plugin) => {
|
||||
const nextPlugins = produce(pluginsRef.current, (draft) => {
|
||||
draft[index] = p
|
||||
})
|
||||
setPlugins(nextPlugins)
|
||||
}
|
||||
}, [setPlugins])
|
||||
|
||||
const handleGitHubPluginFetchError = useCallback((index: number) => {
|
||||
return () => {
|
||||
setErrorIndexes([...errorIndexes, index])
|
||||
}
|
||||
}, [errorIndexes])
|
||||
|
||||
const marketPlaceInDSLIndex = useMemo(() => {
|
||||
const res: number[] = []
|
||||
allPlugins.forEach((d, index) => {
|
||||
if (d.type === 'marketplace')
|
||||
res.push(index)
|
||||
})
|
||||
return res
|
||||
}, [allPlugins])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingMarketplaceDataById && infoGetById?.data.list) {
|
||||
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]
|
||||
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[] = []
|
||||
const nextPlugins = produce(pluginsRef.current, (draft) => {
|
||||
marketPlaceInDSLIndex.forEach((index, i) => {
|
||||
if (payloads[i]) {
|
||||
draft[index] = {
|
||||
...payloads[i],
|
||||
version: payloads[i]!.version || payloads[i]!.latest_version,
|
||||
}
|
||||
}
|
||||
else { failedIndex.push(index) }
|
||||
})
|
||||
})
|
||||
setPlugins(nextPlugins)
|
||||
|
||||
if (failedIndex.length > 0)
|
||||
setErrorIndexes([...errorIndexes, ...failedIndex])
|
||||
}
|
||||
}, [isFetchingMarketplaceDataById])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingDataByMeta && infoByMeta?.data.list) {
|
||||
const payloads = infoByMeta?.data.list
|
||||
const failedIndex: number[] = []
|
||||
const nextPlugins = produce(pluginsRef.current, (draft) => {
|
||||
marketPlaceInDSLIndex.forEach((index, i) => {
|
||||
if (payloads[i]) {
|
||||
const item = payloads[i]
|
||||
draft[index] = {
|
||||
...item.plugin,
|
||||
plugin_id: item.version.unique_identifier,
|
||||
}
|
||||
}
|
||||
else {
|
||||
failedIndex.push(index)
|
||||
}
|
||||
})
|
||||
})
|
||||
setPlugins(nextPlugins)
|
||||
if (failedIndex.length > 0)
|
||||
setErrorIndexes([...errorIndexes, ...failedIndex])
|
||||
}
|
||||
}, [isFetchingDataByMeta])
|
||||
|
||||
useEffect(() => {
|
||||
// get info all failed
|
||||
if (infoByMetaError || infoByIdError)
|
||||
setErrorIndexes([...errorIndexes, ...marketPlaceInDSLIndex])
|
||||
}, [infoByMetaError, infoByIdError])
|
||||
|
||||
const isLoadedAllData = (plugins.filter(p => !!p).length + errorIndexes.length) === allPlugins.length
|
||||
|
||||
const { installedInfo } = useCheckInstalled({
|
||||
pluginIds: plugins?.filter(p => !!p).map((d) => {
|
||||
return `${d?.org || d?.author}/${d?.name}`
|
||||
}) || [],
|
||||
enabled: isLoadedAllData,
|
||||
})
|
||||
|
||||
const getVersionInfo = useCallback((pluginId: string) => {
|
||||
const pluginDetail = installedInfo?.[pluginId]
|
||||
const hasInstalled = !!pluginDetail
|
||||
return {
|
||||
hasInstalled,
|
||||
installedVersion: pluginDetail?.installedVersion,
|
||||
toInstallVersion: '',
|
||||
}
|
||||
}, [installedInfo])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadedAllData && installedInfo)
|
||||
onLoadedAllPlugin(installedInfo!)
|
||||
}, [isLoadedAllData, installedInfo])
|
||||
|
||||
const handleSelect = useCallback((index: number) => {
|
||||
return () => {
|
||||
const canSelectPlugins = plugins.filter((p) => {
|
||||
const { canInstall } = pluginInstallLimit(p!, systemFeatures)
|
||||
return canInstall
|
||||
})
|
||||
onSelect(plugins[index]!, index, canSelectPlugins.length)
|
||||
}
|
||||
}, [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) => {
|
||||
if (errorIndexes.includes(index)) {
|
||||
return (
|
||||
<LoadingError key={index} />
|
||||
)
|
||||
}
|
||||
const plugin = plugins[index]
|
||||
if (d.type === 'github') {
|
||||
return (<GithubItem
|
||||
key={index}
|
||||
checked={!!selectedPlugins.find(p => p.plugin_id === plugins[index]?.plugin_id)}
|
||||
onCheckedChange={handleSelect(index)}
|
||||
dependency={d as GitHubItemAndMarketPlaceDependency}
|
||||
onFetchedPayload={handleGitHubPluginFetched(index)}
|
||||
onFetchError={handleGitHubPluginFetchError(index)}
|
||||
versionInfo={getVersionInfo(`${plugin?.org || plugin?.author}/${plugin?.name}`)}
|
||||
/>)
|
||||
}
|
||||
|
||||
if (d.type === 'marketplace') {
|
||||
return (
|
||||
<MarketplaceItem
|
||||
key={index}
|
||||
checked={!!selectedPlugins.find(p => p.plugin_id === plugins[index]?.plugin_id)}
|
||||
onCheckedChange={handleSelect(index)}
|
||||
payload={{ ...plugin, from: d.type } as Plugin}
|
||||
version={(d as GitHubItemAndMarketPlaceDependency).value.version! || plugin?.version || ''}
|
||||
versionInfo={getVersionInfo(`${plugin?.org || plugin?.author}/${plugin?.name}`)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Local package
|
||||
return (
|
||||
<PackageItem
|
||||
key={index}
|
||||
checked={!!selectedPlugins.find(p => p.plugin_id === plugins[index]?.plugin_id)}
|
||||
onCheckedChange={handleSelect(index)}
|
||||
payload={d as PackageDependency}
|
||||
isFromMarketPlace={isFromMarketPlace}
|
||||
versionInfo={getVersionInfo(`${plugin?.org || plugin?.author}/${plugin?.name}`)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default InstallByDSLList
|
||||
@@ -0,0 +1,223 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { useRef } from 'react'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import {
|
||||
type Dependency,
|
||||
type InstallStatus,
|
||||
type InstallStatusResponse,
|
||||
type Plugin,
|
||||
TaskStatus,
|
||||
type 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, usePluginTaskList } from '@/service/use-plugins'
|
||||
import useRefreshPluginList from '../../hooks/use-refresh-plugin-list'
|
||||
import { useCanInstallPluginFromMarketplace } from '@/app/components/plugins/plugin-page/use-reference-setting'
|
||||
import { useMittContextSelector } from '@/context/mitt-context'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import checkTaskStatus from '../../base/check-task-status'
|
||||
const i18nPrefix = 'plugin.installModal'
|
||||
|
||||
type Props = {
|
||||
allPlugins: Dependency[]
|
||||
onStartToInstall?: () => void
|
||||
onInstalled: (plugins: Plugin[], installStatus: InstallStatus[]) => void
|
||||
onCancel: () => void
|
||||
isFromMarketPlace?: boolean
|
||||
isHideButton?: boolean
|
||||
}
|
||||
|
||||
const Install: FC<Props> = ({
|
||||
allPlugins,
|
||||
onStartToInstall,
|
||||
onInstalled,
|
||||
onCancel,
|
||||
isFromMarketPlace,
|
||||
isHideButton,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const emit = useMittContextSelector(s => s.emit)
|
||||
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 [isSelectAll, setIsSelectAll] = useState(false)
|
||||
const handleClickSelectAll = useCallback(() => {
|
||||
if (isSelectAll)
|
||||
installMultiRef.current?.deSelectAllPlugins()
|
||||
else
|
||||
installMultiRef.current?.selectAllPlugins()
|
||||
}, [isSelectAll])
|
||||
const [canInstall, setCanInstall] = React.useState(false)
|
||||
const [installedInfo, setInstalledInfo] = useState<Record<string, VersionInfo> | undefined>(undefined)
|
||||
|
||||
const handleLoadedAllPlugin = useCallback((installedInfo: Record<string, VersionInfo> | undefined) => {
|
||||
handleClickSelectAll()
|
||||
setInstalledInfo(installedInfo)
|
||||
setCanInstall(true)
|
||||
}, [])
|
||||
|
||||
const {
|
||||
check,
|
||||
stop,
|
||||
} = checkTaskStatus()
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
stop()
|
||||
onCancel()
|
||||
}, [onCancel, stop])
|
||||
|
||||
const { handleRefetch } = usePluginTaskList()
|
||||
|
||||
// Install from marketplace and github
|
||||
const { mutate: installOrUpdate, isPending: isInstalling } = useInstallOrUpdate({
|
||||
onSuccess: async (res: InstallStatusResponse[]) => {
|
||||
const isAllSettled = res.every(r => r.status === TaskStatus.success || r.status === TaskStatus.failed)
|
||||
// if all settled, return the install status
|
||||
if (isAllSettled) {
|
||||
onInstalled(selectedPlugins, res.map((r, i) => {
|
||||
return ({
|
||||
success: r.status === TaskStatus.success,
|
||||
isFromMarketPlace: allPlugins[selectedIndexes[i]].type === 'marketplace',
|
||||
})
|
||||
}))
|
||||
const hasInstallSuccess = res.some(r => r.status === TaskStatus.success)
|
||||
if (hasInstallSuccess) {
|
||||
refreshPluginList(undefined, true)
|
||||
emit('plugin:install:success', selectedPlugins.map((p) => {
|
||||
return `${p.plugin_id}/${p.name}`
|
||||
}))
|
||||
}
|
||||
return
|
||||
}
|
||||
// if not all settled, keep checking the status of the plugins
|
||||
handleRefetch()
|
||||
const installStatus = await Promise.all(res.map(async (item, index) => {
|
||||
if (item.status !== TaskStatus.running) {
|
||||
return {
|
||||
success: item.status === TaskStatus.success,
|
||||
isFromMarketPlace: allPlugins[selectedIndexes[index]].type === 'marketplace',
|
||||
}
|
||||
}
|
||||
const { status } = await check({
|
||||
taskId: item.taskId,
|
||||
pluginUniqueIdentifier: item.uniqueIdentifier,
|
||||
})
|
||||
return {
|
||||
success: status === TaskStatus.success,
|
||||
isFromMarketPlace: allPlugins[selectedIndexes[index]].type === 'marketplace',
|
||||
}
|
||||
}))
|
||||
onInstalled(selectedPlugins, installStatus)
|
||||
const hasInstallSuccess = installStatus.some(r => r.success)
|
||||
if (hasInstallSuccess) {
|
||||
emit('plugin:install:success', selectedPlugins.map((p) => {
|
||||
return `${p.plugin_id}/${p.name}`
|
||||
}))
|
||||
}
|
||||
},
|
||||
})
|
||||
const handleInstall = () => {
|
||||
onStartToInstall?.()
|
||||
installOrUpdate({
|
||||
payload: allPlugins.filter((_d, index) => selectedIndexes.includes(index)),
|
||||
plugin: selectedPlugins,
|
||||
installedInfo: installedInfo!,
|
||||
})
|
||||
}
|
||||
const [isIndeterminate, setIsIndeterminate] = useState(false)
|
||||
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 (
|
||||
<>
|
||||
<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}.${selectedPluginsNum > 1 ? 'readyToInstallPackages' : 'readyToInstallPackage'}`, { num: selectedPluginsNum })}</p>
|
||||
</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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Action Buttons */}
|
||||
{!isHideButton && (
|
||||
<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={handleCancel}>
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default React.memo(Install)
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { InstallStatus, Plugin } from '../../../types'
|
||||
import Card from '@/app/components/plugins/card'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge, { BadgeState } from '@/app/components/base/badge/index'
|
||||
import useGetIcon from '../../base/use-get-icon'
|
||||
import { MARKETPLACE_API_PREFIX } from '@/config'
|
||||
|
||||
type Props = {
|
||||
list: Plugin[]
|
||||
installStatus: InstallStatus[]
|
||||
onCancel: () => void
|
||||
isHideButton?: boolean
|
||||
}
|
||||
|
||||
const Installed: FC<Props> = ({
|
||||
list,
|
||||
installStatus,
|
||||
onCancel,
|
||||
isHideButton,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { getIconUrl } = useGetIcon()
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3'>
|
||||
{/* <p className='text-text-secondary system-md-regular'>{(isFailed && errMsg) ? errMsg : t(`plugin.installModal.${isFailed ? 'installFailedDesc' : 'installedSuccessfullyDesc'}`)}</p> */}
|
||||
<div className='flex flex-wrap content-start items-start gap-1 space-y-1 self-stretch rounded-2xl bg-background-section-burn p-2'>
|
||||
{list.map((plugin, index) => {
|
||||
return (
|
||||
<Card
|
||||
key={plugin.plugin_id}
|
||||
className='w-full'
|
||||
payload={{
|
||||
...plugin,
|
||||
icon: installStatus[index].isFromMarketPlace ? `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon` : getIconUrl(plugin.icon),
|
||||
}}
|
||||
installed={installStatus[index].success}
|
||||
installFailed={!installStatus[index].success}
|
||||
titleLeft={plugin.version ? <Badge className='mx-1' size="s" state={BadgeState.Default}>{plugin.version}</Badge> : null}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Action Buttons */}
|
||||
{!isHideButton && (
|
||||
<div className='flex items-center justify-end gap-2 self-stretch p-6 pt-5'>
|
||||
<Button
|
||||
variant='primary'
|
||||
className='min-w-[72px]'
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('common.operation.close')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Installed)
|
||||
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import type { InstallState } from '@/app/components/plugins/types'
|
||||
import { useGitHubReleases } from '../hooks'
|
||||
import { convertRepoToUrl, parseGitHubUrl } from '../utils'
|
||||
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../types'
|
||||
import { InstallStepFromGitHub } from '../../types'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import SetURL from './steps/setURL'
|
||||
import SelectPackage from './steps/selectPackage'
|
||||
import Installed from '../base/installed'
|
||||
import Loaded from './steps/loaded'
|
||||
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useRefreshPluginList from '../hooks/use-refresh-plugin-list'
|
||||
import cn from '@/utils/classnames'
|
||||
import useHideLogic from '../hooks/use-hide-logic'
|
||||
|
||||
const i18nPrefix = 'plugin.installFromGitHub'
|
||||
|
||||
type InstallFromGitHubProps = {
|
||||
updatePayload?: UpdateFromGitHubPayload
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ updatePayload, onClose, onSuccess }) => {
|
||||
const { t } = useTranslation()
|
||||
const { getIconUrl } = useGetIcon()
|
||||
const { fetchReleases } = useGitHubReleases()
|
||||
const { refreshPluginList } = useRefreshPluginList()
|
||||
|
||||
const {
|
||||
modalClassName,
|
||||
foldAnimInto,
|
||||
setIsInstalling,
|
||||
handleStartToInstall,
|
||||
} = useHideLogic(onClose)
|
||||
|
||||
const [state, setState] = useState<InstallState>({
|
||||
step: updatePayload ? InstallStepFromGitHub.selectPackage : InstallStepFromGitHub.setUrl,
|
||||
repoUrl: updatePayload?.originalPackageInfo?.repo
|
||||
? convertRepoToUrl(updatePayload.originalPackageInfo.repo)
|
||||
: '',
|
||||
selectedVersion: '',
|
||||
selectedPackage: '',
|
||||
releases: updatePayload ? updatePayload.originalPackageInfo.releases : [],
|
||||
})
|
||||
const [uniqueIdentifier, setUniqueIdentifier] = useState<string | null>(null)
|
||||
const [manifest, setManifest] = useState<PluginDeclaration | null>(null)
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||
|
||||
const versions: Item[] = state.releases.map(release => ({
|
||||
value: release.tag_name,
|
||||
name: release.tag_name,
|
||||
}))
|
||||
|
||||
const packages: Item[] = state.selectedVersion
|
||||
? (state.releases
|
||||
.find(release => release.tag_name === state.selectedVersion)
|
||||
?.assets
|
||||
.map(asset => ({
|
||||
value: asset.name,
|
||||
name: asset.name,
|
||||
})) || [])
|
||||
: []
|
||||
|
||||
const getTitle = useCallback(() => {
|
||||
if (state.step === InstallStepFromGitHub.installed)
|
||||
return t(`${i18nPrefix}.installedSuccessfully`)
|
||||
if (state.step === InstallStepFromGitHub.installFailed)
|
||||
return t(`${i18nPrefix}.installFailed`)
|
||||
|
||||
return updatePayload ? t(`${i18nPrefix}.updatePlugin`) : t(`${i18nPrefix}.installPlugin`)
|
||||
}, [state.step, t, updatePayload])
|
||||
|
||||
const handleUrlSubmit = async () => {
|
||||
const { isValid, owner, repo } = parseGitHubUrl(state.repoUrl)
|
||||
if (!isValid || !owner || !repo) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('plugin.error.inValidGitHubUrl'),
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
const fetchedReleases = await fetchReleases(owner, repo)
|
||||
if (fetchedReleases.length > 0) {
|
||||
setState(prevState => ({
|
||||
...prevState,
|
||||
releases: fetchedReleases,
|
||||
step: InstallStepFromGitHub.selectPackage,
|
||||
}))
|
||||
}
|
||||
else {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('plugin.error.noReleasesFound'),
|
||||
})
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('plugin.error.fetchReleasesError'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleError = (e: any, isInstall: boolean) => {
|
||||
const message = e?.response?.message || t('plugin.installModal.installFailedDesc')
|
||||
setErrorMsg(message)
|
||||
setState(prevState => ({ ...prevState, step: isInstall ? InstallStepFromGitHub.installFailed : InstallStepFromGitHub.uploadFailed }))
|
||||
}
|
||||
|
||||
const handleUploaded = async (GitHubPackage: any) => {
|
||||
try {
|
||||
const icon = await getIconUrl(GitHubPackage.manifest.icon)
|
||||
setManifest({
|
||||
...GitHubPackage.manifest,
|
||||
icon,
|
||||
})
|
||||
setUniqueIdentifier(GitHubPackage.uniqueIdentifier)
|
||||
setState(prevState => ({ ...prevState, step: InstallStepFromGitHub.readyToInstall }))
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e, false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadFail = useCallback((errorMsg: string) => {
|
||||
setErrorMsg(errorMsg)
|
||||
setState(prevState => ({ ...prevState, step: InstallStepFromGitHub.uploadFailed }))
|
||||
}, [])
|
||||
|
||||
const handleInstalled = useCallback((notRefresh?: boolean) => {
|
||||
setState(prevState => ({ ...prevState, step: InstallStepFromGitHub.installed }))
|
||||
if (!notRefresh)
|
||||
refreshPluginList(manifest)
|
||||
setIsInstalling(false)
|
||||
onSuccess()
|
||||
}, [manifest, onSuccess, refreshPluginList, setIsInstalling])
|
||||
|
||||
const handleFailed = useCallback((errorMsg?: string) => {
|
||||
setState(prevState => ({ ...prevState, step: InstallStepFromGitHub.installFailed }))
|
||||
setIsInstalling(false)
|
||||
if (errorMsg)
|
||||
setErrorMsg(errorMsg)
|
||||
}, [setIsInstalling])
|
||||
|
||||
const handleBack = () => {
|
||||
setState((prevState) => {
|
||||
switch (prevState.step) {
|
||||
case InstallStepFromGitHub.selectPackage:
|
||||
return { ...prevState, step: InstallStepFromGitHub.setUrl }
|
||||
case InstallStepFromGitHub.readyToInstall:
|
||||
return { ...prevState, step: InstallStepFromGitHub.selectPackage }
|
||||
default:
|
||||
return prevState
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={true}
|
||||
onClose={foldAnimInto}
|
||||
className={cn(modalClassName, `shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px]
|
||||
border-components-panel-border bg-components-panel-bg p-0`)}
|
||||
closable
|
||||
>
|
||||
<div className='flex items-start gap-2 self-stretch pb-3 pl-6 pr-14 pt-6'>
|
||||
<div className='flex grow flex-col items-start gap-1'>
|
||||
<div className='title-2xl-semi-bold self-stretch text-text-primary'>
|
||||
{getTitle()}
|
||||
</div>
|
||||
<div className='system-xs-regular self-stretch text-text-tertiary'>
|
||||
{!([InstallStepFromGitHub.uploadFailed, InstallStepFromGitHub.installed, InstallStepFromGitHub.installFailed].includes(state.step)) && t('plugin.installFromGitHub.installNote')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{([InstallStepFromGitHub.uploadFailed, InstallStepFromGitHub.installed, InstallStepFromGitHub.installFailed].includes(state.step))
|
||||
? <Installed
|
||||
payload={manifest}
|
||||
isFailed={[InstallStepFromGitHub.uploadFailed, InstallStepFromGitHub.installFailed].includes(state.step)}
|
||||
errMsg={errorMsg}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
: <div className={`flex flex-col items-start justify-center self-stretch px-6 py-3 ${state.step === InstallStepFromGitHub.installed ? 'gap-2' : 'gap-4'}`}>
|
||||
{state.step === InstallStepFromGitHub.setUrl && (
|
||||
<SetURL
|
||||
repoUrl={state.repoUrl}
|
||||
onChange={value => setState(prevState => ({ ...prevState, repoUrl: value }))}
|
||||
onNext={handleUrlSubmit}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
)}
|
||||
{state.step === InstallStepFromGitHub.selectPackage && (
|
||||
<SelectPackage
|
||||
updatePayload={updatePayload!}
|
||||
repoUrl={state.repoUrl}
|
||||
selectedVersion={state.selectedVersion}
|
||||
versions={versions}
|
||||
onSelectVersion={item => setState(prevState => ({ ...prevState, selectedVersion: item.value as string }))}
|
||||
selectedPackage={state.selectedPackage}
|
||||
packages={packages}
|
||||
onSelectPackage={item => setState(prevState => ({ ...prevState, selectedPackage: item.value as string }))}
|
||||
onUploaded={handleUploaded}
|
||||
onFailed={handleUploadFail}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
)}
|
||||
{state.step === InstallStepFromGitHub.readyToInstall && (
|
||||
<Loaded
|
||||
updatePayload={updatePayload!}
|
||||
uniqueIdentifier={uniqueIdentifier!}
|
||||
payload={manifest as any}
|
||||
repoUrl={state.repoUrl}
|
||||
selectedVersion={state.selectedVersion}
|
||||
selectedPackage={state.selectedPackage}
|
||||
onBack={handleBack}
|
||||
onStartToInstall={handleStartToInstall}
|
||||
onInstalled={handleInstalled}
|
||||
onFailed={handleFailed}
|
||||
/>
|
||||
)}
|
||||
</div>}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstallFromGitHub
|
||||
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { type Plugin, type PluginDeclaration, TaskStatus, type UpdateFromGitHubPayload } from '../../../types'
|
||||
import Card from '../../../card'
|
||||
import { pluginManifestToCardPluginProps } from '../../utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { updateFromGitHub } from '@/service/plugins'
|
||||
import { useInstallPackageFromGitHub } from '@/service/use-plugins'
|
||||
import { RiLoader2Line } from '@remixicon/react'
|
||||
import { usePluginTaskList } from '@/service/use-plugins'
|
||||
import checkTaskStatus from '../../base/check-task-status'
|
||||
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
|
||||
import { parseGitHubUrl } from '../../utils'
|
||||
import Version from '../../base/version'
|
||||
|
||||
type LoadedProps = {
|
||||
updatePayload: UpdateFromGitHubPayload
|
||||
uniqueIdentifier: string
|
||||
payload: PluginDeclaration | Plugin
|
||||
repoUrl: string
|
||||
selectedVersion: string
|
||||
selectedPackage: string
|
||||
onBack: () => void
|
||||
onStartToInstall?: () => void
|
||||
onInstalled: (notRefresh?: boolean) => void
|
||||
onFailed: (message?: string) => void
|
||||
}
|
||||
|
||||
const i18nPrefix = 'plugin.installModal'
|
||||
|
||||
const Loaded: React.FC<LoadedProps> = ({
|
||||
updatePayload,
|
||||
uniqueIdentifier,
|
||||
payload,
|
||||
repoUrl,
|
||||
selectedVersion,
|
||||
selectedPackage,
|
||||
onBack,
|
||||
onStartToInstall,
|
||||
onInstalled,
|
||||
onFailed,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const toInstallVersion = payload.version
|
||||
const pluginId = (payload as Plugin).plugin_id
|
||||
const { installedInfo, isLoading } = useCheckInstalled({
|
||||
pluginIds: [pluginId],
|
||||
enabled: !!pluginId,
|
||||
})
|
||||
const installedInfoPayload = installedInfo?.[pluginId]
|
||||
const installedVersion = installedInfoPayload?.installedVersion
|
||||
const hasInstalled = !!installedVersion
|
||||
|
||||
const [isInstalling, setIsInstalling] = React.useState(false)
|
||||
const { mutateAsync: installPackageFromGitHub } = useInstallPackageFromGitHub()
|
||||
const { handleRefetch } = usePluginTaskList(payload.category)
|
||||
const { check } = checkTaskStatus()
|
||||
|
||||
useEffect(() => {
|
||||
if (hasInstalled && uniqueIdentifier === installedInfoPayload.uniqueIdentifier)
|
||||
onInstalled()
|
||||
}, [hasInstalled])
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (isInstalling) return
|
||||
setIsInstalling(true)
|
||||
onStartToInstall?.()
|
||||
|
||||
try {
|
||||
const { owner, repo } = parseGitHubUrl(repoUrl)
|
||||
let taskId
|
||||
let isInstalled
|
||||
if (updatePayload) {
|
||||
const { all_installed, task_id } = await updateFromGitHub(
|
||||
`${owner}/${repo}`,
|
||||
selectedVersion,
|
||||
selectedPackage,
|
||||
updatePayload.originalPackageInfo.id,
|
||||
uniqueIdentifier,
|
||||
)
|
||||
|
||||
taskId = task_id
|
||||
isInstalled = all_installed
|
||||
}
|
||||
else {
|
||||
if (hasInstalled) {
|
||||
const {
|
||||
all_installed,
|
||||
task_id,
|
||||
} = await updateFromGitHub(
|
||||
`${owner}/${repo}`,
|
||||
selectedVersion,
|
||||
selectedPackage,
|
||||
installedInfoPayload.uniqueIdentifier,
|
||||
uniqueIdentifier,
|
||||
)
|
||||
taskId = task_id
|
||||
isInstalled = all_installed
|
||||
}
|
||||
else {
|
||||
const { all_installed, task_id } = await installPackageFromGitHub({
|
||||
repoUrl: `${owner}/${repo}`,
|
||||
selectedVersion,
|
||||
selectedPackage,
|
||||
uniqueIdentifier,
|
||||
})
|
||||
|
||||
taskId = task_id
|
||||
isInstalled = all_installed
|
||||
}
|
||||
}
|
||||
if (isInstalled) {
|
||||
onInstalled()
|
||||
return
|
||||
}
|
||||
|
||||
handleRefetch()
|
||||
|
||||
const { status, error } = await check({
|
||||
taskId,
|
||||
pluginUniqueIdentifier: uniqueIdentifier,
|
||||
})
|
||||
if (status === TaskStatus.failed) {
|
||||
onFailed(error)
|
||||
return
|
||||
}
|
||||
onInstalled(true)
|
||||
}
|
||||
catch (e) {
|
||||
if (typeof e === 'string') {
|
||||
onFailed(e)
|
||||
return
|
||||
}
|
||||
onFailed()
|
||||
}
|
||||
finally {
|
||||
setIsInstalling(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='system-md-regular text-text-secondary'>
|
||||
<p>{t(`${i18nPrefix}.readyToInstall`)}</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2'>
|
||||
<Card
|
||||
className='w-full'
|
||||
payload={pluginManifestToCardPluginProps(payload as PluginDeclaration)}
|
||||
titleLeft={!isLoading && <Version
|
||||
hasInstalled={hasInstalled}
|
||||
installedVersion={installedVersion}
|
||||
toInstallVersion={toInstallVersion}
|
||||
/>}
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-4 flex items-center justify-end gap-2 self-stretch'>
|
||||
{!isInstalling && (
|
||||
<Button variant='secondary' className='min-w-[72px]' onClick={onBack}>
|
||||
{t('plugin.installModal.back')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='primary'
|
||||
className='flex min-w-[72px] space-x-0.5'
|
||||
onClick={handleInstall}
|
||||
disabled={isInstalling || isLoading}
|
||||
>
|
||||
{isInstalling && <RiLoader2Line className='h-4 w-4 animate-spin-slow' />}
|
||||
<span>{t(`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Loaded
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import { PortalSelect } from '@/app/components/base/select'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useGitHubUpload } from '../../hooks'
|
||||
|
||||
const i18nPrefix = 'plugin.installFromGitHub'
|
||||
|
||||
type SelectPackageProps = {
|
||||
updatePayload: UpdateFromGitHubPayload
|
||||
repoUrl: string
|
||||
selectedVersion: string
|
||||
versions: Item[]
|
||||
onSelectVersion: (item: Item) => void
|
||||
selectedPackage: string
|
||||
packages: Item[]
|
||||
onSelectPackage: (item: Item) => void
|
||||
onUploaded: (result: {
|
||||
uniqueIdentifier: string
|
||||
manifest: PluginDeclaration
|
||||
}) => void
|
||||
onFailed: (errorMsg: string) => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
const SelectPackage: React.FC<SelectPackageProps> = ({
|
||||
updatePayload,
|
||||
repoUrl,
|
||||
selectedVersion,
|
||||
versions,
|
||||
onSelectVersion,
|
||||
selectedPackage,
|
||||
packages,
|
||||
onSelectPackage,
|
||||
onUploaded,
|
||||
onFailed,
|
||||
onBack,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isEdit = Boolean(updatePayload)
|
||||
const [isUploading, setIsUploading] = React.useState(false)
|
||||
const { handleUpload } = useGitHubUpload()
|
||||
|
||||
const handleUploadPackage = async () => {
|
||||
if (isUploading) return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const repo = repoUrl.replace('https://github.com/', '')
|
||||
await handleUpload(repo, selectedVersion, selectedPackage, (GitHubPackage) => {
|
||||
onUploaded({
|
||||
uniqueIdentifier: GitHubPackage.unique_identifier,
|
||||
manifest: GitHubPackage.manifest,
|
||||
})
|
||||
})
|
||||
}
|
||||
catch (e: any) {
|
||||
if (e.response?.message)
|
||||
onFailed(e.response?.message)
|
||||
else
|
||||
onFailed(t(`${i18nPrefix}.uploadFailed`))
|
||||
}
|
||||
finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<label
|
||||
htmlFor='version'
|
||||
className='flex flex-col items-start justify-center self-stretch text-text-secondary'
|
||||
>
|
||||
<span className='system-sm-semibold'>{t(`${i18nPrefix}.selectVersion`)}</span>
|
||||
</label>
|
||||
<PortalSelect
|
||||
value={selectedVersion}
|
||||
onSelect={onSelectVersion}
|
||||
items={versions}
|
||||
installedValue={updatePayload?.originalPackageInfo.version}
|
||||
placeholder={t(`${i18nPrefix}.selectVersionPlaceholder`) || ''}
|
||||
popupClassName='w-[512px] z-[1001]'
|
||||
triggerClassName='text-components-input-text-filled'
|
||||
/>
|
||||
<label
|
||||
htmlFor='package'
|
||||
className='flex flex-col items-start justify-center self-stretch text-text-secondary'
|
||||
>
|
||||
<span className='system-sm-semibold'>{t(`${i18nPrefix}.selectPackage`)}</span>
|
||||
</label>
|
||||
<PortalSelect
|
||||
value={selectedPackage}
|
||||
onSelect={onSelectPackage}
|
||||
items={packages}
|
||||
readonly={!selectedVersion}
|
||||
placeholder={t(`${i18nPrefix}.selectPackagePlaceholder`) || ''}
|
||||
popupClassName='w-[512px] z-[1001]'
|
||||
triggerClassName='text-components-input-text-filled'
|
||||
/>
|
||||
<div className='mt-4 flex items-center justify-end gap-2 self-stretch'>
|
||||
{!isEdit
|
||||
&& <Button
|
||||
variant='secondary'
|
||||
className='min-w-[72px]'
|
||||
onClick={onBack}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{t('plugin.installModal.back')}
|
||||
</Button>
|
||||
}
|
||||
<Button
|
||||
variant='primary'
|
||||
className='min-w-[72px]'
|
||||
onClick={handleUploadPackage}
|
||||
disabled={!selectedVersion || !selectedPackage || isUploading}
|
||||
>
|
||||
{t('plugin.installModal.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectPackage
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type SetURLProps = {
|
||||
repoUrl: string
|
||||
onChange: (value: string) => void
|
||||
onNext: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const SetURL: React.FC<SetURLProps> = ({ repoUrl, onChange, onNext, onCancel }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<>
|
||||
<label
|
||||
htmlFor='repoUrl'
|
||||
className='flex flex-col items-start justify-center self-stretch text-text-secondary'
|
||||
>
|
||||
<span className='system-sm-semibold'>{t('plugin.installFromGitHub.gitHubRepo')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='url'
|
||||
id='repoUrl'
|
||||
name='repoUrl'
|
||||
value={repoUrl}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className='shadows-shadow-xs system-sm-regular flex grow items-center gap-[2px]
|
||||
self-stretch overflow-hidden text-ellipsis rounded-lg border border-components-input-border-active
|
||||
bg-components-input-bg-active p-2 text-components-input-text-filled'
|
||||
placeholder='Please enter GitHub repo URL'
|
||||
/>
|
||||
<div className='mt-4 flex items-center justify-end gap-2 self-stretch'>
|
||||
<Button
|
||||
variant='secondary'
|
||||
className='min-w-[72px]'
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('plugin.installModal.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
className='min-w-[72px]'
|
||||
onClick={onNext}
|
||||
disabled={!repoUrl.trim()}
|
||||
>
|
||||
{t('plugin.installModal.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SetURL
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import type { Dependency, PluginDeclaration } from '../../types'
|
||||
import { InstallStep } from '../../types'
|
||||
import Uploading from './steps/uploading'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
|
||||
import ReadyToInstallPackage from './ready-to-install'
|
||||
import ReadyToInstallBundle from '../install-bundle/ready-to-install'
|
||||
import useHideLogic from '../hooks/use-hide-logic'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
const i18nPrefix = 'plugin.installModal'
|
||||
|
||||
type InstallFromLocalPackageProps = {
|
||||
file: File
|
||||
onSuccess: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({
|
||||
file,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
// uploading -> !uploadFailed -> readyToInstall -> installed/failed
|
||||
const [step, setStep] = useState<InstallStep>(InstallStep.uploading)
|
||||
const [uniqueIdentifier, setUniqueIdentifier] = useState<string | null>(null)
|
||||
const [manifest, setManifest] = useState<PluginDeclaration | null>(null)
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||
const isBundle = file.name.endsWith('.difybndl')
|
||||
const [dependencies, setDependencies] = useState<Dependency[]>([])
|
||||
|
||||
const {
|
||||
modalClassName,
|
||||
foldAnimInto,
|
||||
setIsInstalling,
|
||||
handleStartToInstall,
|
||||
} = useHideLogic(onClose)
|
||||
|
||||
const getTitle = useCallback(() => {
|
||||
if (step === InstallStep.uploadFailed)
|
||||
return t(`${i18nPrefix}.uploadFailed`)
|
||||
if (isBundle && step === InstallStep.installed)
|
||||
return t(`${i18nPrefix}.installComplete`)
|
||||
if (step === InstallStep.installed)
|
||||
return t(`${i18nPrefix}.installedSuccessfully`)
|
||||
if (step === InstallStep.installFailed)
|
||||
return t(`${i18nPrefix}.installFailed`)
|
||||
|
||||
return t(`${i18nPrefix}.installPlugin`)
|
||||
}, [isBundle, step, t])
|
||||
|
||||
const { getIconUrl } = useGetIcon()
|
||||
|
||||
const handlePackageUploaded = useCallback(async (result: {
|
||||
uniqueIdentifier: string
|
||||
manifest: PluginDeclaration
|
||||
}) => {
|
||||
const {
|
||||
manifest,
|
||||
uniqueIdentifier,
|
||||
} = result
|
||||
const icon = await getIconUrl(manifest!.icon)
|
||||
setUniqueIdentifier(uniqueIdentifier)
|
||||
setManifest({
|
||||
...manifest,
|
||||
icon,
|
||||
})
|
||||
setStep(InstallStep.readyToInstall)
|
||||
}, [getIconUrl])
|
||||
|
||||
const handleBundleUploaded = useCallback((result: Dependency[]) => {
|
||||
setDependencies(result)
|
||||
setStep(InstallStep.readyToInstall)
|
||||
}, [])
|
||||
|
||||
const handleUploadFail = useCallback((errorMsg: string) => {
|
||||
setErrorMsg(errorMsg)
|
||||
setStep(InstallStep.uploadFailed)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={true}
|
||||
onClose={foldAnimInto}
|
||||
className={cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0')}
|
||||
closable
|
||||
>
|
||||
<div className='flex items-start gap-2 self-stretch pb-3 pl-6 pr-14 pt-6'>
|
||||
<div className='title-2xl-semi-bold self-stretch text-text-primary'>
|
||||
{getTitle()}
|
||||
</div>
|
||||
</div>
|
||||
{step === InstallStep.uploading && (
|
||||
<Uploading
|
||||
isBundle={isBundle}
|
||||
file={file}
|
||||
onCancel={onClose}
|
||||
onPackageUploaded={handlePackageUploaded}
|
||||
onBundleUploaded={handleBundleUploaded}
|
||||
onFailed={handleUploadFail}
|
||||
/>
|
||||
)}
|
||||
{isBundle ? (
|
||||
<ReadyToInstallBundle
|
||||
step={step}
|
||||
onStepChange={setStep}
|
||||
onStartToInstall={handleStartToInstall}
|
||||
setIsInstalling={setIsInstalling}
|
||||
onClose={onClose}
|
||||
allPlugins={dependencies}
|
||||
/>
|
||||
) : (
|
||||
<ReadyToInstallPackage
|
||||
step={step}
|
||||
onStepChange={setStep}
|
||||
onStartToInstall={handleStartToInstall}
|
||||
setIsInstalling={setIsInstalling}
|
||||
onClose={onClose}
|
||||
uniqueIdentifier={uniqueIdentifier}
|
||||
manifest={manifest}
|
||||
errorMsg={errorMsg}
|
||||
onError={setErrorMsg}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstallFromLocalPackage
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import type { PluginDeclaration } from '../../types'
|
||||
import { InstallStep } from '../../types'
|
||||
import Install from './steps/install'
|
||||
import Installed from '../base/installed'
|
||||
import useRefreshPluginList from '../hooks/use-refresh-plugin-list'
|
||||
|
||||
type Props = {
|
||||
step: InstallStep
|
||||
onStepChange: (step: InstallStep) => void,
|
||||
onStartToInstall: () => void
|
||||
setIsInstalling: (isInstalling: boolean) => void
|
||||
onClose: () => void
|
||||
uniqueIdentifier: string | null,
|
||||
manifest: PluginDeclaration | null,
|
||||
errorMsg: string | null,
|
||||
onError: (errorMsg: string) => void,
|
||||
}
|
||||
|
||||
const ReadyToInstall: FC<Props> = ({
|
||||
step,
|
||||
onStepChange,
|
||||
onStartToInstall,
|
||||
setIsInstalling,
|
||||
onClose,
|
||||
uniqueIdentifier,
|
||||
manifest,
|
||||
errorMsg,
|
||||
onError,
|
||||
}) => {
|
||||
const { refreshPluginList } = useRefreshPluginList()
|
||||
|
||||
const handleInstalled = useCallback((notRefresh?: boolean) => {
|
||||
onStepChange(InstallStep.installed)
|
||||
if (!notRefresh)
|
||||
refreshPluginList(manifest)
|
||||
setIsInstalling(false)
|
||||
}, [manifest, onStepChange, refreshPluginList, setIsInstalling])
|
||||
|
||||
const handleFailed = useCallback((errorMsg?: string) => {
|
||||
onStepChange(InstallStep.installFailed)
|
||||
setIsInstalling(false)
|
||||
if (errorMsg)
|
||||
onError(errorMsg)
|
||||
}, [onError, onStepChange, setIsInstalling])
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
step === InstallStep.readyToInstall && (
|
||||
<Install
|
||||
uniqueIdentifier={uniqueIdentifier!}
|
||||
payload={manifest!}
|
||||
onCancel={onClose}
|
||||
onInstalled={handleInstalled}
|
||||
onFailed={handleFailed}
|
||||
onStartToInstall={onStartToInstall}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
([InstallStep.uploadFailed, InstallStep.installed, InstallStep.installFailed].includes(step)) && (
|
||||
<Installed
|
||||
payload={manifest}
|
||||
isFailed={[InstallStep.uploadFailed, InstallStep.installFailed].includes(step)}
|
||||
errMsg={errorMsg}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default React.memo(ReadyToInstall)
|
||||
@@ -0,0 +1,163 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { type PluginDeclaration, TaskStatus } from '../../../types'
|
||||
import Card from '../../../card'
|
||||
import { pluginManifestToCardPluginProps } from '../../utils'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { RiLoader2Line } from '@remixicon/react'
|
||||
import checkTaskStatus from '../../base/check-task-status'
|
||||
import { useInstallPackageFromLocal, usePluginTaskList } from '@/service/use-plugins'
|
||||
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
|
||||
import { uninstallPlugin } from '@/service/plugins'
|
||||
import Version from '../../base/version'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { gte } from 'semver'
|
||||
|
||||
const i18nPrefix = 'plugin.installModal'
|
||||
|
||||
type Props = {
|
||||
uniqueIdentifier: string
|
||||
payload: PluginDeclaration
|
||||
onCancel: () => void
|
||||
onStartToInstall?: () => void
|
||||
onInstalled: (notRefresh?: boolean) => void
|
||||
onFailed: (message?: string) => void
|
||||
}
|
||||
|
||||
const Installed: FC<Props> = ({
|
||||
uniqueIdentifier,
|
||||
payload,
|
||||
onCancel,
|
||||
onStartToInstall,
|
||||
onInstalled,
|
||||
onFailed,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const toInstallVersion = payload.version
|
||||
const pluginId = `${payload.author}/${payload.name}`
|
||||
const { installedInfo, isLoading } = useCheckInstalled({
|
||||
pluginIds: [pluginId],
|
||||
enabled: !!pluginId,
|
||||
})
|
||||
const installedInfoPayload = installedInfo?.[pluginId]
|
||||
const installedVersion = installedInfoPayload?.installedVersion
|
||||
const hasInstalled = !!installedVersion
|
||||
|
||||
useEffect(() => {
|
||||
if (hasInstalled && uniqueIdentifier === installedInfoPayload.uniqueIdentifier)
|
||||
onInstalled()
|
||||
}, [hasInstalled])
|
||||
|
||||
const [isInstalling, setIsInstalling] = React.useState(false)
|
||||
const { mutateAsync: installPackageFromLocal } = useInstallPackageFromLocal()
|
||||
|
||||
const {
|
||||
check,
|
||||
stop,
|
||||
} = checkTaskStatus()
|
||||
|
||||
const handleCancel = () => {
|
||||
stop()
|
||||
onCancel()
|
||||
}
|
||||
|
||||
const { handleRefetch } = usePluginTaskList(payload.category)
|
||||
const handleInstall = async () => {
|
||||
if (isInstalling) return
|
||||
setIsInstalling(true)
|
||||
onStartToInstall?.()
|
||||
|
||||
try {
|
||||
if (hasInstalled)
|
||||
await uninstallPlugin(installedInfoPayload.installedId)
|
||||
|
||||
const {
|
||||
all_installed,
|
||||
task_id,
|
||||
} = await installPackageFromLocal(uniqueIdentifier)
|
||||
const taskId = task_id
|
||||
const isInstalled = all_installed
|
||||
|
||||
if (isInstalled) {
|
||||
onInstalled()
|
||||
return
|
||||
}
|
||||
handleRefetch()
|
||||
const { status, error } = await check({
|
||||
taskId,
|
||||
pluginUniqueIdentifier: uniqueIdentifier,
|
||||
})
|
||||
if (status === TaskStatus.failed) {
|
||||
onFailed(error)
|
||||
return
|
||||
}
|
||||
onInstalled(true)
|
||||
}
|
||||
catch (e) {
|
||||
if (typeof e === 'string') {
|
||||
onFailed(e)
|
||||
return
|
||||
}
|
||||
onFailed()
|
||||
}
|
||||
}
|
||||
|
||||
const { langGeniusVersionInfo } = useAppContext()
|
||||
const isDifyVersionCompatible = useMemo(() => {
|
||||
if (!langGeniusVersionInfo.current_version)
|
||||
return true
|
||||
return gte(langGeniusVersionInfo.current_version, payload.meta.minimum_dify_version ?? '0.0.0')
|
||||
}, [langGeniusVersionInfo.current_version, payload.meta.minimum_dify_version])
|
||||
|
||||
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>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey={`${i18nPrefix}.fromTrustSource`}
|
||||
components={{ trustSource: <span className='system-md-semibold' /> }}
|
||||
/>
|
||||
</p>
|
||||
{!isDifyVersionCompatible && (
|
||||
<p className='system-md-regular flex items-center gap-1 text-text-warning'>
|
||||
{t('plugin.difyVersionNotCompatible', { minimalDifyVersion: payload.meta.minimum_dify_version })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2'>
|
||||
<Card
|
||||
className='w-full'
|
||||
payload={pluginManifestToCardPluginProps(payload)}
|
||||
titleLeft={!isLoading && <Version
|
||||
hasInstalled={hasInstalled}
|
||||
installedVersion={installedVersion}
|
||||
toInstallVersion={toInstallVersion}
|
||||
/>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Action Buttons */}
|
||||
<div className='flex items-center justify-end gap-2 self-stretch p-6 pt-5'>
|
||||
{!isInstalling && (
|
||||
<Button variant='secondary' className='min-w-[72px]' onClick={handleCancel}>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='primary'
|
||||
className='flex min-w-[72px] space-x-0.5'
|
||||
disabled={isInstalling || isLoading}
|
||||
onClick={handleInstall}
|
||||
>
|
||||
{isInstalling && <RiLoader2Line className='h-4 w-4 animate-spin-slow' />}
|
||||
<span>{t(`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default React.memo(Installed)
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { RiLoader2Line } from '@remixicon/react'
|
||||
import Card from '../../../card'
|
||||
import type { Dependency, PluginDeclaration } from '../../../types'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { uploadFile } from '@/service/plugins'
|
||||
const i18nPrefix = 'plugin.installModal'
|
||||
|
||||
type Props = {
|
||||
isBundle: boolean
|
||||
file: File
|
||||
onCancel: () => void
|
||||
onPackageUploaded: (result: {
|
||||
uniqueIdentifier: string
|
||||
manifest: PluginDeclaration
|
||||
}) => void
|
||||
onBundleUploaded: (result: Dependency[]) => void
|
||||
onFailed: (errorMsg: string) => void
|
||||
}
|
||||
|
||||
const Uploading: FC<Props> = ({
|
||||
isBundle,
|
||||
file,
|
||||
onCancel,
|
||||
onPackageUploaded,
|
||||
onBundleUploaded,
|
||||
onFailed,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const fileName = file.name
|
||||
const handleUpload = async () => {
|
||||
try {
|
||||
await uploadFile(file, isBundle)
|
||||
}
|
||||
catch (e: any) {
|
||||
if (e.response?.message) {
|
||||
onFailed(e.response?.message)
|
||||
}
|
||||
else { // Why it would into this branch?
|
||||
const res = e.response
|
||||
if (isBundle) {
|
||||
onBundleUploaded(res)
|
||||
return
|
||||
}
|
||||
onPackageUploaded({
|
||||
uniqueIdentifier: res.unique_identifier,
|
||||
manifest: res.manifest,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
handleUpload()
|
||||
}, [])
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3'>
|
||||
<div className='flex items-center gap-1 self-stretch'>
|
||||
<RiLoader2Line className='h-4 w-4 animate-spin-slow text-text-accent' />
|
||||
<div className='system-md-regular text-text-secondary'>
|
||||
{t(`${i18nPrefix}.uploadingPackage`, {
|
||||
packageName: fileName,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2'>
|
||||
<Card
|
||||
className='w-full'
|
||||
payload={{ name: fileName } as any}
|
||||
isLoading
|
||||
loadingFileName={fileName}
|
||||
installed={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className='flex items-center justify-end gap-2 self-stretch p-6 pt-5'>
|
||||
<Button variant='secondary' className='min-w-[72px]' onClick={onCancel}>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
className='min-w-[72px]'
|
||||
disabled
|
||||
>
|
||||
{t(`${i18nPrefix}.install`)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Uploading)
|
||||
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import type { Dependency, Plugin, PluginManifestInMarket } from '../../types'
|
||||
import { InstallStep } from '../../types'
|
||||
import Install from './steps/install'
|
||||
import Installed from '../base/installed'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useRefreshPluginList from '../hooks/use-refresh-plugin-list'
|
||||
import ReadyToInstallBundle from '../install-bundle/ready-to-install'
|
||||
import cn from '@/utils/classnames'
|
||||
import useHideLogic from '../hooks/use-hide-logic'
|
||||
|
||||
const i18nPrefix = 'plugin.installModal'
|
||||
|
||||
type InstallFromMarketplaceProps = {
|
||||
uniqueIdentifier: string
|
||||
manifest: PluginManifestInMarket | Plugin
|
||||
isBundle?: boolean
|
||||
dependencies?: Dependency[]
|
||||
onSuccess: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const InstallFromMarketplace: React.FC<InstallFromMarketplaceProps> = ({
|
||||
uniqueIdentifier,
|
||||
manifest,
|
||||
isBundle,
|
||||
dependencies,
|
||||
onSuccess,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
// readyToInstall -> check installed -> installed/failed
|
||||
const [step, setStep] = useState<InstallStep>(InstallStep.readyToInstall)
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||
const { refreshPluginList } = useRefreshPluginList()
|
||||
|
||||
const {
|
||||
modalClassName,
|
||||
foldAnimInto,
|
||||
setIsInstalling,
|
||||
handleStartToInstall,
|
||||
} = useHideLogic(onClose)
|
||||
|
||||
const getTitle = useCallback(() => {
|
||||
if (isBundle && step === InstallStep.installed)
|
||||
return t(`${i18nPrefix}.installComplete`)
|
||||
if (step === InstallStep.installed)
|
||||
return t(`${i18nPrefix}.installedSuccessfully`)
|
||||
if (step === InstallStep.installFailed)
|
||||
return t(`${i18nPrefix}.installFailed`)
|
||||
return t(`${i18nPrefix}.installPlugin`)
|
||||
}, [isBundle, step, t])
|
||||
|
||||
const handleInstalled = useCallback((notRefresh?: boolean) => {
|
||||
setStep(InstallStep.installed)
|
||||
if (!notRefresh)
|
||||
refreshPluginList(manifest)
|
||||
setIsInstalling(false)
|
||||
}, [manifest, refreshPluginList, setIsInstalling])
|
||||
|
||||
const handleFailed = useCallback((errorMsg?: string) => {
|
||||
setStep(InstallStep.installFailed)
|
||||
setIsInstalling(false)
|
||||
if (errorMsg)
|
||||
setErrorMsg(errorMsg)
|
||||
}, [setIsInstalling])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={true}
|
||||
onClose={foldAnimInto}
|
||||
wrapperClassName='z-[9999]'
|
||||
className={cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0')}
|
||||
closable
|
||||
>
|
||||
<div className='flex items-start gap-2 self-stretch pb-3 pl-6 pr-14 pt-6'>
|
||||
<div className='title-2xl-semi-bold self-stretch text-text-primary'>
|
||||
{getTitle()}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
isBundle ? (
|
||||
<ReadyToInstallBundle
|
||||
step={step}
|
||||
onStepChange={setStep}
|
||||
onStartToInstall={handleStartToInstall}
|
||||
setIsInstalling={setIsInstalling}
|
||||
onClose={onClose}
|
||||
allPlugins={dependencies!}
|
||||
isFromMarketPlace
|
||||
/>
|
||||
) : (<>
|
||||
{
|
||||
step === InstallStep.readyToInstall && (
|
||||
<Install
|
||||
uniqueIdentifier={uniqueIdentifier}
|
||||
payload={manifest!}
|
||||
onCancel={onClose}
|
||||
onInstalled={handleInstalled}
|
||||
onFailed={handleFailed}
|
||||
onStartToInstall={handleStartToInstall}
|
||||
/>
|
||||
)}
|
||||
{
|
||||
[InstallStep.installed, InstallStep.installFailed].includes(step) && (
|
||||
<Installed
|
||||
payload={manifest!}
|
||||
isMarketPayload
|
||||
isFailed={step === InstallStep.installFailed}
|
||||
errMsg={errorMsg}
|
||||
onCancel={onSuccess}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Modal >
|
||||
)
|
||||
}
|
||||
|
||||
export default InstallFromMarketplace
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
// import { RiInformation2Line } from '@remixicon/react'
|
||||
import { type Plugin, type PluginManifestInMarket, TaskStatus } from '../../../types'
|
||||
import Card from '../../../card'
|
||||
import { pluginManifestInMarketToPluginProps } from '../../utils'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiLoader2Line } from '@remixicon/react'
|
||||
import { useInstallPackageFromMarketPlace, usePluginDeclarationFromMarketPlace, useUpdatePackageFromMarketPlace } from '@/service/use-plugins'
|
||||
import checkTaskStatus from '../../base/check-task-status'
|
||||
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
|
||||
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'
|
||||
|
||||
type Props = {
|
||||
uniqueIdentifier: string
|
||||
payload: PluginManifestInMarket | Plugin
|
||||
onCancel: () => void
|
||||
onStartToInstall?: () => void
|
||||
onInstalled: (notRefresh?: boolean) => void
|
||||
onFailed: (message?: string) => void
|
||||
}
|
||||
|
||||
const Installed: FC<Props> = ({
|
||||
uniqueIdentifier,
|
||||
payload,
|
||||
onCancel,
|
||||
onStartToInstall,
|
||||
onInstalled,
|
||||
onFailed,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const toInstallVersion = payload.version || payload.latest_version
|
||||
const pluginId = (payload as Plugin).plugin_id
|
||||
const { installedInfo, isLoading } = useCheckInstalled({
|
||||
pluginIds: [pluginId],
|
||||
enabled: !!pluginId,
|
||||
})
|
||||
const installedInfoPayload = installedInfo?.[pluginId]
|
||||
const installedVersion = installedInfoPayload?.installedVersion
|
||||
const hasInstalled = !!installedVersion
|
||||
|
||||
const { mutateAsync: installPackageFromMarketPlace } = useInstallPackageFromMarketPlace()
|
||||
const { mutateAsync: updatePackageFromMarketPlace } = useUpdatePackageFromMarketPlace()
|
||||
const [isInstalling, setIsInstalling] = React.useState(false)
|
||||
const {
|
||||
check,
|
||||
stop,
|
||||
} = checkTaskStatus()
|
||||
const { handleRefetch } = usePluginTaskList(payload.category)
|
||||
|
||||
useEffect(() => {
|
||||
if (hasInstalled && uniqueIdentifier === installedInfoPayload.uniqueIdentifier)
|
||||
onInstalled()
|
||||
}, [hasInstalled])
|
||||
|
||||
const handleCancel = () => {
|
||||
stop()
|
||||
onCancel()
|
||||
}
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (isInstalling) return
|
||||
onStartToInstall?.()
|
||||
setIsInstalling(true)
|
||||
try {
|
||||
let taskId
|
||||
let isInstalled
|
||||
if (hasInstalled) {
|
||||
const {
|
||||
all_installed,
|
||||
task_id,
|
||||
} = await updatePackageFromMarketPlace({
|
||||
original_plugin_unique_identifier: installedInfoPayload.uniqueIdentifier,
|
||||
new_plugin_unique_identifier: uniqueIdentifier,
|
||||
})
|
||||
taskId = task_id
|
||||
isInstalled = all_installed
|
||||
}
|
||||
else {
|
||||
const {
|
||||
all_installed,
|
||||
task_id,
|
||||
} = await installPackageFromMarketPlace(uniqueIdentifier)
|
||||
taskId = task_id
|
||||
isInstalled = all_installed
|
||||
}
|
||||
|
||||
if (isInstalled) {
|
||||
onInstalled()
|
||||
return
|
||||
}
|
||||
|
||||
handleRefetch()
|
||||
|
||||
const { status, error } = await check({
|
||||
taskId,
|
||||
pluginUniqueIdentifier: uniqueIdentifier,
|
||||
})
|
||||
if (status === TaskStatus.failed) {
|
||||
onFailed(error)
|
||||
return
|
||||
}
|
||||
onInstalled(true)
|
||||
}
|
||||
catch (e) {
|
||||
if (typeof e === 'string') {
|
||||
onFailed(e)
|
||||
return
|
||||
}
|
||||
onFailed()
|
||||
}
|
||||
}
|
||||
|
||||
const { langGeniusVersionInfo } = useAppContext()
|
||||
const { data: pluginDeclaration } = usePluginDeclarationFromMarketPlace(uniqueIdentifier)
|
||||
const isDifyVersionCompatible = useMemo(() => {
|
||||
if (!pluginDeclaration || !langGeniusVersionInfo.current_version) return true
|
||||
return gte(langGeniusVersionInfo.current_version, pluginDeclaration?.manifest.meta.minimum_dify_version ?? '0.0.0')
|
||||
}, [langGeniusVersionInfo.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-warning'>
|
||||
{t('plugin.difyVersionNotCompatible', { minimalDifyVersion: pluginDeclaration?.manifest.meta.minimum_dify_version })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2'>
|
||||
<Card
|
||||
className='w-full'
|
||||
payload={pluginManifestInMarketToPluginProps(payload as PluginManifestInMarket)}
|
||||
titleLeft={!isLoading && <Version
|
||||
hasInstalled={hasInstalled}
|
||||
installedVersion={installedVersion}
|
||||
toInstallVersion={toInstallVersion}
|
||||
/>}
|
||||
limitedInstall={!canInstall}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Action Buttons */}
|
||||
<div className='flex items-center justify-end gap-2 self-stretch p-6 pt-5'>
|
||||
{!isInstalling && (
|
||||
<Button variant='secondary' className='min-w-[72px]' onClick={handleCancel}>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='primary'
|
||||
className='flex min-w-[72px] space-x-0.5'
|
||||
disabled={isInstalling || isLoading || !canInstall}
|
||||
onClick={handleInstall}
|
||||
>
|
||||
{isInstalling && <RiLoader2Line className='h-4 w-4 animate-spin-slow' />}
|
||||
<span>{t(`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default React.memo(Installed)
|
||||
69
dify/web/app/components/plugins/install-plugin/utils.ts
Normal file
69
dify/web/app/components/plugins/install-plugin/utils.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
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 {
|
||||
plugin_id: pluginManifest.plugin_unique_identifier,
|
||||
type: pluginManifest.category as Plugin['type'],
|
||||
category: pluginManifest.category,
|
||||
name: pluginManifest.name,
|
||||
version: pluginManifest.version,
|
||||
latest_version: '',
|
||||
latest_package_identifier: '',
|
||||
org: pluginManifest.author,
|
||||
author: pluginManifest.author,
|
||||
label: pluginManifest.label,
|
||||
brief: pluginManifest.description,
|
||||
description: pluginManifest.description,
|
||||
icon: pluginManifest.icon,
|
||||
verified: pluginManifest.verified,
|
||||
introduction: '',
|
||||
repository: '',
|
||||
install_count: 0,
|
||||
endpoint: {
|
||||
settings: [],
|
||||
},
|
||||
tags: pluginManifest.tags.map(tag => ({ name: tag })),
|
||||
badges: [],
|
||||
verification: { authorized_category: 'langgenius' },
|
||||
from: 'package',
|
||||
}
|
||||
}
|
||||
|
||||
export const pluginManifestInMarketToPluginProps = (pluginManifest: PluginManifestInMarket): Plugin => {
|
||||
return {
|
||||
plugin_id: pluginManifest.plugin_unique_identifier,
|
||||
type: pluginManifest.category as Plugin['type'],
|
||||
category: pluginManifest.category,
|
||||
name: pluginManifest.name,
|
||||
version: pluginManifest.latest_version,
|
||||
latest_version: pluginManifest.latest_version,
|
||||
latest_package_identifier: '',
|
||||
org: pluginManifest.org,
|
||||
label: pluginManifest.label,
|
||||
brief: pluginManifest.brief,
|
||||
description: pluginManifest.brief,
|
||||
icon: pluginManifest.icon,
|
||||
verified: true,
|
||||
introduction: pluginManifest.introduction,
|
||||
repository: '',
|
||||
install_count: 0,
|
||||
endpoint: {
|
||||
settings: [],
|
||||
},
|
||||
tags: [],
|
||||
badges: pluginManifest.badges,
|
||||
verification: isEmpty(pluginManifest.verification) ? { authorized_category: 'langgenius' } : pluginManifest.verification,
|
||||
from: pluginManifest.from,
|
||||
}
|
||||
}
|
||||
|
||||
export const parseGitHubUrl = (url: string): GitHubUrlInfo => {
|
||||
const match = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/?$/)
|
||||
return match ? { isValid: true, owner: match[1], repo: match[2] } : { isValid: false }
|
||||
}
|
||||
|
||||
export const convertRepoToUrl = (repo: string) => {
|
||||
return repo ? `https://github.com/${repo}` : ''
|
||||
}
|
||||
4
dify/web/app/components/plugins/marketplace/constants.ts
Normal file
4
dify/web/app/components/plugins/marketplace/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const DEFAULT_SORT = {
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
}
|
||||
347
dify/web/app/components/plugins/marketplace/context.tsx
Normal file
347
dify/web/app/components/plugins/marketplace/context.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useContextSelector,
|
||||
} from 'use-context-selector'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch'
|
||||
import type { Plugin } from '../types'
|
||||
import {
|
||||
getValidCategoryKeys,
|
||||
getValidTagKeys,
|
||||
} from '../utils'
|
||||
import type {
|
||||
MarketplaceCollection,
|
||||
PluginsSort,
|
||||
SearchParams,
|
||||
SearchParamsFromCollection,
|
||||
} from './types'
|
||||
import { DEFAULT_SORT } from './constants'
|
||||
import {
|
||||
useMarketplaceCollectionsAndPlugins,
|
||||
useMarketplaceContainerScroll,
|
||||
useMarketplacePlugins,
|
||||
} from './hooks'
|
||||
import {
|
||||
getMarketplaceListCondition,
|
||||
getMarketplaceListFilterType,
|
||||
updateSearchParams,
|
||||
} from './utils'
|
||||
import { useInstalledPluginList } from '@/service/use-plugins'
|
||||
import { debounce, noop } from 'lodash-es'
|
||||
|
||||
export type MarketplaceContextValue = {
|
||||
intersected: boolean
|
||||
setIntersected: (intersected: boolean) => void
|
||||
searchPluginText: string
|
||||
handleSearchPluginTextChange: (text: string) => void
|
||||
filterPluginTags: string[]
|
||||
handleFilterPluginTagsChange: (tags: string[]) => void
|
||||
activePluginType: string
|
||||
handleActivePluginTypeChange: (type: string) => void
|
||||
page: number
|
||||
handlePageChange: (page: number) => void
|
||||
plugins?: Plugin[]
|
||||
pluginsTotal?: number
|
||||
resetPlugins: () => void
|
||||
sort: PluginsSort
|
||||
handleSortChange: (sort: PluginsSort) => void
|
||||
handleQueryPlugins: () => void
|
||||
handleMoreClick: (searchParams: SearchParamsFromCollection) => void
|
||||
marketplaceCollectionsFromClient?: MarketplaceCollection[]
|
||||
setMarketplaceCollectionsFromClient: (collections: MarketplaceCollection[]) => void
|
||||
marketplaceCollectionPluginsMapFromClient?: Record<string, Plugin[]>
|
||||
setMarketplaceCollectionPluginsMapFromClient: (map: Record<string, Plugin[]>) => void
|
||||
isLoading: boolean
|
||||
isSuccessCollections: boolean
|
||||
}
|
||||
|
||||
export const MarketplaceContext = createContext<MarketplaceContextValue>({
|
||||
intersected: true,
|
||||
setIntersected: noop,
|
||||
searchPluginText: '',
|
||||
handleSearchPluginTextChange: noop,
|
||||
filterPluginTags: [],
|
||||
handleFilterPluginTagsChange: noop,
|
||||
activePluginType: 'all',
|
||||
handleActivePluginTypeChange: noop,
|
||||
page: 1,
|
||||
handlePageChange: noop,
|
||||
plugins: undefined,
|
||||
pluginsTotal: 0,
|
||||
resetPlugins: noop,
|
||||
sort: DEFAULT_SORT,
|
||||
handleSortChange: noop,
|
||||
handleQueryPlugins: noop,
|
||||
handleMoreClick: noop,
|
||||
marketplaceCollectionsFromClient: [],
|
||||
setMarketplaceCollectionsFromClient: noop,
|
||||
marketplaceCollectionPluginsMapFromClient: {},
|
||||
setMarketplaceCollectionPluginsMapFromClient: noop,
|
||||
isLoading: false,
|
||||
isSuccessCollections: false,
|
||||
})
|
||||
|
||||
type MarketplaceContextProviderProps = {
|
||||
children: ReactNode
|
||||
searchParams?: SearchParams
|
||||
shouldExclude?: boolean
|
||||
scrollContainerId?: string
|
||||
showSearchParams?: boolean
|
||||
}
|
||||
|
||||
export function useMarketplaceContext(selector: (value: MarketplaceContextValue) => any) {
|
||||
return useContextSelector(MarketplaceContext, selector)
|
||||
}
|
||||
|
||||
export const MarketplaceContextProvider = ({
|
||||
children,
|
||||
searchParams,
|
||||
shouldExclude,
|
||||
scrollContainerId,
|
||||
showSearchParams,
|
||||
}: MarketplaceContextProviderProps) => {
|
||||
const { data, isSuccess } = useInstalledPluginList(!shouldExclude)
|
||||
const exclude = useMemo(() => {
|
||||
if (shouldExclude)
|
||||
return data?.plugins.map(plugin => plugin.plugin_id)
|
||||
}, [data?.plugins, shouldExclude])
|
||||
const queryFromSearchParams = searchParams?.q || ''
|
||||
const tagsFromSearchParams = searchParams?.tags ? getValidTagKeys(searchParams.tags.split(',')) : []
|
||||
const hasValidTags = !!tagsFromSearchParams.length
|
||||
const hasValidCategory = getValidCategoryKeys(searchParams?.category)
|
||||
const categoryFromSearchParams = hasValidCategory || PLUGIN_TYPE_SEARCH_MAP.all
|
||||
const [intersected, setIntersected] = useState(true)
|
||||
const [searchPluginText, setSearchPluginText] = useState(queryFromSearchParams)
|
||||
const searchPluginTextRef = useRef(searchPluginText)
|
||||
const [filterPluginTags, setFilterPluginTags] = useState<string[]>(tagsFromSearchParams)
|
||||
const filterPluginTagsRef = useRef(filterPluginTags)
|
||||
const [activePluginType, setActivePluginType] = useState(categoryFromSearchParams)
|
||||
const activePluginTypeRef = useRef(activePluginType)
|
||||
const [page, setPage] = useState(1)
|
||||
const pageRef = useRef(page)
|
||||
const [sort, setSort] = useState(DEFAULT_SORT)
|
||||
const sortRef = useRef(sort)
|
||||
const {
|
||||
marketplaceCollections: marketplaceCollectionsFromClient,
|
||||
setMarketplaceCollections: setMarketplaceCollectionsFromClient,
|
||||
marketplaceCollectionPluginsMap: marketplaceCollectionPluginsMapFromClient,
|
||||
setMarketplaceCollectionPluginsMap: setMarketplaceCollectionPluginsMapFromClient,
|
||||
queryMarketplaceCollectionsAndPlugins,
|
||||
isLoading,
|
||||
isSuccess: isSuccessCollections,
|
||||
} = useMarketplaceCollectionsAndPlugins()
|
||||
const {
|
||||
plugins,
|
||||
total: pluginsTotal,
|
||||
resetPlugins,
|
||||
queryPlugins,
|
||||
queryPluginsWithDebounced,
|
||||
cancelQueryPluginsWithDebounced,
|
||||
isLoading: isPluginsLoading,
|
||||
} = useMarketplacePlugins()
|
||||
|
||||
useEffect(() => {
|
||||
if (queryFromSearchParams || hasValidTags || hasValidCategory) {
|
||||
queryPlugins({
|
||||
query: queryFromSearchParams,
|
||||
category: hasValidCategory,
|
||||
tags: hasValidTags ? tagsFromSearchParams : [],
|
||||
sortBy: sortRef.current.sortBy,
|
||||
sortOrder: sortRef.current.sortOrder,
|
||||
type: getMarketplaceListFilterType(activePluginTypeRef.current),
|
||||
page: pageRef.current,
|
||||
})
|
||||
const url = new URL(window.location.href)
|
||||
if (searchParams?.language)
|
||||
url.searchParams.set('language', searchParams?.language)
|
||||
history.replaceState({}, '', url)
|
||||
}
|
||||
else {
|
||||
if (shouldExclude && isSuccess) {
|
||||
queryMarketplaceCollectionsAndPlugins({
|
||||
exclude,
|
||||
type: getMarketplaceListFilterType(activePluginTypeRef.current),
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [queryPlugins, queryMarketplaceCollectionsAndPlugins, isSuccess, exclude])
|
||||
|
||||
const handleQueryMarketplaceCollectionsAndPlugins = useCallback(() => {
|
||||
queryMarketplaceCollectionsAndPlugins({
|
||||
category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current,
|
||||
condition: getMarketplaceListCondition(activePluginTypeRef.current),
|
||||
exclude,
|
||||
type: getMarketplaceListFilterType(activePluginTypeRef.current),
|
||||
})
|
||||
resetPlugins()
|
||||
}, [exclude, queryMarketplaceCollectionsAndPlugins, resetPlugins])
|
||||
|
||||
const debouncedUpdateSearchParams = useMemo(() => debounce(() => {
|
||||
updateSearchParams({
|
||||
query: searchPluginTextRef.current,
|
||||
category: activePluginTypeRef.current,
|
||||
tags: filterPluginTagsRef.current,
|
||||
})
|
||||
}, 500), [])
|
||||
|
||||
const handleUpdateSearchParams = useCallback((debounced?: boolean) => {
|
||||
if (!showSearchParams)
|
||||
return
|
||||
if (debounced) {
|
||||
debouncedUpdateSearchParams()
|
||||
}
|
||||
else {
|
||||
updateSearchParams({
|
||||
query: searchPluginTextRef.current,
|
||||
category: activePluginTypeRef.current,
|
||||
tags: filterPluginTagsRef.current,
|
||||
})
|
||||
}
|
||||
}, [debouncedUpdateSearchParams, showSearchParams])
|
||||
|
||||
const handleQueryPlugins = useCallback((debounced?: boolean) => {
|
||||
handleUpdateSearchParams(debounced)
|
||||
if (debounced) {
|
||||
queryPluginsWithDebounced({
|
||||
query: searchPluginTextRef.current,
|
||||
category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current,
|
||||
tags: filterPluginTagsRef.current,
|
||||
sortBy: sortRef.current.sortBy,
|
||||
sortOrder: sortRef.current.sortOrder,
|
||||
exclude,
|
||||
type: getMarketplaceListFilterType(activePluginTypeRef.current),
|
||||
page: pageRef.current,
|
||||
})
|
||||
}
|
||||
else {
|
||||
queryPlugins({
|
||||
query: searchPluginTextRef.current,
|
||||
category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current,
|
||||
tags: filterPluginTagsRef.current,
|
||||
sortBy: sortRef.current.sortBy,
|
||||
sortOrder: sortRef.current.sortOrder,
|
||||
exclude,
|
||||
type: getMarketplaceListFilterType(activePluginTypeRef.current),
|
||||
page: pageRef.current,
|
||||
})
|
||||
}
|
||||
}, [exclude, queryPluginsWithDebounced, queryPlugins, handleUpdateSearchParams])
|
||||
|
||||
const handleQuery = useCallback((debounced?: boolean) => {
|
||||
if (!searchPluginTextRef.current && !filterPluginTagsRef.current.length) {
|
||||
handleUpdateSearchParams(debounced)
|
||||
cancelQueryPluginsWithDebounced()
|
||||
handleQueryMarketplaceCollectionsAndPlugins()
|
||||
return
|
||||
}
|
||||
|
||||
handleQueryPlugins(debounced)
|
||||
}, [handleQueryMarketplaceCollectionsAndPlugins, handleQueryPlugins, cancelQueryPluginsWithDebounced, handleUpdateSearchParams])
|
||||
|
||||
const handleSearchPluginTextChange = useCallback((text: string) => {
|
||||
setSearchPluginText(text)
|
||||
searchPluginTextRef.current = text
|
||||
setPage(1)
|
||||
pageRef.current = 1
|
||||
|
||||
handleQuery(true)
|
||||
}, [handleQuery])
|
||||
|
||||
const handleFilterPluginTagsChange = useCallback((tags: string[]) => {
|
||||
setFilterPluginTags(tags)
|
||||
filterPluginTagsRef.current = tags
|
||||
setPage(1)
|
||||
pageRef.current = 1
|
||||
|
||||
handleQuery()
|
||||
}, [handleQuery])
|
||||
|
||||
const handleActivePluginTypeChange = useCallback((type: string) => {
|
||||
setActivePluginType(type)
|
||||
activePluginTypeRef.current = type
|
||||
setPage(1)
|
||||
pageRef.current = 1
|
||||
|
||||
handleQuery()
|
||||
}, [handleQuery])
|
||||
|
||||
const handleSortChange = useCallback((sort: PluginsSort) => {
|
||||
setSort(sort)
|
||||
sortRef.current = sort
|
||||
setPage(1)
|
||||
pageRef.current = 1
|
||||
|
||||
handleQueryPlugins()
|
||||
}, [handleQueryPlugins])
|
||||
|
||||
const handlePageChange = useCallback(() => {
|
||||
if (pluginsTotal && plugins && pluginsTotal > plugins.length) {
|
||||
setPage(pageRef.current + 1)
|
||||
pageRef.current++
|
||||
|
||||
handleQueryPlugins()
|
||||
}
|
||||
}, [handleQueryPlugins, plugins, pluginsTotal])
|
||||
|
||||
const handleMoreClick = useCallback((searchParams: SearchParamsFromCollection) => {
|
||||
setSearchPluginText(searchParams?.query || '')
|
||||
searchPluginTextRef.current = searchParams?.query || ''
|
||||
setSort({
|
||||
sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy,
|
||||
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
|
||||
})
|
||||
sortRef.current = {
|
||||
sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy,
|
||||
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
|
||||
}
|
||||
setPage(1)
|
||||
pageRef.current = 1
|
||||
|
||||
handleQueryPlugins()
|
||||
}, [handleQueryPlugins])
|
||||
|
||||
useMarketplaceContainerScroll(handlePageChange, scrollContainerId)
|
||||
|
||||
return (
|
||||
<MarketplaceContext.Provider
|
||||
value={{
|
||||
intersected,
|
||||
setIntersected,
|
||||
searchPluginText,
|
||||
handleSearchPluginTextChange,
|
||||
filterPluginTags,
|
||||
handleFilterPluginTagsChange,
|
||||
activePluginType,
|
||||
handleActivePluginTypeChange,
|
||||
page,
|
||||
handlePageChange,
|
||||
plugins,
|
||||
pluginsTotal,
|
||||
resetPlugins,
|
||||
sort,
|
||||
handleSortChange,
|
||||
handleQueryPlugins,
|
||||
handleMoreClick,
|
||||
marketplaceCollectionsFromClient,
|
||||
setMarketplaceCollectionsFromClient,
|
||||
marketplaceCollectionPluginsMapFromClient,
|
||||
setMarketplaceCollectionPluginsMapFromClient,
|
||||
isLoading: isLoading || isPluginsLoading,
|
||||
isSuccessCollections,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MarketplaceContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
getLocaleOnServer,
|
||||
useTranslation as translate,
|
||||
} from '@/i18n-config/server'
|
||||
|
||||
type DescriptionProps = {
|
||||
locale?: string
|
||||
}
|
||||
const Description = async ({
|
||||
locale: localeFromProps,
|
||||
}: DescriptionProps) => {
|
||||
const localeDefault = await getLocaleOnServer()
|
||||
const { t } = await translate(localeFromProps || localeDefault, 'plugin')
|
||||
const { t: tCommon } = await translate(localeFromProps || localeDefault, 'common')
|
||||
const isZhHans = localeFromProps === 'zh-Hans'
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className='title-4xl-semi-bold mb-2 shrink-0 text-center text-text-primary'>
|
||||
{t('marketplace.empower')}
|
||||
</h1>
|
||||
<h2 className='body-md-regular flex shrink-0 items-center justify-center text-center text-text-tertiary'>
|
||||
{
|
||||
isZhHans && (
|
||||
<>
|
||||
<span className='mr-1'>{tCommon('operation.in')}</span>
|
||||
{t('marketplace.difyMarketplace')}
|
||||
{t('marketplace.discover')}
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isZhHans && (
|
||||
<>
|
||||
{t('marketplace.discover')}
|
||||
</>
|
||||
)
|
||||
}
|
||||
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
|
||||
{t('category.models')}
|
||||
</span>
|
||||
,
|
||||
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
|
||||
{t('category.tools')}
|
||||
</span>
|
||||
,
|
||||
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
|
||||
{t('category.datasources')}
|
||||
</span>
|
||||
,
|
||||
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
|
||||
{t('category.triggers')}
|
||||
</span>
|
||||
,
|
||||
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
|
||||
{t('category.agents')}
|
||||
</span>
|
||||
,
|
||||
<span className="body-md-medium relative z-[1] ml-1 mr-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
|
||||
{t('category.extensions')}
|
||||
</span>
|
||||
{t('marketplace.and')}
|
||||
<span className="body-md-medium relative z-[1] ml-1 mr-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
|
||||
{t('category.bundles')}
|
||||
</span>
|
||||
{
|
||||
!isZhHans && (
|
||||
<>
|
||||
<span className='mr-1'>{tCommon('operation.in')}</span>
|
||||
{t('marketplace.difyMarketplace')}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</h2>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Description
|
||||
63
dify/web/app/components/plugins/marketplace/empty/index.tsx
Normal file
63
dify/web/app/components/plugins/marketplace/empty/index.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
import { Group } from '@/app/components/base/icons/src/vender/other'
|
||||
import Line from './line'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
|
||||
type Props = {
|
||||
text?: string
|
||||
lightCard?: boolean
|
||||
className?: string
|
||||
locale?: string
|
||||
}
|
||||
|
||||
const Empty = ({
|
||||
text,
|
||||
lightCard,
|
||||
className,
|
||||
locale,
|
||||
}: Props) => {
|
||||
const { t } = useMixedTranslation(locale)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative flex h-0 grow flex-wrap overflow-hidden p-2', className)}
|
||||
>
|
||||
{
|
||||
Array.from({ length: 16 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'mb-3 mr-3 h-[144px] w-[calc((100%-36px)/4)] rounded-xl bg-background-section-burn',
|
||||
index % 4 === 3 && 'mr-0',
|
||||
index > 11 && 'mb-0',
|
||||
lightCard && 'bg-background-default-lighter opacity-75',
|
||||
)}
|
||||
>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
{
|
||||
!lightCard && (
|
||||
<div
|
||||
className='absolute inset-0 z-[1] bg-marketplace-plugin-empty'
|
||||
></div>
|
||||
)
|
||||
}
|
||||
<div className='absolute left-1/2 top-1/2 z-[2] flex -translate-x-1/2 -translate-y-1/2 flex-col items-center'>
|
||||
<div className='relative mb-3 flex h-14 w-14 items-center justify-center rounded-xl border border-dashed border-divider-deep bg-components-card-bg shadow-lg'>
|
||||
<Group className='h-5 w-5 text-text-primary' />
|
||||
<Line className='absolute right-[-1px] top-1/2 -translate-y-1/2' />
|
||||
<Line className='absolute left-[-1px] top-1/2 -translate-y-1/2' />
|
||||
<Line className='absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 rotate-90' />
|
||||
<Line className='absolute left-1/2 top-full -translate-x-1/2 -translate-y-1/2 rotate-90' />
|
||||
</div>
|
||||
<div className='system-md-regular text-center text-text-tertiary'>
|
||||
{text || t('plugin.marketplace.noPluginFound')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Empty
|
||||
43
dify/web/app/components/plugins/marketplace/empty/line.tsx
Normal file
43
dify/web/app/components/plugins/marketplace/empty/line.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
|
||||
type LineProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const Line = ({
|
||||
className,
|
||||
}: LineProps) => {
|
||||
const { theme } = useTheme()
|
||||
const isDarkMode = theme === 'dark'
|
||||
|
||||
if (isDarkMode) {
|
||||
return (
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='2' height='240' viewBox='0 0 2 240' fill='none' className={className}>
|
||||
<path d='M1 0L1 240' stroke='url(#paint0_linear_6295_52176)' />
|
||||
<defs>
|
||||
<linearGradient id='paint0_linear_6295_52176' x1='-7.99584' y1='240' x2='-7.88094' y2='3.95539e-05' gradientUnits='userSpaceOnUse'>
|
||||
<stop stopOpacity='0.01' />
|
||||
<stop offset='0.503965' stopColor='#C8CEDA' stopOpacity='0.14' />
|
||||
<stop offset='1' stopOpacity='0.01' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='2' height='241' viewBox='0 0 2 241' fill='none' className={className}>
|
||||
<path d='M1 0.5L1 240.5' stroke='url(#paint0_linear_1989_74474)' />
|
||||
<defs>
|
||||
<linearGradient id='paint0_linear_1989_74474' x1='-7.99584' y1='240.5' x2='-7.88094' y2='0.50004' gradientUnits='userSpaceOnUse'>
|
||||
<stop stopColor='white' stopOpacity='0.01' />
|
||||
<stop offset='0.503965' stopColor='#101828' stopOpacity='0.08' />
|
||||
<stop offset='1' stopColor='white' stopOpacity='0.01' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default Line
|
||||
179
dify/web/app/components/plugins/marketplace/hooks.ts
Normal file
179
dify/web/app/components/plugins/marketplace/hooks.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import type {
|
||||
Plugin,
|
||||
} from '../types'
|
||||
import type {
|
||||
CollectionsAndPluginsSearchParams,
|
||||
MarketplaceCollection,
|
||||
PluginsSearchParams,
|
||||
} from './types'
|
||||
import {
|
||||
getFormattedPlugin,
|
||||
getMarketplaceCollectionsAndPlugins,
|
||||
} from './utils'
|
||||
import i18n from '@/i18n-config/i18next-config'
|
||||
import {
|
||||
useMutationPluginsFromMarketplace,
|
||||
} from '@/service/use-plugins'
|
||||
|
||||
export const useMarketplaceCollectionsAndPlugins = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
const [marketplaceCollections, setMarketplaceCollections] = useState<MarketplaceCollection[]>()
|
||||
const [marketplaceCollectionPluginsMap, setMarketplaceCollectionPluginsMap] = useState<Record<string, Plugin[]>>()
|
||||
|
||||
const queryMarketplaceCollectionsAndPlugins = useCallback(async (query?: CollectionsAndPluginsSearchParams) => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setIsSuccess(false)
|
||||
const { marketplaceCollections, marketplaceCollectionPluginsMap } = await getMarketplaceCollectionsAndPlugins(query)
|
||||
setIsLoading(false)
|
||||
setIsSuccess(true)
|
||||
setMarketplaceCollections(marketplaceCollections)
|
||||
setMarketplaceCollectionPluginsMap(marketplaceCollectionPluginsMap)
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (e) {
|
||||
setIsLoading(false)
|
||||
setIsSuccess(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
marketplaceCollections,
|
||||
setMarketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
setMarketplaceCollectionPluginsMap,
|
||||
queryMarketplaceCollectionsAndPlugins,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
}
|
||||
}
|
||||
|
||||
export const useMarketplacePlugins = () => {
|
||||
const {
|
||||
data,
|
||||
mutateAsync,
|
||||
reset,
|
||||
isPending,
|
||||
} = useMutationPluginsFromMarketplace()
|
||||
|
||||
const [prevPlugins, setPrevPlugins] = useState<Plugin[] | undefined>()
|
||||
|
||||
const resetPlugins = useCallback(() => {
|
||||
reset()
|
||||
setPrevPlugins(undefined)
|
||||
}, [reset])
|
||||
|
||||
const handleUpdatePlugins = useCallback((pluginsSearchParams: PluginsSearchParams) => {
|
||||
mutateAsync(pluginsSearchParams).then((res) => {
|
||||
const currentPage = pluginsSearchParams.page || 1
|
||||
const resPlugins = res.data.bundles || res.data.plugins
|
||||
if (currentPage > 1) {
|
||||
setPrevPlugins(prevPlugins => [...(prevPlugins || []), ...resPlugins.map((plugin) => {
|
||||
return getFormattedPlugin(plugin)
|
||||
})])
|
||||
}
|
||||
else {
|
||||
setPrevPlugins(resPlugins.map((plugin) => {
|
||||
return getFormattedPlugin(plugin)
|
||||
}))
|
||||
}
|
||||
})
|
||||
}, [mutateAsync])
|
||||
|
||||
const { run: queryPluginsWithDebounced, cancel: cancelQueryPluginsWithDebounced } = useDebounceFn((pluginsSearchParams: PluginsSearchParams) => {
|
||||
handleUpdatePlugins(pluginsSearchParams)
|
||||
}, {
|
||||
wait: 500,
|
||||
})
|
||||
|
||||
return {
|
||||
plugins: prevPlugins,
|
||||
total: data?.data?.total,
|
||||
resetPlugins,
|
||||
queryPlugins: handleUpdatePlugins,
|
||||
queryPluginsWithDebounced,
|
||||
cancelQueryPluginsWithDebounced,
|
||||
isLoading: isPending,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ! Support zh-Hans, pt-BR, ja-JP and en-US for Marketplace page
|
||||
* ! For other languages, use en-US as fallback
|
||||
*/
|
||||
export const useMixedTranslation = (localeFromOuter?: string) => {
|
||||
let t = useTranslation().t
|
||||
|
||||
if (localeFromOuter)
|
||||
t = i18n.getFixedT(localeFromOuter)
|
||||
|
||||
return {
|
||||
t,
|
||||
}
|
||||
}
|
||||
|
||||
export const useMarketplaceContainerScroll = (
|
||||
callback: () => void,
|
||||
scrollContainerId = 'marketplace-container',
|
||||
) => {
|
||||
const handleScroll = useCallback((e: Event) => {
|
||||
const target = e.target as HTMLDivElement
|
||||
const {
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
} = target
|
||||
if (scrollTop + clientHeight >= scrollHeight - 5 && scrollTop > 0)
|
||||
callback()
|
||||
}, [callback])
|
||||
|
||||
useEffect(() => {
|
||||
const container = document.getElementById(scrollContainerId)
|
||||
if (container)
|
||||
container.addEventListener('scroll', handleScroll)
|
||||
|
||||
return () => {
|
||||
if (container)
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [handleScroll])
|
||||
}
|
||||
|
||||
export const useSearchBoxAutoAnimate = (searchBoxAutoAnimate?: boolean) => {
|
||||
const [searchBoxCanAnimate, setSearchBoxCanAnimate] = useState(true)
|
||||
|
||||
const handleSearchBoxCanAnimateChange = useCallback(() => {
|
||||
if (!searchBoxAutoAnimate) {
|
||||
const clientWidth = document.documentElement.clientWidth
|
||||
|
||||
if (clientWidth < 1400)
|
||||
setSearchBoxCanAnimate(false)
|
||||
else
|
||||
setSearchBoxCanAnimate(true)
|
||||
}
|
||||
}, [searchBoxAutoAnimate])
|
||||
|
||||
useEffect(() => {
|
||||
handleSearchBoxCanAnimateChange()
|
||||
}, [handleSearchBoxCanAnimateChange])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', handleSearchBoxCanAnimateChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleSearchBoxCanAnimateChange)
|
||||
}
|
||||
}, [handleSearchBoxCanAnimateChange])
|
||||
|
||||
return {
|
||||
searchBoxCanAnimate,
|
||||
}
|
||||
}
|
||||
72
dify/web/app/components/plugins/marketplace/index.tsx
Normal file
72
dify/web/app/components/plugins/marketplace/index.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { MarketplaceContextProvider } from './context'
|
||||
import Description from './description'
|
||||
import IntersectionLine from './intersection-line'
|
||||
import SearchBoxWrapper from './search-box/search-box-wrapper'
|
||||
import PluginTypeSwitch from './plugin-type-switch'
|
||||
import ListWrapper from './list/list-wrapper'
|
||||
import type { SearchParams } from './types'
|
||||
import { getMarketplaceCollectionsAndPlugins } from './utils'
|
||||
import { TanstackQueryInitializer } from '@/context/query-client'
|
||||
|
||||
type MarketplaceProps = {
|
||||
locale: string
|
||||
searchBoxAutoAnimate?: boolean
|
||||
showInstallButton?: boolean
|
||||
shouldExclude?: boolean
|
||||
searchParams?: SearchParams
|
||||
pluginTypeSwitchClassName?: string
|
||||
intersectionContainerId?: string
|
||||
scrollContainerId?: string
|
||||
showSearchParams?: boolean
|
||||
}
|
||||
const Marketplace = async ({
|
||||
locale,
|
||||
searchBoxAutoAnimate = true,
|
||||
showInstallButton = true,
|
||||
shouldExclude,
|
||||
searchParams,
|
||||
pluginTypeSwitchClassName,
|
||||
intersectionContainerId,
|
||||
scrollContainerId,
|
||||
showSearchParams = true,
|
||||
}: MarketplaceProps) => {
|
||||
let marketplaceCollections: any = []
|
||||
let marketplaceCollectionPluginsMap = {}
|
||||
if (!shouldExclude) {
|
||||
const marketplaceCollectionsAndPluginsData = await getMarketplaceCollectionsAndPlugins()
|
||||
marketplaceCollections = marketplaceCollectionsAndPluginsData.marketplaceCollections
|
||||
marketplaceCollectionPluginsMap = marketplaceCollectionsAndPluginsData.marketplaceCollectionPluginsMap
|
||||
}
|
||||
|
||||
return (
|
||||
<TanstackQueryInitializer>
|
||||
<MarketplaceContextProvider
|
||||
searchParams={searchParams}
|
||||
shouldExclude={shouldExclude}
|
||||
scrollContainerId={scrollContainerId}
|
||||
showSearchParams={showSearchParams}
|
||||
>
|
||||
<Description locale={locale} />
|
||||
<IntersectionLine intersectionContainerId={intersectionContainerId} />
|
||||
<SearchBoxWrapper
|
||||
locale={locale}
|
||||
searchBoxAutoAnimate={searchBoxAutoAnimate}
|
||||
/>
|
||||
<PluginTypeSwitch
|
||||
locale={locale}
|
||||
className={pluginTypeSwitchClassName}
|
||||
searchBoxAutoAnimate={searchBoxAutoAnimate}
|
||||
showSearchParams={showSearchParams}
|
||||
/>
|
||||
<ListWrapper
|
||||
locale={locale}
|
||||
marketplaceCollections={marketplaceCollections}
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
|
||||
showInstallButton={showInstallButton}
|
||||
/>
|
||||
</MarketplaceContextProvider>
|
||||
</TanstackQueryInitializer>
|
||||
)
|
||||
}
|
||||
|
||||
export default Marketplace
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useMarketplaceContext } from '@/app/components/plugins/marketplace/context'
|
||||
|
||||
export const useScrollIntersection = (
|
||||
anchorRef: React.RefObject<HTMLDivElement | null>,
|
||||
intersectionContainerId = 'marketplace-container',
|
||||
) => {
|
||||
const intersected = useMarketplaceContext(v => v.intersected)
|
||||
const setIntersected = useMarketplaceContext(v => v.setIntersected)
|
||||
|
||||
useEffect(() => {
|
||||
const container = document.getElementById(intersectionContainerId)
|
||||
let observer: IntersectionObserver | undefined
|
||||
if (container && anchorRef.current) {
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
const isIntersecting = entries[0].isIntersecting
|
||||
|
||||
if (isIntersecting && !intersected)
|
||||
setIntersected(true)
|
||||
|
||||
if (!isIntersecting && intersected)
|
||||
setIntersected(false)
|
||||
}, {
|
||||
root: container,
|
||||
})
|
||||
observer.observe(anchorRef.current)
|
||||
}
|
||||
return () => observer?.disconnect()
|
||||
}, [anchorRef, intersected, setIntersected, intersectionContainerId])
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { useScrollIntersection } from './hooks'
|
||||
|
||||
type IntersectionLineProps = {
|
||||
intersectionContainerId?: string
|
||||
}
|
||||
const IntersectionLine = ({
|
||||
intersectionContainerId,
|
||||
}: IntersectionLineProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useScrollIntersection(ref, intersectionContainerId)
|
||||
|
||||
return (
|
||||
<div ref={ref} className='mb-4 h-px shrink-0 bg-transparent'></div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IntersectionLine
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { RiArrowRightUpLine } from '@remixicon/react'
|
||||
import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '../utils'
|
||||
import Card from '@/app/components/plugins/card'
|
||||
import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useI18N } from '@/context/i18n'
|
||||
import { useTags } from '@/app/components/plugins/hooks'
|
||||
|
||||
type CardWrapperProps = {
|
||||
plugin: Plugin
|
||||
showInstallButton?: boolean
|
||||
locale?: string
|
||||
}
|
||||
const CardWrapper = ({
|
||||
plugin,
|
||||
showInstallButton,
|
||||
locale,
|
||||
}: CardWrapperProps) => {
|
||||
const { t } = useMixedTranslation(locale)
|
||||
const { theme } = useTheme()
|
||||
const [isShowInstallFromMarketplace, {
|
||||
setTrue: showInstallFromMarketplace,
|
||||
setFalse: hideInstallFromMarketplace,
|
||||
}] = useBoolean(false)
|
||||
const { locale: localeFromLocale } = useI18N()
|
||||
const { getTagLabel } = useTags(t)
|
||||
|
||||
if (showInstallButton) {
|
||||
return (
|
||||
<div
|
||||
className='group relative cursor-pointer rounded-xl hover:bg-components-panel-on-panel-item-bg-hover'
|
||||
>
|
||||
<Card
|
||||
key={plugin.name}
|
||||
payload={plugin}
|
||||
locale={locale}
|
||||
footer={
|
||||
<CardMoreInfo
|
||||
downloadCount={plugin.install_count}
|
||||
tags={plugin.tags.map(tag => getTagLabel(tag.name))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{
|
||||
<div className='absolute bottom-0 hidden w-full items-center space-x-2 rounded-b-xl bg-gradient-to-tr from-components-panel-on-panel-item-bg to-background-gradient-mask-transparent px-4 pb-4 pt-8 group-hover:flex'>
|
||||
<Button
|
||||
variant='primary'
|
||||
className='w-[calc(50%-4px)]'
|
||||
onClick={showInstallFromMarketplace}
|
||||
>
|
||||
{t('plugin.detailPanel.operation.install')}
|
||||
</Button>
|
||||
<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'
|
||||
>
|
||||
{t('plugin.detailPanel.operation.detail')}
|
||||
<RiArrowRightUpLine className='ml-1 h-4 w-4' />
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
{
|
||||
isShowInstallFromMarketplace && (
|
||||
<InstallFromMarketplace
|
||||
manifest={plugin}
|
||||
uniqueIdentifier={plugin.latest_package_identifier}
|
||||
onClose={hideInstallFromMarketplace}
|
||||
onSuccess={hideInstallFromMarketplace}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
className='group relative inline-block cursor-pointer rounded-xl'
|
||||
href={getPluginDetailLinkInMarketplace(plugin)}
|
||||
>
|
||||
<Card
|
||||
key={plugin.name}
|
||||
payload={plugin}
|
||||
locale={locale}
|
||||
footer={
|
||||
<CardMoreInfo
|
||||
downloadCount={plugin.install_count}
|
||||
tags={plugin.tags.map(tag => getTagLabel(tag.name))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default CardWrapper
|
||||
79
dify/web/app/components/plugins/marketplace/list/index.tsx
Normal file
79
dify/web/app/components/plugins/marketplace/list/index.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
import type { Plugin } from '../../types'
|
||||
import type { MarketplaceCollection } from '../types'
|
||||
import ListWithCollection from './list-with-collection'
|
||||
import CardWrapper from './card-wrapper'
|
||||
import Empty from '../empty'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type ListProps = {
|
||||
marketplaceCollections: MarketplaceCollection[]
|
||||
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
|
||||
plugins?: Plugin[]
|
||||
showInstallButton?: boolean
|
||||
locale: string
|
||||
cardContainerClassName?: string
|
||||
cardRender?: (plugin: Plugin) => React.JSX.Element | null
|
||||
onMoreClick?: () => void
|
||||
emptyClassName?: string
|
||||
}
|
||||
const List = ({
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
plugins,
|
||||
showInstallButton,
|
||||
locale,
|
||||
cardContainerClassName,
|
||||
cardRender,
|
||||
onMoreClick,
|
||||
emptyClassName,
|
||||
}: ListProps) => {
|
||||
return (
|
||||
<>
|
||||
{
|
||||
!plugins && (
|
||||
<ListWithCollection
|
||||
marketplaceCollections={marketplaceCollections}
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
|
||||
showInstallButton={showInstallButton}
|
||||
locale={locale}
|
||||
cardContainerClassName={cardContainerClassName}
|
||||
cardRender={cardRender}
|
||||
onMoreClick={onMoreClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
plugins && !!plugins.length && (
|
||||
<div className={cn(
|
||||
'grid grid-cols-4 gap-3',
|
||||
cardContainerClassName,
|
||||
)}>
|
||||
{
|
||||
plugins.map((plugin) => {
|
||||
if (cardRender)
|
||||
return cardRender(plugin)
|
||||
|
||||
return (
|
||||
<CardWrapper
|
||||
key={`${plugin.org}/${plugin.name}`}
|
||||
plugin={plugin}
|
||||
showInstallButton={showInstallButton}
|
||||
locale={locale}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
plugins && !plugins.length && (
|
||||
<Empty className={emptyClassName} locale={locale} />
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default List
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import { RiArrowRightSLine } from '@remixicon/react'
|
||||
import type { MarketplaceCollection } from '../types'
|
||||
import CardWrapper from './card-wrapper'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { SearchParamsFromCollection } from '@/app/components/plugins/marketplace/types'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
|
||||
type ListWithCollectionProps = {
|
||||
marketplaceCollections: MarketplaceCollection[]
|
||||
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
|
||||
showInstallButton?: boolean
|
||||
locale: string
|
||||
cardContainerClassName?: string
|
||||
cardRender?: (plugin: Plugin) => React.JSX.Element | null
|
||||
onMoreClick?: (searchParams?: SearchParamsFromCollection) => void
|
||||
}
|
||||
const ListWithCollection = ({
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
showInstallButton,
|
||||
locale,
|
||||
cardContainerClassName,
|
||||
cardRender,
|
||||
onMoreClick,
|
||||
}: ListWithCollectionProps) => {
|
||||
const { t } = useMixedTranslation(locale)
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
marketplaceCollections.filter((collection) => {
|
||||
return marketplaceCollectionPluginsMap[collection.name]?.length
|
||||
}).map(collection => (
|
||||
<div
|
||||
key={collection.name}
|
||||
className='py-3'
|
||||
>
|
||||
<div className='flex items-end justify-between'>
|
||||
<div>
|
||||
<div className='title-xl-semi-bold text-text-primary'>{collection.label[getLanguage(locale)]}</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>{collection.description[getLanguage(locale)]}</div>
|
||||
</div>
|
||||
{
|
||||
collection.searchable && onMoreClick && (
|
||||
<div
|
||||
className='system-xs-medium flex cursor-pointer items-center text-text-accent '
|
||||
onClick={() => onMoreClick?.(collection.search_params)}
|
||||
>
|
||||
{t('plugin.marketplace.viewMore')}
|
||||
<RiArrowRightSLine className='h-4 w-4' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'mt-2 grid grid-cols-4 gap-3',
|
||||
cardContainerClassName,
|
||||
)}>
|
||||
{
|
||||
marketplaceCollectionPluginsMap[collection.name].map((plugin) => {
|
||||
if (cardRender)
|
||||
return cardRender(plugin)
|
||||
|
||||
return (
|
||||
<CardWrapper
|
||||
key={plugin.plugin_id}
|
||||
plugin={plugin}
|
||||
showInstallButton={showInstallButton}
|
||||
locale={locale}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ListWithCollection
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
import { useEffect } from 'react'
|
||||
import type { Plugin } from '../../types'
|
||||
import type { MarketplaceCollection } from '../types'
|
||||
import { useMarketplaceContext } from '../context'
|
||||
import List from './index'
|
||||
import SortDropdown from '../sort-dropdown'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
|
||||
type ListWrapperProps = {
|
||||
marketplaceCollections: MarketplaceCollection[]
|
||||
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
|
||||
showInstallButton?: boolean
|
||||
locale: string
|
||||
}
|
||||
const ListWrapper = ({
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
showInstallButton,
|
||||
locale,
|
||||
}: ListWrapperProps) => {
|
||||
const { t } = useMixedTranslation(locale)
|
||||
const plugins = useMarketplaceContext(v => v.plugins)
|
||||
const pluginsTotal = useMarketplaceContext(v => v.pluginsTotal)
|
||||
const marketplaceCollectionsFromClient = useMarketplaceContext(v => v.marketplaceCollectionsFromClient)
|
||||
const marketplaceCollectionPluginsMapFromClient = useMarketplaceContext(v => v.marketplaceCollectionPluginsMapFromClient)
|
||||
const isLoading = useMarketplaceContext(v => v.isLoading)
|
||||
const isSuccessCollections = useMarketplaceContext(v => v.isSuccessCollections)
|
||||
const handleQueryPlugins = useMarketplaceContext(v => v.handleQueryPlugins)
|
||||
const page = useMarketplaceContext(v => v.page)
|
||||
const handleMoreClick = useMarketplaceContext(v => v.handleMoreClick)
|
||||
|
||||
useEffect(() => {
|
||||
if (!marketplaceCollectionsFromClient?.length && isSuccessCollections)
|
||||
handleQueryPlugins()
|
||||
}, [handleQueryPlugins, marketplaceCollections, marketplaceCollectionsFromClient, isSuccessCollections])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
className='relative flex grow flex-col bg-background-default-subtle px-12 py-2'>
|
||||
{
|
||||
plugins && (
|
||||
<div className='mb-4 flex items-center pt-3'>
|
||||
<div className='title-xl-semi-bold text-text-primary'>{t('plugin.marketplace.pluginsResult', { num: pluginsTotal })}</div>
|
||||
<div className='mx-3 h-3.5 w-[1px] bg-divider-regular'></div>
|
||||
<SortDropdown locale={locale} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
isLoading && page === 1 && (
|
||||
<div className='absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2'>
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
(!isLoading || page > 1) && (
|
||||
<List
|
||||
marketplaceCollections={marketplaceCollectionsFromClient || marketplaceCollections}
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMapFromClient || marketplaceCollectionPluginsMap}
|
||||
plugins={plugins}
|
||||
showInstallButton={showInstallButton}
|
||||
locale={locale}
|
||||
onMoreClick={handleMoreClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ListWrapper
|
||||
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import cn from '@/utils/classnames'
|
||||
import {
|
||||
RiArchive2Line,
|
||||
RiBrain2Line,
|
||||
RiDatabase2Line,
|
||||
RiHammerLine,
|
||||
RiPuzzle2Line,
|
||||
RiSpeakAiLine,
|
||||
} from '@remixicon/react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { PluginCategoryEnum } from '../types'
|
||||
import { useMarketplaceContext } from './context'
|
||||
import {
|
||||
useMixedTranslation,
|
||||
useSearchBoxAutoAnimate,
|
||||
} from './hooks'
|
||||
|
||||
export const PLUGIN_TYPE_SEARCH_MAP = {
|
||||
all: 'all',
|
||||
model: PluginCategoryEnum.model,
|
||||
tool: PluginCategoryEnum.tool,
|
||||
agent: PluginCategoryEnum.agent,
|
||||
extension: PluginCategoryEnum.extension,
|
||||
datasource: PluginCategoryEnum.datasource,
|
||||
trigger: PluginCategoryEnum.trigger,
|
||||
bundle: 'bundle',
|
||||
}
|
||||
type PluginTypeSwitchProps = {
|
||||
locale?: string
|
||||
className?: string
|
||||
searchBoxAutoAnimate?: boolean
|
||||
showSearchParams?: boolean
|
||||
}
|
||||
const PluginTypeSwitch = ({
|
||||
locale,
|
||||
className,
|
||||
searchBoxAutoAnimate,
|
||||
showSearchParams,
|
||||
}: PluginTypeSwitchProps) => {
|
||||
const { t } = useMixedTranslation(locale)
|
||||
const activePluginType = useMarketplaceContext(s => s.activePluginType)
|
||||
const handleActivePluginTypeChange = useMarketplaceContext(s => s.handleActivePluginTypeChange)
|
||||
const { searchBoxCanAnimate } = useSearchBoxAutoAnimate(searchBoxAutoAnimate)
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.all,
|
||||
text: t('plugin.category.all'),
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.model,
|
||||
text: t('plugin.category.models'),
|
||||
icon: <RiBrain2Line className='mr-1.5 h-4 w-4' />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.tool,
|
||||
text: t('plugin.category.tools'),
|
||||
icon: <RiHammerLine className='mr-1.5 h-4 w-4' />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.datasource,
|
||||
text: t('plugin.category.datasources'),
|
||||
icon: <RiDatabase2Line className='mr-1.5 h-4 w-4' />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.trigger,
|
||||
text: t('plugin.category.triggers'),
|
||||
icon: <TriggerIcon className='mr-1.5 h-4 w-4' />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.agent,
|
||||
text: t('plugin.category.agents'),
|
||||
icon: <RiSpeakAiLine className='mr-1.5 h-4 w-4' />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.extension,
|
||||
text: t('plugin.category.extensions'),
|
||||
icon: <RiPuzzle2Line className='mr-1.5 h-4 w-4' />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.bundle,
|
||||
text: t('plugin.category.bundles'),
|
||||
icon: <RiArchive2Line className='mr-1.5 h-4 w-4' />,
|
||||
},
|
||||
]
|
||||
|
||||
const handlePopState = useCallback(() => {
|
||||
if (!showSearchParams)
|
||||
return
|
||||
const url = new URL(window.location.href)
|
||||
const category = url.searchParams.get('category') || PLUGIN_TYPE_SEARCH_MAP.all
|
||||
handleActivePluginTypeChange(category)
|
||||
}, [showSearchParams, handleActivePluginTypeChange])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('popstate', handlePopState)
|
||||
return () => {
|
||||
window.removeEventListener('popstate', handlePopState)
|
||||
}
|
||||
}, [handlePopState])
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3',
|
||||
searchBoxCanAnimate && 'sticky top-[56px] z-10',
|
||||
className,
|
||||
)}>
|
||||
{
|
||||
options.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={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',
|
||||
activePluginType === option.value && 'border-components-main-nav-nav-button-border !bg-components-main-nav-nav-button-bg-active !text-components-main-nav-nav-button-text-active shadow-xs',
|
||||
)}
|
||||
onClick={() => {
|
||||
handleActivePluginTypeChange(option.value)
|
||||
}}
|
||||
>
|
||||
{option.icon}
|
||||
{option.text}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PluginTypeSwitch
|
||||
136
dify/web/app/components/plugins/marketplace/search-box/index.tsx
Normal file
136
dify/web/app/components/plugins/marketplace/search-box/index.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
import { RiCloseLine, RiSearchLine } from '@remixicon/react'
|
||||
import TagsFilter from './tags-filter'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
|
||||
type SearchBoxProps = {
|
||||
search: string
|
||||
onSearchChange: (search: string) => void
|
||||
wrapperClassName?: string
|
||||
inputClassName?: string
|
||||
tags: string[]
|
||||
onTagsChange: (tags: string[]) => void
|
||||
placeholder?: string
|
||||
locale?: string
|
||||
supportAddCustomTool?: boolean
|
||||
usedInMarketplace?: boolean
|
||||
onShowAddCustomCollectionModal?: () => void
|
||||
onAddedCustomTool?: () => void
|
||||
autoFocus?: boolean
|
||||
}
|
||||
const SearchBox = ({
|
||||
search,
|
||||
onSearchChange,
|
||||
wrapperClassName,
|
||||
inputClassName,
|
||||
tags,
|
||||
onTagsChange,
|
||||
placeholder = '',
|
||||
locale,
|
||||
usedInMarketplace = false,
|
||||
supportAddCustomTool,
|
||||
onShowAddCustomCollectionModal,
|
||||
autoFocus = false,
|
||||
}: SearchBoxProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn('z-[11] flex items-center', wrapperClassName)}
|
||||
>
|
||||
<div className={
|
||||
cn('flex items-center',
|
||||
usedInMarketplace && 'rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur p-1.5 shadow-md',
|
||||
!usedInMarketplace && 'radius-md border border-transparent bg-components-input-bg-normal focus-within:border-components-input-border-active hover:border-components-input-border-hover',
|
||||
inputClassName,
|
||||
)
|
||||
}>
|
||||
{
|
||||
usedInMarketplace && (
|
||||
<>
|
||||
<TagsFilter
|
||||
tags={tags}
|
||||
onTagsChange={onTagsChange}
|
||||
usedInMarketplace
|
||||
locale={locale}
|
||||
/>
|
||||
<Divider type='vertical' className='mx-1 h-3.5' />
|
||||
<div className='flex grow items-center gap-x-2 p-1'>
|
||||
<input
|
||||
className={cn(
|
||||
'body-md-medium inline-block grow appearance-none bg-transparent text-text-secondary outline-none',
|
||||
)}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.target.value)
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{
|
||||
search && (
|
||||
<ActionButton
|
||||
onClick={() => onSearchChange('')}
|
||||
className='shrink-0'
|
||||
>
|
||||
<RiCloseLine className='size-4' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
!usedInMarketplace && (
|
||||
<>
|
||||
<div className='flex grow items-center py-[7px] pl-2 pr-3'>
|
||||
<RiSearchLine className='size-4 text-components-input-text-placeholder' />
|
||||
<input
|
||||
autoFocus={autoFocus}
|
||||
className={cn(
|
||||
'system-sm-regular ml-1.5 mr-1 inline-block grow appearance-none bg-transparent text-components-input-text-filled outline-none placeholder:text-components-input-text-placeholder',
|
||||
search && 'mr-2',
|
||||
)}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.target.value)
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{
|
||||
search && (
|
||||
<ActionButton
|
||||
onClick={() => onSearchChange('')}
|
||||
className='shrink-0'
|
||||
>
|
||||
<RiCloseLine className='size-4' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<Divider type='vertical' className='mx-0 mr-0.5 h-3.5' />
|
||||
<TagsFilter
|
||||
tags={tags}
|
||||
onTagsChange={onTagsChange}
|
||||
locale={locale}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{supportAddCustomTool && (
|
||||
<div className='flex shrink-0 items-center'>
|
||||
<ActionButton
|
||||
className='ml-2 rounded-full bg-components-button-primary-bg text-components-button-primary-text hover:bg-components-button-primary-bg hover:text-components-button-primary-text'
|
||||
onClick={onShowAddCustomCollectionModal}
|
||||
>
|
||||
<RiAddLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchBox
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { useMarketplaceContext } from '../context'
|
||||
import {
|
||||
useMixedTranslation,
|
||||
useSearchBoxAutoAnimate,
|
||||
} from '../hooks'
|
||||
import SearchBox from './index'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type SearchBoxWrapperProps = {
|
||||
locale?: string
|
||||
searchBoxAutoAnimate?: boolean
|
||||
}
|
||||
const SearchBoxWrapper = ({
|
||||
locale,
|
||||
searchBoxAutoAnimate,
|
||||
}: SearchBoxWrapperProps) => {
|
||||
const { t } = useMixedTranslation(locale)
|
||||
const intersected = useMarketplaceContext(v => v.intersected)
|
||||
const searchPluginText = useMarketplaceContext(v => v.searchPluginText)
|
||||
const handleSearchPluginTextChange = useMarketplaceContext(v => v.handleSearchPluginTextChange)
|
||||
const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags)
|
||||
const handleFilterPluginTagsChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange)
|
||||
const { searchBoxCanAnimate } = useSearchBoxAutoAnimate(searchBoxAutoAnimate)
|
||||
|
||||
return (
|
||||
<SearchBox
|
||||
wrapperClassName={cn(
|
||||
'z-[0] mx-auto w-[640px] shrink-0',
|
||||
searchBoxCanAnimate && 'sticky top-3 z-[11]',
|
||||
!intersected && searchBoxCanAnimate && 'w-[508px] transition-[width] duration-300',
|
||||
)}
|
||||
inputClassName='w-full'
|
||||
search={searchPluginText}
|
||||
onSearchChange={handleSearchPluginTextChange}
|
||||
tags={filterPluginTags}
|
||||
onTagsChange={handleFilterPluginTagsChange}
|
||||
locale={locale}
|
||||
placeholder={t('plugin.searchPlugins')}
|
||||
usedInMarketplace
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchBoxWrapper
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { useTags } from '@/app/components/plugins/hooks'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
import MarketplaceTrigger from './trigger/marketplace'
|
||||
import ToolSelectorTrigger from './trigger/tool-selector'
|
||||
|
||||
type TagsFilterProps = {
|
||||
tags: string[]
|
||||
onTagsChange: (tags: string[]) => void
|
||||
usedInMarketplace?: boolean
|
||||
locale?: string
|
||||
}
|
||||
const TagsFilter = ({
|
||||
tags,
|
||||
onTagsChange,
|
||||
usedInMarketplace = false,
|
||||
locale,
|
||||
}: TagsFilterProps) => {
|
||||
const { t } = useMixedTranslation(locale)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const { tags: options, tagsMap } = useTags(t)
|
||||
const filteredOptions = options.filter(option => option.label.toLowerCase().includes(searchText.toLowerCase()))
|
||||
const handleCheck = (id: string) => {
|
||||
if (tags.includes(id))
|
||||
onTagsChange(tags.filter((tag: string) => tag !== id))
|
||||
else
|
||||
onTagsChange([...tags, id])
|
||||
}
|
||||
const selectedTagsLength = tags.length
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement='bottom-start'
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -6,
|
||||
}}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
className='shrink-0'
|
||||
onClick={() => setOpen(v => !v)}
|
||||
>
|
||||
{
|
||||
usedInMarketplace && (
|
||||
<MarketplaceTrigger
|
||||
selectedTagsLength={selectedTagsLength}
|
||||
open={open}
|
||||
tags={tags}
|
||||
tagsMap={tagsMap}
|
||||
locale={locale}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!usedInMarketplace && (
|
||||
<ToolSelectorTrigger
|
||||
selectedTagsLength={selectedTagsLength}
|
||||
open={open}
|
||||
tags={tags}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[1000]'>
|
||||
<div className='w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm'>
|
||||
<div className='p-2 pb-1'>
|
||||
<Input
|
||||
showLeftIcon
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
placeholder={t('pluginTags.searchTags') || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className='max-h-[448px] overflow-y-auto p-1'>
|
||||
{
|
||||
filteredOptions.map(option => (
|
||||
<div
|
||||
key={option.name}
|
||||
className='flex h-7 cursor-pointer select-none items-center rounded-lg px-2 py-1.5 hover:bg-state-base-hover'
|
||||
onClick={() => handleCheck(option.name)}
|
||||
>
|
||||
<Checkbox
|
||||
className='mr-1'
|
||||
checked={tags.includes(option.name)}
|
||||
/>
|
||||
<div className='system-sm-medium px-1 text-text-secondary'>
|
||||
{option.label}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default TagsFilter
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react'
|
||||
import { RiArrowDownSLine, RiCloseCircleFill, RiFilter3Line } from '@remixicon/react'
|
||||
import type { Tag } from '../../../hooks'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useMixedTranslation } from '../../hooks'
|
||||
|
||||
type MarketplaceTriggerProps = {
|
||||
selectedTagsLength: number
|
||||
open: boolean
|
||||
tags: string[]
|
||||
tagsMap: Record<string, Tag>
|
||||
locale?: string
|
||||
onTagsChange: (tags: string[]) => void
|
||||
}
|
||||
|
||||
const MarketplaceTrigger = ({
|
||||
selectedTagsLength,
|
||||
open,
|
||||
tags,
|
||||
tagsMap,
|
||||
locale,
|
||||
onTagsChange,
|
||||
}: MarketplaceTriggerProps) => {
|
||||
const { t } = useMixedTranslation(locale)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer select-none items-center rounded-lg px-2 py-1 text-text-tertiary',
|
||||
!!selectedTagsLength && 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs shadow-shadow-shadow-3',
|
||||
open && !selectedTagsLength && 'bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<div className='p-0.5'>
|
||||
<RiFilter3Line className={cn('size-4', !!selectedTagsLength && 'text-text-secondary')} />
|
||||
</div>
|
||||
<div className='system-sm-medium flex items-center gap-x-1 p-1'>
|
||||
{
|
||||
!selectedTagsLength && <span>{t('pluginTags.allTags')}</span>
|
||||
}
|
||||
{
|
||||
!!selectedTagsLength && (
|
||||
<span className='text-text-secondary'>
|
||||
{tags.map(tag => tagsMap[tag].label).slice(0, 2).join(',')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
selectedTagsLength > 2 && (
|
||||
<div className='system-xs-medium text-text-tertiary'>
|
||||
+{selectedTagsLength - 2}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
!!selectedTagsLength && (
|
||||
<RiCloseCircleFill
|
||||
className='size-4 text-text-quaternary'
|
||||
onClick={() => onTagsChange([])}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!selectedTagsLength && (
|
||||
<div className='p-0.5'>
|
||||
<RiArrowDownSLine className='size-4 text-text-tertiary' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(MarketplaceTrigger)
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react'
|
||||
import type { Tag } from '../../../hooks'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiCloseCircleFill, RiPriceTag3Line } from '@remixicon/react'
|
||||
|
||||
type ToolSelectorTriggerProps = {
|
||||
selectedTagsLength: number
|
||||
open: boolean
|
||||
tags: string[]
|
||||
tagsMap: Record<string, Tag>
|
||||
onTagsChange: (tags: string[]) => void
|
||||
}
|
||||
|
||||
const ToolSelectorTrigger = ({
|
||||
selectedTagsLength,
|
||||
open,
|
||||
tags,
|
||||
tagsMap,
|
||||
onTagsChange,
|
||||
}: ToolSelectorTriggerProps) => {
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex h-7 cursor-pointer select-none items-center rounded-md p-0.5 text-text-tertiary',
|
||||
!selectedTagsLength && 'py-1 pl-1.5 pr-2',
|
||||
!!selectedTagsLength && 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg py-0.5 pl-1 pr-1.5 shadow-xs shadow-shadow-shadow-3',
|
||||
open && !selectedTagsLength && 'bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<div className='p-0.5'>
|
||||
<RiPriceTag3Line className={cn('size-4', !!selectedTagsLength && 'text-text-secondary')} />
|
||||
</div>
|
||||
{
|
||||
!!selectedTagsLength && (
|
||||
<div className='system-sm-medium flex items-center gap-x-0.5 px-0.5 py-1'>
|
||||
<span className='text-text-secondary'>
|
||||
{tags.map(tag => tagsMap[tag].label).slice(0, 2).join(',')}
|
||||
</span>
|
||||
{
|
||||
selectedTagsLength > 2 && (
|
||||
<div className='system-xs-medium text-text-tertiary'>
|
||||
+{selectedTagsLength - 2}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!selectedTagsLength && (
|
||||
<RiCloseCircleFill
|
||||
className='size-4 text-text-quaternary'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onTagsChange([])
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ToolSelectorTrigger)
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiCheckLine,
|
||||
} from '@remixicon/react'
|
||||
import { useMarketplaceContext } from '../context'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
|
||||
|
||||
type SortDropdownProps = {
|
||||
locale?: string
|
||||
}
|
||||
const SortDropdown = ({
|
||||
locale,
|
||||
}: SortDropdownProps) => {
|
||||
const { t } = useMixedTranslation(locale)
|
||||
const options = [
|
||||
{
|
||||
value: 'install_count',
|
||||
order: 'DESC',
|
||||
text: t('plugin.marketplace.sortOption.mostPopular'),
|
||||
},
|
||||
{
|
||||
value: 'version_updated_at',
|
||||
order: 'DESC',
|
||||
text: t('plugin.marketplace.sortOption.recentlyUpdated'),
|
||||
},
|
||||
{
|
||||
value: 'created_at',
|
||||
order: 'DESC',
|
||||
text: t('plugin.marketplace.sortOption.newlyReleased'),
|
||||
},
|
||||
{
|
||||
value: 'created_at',
|
||||
order: 'ASC',
|
||||
text: t('plugin.marketplace.sortOption.firstReleased'),
|
||||
},
|
||||
]
|
||||
const sort = useMarketplaceContext(v => v.sort)
|
||||
const handleSortChange = useMarketplaceContext(v => v.handleSortChange)
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder)!
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement='bottom-start'
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 0,
|
||||
}}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<div className='flex h-8 cursor-pointer items-center rounded-lg bg-state-base-hover-alt px-2 pr-3'>
|
||||
<span className='system-sm-regular mr-1 text-text-secondary'>
|
||||
{t('plugin.marketplace.sortBy')}
|
||||
</span>
|
||||
<span className='system-sm-medium mr-1 text-text-primary'>
|
||||
{selectedOption.text}
|
||||
</span>
|
||||
<RiArrowDownSLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent>
|
||||
<div className='rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-sm'>
|
||||
{
|
||||
options.map(option => (
|
||||
<div
|
||||
key={`${option.value}-${option.order}`}
|
||||
className='system-md-regular flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 pr-2 text-text-primary hover:bg-components-panel-on-panel-item-bg-hover'
|
||||
onClick={() => handleSortChange({ sortBy: option.value, sortOrder: option.order })}
|
||||
>
|
||||
{option.text}
|
||||
{
|
||||
sort.sortBy === option.value && sort.sortOrder === option.order && (
|
||||
<RiCheckLine className='ml-2 h-4 w-4 text-text-accent' />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default SortDropdown
|
||||
59
dify/web/app/components/plugins/marketplace/types.ts
Normal file
59
dify/web/app/components/plugins/marketplace/types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { Plugin } from '../types'
|
||||
|
||||
export type SearchParamsFromCollection = {
|
||||
query?: string
|
||||
sort_by?: string
|
||||
sort_order?: string
|
||||
}
|
||||
|
||||
export type MarketplaceCollection = {
|
||||
name: string
|
||||
label: Record<string, string>
|
||||
description: Record<string, string>
|
||||
rule: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
searchable?: boolean
|
||||
search_params?: SearchParamsFromCollection
|
||||
}
|
||||
|
||||
export type MarketplaceCollectionsResponse = {
|
||||
collections: MarketplaceCollection[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export type MarketplaceCollectionPluginsResponse = {
|
||||
plugins: Plugin[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export type PluginsSearchParams = {
|
||||
query: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
sortBy?: string
|
||||
sortOrder?: string
|
||||
category?: string
|
||||
tags?: string[]
|
||||
exclude?: string[]
|
||||
type?: 'plugin' | 'bundle'
|
||||
}
|
||||
|
||||
export type PluginsSort = {
|
||||
sortBy: string
|
||||
sortOrder: string
|
||||
}
|
||||
|
||||
export type CollectionsAndPluginsSearchParams = {
|
||||
category?: string
|
||||
condition?: string
|
||||
exclude?: string[]
|
||||
type?: 'plugin' | 'bundle'
|
||||
}
|
||||
|
||||
export type SearchParams = {
|
||||
language?: string
|
||||
q?: string
|
||||
tags?: string
|
||||
category?: string
|
||||
}
|
||||
156
dify/web/app/components/plugins/marketplace/utils.ts
Normal file
156
dify/web/app/components/plugins/marketplace/utils.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import type {
|
||||
CollectionsAndPluginsSearchParams,
|
||||
MarketplaceCollection,
|
||||
PluginsSearchParams,
|
||||
} from '@/app/components/plugins/marketplace/types'
|
||||
import {
|
||||
APP_VERSION,
|
||||
IS_MARKETPLACE,
|
||||
MARKETPLACE_API_PREFIX,
|
||||
} from '@/config'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
|
||||
export const getPluginIconInMarketplace = (plugin: Plugin) => {
|
||||
if (plugin.type === 'bundle')
|
||||
return `${MARKETPLACE_API_PREFIX}/bundles/${plugin.org}/${plugin.name}/icon`
|
||||
return `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon`
|
||||
}
|
||||
|
||||
export const getFormattedPlugin = (bundle: any) => {
|
||||
if (bundle.type === 'bundle') {
|
||||
return {
|
||||
...bundle,
|
||||
icon: getPluginIconInMarketplace(bundle),
|
||||
brief: bundle.description,
|
||||
label: bundle.labels,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...bundle,
|
||||
icon: getPluginIconInMarketplace(bundle),
|
||||
}
|
||||
}
|
||||
|
||||
export const getPluginLinkInMarketplace = (plugin: Plugin, params?: Record<string, string | undefined>) => {
|
||||
if (plugin.type === 'bundle')
|
||||
return getMarketplaceUrl(`/bundles/${plugin.org}/${plugin.name}`, params)
|
||||
return getMarketplaceUrl(`/plugins/${plugin.org}/${plugin.name}`, params)
|
||||
}
|
||||
|
||||
export const getPluginDetailLinkInMarketplace = (plugin: Plugin) => {
|
||||
if (plugin.type === 'bundle')
|
||||
return `/bundles/${plugin.org}/${plugin.name}`
|
||||
return `/plugins/${plugin.org}/${plugin.name}`
|
||||
}
|
||||
|
||||
export const getMarketplacePluginsByCollectionId = async (collectionId: string, query?: CollectionsAndPluginsSearchParams) => {
|
||||
let plugins: Plugin[]
|
||||
|
||||
try {
|
||||
const url = `${MARKETPLACE_API_PREFIX}/collections/${collectionId}/plugins`
|
||||
const headers = new Headers({
|
||||
'X-Dify-Version': !IS_MARKETPLACE ? APP_VERSION : '999.0.0',
|
||||
})
|
||||
const marketplaceCollectionPluginsData = await globalThis.fetch(
|
||||
url,
|
||||
{
|
||||
cache: 'no-store',
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
category: query?.category,
|
||||
exclude: query?.exclude,
|
||||
type: query?.type,
|
||||
}),
|
||||
},
|
||||
)
|
||||
const marketplaceCollectionPluginsDataJson = await marketplaceCollectionPluginsData.json()
|
||||
plugins = marketplaceCollectionPluginsDataJson.data.plugins.map((plugin: Plugin) => {
|
||||
return getFormattedPlugin(plugin)
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (e) {
|
||||
plugins = []
|
||||
}
|
||||
|
||||
return plugins
|
||||
}
|
||||
|
||||
export const getMarketplaceCollectionsAndPlugins = async (query?: CollectionsAndPluginsSearchParams) => {
|
||||
let marketplaceCollections = [] as MarketplaceCollection[]
|
||||
let marketplaceCollectionPluginsMap = {} as Record<string, Plugin[]>
|
||||
try {
|
||||
let marketplaceUrl = `${MARKETPLACE_API_PREFIX}/collections?page=1&page_size=100`
|
||||
if (query?.condition)
|
||||
marketplaceUrl += `&condition=${query.condition}`
|
||||
if (query?.type)
|
||||
marketplaceUrl += `&type=${query.type}`
|
||||
const headers = new Headers({
|
||||
'X-Dify-Version': !IS_MARKETPLACE ? APP_VERSION : '999.0.0',
|
||||
})
|
||||
const marketplaceCollectionsData = await globalThis.fetch(marketplaceUrl, { headers, cache: 'no-store' })
|
||||
const marketplaceCollectionsDataJson = await marketplaceCollectionsData.json()
|
||||
marketplaceCollections = marketplaceCollectionsDataJson.data.collections
|
||||
await Promise.all(marketplaceCollections.map(async (collection: MarketplaceCollection) => {
|
||||
const plugins = await getMarketplacePluginsByCollectionId(collection.name, query)
|
||||
|
||||
marketplaceCollectionPluginsMap[collection.name] = plugins
|
||||
}))
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (e) {
|
||||
marketplaceCollections = []
|
||||
marketplaceCollectionPluginsMap = {}
|
||||
}
|
||||
|
||||
return {
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
}
|
||||
}
|
||||
|
||||
export const getMarketplaceListCondition = (pluginType: string) => {
|
||||
if ([PluginCategoryEnum.tool, PluginCategoryEnum.agent, PluginCategoryEnum.model, PluginCategoryEnum.datasource, PluginCategoryEnum.trigger].includes(pluginType as PluginCategoryEnum))
|
||||
return `category=${pluginType}`
|
||||
|
||||
if (pluginType === PluginCategoryEnum.extension)
|
||||
return 'category=endpoint'
|
||||
|
||||
if (pluginType === 'bundle')
|
||||
return 'type=bundle'
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export const getMarketplaceListFilterType = (category: string) => {
|
||||
if (category === PLUGIN_TYPE_SEARCH_MAP.all)
|
||||
return undefined
|
||||
|
||||
if (category === PLUGIN_TYPE_SEARCH_MAP.bundle)
|
||||
return 'bundle'
|
||||
|
||||
return 'plugin'
|
||||
}
|
||||
|
||||
export const updateSearchParams = (pluginsSearchParams: PluginsSearchParams) => {
|
||||
const { query, category, tags } = pluginsSearchParams
|
||||
const url = new URL(window.location.href)
|
||||
const categoryChanged = url.searchParams.get('category') !== category
|
||||
if (query)
|
||||
url.searchParams.set('q', query)
|
||||
else
|
||||
url.searchParams.delete('q')
|
||||
if (category)
|
||||
url.searchParams.set('category', category)
|
||||
else
|
||||
url.searchParams.delete('category')
|
||||
if (tags && tags.length)
|
||||
url.searchParams.set('tags', tags.join(','))
|
||||
else
|
||||
url.searchParams.delete('tags')
|
||||
history[`${categoryChanged ? 'pushState' : 'replaceState'}`]({}, '', url)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { ButtonProps } from '@/app/components/base/button'
|
||||
import ApiKeyModal from './api-key-modal'
|
||||
import type { PluginPayload } from '../types'
|
||||
import type { FormSchema } from '@/app/components/base/form/types'
|
||||
|
||||
export type AddApiKeyButtonProps = {
|
||||
pluginPayload: PluginPayload
|
||||
buttonVariant?: ButtonProps['variant']
|
||||
buttonText?: string
|
||||
disabled?: boolean
|
||||
onUpdate?: () => void
|
||||
formSchemas?: FormSchema[]
|
||||
}
|
||||
const AddApiKeyButton = ({
|
||||
pluginPayload,
|
||||
buttonVariant = 'secondary-accent',
|
||||
buttonText = 'Use Api Key',
|
||||
disabled,
|
||||
onUpdate,
|
||||
formSchemas = [],
|
||||
}: AddApiKeyButtonProps) => {
|
||||
const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
className='w-full'
|
||||
variant={buttonVariant}
|
||||
onClick={() => setIsApiKeyModalOpen(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
{
|
||||
isApiKeyModalOpen && (
|
||||
<ApiKeyModal
|
||||
pluginPayload={pluginPayload}
|
||||
onClose={() => setIsApiKeyModalOpen(false)}
|
||||
onUpdate={onUpdate}
|
||||
formSchemas={formSchemas}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(AddApiKeyButton)
|
||||
@@ -0,0 +1,273 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiClipboardLine,
|
||||
RiEqualizer2Line,
|
||||
RiInformation2Fill,
|
||||
} from '@remixicon/react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { ButtonProps } from '@/app/components/base/button'
|
||||
import OAuthClientSettings from './oauth-client-settings'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { PluginPayload } from '../types'
|
||||
import { openOAuthPopup } from '@/hooks/use-oauth'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import {
|
||||
useGetPluginOAuthClientSchemaHook,
|
||||
useGetPluginOAuthUrlHook,
|
||||
} from '../hooks/use-credential'
|
||||
import type { FormSchema } from '@/app/components/base/form/types'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
|
||||
export type AddOAuthButtonProps = {
|
||||
pluginPayload: PluginPayload
|
||||
buttonVariant?: ButtonProps['variant']
|
||||
buttonText?: string
|
||||
className?: string
|
||||
buttonLeftClassName?: string
|
||||
buttonRightClassName?: string
|
||||
dividerClassName?: string
|
||||
disabled?: boolean
|
||||
onUpdate?: () => void
|
||||
oAuthData?: {
|
||||
schema?: FormSchema[]
|
||||
is_oauth_custom_client_enabled?: boolean
|
||||
is_system_oauth_params_exists?: boolean
|
||||
client_params?: Record<string, any>
|
||||
redirect_uri?: string
|
||||
}
|
||||
}
|
||||
const AddOAuthButton = ({
|
||||
pluginPayload,
|
||||
buttonVariant = 'primary',
|
||||
buttonText = 'use oauth',
|
||||
className,
|
||||
buttonLeftClassName,
|
||||
buttonRightClassName,
|
||||
dividerClassName,
|
||||
disabled,
|
||||
onUpdate,
|
||||
oAuthData,
|
||||
}: AddOAuthButtonProps) => {
|
||||
const { t } = useTranslation()
|
||||
const renderI18nObject = useRenderI18nObject()
|
||||
const [isOAuthSettingsOpen, setIsOAuthSettingsOpen] = useState(false)
|
||||
const { mutateAsync: getPluginOAuthUrl } = useGetPluginOAuthUrlHook(pluginPayload)
|
||||
const { data, isLoading } = useGetPluginOAuthClientSchemaHook(pluginPayload)
|
||||
const mergedOAuthData = useMemo(() => {
|
||||
if (oAuthData)
|
||||
return oAuthData
|
||||
|
||||
return data
|
||||
}, [oAuthData, data])
|
||||
const {
|
||||
schema = [],
|
||||
is_oauth_custom_client_enabled,
|
||||
is_system_oauth_params_exists,
|
||||
client_params,
|
||||
redirect_uri,
|
||||
} = mergedOAuthData as any || {}
|
||||
const isConfigured = is_system_oauth_params_exists || is_oauth_custom_client_enabled
|
||||
const handleOAuth = useCallback(async () => {
|
||||
const { authorization_url } = await getPluginOAuthUrl()
|
||||
|
||||
if (authorization_url) {
|
||||
openOAuthPopup(
|
||||
authorization_url,
|
||||
() => onUpdate?.(),
|
||||
)
|
||||
}
|
||||
}, [getPluginOAuthUrl, onUpdate])
|
||||
|
||||
const renderCustomLabel = useCallback((item: FormSchema) => {
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<div className='mb-4 flex rounded-xl bg-background-section-burn p-4'>
|
||||
<div className='mr-3 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg'>
|
||||
<RiInformation2Fill className='h-5 w-5 text-text-accent' />
|
||||
</div>
|
||||
<div className='w-0 grow'>
|
||||
<div className='system-sm-regular mb-1.5'>
|
||||
{t('plugin.auth.clientInfo')}
|
||||
</div>
|
||||
{
|
||||
redirect_uri && (
|
||||
<div className='system-sm-medium flex w-full py-0.5'>
|
||||
<div className='w-0 grow break-words break-all'>{redirect_uri}</div>
|
||||
<ActionButton
|
||||
className='shrink-0'
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(redirect_uri || '')
|
||||
}}
|
||||
>
|
||||
<RiClipboardLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className='system-sm-medium flex h-6 items-center text-text-secondary'>
|
||||
{renderI18nObject(item.label as Record<string, string>)}
|
||||
{
|
||||
item.required && (
|
||||
<span className='ml-1 text-text-destructive-secondary'>*</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [t, redirect_uri, renderI18nObject])
|
||||
const memorizedSchemas = useMemo(() => {
|
||||
const result: FormSchema[] = (schema as FormSchema[]).map((item, index) => {
|
||||
return {
|
||||
...item,
|
||||
label: index === 0 ? renderCustomLabel(item) : item.label,
|
||||
labelClassName: index === 0 ? 'h-auto' : undefined,
|
||||
}
|
||||
})
|
||||
if (is_system_oauth_params_exists) {
|
||||
result.unshift({
|
||||
name: '__oauth_client__',
|
||||
label: t('plugin.auth.oauthClient'),
|
||||
type: FormTypeEnum.radio,
|
||||
options: [
|
||||
{
|
||||
label: t('plugin.auth.default'),
|
||||
value: 'default',
|
||||
},
|
||||
{
|
||||
label: t('plugin.auth.custom'),
|
||||
value: 'custom',
|
||||
},
|
||||
],
|
||||
required: false,
|
||||
default: is_oauth_custom_client_enabled ? 'custom' : 'default',
|
||||
} as FormSchema)
|
||||
result.forEach((item, index) => {
|
||||
if (index > 0) {
|
||||
item.show_on = [
|
||||
{
|
||||
variable: '__oauth_client__',
|
||||
value: 'custom',
|
||||
},
|
||||
]
|
||||
if (client_params)
|
||||
item.default = client_params[item.name] || item.default
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}, [schema, renderCustomLabel, t, is_system_oauth_params_exists, is_oauth_custom_client_enabled, client_params])
|
||||
|
||||
const __auth_client__ = useMemo(() => {
|
||||
if (isConfigured) {
|
||||
if (is_oauth_custom_client_enabled)
|
||||
return 'custom'
|
||||
return 'default'
|
||||
}
|
||||
else {
|
||||
if (is_system_oauth_params_exists)
|
||||
return 'default'
|
||||
return 'custom'
|
||||
}
|
||||
}, [isConfigured, is_oauth_custom_client_enabled, is_system_oauth_params_exists])
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
isConfigured && (
|
||||
<Button
|
||||
variant={buttonVariant}
|
||||
className={cn(
|
||||
'w-full px-0 py-0 hover:bg-components-button-primary-bg',
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={handleOAuth}
|
||||
>
|
||||
<div className={cn(
|
||||
'flex h-full w-0 grow items-center justify-center rounded-l-lg pl-0.5 hover:bg-components-button-primary-bg-hover',
|
||||
buttonLeftClassName,
|
||||
)}>
|
||||
<div
|
||||
className='truncate'
|
||||
title={buttonText}
|
||||
>
|
||||
{buttonText}
|
||||
</div>
|
||||
{
|
||||
is_oauth_custom_client_enabled && (
|
||||
<Badge
|
||||
className={cn(
|
||||
'ml-1 mr-0.5',
|
||||
buttonVariant === 'primary' && 'border-text-primary-on-surface bg-components-badge-bg-dimm text-text-primary-on-surface',
|
||||
)}
|
||||
>
|
||||
{t('plugin.auth.custom')}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'h-4 w-[1px] shrink-0 bg-text-primary-on-surface opacity-[0.15]',
|
||||
dividerClassName,
|
||||
)}></div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full w-8 shrink-0 items-center justify-center rounded-r-lg hover:bg-components-button-primary-bg-hover',
|
||||
buttonRightClassName,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsOAuthSettingsOpen(true)
|
||||
}}
|
||||
>
|
||||
<RiEqualizer2Line className='h-4 w-4' />
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isConfigured && (
|
||||
<Button
|
||||
variant={buttonVariant}
|
||||
onClick={() => setIsOAuthSettingsOpen(true)}
|
||||
disabled={disabled}
|
||||
className='w-full'
|
||||
>
|
||||
<RiEqualizer2Line className='mr-0.5 h-4 w-4' />
|
||||
{t('plugin.auth.setupOAuth')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
{
|
||||
isOAuthSettingsOpen && (
|
||||
<OAuthClientSettings
|
||||
pluginPayload={pluginPayload}
|
||||
onClose={() => setIsOAuthSettingsOpen(false)}
|
||||
disabled={disabled || isLoading}
|
||||
schemas={memorizedSchemas}
|
||||
onAuth={handleOAuth}
|
||||
editValues={{
|
||||
...client_params,
|
||||
__oauth_client__: __auth_client__,
|
||||
}}
|
||||
hasOriginalClientParams={Object.keys(client_params || {}).length > 0}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(AddOAuthButton)
|
||||
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Modal from '@/app/components/base/modal/modal'
|
||||
import { CredentialTypeEnum } from '../types'
|
||||
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
|
||||
import type {
|
||||
FormRefObject,
|
||||
FormSchema,
|
||||
} from '@/app/components/base/form/types'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import type { PluginPayload } from '../types'
|
||||
import {
|
||||
useAddPluginCredentialHook,
|
||||
useGetPluginCredentialSchemaHook,
|
||||
useUpdatePluginCredentialHook,
|
||||
} from '../hooks/use-credential'
|
||||
import { ReadmeEntrance } from '../../readme-panel/entrance'
|
||||
import { ReadmeShowType } from '../../readme-panel/store'
|
||||
import { EncryptedBottom } from '@/app/components/base/encrypted-bottom'
|
||||
|
||||
export type ApiKeyModalProps = {
|
||||
pluginPayload: PluginPayload
|
||||
onClose?: () => void
|
||||
editValues?: Record<string, any>
|
||||
onRemove?: () => void
|
||||
disabled?: boolean
|
||||
onUpdate?: () => void
|
||||
formSchemas?: FormSchema[]
|
||||
}
|
||||
const ApiKeyModal = ({
|
||||
pluginPayload,
|
||||
onClose,
|
||||
editValues,
|
||||
onRemove,
|
||||
disabled,
|
||||
onUpdate,
|
||||
formSchemas: formSchemasFromProps = [],
|
||||
}: ApiKeyModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const [doingAction, setDoingAction] = useState(false)
|
||||
const doingActionRef = useRef(doingAction)
|
||||
const handleSetDoingAction = useCallback((value: boolean) => {
|
||||
doingActionRef.current = value
|
||||
setDoingAction(value)
|
||||
}, [])
|
||||
const { data = [], isLoading } = useGetPluginCredentialSchemaHook(pluginPayload, CredentialTypeEnum.API_KEY)
|
||||
const mergedData = useMemo(() => {
|
||||
if (formSchemasFromProps?.length)
|
||||
return formSchemasFromProps
|
||||
|
||||
return data
|
||||
}, [formSchemasFromProps, data])
|
||||
const formSchemas = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
type: FormTypeEnum.textInput,
|
||||
name: '__name__',
|
||||
label: t('plugin.auth.authorizationName'),
|
||||
required: false,
|
||||
},
|
||||
...mergedData,
|
||||
]
|
||||
}, [mergedData, t])
|
||||
const defaultValues = formSchemas.reduce((acc, schema) => {
|
||||
if (schema.default)
|
||||
acc[schema.name] = schema.default
|
||||
return acc
|
||||
}, {} as Record<string, any>)
|
||||
const { mutateAsync: addPluginCredential } = useAddPluginCredentialHook(pluginPayload)
|
||||
const { mutateAsync: updatePluginCredential } = useUpdatePluginCredentialHook(pluginPayload)
|
||||
const formRef = useRef<FormRefObject>(null)
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (doingActionRef.current)
|
||||
return
|
||||
const {
|
||||
isCheckValidated,
|
||||
values,
|
||||
} = formRef.current?.getFormValues({
|
||||
needCheckValidatedValues: true,
|
||||
needTransformWhenSecretFieldIsPristine: true,
|
||||
}) || { isCheckValidated: false, values: {} }
|
||||
if (!isCheckValidated)
|
||||
return
|
||||
|
||||
try {
|
||||
const {
|
||||
__name__,
|
||||
__credential_id__,
|
||||
...restValues
|
||||
} = values
|
||||
|
||||
handleSetDoingAction(true)
|
||||
if (editValues) {
|
||||
await updatePluginCredential({
|
||||
credentials: restValues,
|
||||
credential_id: __credential_id__,
|
||||
name: __name__ || '',
|
||||
})
|
||||
}
|
||||
else {
|
||||
await addPluginCredential({
|
||||
credentials: restValues,
|
||||
type: CredentialTypeEnum.API_KEY,
|
||||
name: __name__ || '',
|
||||
})
|
||||
}
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
})
|
||||
|
||||
onClose?.()
|
||||
onUpdate?.()
|
||||
}
|
||||
finally {
|
||||
handleSetDoingAction(false)
|
||||
}
|
||||
}, [addPluginCredential, onClose, onUpdate, updatePluginCredential, notify, t, editValues, handleSetDoingAction])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size='md'
|
||||
title={t('plugin.auth.useApiAuth')}
|
||||
subTitle={t('plugin.auth.useApiAuthDesc')}
|
||||
onClose={onClose}
|
||||
onCancel={onClose}
|
||||
footerSlot={
|
||||
(<div></div>)
|
||||
}
|
||||
bottomSlot={<EncryptedBottom />}
|
||||
onConfirm={handleConfirm}
|
||||
showExtraButton={!!editValues}
|
||||
onExtraButtonClick={onRemove}
|
||||
disabled={disabled || isLoading || doingAction}
|
||||
clickOutsideNotClose={true}
|
||||
wrapperClassName='!z-[101]'
|
||||
>
|
||||
{pluginPayload.detail && (
|
||||
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
|
||||
)}
|
||||
{
|
||||
isLoading && (
|
||||
<div className='flex h-40 items-center justify-center'>
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isLoading && !!mergedData.length && (
|
||||
<AuthForm
|
||||
ref={formRef}
|
||||
formSchemas={formSchemas}
|
||||
defaultValues={editValues || defaultValues}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ApiKeyModal)
|
||||
138
dify/web/app/components/plugins/plugin-auth/authorize/index.tsx
Normal file
138
dify/web/app/components/plugins/plugin-auth/authorize/index.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AddOAuthButton from './add-oauth-button'
|
||||
import type { AddOAuthButtonProps } from './add-oauth-button'
|
||||
import AddApiKeyButton from './add-api-key-button'
|
||||
import type { AddApiKeyButtonProps } from './add-api-key-button'
|
||||
import type { PluginPayload } from '../types'
|
||||
import cn from '@/utils/classnames'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type AuthorizeProps = {
|
||||
pluginPayload: PluginPayload
|
||||
theme?: 'primary' | 'secondary'
|
||||
showDivider?: boolean
|
||||
canOAuth?: boolean
|
||||
canApiKey?: boolean
|
||||
disabled?: boolean
|
||||
onUpdate?: () => void
|
||||
notAllowCustomCredential?: boolean
|
||||
}
|
||||
const Authorize = ({
|
||||
pluginPayload,
|
||||
theme = 'primary',
|
||||
showDivider = true,
|
||||
canOAuth,
|
||||
canApiKey,
|
||||
disabled,
|
||||
onUpdate,
|
||||
notAllowCustomCredential,
|
||||
}: AuthorizeProps) => {
|
||||
const { t } = useTranslation()
|
||||
const oAuthButtonProps: AddOAuthButtonProps = useMemo(() => {
|
||||
if (theme === 'secondary') {
|
||||
return {
|
||||
buttonText: !canApiKey ? t('plugin.auth.useOAuthAuth') : t('plugin.auth.addOAuth'),
|
||||
buttonVariant: 'secondary',
|
||||
className: 'hover:bg-components-button-secondary-bg',
|
||||
buttonLeftClassName: 'hover:bg-components-button-secondary-bg-hover',
|
||||
buttonRightClassName: 'hover:bg-components-button-secondary-bg-hover',
|
||||
dividerClassName: 'bg-divider-regular opacity-100',
|
||||
pluginPayload,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
buttonText: !canApiKey ? t('plugin.auth.useOAuthAuth') : t('plugin.auth.addOAuth'),
|
||||
pluginPayload,
|
||||
}
|
||||
}, [canApiKey, theme, pluginPayload, t])
|
||||
|
||||
const apiKeyButtonProps: AddApiKeyButtonProps = useMemo(() => {
|
||||
if (theme === 'secondary') {
|
||||
return {
|
||||
pluginPayload,
|
||||
buttonVariant: 'secondary',
|
||||
buttonText: !canOAuth ? t('plugin.auth.useApiAuth') : t('plugin.auth.addApi'),
|
||||
}
|
||||
}
|
||||
return {
|
||||
pluginPayload,
|
||||
buttonText: !canOAuth ? t('plugin.auth.useApiAuth') : t('plugin.auth.addApi'),
|
||||
buttonVariant: !canOAuth ? 'primary' : 'secondary-accent',
|
||||
}
|
||||
}, [canOAuth, theme, pluginPayload, t])
|
||||
|
||||
const OAuthButton = useMemo(() => {
|
||||
const Item = (
|
||||
<div className={cn('min-w-0 flex-[1]', notAllowCustomCredential && 'opacity-50')}>
|
||||
<AddOAuthButton
|
||||
{...oAuthButtonProps}
|
||||
disabled={disabled || notAllowCustomCredential}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (notAllowCustomCredential) {
|
||||
return (
|
||||
<Tooltip popupContent={t('plugin.auth.credentialUnavailable')}>
|
||||
{Item}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return Item
|
||||
}, [notAllowCustomCredential, oAuthButtonProps, disabled, onUpdate, t])
|
||||
|
||||
const ApiKeyButton = useMemo(() => {
|
||||
const Item = (
|
||||
<div className={cn('min-w-0 flex-[1]', notAllowCustomCredential && 'opacity-50')}>
|
||||
<AddApiKeyButton
|
||||
{...apiKeyButtonProps}
|
||||
disabled={disabled || notAllowCustomCredential}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (notAllowCustomCredential) {
|
||||
return (
|
||||
<Tooltip popupContent={t('plugin.auth.credentialUnavailable')}>
|
||||
{Item}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return Item
|
||||
}, [notAllowCustomCredential, apiKeyButtonProps, disabled, onUpdate, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center space-x-1.5'>
|
||||
{
|
||||
canOAuth && (
|
||||
OAuthButton
|
||||
)
|
||||
}
|
||||
{
|
||||
showDivider && canOAuth && canApiKey && (
|
||||
<div className='system-2xs-medium-uppercase flex shrink-0 flex-col items-center justify-between text-text-tertiary'>
|
||||
<div className='h-2 w-[1px] bg-divider-subtle'></div>
|
||||
or
|
||||
<div className='h-2 w-[1px] bg-divider-subtle'></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
canApiKey && (
|
||||
ApiKeyButton
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Authorize)
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
useForm,
|
||||
useStore,
|
||||
} from '@tanstack/react-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Modal from '@/app/components/base/modal/modal'
|
||||
import {
|
||||
useDeletePluginOAuthCustomClientHook,
|
||||
useInvalidPluginOAuthClientSchemaHook,
|
||||
useSetPluginOAuthCustomClientHook,
|
||||
} from '../hooks/use-credential'
|
||||
import type { PluginPayload } from '../types'
|
||||
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
|
||||
import type {
|
||||
FormRefObject,
|
||||
FormSchema,
|
||||
} from '@/app/components/base/form/types'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { ReadmeEntrance } from '../../readme-panel/entrance'
|
||||
import { ReadmeShowType } from '../../readme-panel/store'
|
||||
|
||||
type OAuthClientSettingsProps = {
|
||||
pluginPayload: PluginPayload
|
||||
onClose?: () => void
|
||||
editValues?: Record<string, any>
|
||||
disabled?: boolean
|
||||
schemas: FormSchema[]
|
||||
onAuth?: () => Promise<void>
|
||||
hasOriginalClientParams?: boolean
|
||||
onUpdate?: () => void
|
||||
}
|
||||
const OAuthClientSettings = ({
|
||||
pluginPayload,
|
||||
onClose,
|
||||
editValues,
|
||||
disabled,
|
||||
schemas,
|
||||
onAuth,
|
||||
hasOriginalClientParams,
|
||||
onUpdate,
|
||||
}: OAuthClientSettingsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const [doingAction, setDoingAction] = useState(false)
|
||||
const doingActionRef = useRef(doingAction)
|
||||
const handleSetDoingAction = useCallback((value: boolean) => {
|
||||
doingActionRef.current = value
|
||||
setDoingAction(value)
|
||||
}, [])
|
||||
const defaultValues = schemas.reduce((acc, schema) => {
|
||||
if (schema.default)
|
||||
acc[schema.name] = schema.default
|
||||
return acc
|
||||
}, {} as Record<string, any>)
|
||||
const { mutateAsync: setPluginOAuthCustomClient } = useSetPluginOAuthCustomClientHook(pluginPayload)
|
||||
const invalidPluginOAuthClientSchema = useInvalidPluginOAuthClientSchemaHook(pluginPayload)
|
||||
const formRef = useRef<FormRefObject>(null)
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (doingActionRef.current)
|
||||
return
|
||||
|
||||
try {
|
||||
const {
|
||||
isCheckValidated,
|
||||
values,
|
||||
} = formRef.current?.getFormValues({
|
||||
needCheckValidatedValues: true,
|
||||
needTransformWhenSecretFieldIsPristine: true,
|
||||
}) || { isCheckValidated: false, values: {} }
|
||||
if (!isCheckValidated)
|
||||
throw new Error('error')
|
||||
const {
|
||||
__oauth_client__,
|
||||
...restValues
|
||||
} = values
|
||||
|
||||
handleSetDoingAction(true)
|
||||
await setPluginOAuthCustomClient({
|
||||
client_params: restValues,
|
||||
enable_oauth_custom_client: __oauth_client__ === 'custom',
|
||||
})
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
})
|
||||
|
||||
onClose?.()
|
||||
onUpdate?.()
|
||||
invalidPluginOAuthClientSchema()
|
||||
}
|
||||
finally {
|
||||
handleSetDoingAction(false)
|
||||
}
|
||||
}, [onClose, onUpdate, invalidPluginOAuthClientSchema, setPluginOAuthCustomClient, notify, t, handleSetDoingAction])
|
||||
|
||||
const handleConfirmAndAuthorize = useCallback(async () => {
|
||||
await handleConfirm()
|
||||
if (onAuth)
|
||||
await onAuth()
|
||||
}, [handleConfirm, onAuth])
|
||||
const { mutateAsync: deletePluginOAuthCustomClient } = useDeletePluginOAuthCustomClientHook(pluginPayload)
|
||||
const handleRemove = useCallback(async () => {
|
||||
if (doingActionRef.current)
|
||||
return
|
||||
|
||||
try {
|
||||
handleSetDoingAction(true)
|
||||
await deletePluginOAuthCustomClient()
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
})
|
||||
onClose?.()
|
||||
onUpdate?.()
|
||||
invalidPluginOAuthClientSchema()
|
||||
}
|
||||
finally {
|
||||
handleSetDoingAction(false)
|
||||
}
|
||||
}, [onUpdate, invalidPluginOAuthClientSchema, deletePluginOAuthCustomClient, notify, t, handleSetDoingAction, onClose])
|
||||
const form = useForm({
|
||||
defaultValues: editValues || defaultValues,
|
||||
})
|
||||
const __oauth_client__ = useStore(form.store, s => s.values.__oauth_client__)
|
||||
return (
|
||||
<Modal
|
||||
title={t('plugin.auth.oauthClientSettings')}
|
||||
confirmButtonText={t('plugin.auth.saveAndAuth')}
|
||||
cancelButtonText={t('plugin.auth.saveOnly')}
|
||||
extraButtonText={t('common.operation.cancel')}
|
||||
showExtraButton
|
||||
extraButtonVariant='secondary'
|
||||
onExtraButtonClick={onClose}
|
||||
onClose={onClose}
|
||||
onCancel={handleConfirm}
|
||||
onConfirm={handleConfirmAndAuthorize}
|
||||
disabled={disabled || doingAction}
|
||||
footerSlot={
|
||||
__oauth_client__ === 'custom' && hasOriginalClientParams && (
|
||||
<div className='grow'>
|
||||
<Button
|
||||
variant='secondary'
|
||||
className='text-components-button-destructive-secondary-text'
|
||||
disabled={disabled || doingAction || !editValues}
|
||||
onClick={handleRemove}
|
||||
>
|
||||
{t('common.operation.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
containerClassName='pt-0'
|
||||
wrapperClassName='!z-[101]'
|
||||
clickOutsideNotClose={true}
|
||||
>
|
||||
{pluginPayload.detail && (
|
||||
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
|
||||
)}
|
||||
<AuthForm
|
||||
formFromProps={form}
|
||||
ref={formRef}
|
||||
formSchemas={schemas}
|
||||
defaultValues={editValues || defaultValues}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(OAuthClientSettings)
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
memo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiEqualizer2Line } from '@remixicon/react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type AuthorizedInDataSourceNodeProps = {
|
||||
authorizationsNum: number
|
||||
onJumpToDataSourcePage: () => void
|
||||
}
|
||||
const AuthorizedInDataSourceNode = ({
|
||||
authorizationsNum,
|
||||
onJumpToDataSourcePage,
|
||||
}: AuthorizedInDataSourceNodeProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Button
|
||||
size='small'
|
||||
onClick={onJumpToDataSourcePage}
|
||||
>
|
||||
<Indicator
|
||||
className='mr-1.5'
|
||||
color='green'
|
||||
/>
|
||||
{
|
||||
authorizationsNum > 1
|
||||
? t('plugin.auth.authorizations')
|
||||
: t('plugin.auth.authorization')
|
||||
}
|
||||
<RiEqualizer2Line
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 text-components-button-ghost-text',
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(AuthorizedInDataSourceNode)
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import cn from '@/utils/classnames'
|
||||
import type {
|
||||
Credential,
|
||||
PluginPayload,
|
||||
} from './types'
|
||||
import {
|
||||
Authorized,
|
||||
usePluginAuth,
|
||||
} from '.'
|
||||
|
||||
type AuthorizedInNodeProps = {
|
||||
pluginPayload: PluginPayload
|
||||
onAuthorizationItemClick: (id: string) => void
|
||||
credentialId?: string
|
||||
}
|
||||
const AuthorizedInNode = ({
|
||||
pluginPayload,
|
||||
onAuthorizationItemClick,
|
||||
credentialId,
|
||||
}: AuthorizedInNodeProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const {
|
||||
canApiKey,
|
||||
canOAuth,
|
||||
credentials,
|
||||
disabled,
|
||||
invalidPluginCredentialInfo,
|
||||
notAllowCustomCredential,
|
||||
} = usePluginAuth(pluginPayload, true)
|
||||
const renderTrigger = useCallback((open?: boolean) => {
|
||||
let label = ''
|
||||
let removed = false
|
||||
let unavailable = false
|
||||
let color = 'green'
|
||||
let defaultUnavailable = false
|
||||
if (!credentialId) {
|
||||
label = t('plugin.auth.workspaceDefault')
|
||||
|
||||
const defaultCredential = credentials.find(c => c.is_default)
|
||||
|
||||
if (defaultCredential?.not_allowed_to_use) {
|
||||
color = 'gray'
|
||||
defaultUnavailable = true
|
||||
}
|
||||
}
|
||||
else {
|
||||
const credential = credentials.find(c => c.id === credentialId)
|
||||
label = credential ? credential.name : t('plugin.auth.authRemoved')
|
||||
removed = !credential
|
||||
unavailable = !!credential?.not_allowed_to_use && !credential?.from_enterprise
|
||||
|
||||
if (removed)
|
||||
color = 'red'
|
||||
else if (unavailable)
|
||||
color = 'gray'
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size='small'
|
||||
className={cn(
|
||||
open && !removed && 'bg-components-button-ghost-bg-hover',
|
||||
removed && 'bg-transparent text-text-destructive',
|
||||
)}
|
||||
variant={(defaultUnavailable || unavailable) ? 'ghost' : 'secondary'}
|
||||
>
|
||||
<Indicator
|
||||
className='mr-1.5'
|
||||
color={color as any}
|
||||
/>
|
||||
{label}
|
||||
{
|
||||
(unavailable || defaultUnavailable) && (
|
||||
<>
|
||||
|
||||
{t('plugin.auth.unavailable')}
|
||||
</>
|
||||
)
|
||||
}
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 text-components-button-ghost-text',
|
||||
removed && 'text-text-destructive',
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}, [credentialId, credentials, t])
|
||||
const defaultUnavailable = credentials.find(c => c.is_default)?.not_allowed_to_use
|
||||
const extraAuthorizationItems: Credential[] = [
|
||||
{
|
||||
id: '__workspace_default__',
|
||||
name: t('plugin.auth.workspaceDefault'),
|
||||
provider: '',
|
||||
is_default: !credentialId,
|
||||
isWorkspaceDefault: true,
|
||||
not_allowed_to_use: defaultUnavailable,
|
||||
},
|
||||
]
|
||||
const handleAuthorizationItemClick = useCallback((id: string) => {
|
||||
onAuthorizationItemClick(id)
|
||||
setIsOpen(false)
|
||||
}, [
|
||||
onAuthorizationItemClick,
|
||||
setIsOpen,
|
||||
])
|
||||
|
||||
return (
|
||||
<Authorized
|
||||
pluginPayload={pluginPayload}
|
||||
credentials={credentials}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
renderTrigger={renderTrigger}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
offset={4}
|
||||
placement='bottom-end'
|
||||
triggerPopupSameWidth={false}
|
||||
popupClassName='w-[360px]'
|
||||
disabled={disabled}
|
||||
disableSetDefault
|
||||
onItemClick={handleAuthorizationItemClick}
|
||||
extraAuthorizationItems={extraAuthorizationItems}
|
||||
showItemSelectedIcon
|
||||
selectedCredentialId={credentialId || '__workspace_default__'}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(AuthorizedInNode)
|
||||
357
dify/web/app/components/plugins/plugin-auth/authorized/index.tsx
Normal file
357
dify/web/app/components/plugins/plugin-auth/authorized/index.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import type {
|
||||
PortalToFollowElemOptions,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import cn from '@/utils/classnames'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import Authorize from '../authorize'
|
||||
import type { Credential } from '../types'
|
||||
import { CredentialTypeEnum } from '../types'
|
||||
import ApiKeyModal from '../authorize/api-key-modal'
|
||||
import Item from './item'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import type { PluginPayload } from '../types'
|
||||
import {
|
||||
useDeletePluginCredentialHook,
|
||||
useSetPluginDefaultCredentialHook,
|
||||
useUpdatePluginCredentialHook,
|
||||
} from '../hooks/use-credential'
|
||||
|
||||
type AuthorizedProps = {
|
||||
pluginPayload: PluginPayload
|
||||
credentials: Credential[]
|
||||
canOAuth?: boolean
|
||||
canApiKey?: boolean
|
||||
disabled?: boolean
|
||||
renderTrigger?: (open?: boolean) => React.ReactNode
|
||||
isOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
offset?: PortalToFollowElemOptions['offset']
|
||||
placement?: PortalToFollowElemOptions['placement']
|
||||
triggerPopupSameWidth?: boolean
|
||||
popupClassName?: string
|
||||
disableSetDefault?: boolean
|
||||
onItemClick?: (id: string) => void
|
||||
extraAuthorizationItems?: Credential[]
|
||||
showItemSelectedIcon?: boolean
|
||||
selectedCredentialId?: string
|
||||
onUpdate?: () => void
|
||||
notAllowCustomCredential?: boolean
|
||||
}
|
||||
const Authorized = ({
|
||||
pluginPayload,
|
||||
credentials,
|
||||
canOAuth,
|
||||
canApiKey,
|
||||
disabled,
|
||||
renderTrigger,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
offset = 8,
|
||||
placement = 'bottom-start',
|
||||
triggerPopupSameWidth = true,
|
||||
popupClassName,
|
||||
disableSetDefault,
|
||||
onItemClick,
|
||||
extraAuthorizationItems,
|
||||
showItemSelectedIcon,
|
||||
selectedCredentialId,
|
||||
onUpdate,
|
||||
notAllowCustomCredential,
|
||||
}: AuthorizedProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const [isLocalOpen, setIsLocalOpen] = useState(false)
|
||||
const mergedIsOpen = isOpen ?? isLocalOpen
|
||||
const setMergedIsOpen = useCallback((open: boolean) => {
|
||||
if (onOpenChange)
|
||||
onOpenChange(open)
|
||||
|
||||
setIsLocalOpen(open)
|
||||
}, [onOpenChange])
|
||||
const oAuthCredentials = credentials.filter(credential => credential.credential_type === CredentialTypeEnum.OAUTH2)
|
||||
const apiKeyCredentials = credentials.filter(credential => credential.credential_type === CredentialTypeEnum.API_KEY)
|
||||
const pendingOperationCredentialId = useRef<string | null>(null)
|
||||
const [deleteCredentialId, setDeleteCredentialId] = useState<string | null>(null)
|
||||
const { mutateAsync: deletePluginCredential } = useDeletePluginCredentialHook(pluginPayload)
|
||||
const openConfirm = useCallback((credentialId?: string) => {
|
||||
if (credentialId)
|
||||
pendingOperationCredentialId.current = credentialId
|
||||
|
||||
setDeleteCredentialId(pendingOperationCredentialId.current)
|
||||
}, [])
|
||||
const closeConfirm = useCallback(() => {
|
||||
setDeleteCredentialId(null)
|
||||
pendingOperationCredentialId.current = null
|
||||
}, [])
|
||||
const [doingAction, setDoingAction] = useState(false)
|
||||
const doingActionRef = useRef(doingAction)
|
||||
const handleSetDoingAction = useCallback((doing: boolean) => {
|
||||
doingActionRef.current = doing
|
||||
setDoingAction(doing)
|
||||
}, [])
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (doingActionRef.current)
|
||||
return
|
||||
if (!pendingOperationCredentialId.current) {
|
||||
setDeleteCredentialId(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
handleSetDoingAction(true)
|
||||
await deletePluginCredential({ credential_id: pendingOperationCredentialId.current })
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
})
|
||||
onUpdate?.()
|
||||
setDeleteCredentialId(null)
|
||||
pendingOperationCredentialId.current = null
|
||||
}
|
||||
finally {
|
||||
handleSetDoingAction(false)
|
||||
}
|
||||
}, [deletePluginCredential, onUpdate, notify, t, handleSetDoingAction])
|
||||
const [editValues, setEditValues] = useState<Record<string, any> | null>(null)
|
||||
const handleEdit = useCallback((id: string, values: Record<string, any>) => {
|
||||
pendingOperationCredentialId.current = id
|
||||
setEditValues(values)
|
||||
}, [])
|
||||
const handleRemove = useCallback(() => {
|
||||
setDeleteCredentialId(pendingOperationCredentialId.current)
|
||||
}, [])
|
||||
const { mutateAsync: setPluginDefaultCredential } = useSetPluginDefaultCredentialHook(pluginPayload)
|
||||
const handleSetDefault = useCallback(async (id: string) => {
|
||||
if (doingActionRef.current)
|
||||
return
|
||||
try {
|
||||
handleSetDoingAction(true)
|
||||
await setPluginDefaultCredential(id)
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
})
|
||||
onUpdate?.()
|
||||
}
|
||||
finally {
|
||||
handleSetDoingAction(false)
|
||||
}
|
||||
}, [setPluginDefaultCredential, onUpdate, notify, t, handleSetDoingAction])
|
||||
const { mutateAsync: updatePluginCredential } = useUpdatePluginCredentialHook(pluginPayload)
|
||||
const handleRename = useCallback(async (payload: {
|
||||
credential_id: string
|
||||
name: string
|
||||
}) => {
|
||||
if (doingActionRef.current)
|
||||
return
|
||||
try {
|
||||
handleSetDoingAction(true)
|
||||
await updatePluginCredential(payload)
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
})
|
||||
onUpdate?.()
|
||||
}
|
||||
finally {
|
||||
handleSetDoingAction(false)
|
||||
}
|
||||
}, [updatePluginCredential, notify, t, handleSetDoingAction, onUpdate])
|
||||
const unavailableCredentials = credentials.filter(credential => credential.not_allowed_to_use)
|
||||
const unavailableCredential = credentials.find(credential => credential.not_allowed_to_use && credential.is_default)
|
||||
|
||||
return (
|
||||
<>
|
||||
<PortalToFollowElem
|
||||
open={mergedIsOpen}
|
||||
onOpenChange={setMergedIsOpen}
|
||||
placement={placement}
|
||||
offset={offset}
|
||||
triggerPopupSameWidth={triggerPopupSameWidth}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setMergedIsOpen(!mergedIsOpen)}
|
||||
asChild
|
||||
>
|
||||
{
|
||||
renderTrigger
|
||||
? renderTrigger(mergedIsOpen)
|
||||
: (
|
||||
<Button
|
||||
className={cn(
|
||||
'w-full',
|
||||
isOpen && 'bg-components-button-secondary-bg-hover',
|
||||
)}>
|
||||
<Indicator className='mr-2' color={unavailableCredential ? 'gray' : 'green'} />
|
||||
{credentials.length}
|
||||
{
|
||||
credentials.length > 1
|
||||
? t('plugin.auth.authorizations')
|
||||
: t('plugin.auth.authorization')
|
||||
}
|
||||
{
|
||||
!!unavailableCredentials.length && (
|
||||
` (${unavailableCredentials.length} ${t('plugin.auth.unavailable')})`
|
||||
)
|
||||
}
|
||||
<RiArrowDownSLine className='ml-0.5 h-4 w-4' />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[100]'>
|
||||
<div className={cn(
|
||||
'max-h-[360px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg',
|
||||
popupClassName,
|
||||
)}>
|
||||
<div className='py-1'>
|
||||
{
|
||||
!!extraAuthorizationItems?.length && (
|
||||
<div className='p-1'>
|
||||
{
|
||||
extraAuthorizationItems.map(credential => (
|
||||
<Item
|
||||
key={credential.id}
|
||||
credential={credential}
|
||||
disabled={disabled}
|
||||
onItemClick={onItemClick}
|
||||
disableRename
|
||||
disableEdit
|
||||
disableDelete
|
||||
disableSetDefault
|
||||
showSelectedIcon={showItemSelectedIcon}
|
||||
selectedCredentialId={selectedCredentialId}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!oAuthCredentials.length && (
|
||||
<div className='p-1'>
|
||||
<div className={cn(
|
||||
'system-xs-medium px-3 pb-0.5 pt-1 text-text-tertiary',
|
||||
showItemSelectedIcon && 'pl-7',
|
||||
)}>
|
||||
OAuth
|
||||
</div>
|
||||
{
|
||||
oAuthCredentials.map(credential => (
|
||||
<Item
|
||||
key={credential.id}
|
||||
credential={credential}
|
||||
disabled={disabled}
|
||||
disableEdit
|
||||
onDelete={openConfirm}
|
||||
onSetDefault={handleSetDefault}
|
||||
onRename={handleRename}
|
||||
disableSetDefault={disableSetDefault}
|
||||
onItemClick={onItemClick}
|
||||
showSelectedIcon={showItemSelectedIcon}
|
||||
selectedCredentialId={selectedCredentialId}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!apiKeyCredentials.length && (
|
||||
<div className='p-1'>
|
||||
<div className={cn(
|
||||
'system-xs-medium px-3 pb-0.5 pt-1 text-text-tertiary',
|
||||
showItemSelectedIcon && 'pl-7',
|
||||
)}>
|
||||
API Keys
|
||||
</div>
|
||||
{
|
||||
apiKeyCredentials.map(credential => (
|
||||
<Item
|
||||
key={credential.id}
|
||||
credential={credential}
|
||||
disabled={disabled}
|
||||
onDelete={openConfirm}
|
||||
onEdit={handleEdit}
|
||||
onSetDefault={handleSetDefault}
|
||||
disableSetDefault={disableSetDefault}
|
||||
disableRename
|
||||
onItemClick={onItemClick}
|
||||
onRename={handleRename}
|
||||
showSelectedIcon={showItemSelectedIcon}
|
||||
selectedCredentialId={selectedCredentialId}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
!notAllowCustomCredential && (
|
||||
<>
|
||||
<div className='h-[1px] bg-divider-subtle'></div>
|
||||
<div className='p-2'>
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
theme='secondary'
|
||||
showDivider={false}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
{
|
||||
deleteCredentialId && (
|
||||
<Confirm
|
||||
isShow
|
||||
title={t('datasetDocuments.list.delete.title')}
|
||||
isDisabled={doingAction}
|
||||
onCancel={closeConfirm}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!editValues && (
|
||||
<ApiKeyModal
|
||||
pluginPayload={pluginPayload}
|
||||
editValues={editValues}
|
||||
onClose={() => {
|
||||
setEditValues(null)
|
||||
pendingOperationCredentialId.current = null
|
||||
}}
|
||||
onRemove={handleRemove}
|
||||
disabled={disabled || doingAction}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Authorized)
|
||||
246
dify/web/app/components/plugins/plugin-auth/authorized/item.tsx
Normal file
246
dify/web/app/components/plugins/plugin-auth/authorized/item.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiCheckLine,
|
||||
RiDeleteBinLine,
|
||||
RiEditLine,
|
||||
RiEqualizer2Line,
|
||||
} from '@remixicon/react'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { Credential } from '../types'
|
||||
import { CredentialTypeEnum } from '../types'
|
||||
|
||||
type ItemProps = {
|
||||
credential: Credential
|
||||
disabled?: boolean
|
||||
onDelete?: (id: string) => void
|
||||
onEdit?: (id: string, values: Record<string, any>) => void
|
||||
onSetDefault?: (id: string) => void
|
||||
onRename?: (payload: {
|
||||
credential_id: string
|
||||
name: string
|
||||
}) => void
|
||||
disableRename?: boolean
|
||||
disableEdit?: boolean
|
||||
disableDelete?: boolean
|
||||
disableSetDefault?: boolean
|
||||
onItemClick?: (id: string) => void
|
||||
showSelectedIcon?: boolean
|
||||
selectedCredentialId?: string
|
||||
}
|
||||
const Item = ({
|
||||
credential,
|
||||
disabled,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onSetDefault,
|
||||
onRename,
|
||||
disableRename,
|
||||
disableEdit,
|
||||
disableDelete,
|
||||
disableSetDefault,
|
||||
onItemClick,
|
||||
showSelectedIcon,
|
||||
selectedCredentialId,
|
||||
}: ItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
const [renameValue, setRenameValue] = useState(credential.name)
|
||||
const isOAuth = credential.credential_type === CredentialTypeEnum.OAUTH2
|
||||
const showAction = useMemo(() => {
|
||||
return !(disableRename && disableEdit && disableDelete && disableSetDefault)
|
||||
}, [disableRename, disableEdit, disableDelete, disableSetDefault])
|
||||
|
||||
const CredentialItem = (
|
||||
<div
|
||||
key={credential.id}
|
||||
className={cn(
|
||||
'group flex h-8 items-center rounded-lg p-1 hover:bg-state-base-hover',
|
||||
renaming && 'bg-state-base-hover',
|
||||
(disabled || credential.not_allowed_to_use) && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (credential.not_allowed_to_use || disabled)
|
||||
return
|
||||
onItemClick?.(credential.id === '__workspace_default__' ? '' : credential.id)
|
||||
}}
|
||||
>
|
||||
{
|
||||
renaming && (
|
||||
<div className='flex w-full items-center space-x-1'>
|
||||
<Input
|
||||
wrapperClassName='grow rounded-[6px]'
|
||||
className='h-6'
|
||||
value={renameValue}
|
||||
onChange={e => setRenameValue(e.target.value)}
|
||||
placeholder={t('common.placeholder.input')}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
<Button
|
||||
size='small'
|
||||
variant='primary'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRename?.({
|
||||
credential_id: credential.id,
|
||||
name: renameValue,
|
||||
})
|
||||
setRenaming(false)
|
||||
}}
|
||||
>
|
||||
{t('common.operation.save')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setRenaming(false)
|
||||
}}
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!renaming && (
|
||||
<div className='flex w-0 grow items-center space-x-1.5'>
|
||||
{
|
||||
showSelectedIcon && (
|
||||
<div className='h-4 w-4'>
|
||||
{
|
||||
selectedCredentialId === credential.id && (
|
||||
<RiCheckLine className='h-4 w-4 text-text-accent' />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<Indicator
|
||||
className='ml-2 mr-1.5 shrink-0'
|
||||
color={credential.not_allowed_to_use ? 'gray' : 'green'}
|
||||
/>
|
||||
<div
|
||||
className='system-md-regular truncate text-text-secondary'
|
||||
title={credential.name}
|
||||
>
|
||||
{credential.name}
|
||||
</div>
|
||||
{
|
||||
credential.is_default && (
|
||||
<Badge className='shrink-0'>
|
||||
{t('plugin.auth.default')}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
credential.from_enterprise && (
|
||||
<Badge className='shrink-0'>
|
||||
Enterprise
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
{
|
||||
showAction && !renaming && (
|
||||
<div className='ml-2 hidden shrink-0 items-center group-hover:flex'>
|
||||
{
|
||||
!credential.is_default && !disableSetDefault && !credential.not_allowed_to_use && (
|
||||
<Button
|
||||
size='small'
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSetDefault?.(credential.id)
|
||||
}}
|
||||
>
|
||||
{t('plugin.auth.setDefault')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
{
|
||||
!disableRename && !credential.from_enterprise && !credential.not_allowed_to_use && (
|
||||
<Tooltip popupContent={t('common.operation.rename')}>
|
||||
<ActionButton
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setRenaming(true)
|
||||
setRenameValue(credential.name)
|
||||
}}
|
||||
>
|
||||
<RiEditLine className='h-4 w-4 text-text-tertiary' />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isOAuth && !disableEdit && !credential.from_enterprise && !credential.not_allowed_to_use && (
|
||||
<Tooltip popupContent={t('common.operation.edit')}>
|
||||
<ActionButton
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit?.(
|
||||
credential.id,
|
||||
{
|
||||
...credential.credentials,
|
||||
__name__: credential.name,
|
||||
__credential_id__: credential.id,
|
||||
},
|
||||
)
|
||||
}}
|
||||
>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
{
|
||||
!disableDelete && !credential.from_enterprise && (
|
||||
<Tooltip popupContent={t('common.operation.delete')}>
|
||||
<ActionButton
|
||||
className='hover:bg-transparent'
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete?.(credential.id)
|
||||
}}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4 text-text-tertiary hover:text-text-destructive' />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (credential.not_allowed_to_use) {
|
||||
return (
|
||||
<Tooltip popupContent={t('plugin.auth.customCredentialUnavailable')}>
|
||||
{CredentialItem}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
CredentialItem
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Item)
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
useAddPluginCredential,
|
||||
useDeletePluginCredential,
|
||||
useDeletePluginOAuthCustomClient,
|
||||
useGetPluginCredentialInfo,
|
||||
useGetPluginCredentialSchema,
|
||||
useGetPluginOAuthClientSchema,
|
||||
useGetPluginOAuthUrl,
|
||||
useInvalidPluginCredentialInfo,
|
||||
useInvalidPluginOAuthClientSchema,
|
||||
useSetPluginDefaultCredential,
|
||||
useSetPluginOAuthCustomClient,
|
||||
useUpdatePluginCredential,
|
||||
} from '@/service/use-plugins-auth'
|
||||
import { useGetApi } from './use-get-api'
|
||||
import type { PluginPayload } from '../types'
|
||||
import type { CredentialTypeEnum } from '../types'
|
||||
import { useInvalidToolsByType } from '@/service/use-tools'
|
||||
|
||||
export const useGetPluginCredentialInfoHook = (pluginPayload: PluginPayload, enable?: boolean) => {
|
||||
const apiMap = useGetApi(pluginPayload)
|
||||
return useGetPluginCredentialInfo(enable ? apiMap.getCredentialInfo : '')
|
||||
}
|
||||
|
||||
export const useDeletePluginCredentialHook = (pluginPayload: PluginPayload) => {
|
||||
const apiMap = useGetApi(pluginPayload)
|
||||
|
||||
return useDeletePluginCredential(apiMap.deleteCredential)
|
||||
}
|
||||
|
||||
export const useInvalidPluginCredentialInfoHook = (pluginPayload: PluginPayload) => {
|
||||
const apiMap = useGetApi(pluginPayload)
|
||||
const invalidPluginCredentialInfo = useInvalidPluginCredentialInfo(apiMap.getCredentialInfo)
|
||||
const providerType = pluginPayload.providerType
|
||||
const invalidToolsByType = useInvalidToolsByType(providerType)
|
||||
|
||||
return () => {
|
||||
invalidPluginCredentialInfo()
|
||||
invalidToolsByType()
|
||||
}
|
||||
}
|
||||
|
||||
export const useSetPluginDefaultCredentialHook = (pluginPayload: PluginPayload) => {
|
||||
const apiMap = useGetApi(pluginPayload)
|
||||
|
||||
return useSetPluginDefaultCredential(apiMap.setDefaultCredential)
|
||||
}
|
||||
|
||||
export const useGetPluginCredentialSchemaHook = (pluginPayload: PluginPayload, credentialType: CredentialTypeEnum) => {
|
||||
const apiMap = useGetApi(pluginPayload)
|
||||
|
||||
return useGetPluginCredentialSchema(apiMap.getCredentialSchema(credentialType))
|
||||
}
|
||||
|
||||
export const useAddPluginCredentialHook = (pluginPayload: PluginPayload) => {
|
||||
const apiMap = useGetApi(pluginPayload)
|
||||
|
||||
return useAddPluginCredential(apiMap.addCredential)
|
||||
}
|
||||
|
||||
export const useUpdatePluginCredentialHook = (pluginPayload: PluginPayload) => {
|
||||
const apiMap = useGetApi(pluginPayload)
|
||||
|
||||
return useUpdatePluginCredential(apiMap.updateCredential)
|
||||
}
|
||||
|
||||
export const useGetPluginOAuthUrlHook = (pluginPayload: PluginPayload) => {
|
||||
const apiMap = useGetApi(pluginPayload)
|
||||
|
||||
return useGetPluginOAuthUrl(apiMap.getOauthUrl)
|
||||
}
|
||||
|
||||
export const useGetPluginOAuthClientSchemaHook = (pluginPayload: PluginPayload) => {
|
||||
const apiMap = useGetApi(pluginPayload)
|
||||
|
||||
return useGetPluginOAuthClientSchema(apiMap.getOauthClientSchema)
|
||||
}
|
||||
|
||||
export const useInvalidPluginOAuthClientSchemaHook = (pluginPayload: PluginPayload) => {
|
||||
const apiMap = useGetApi(pluginPayload)
|
||||
|
||||
return useInvalidPluginOAuthClientSchema(apiMap.getOauthClientSchema)
|
||||
}
|
||||
|
||||
export const useSetPluginOAuthCustomClientHook = (pluginPayload: PluginPayload) => {
|
||||
const apiMap = useGetApi(pluginPayload)
|
||||
|
||||
return useSetPluginOAuthCustomClient(apiMap.setCustomOauthClient)
|
||||
}
|
||||
|
||||
export const useDeletePluginOAuthCustomClientHook = (pluginPayload: PluginPayload) => {
|
||||
const apiMap = useGetApi(pluginPayload)
|
||||
|
||||
return useDeletePluginOAuthCustomClient(apiMap.deleteCustomOAuthClient)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
AuthCategory,
|
||||
} from '../types'
|
||||
import type {
|
||||
CredentialTypeEnum,
|
||||
PluginPayload,
|
||||
} from '../types'
|
||||
|
||||
export const useGetApi = ({ category = AuthCategory.tool, provider }: PluginPayload) => {
|
||||
if (category === AuthCategory.tool) {
|
||||
return {
|
||||
getCredentialInfo: `/workspaces/current/tool-provider/builtin/${provider}/credential/info`,
|
||||
setDefaultCredential: `/workspaces/current/tool-provider/builtin/${provider}/default-credential`,
|
||||
getCredentials: `/workspaces/current/tool-provider/builtin/${provider}/credentials`,
|
||||
addCredential: `/workspaces/current/tool-provider/builtin/${provider}/add`,
|
||||
updateCredential: `/workspaces/current/tool-provider/builtin/${provider}/update`,
|
||||
deleteCredential: `/workspaces/current/tool-provider/builtin/${provider}/delete`,
|
||||
getCredentialSchema: (credential_type: CredentialTypeEnum) => `/workspaces/current/tool-provider/builtin/${provider}/credential/schema/${credential_type}`,
|
||||
getOauthUrl: `/oauth/plugin/${provider}/tool/authorization-url`,
|
||||
getOauthClientSchema: `/workspaces/current/tool-provider/builtin/${provider}/oauth/client-schema`,
|
||||
setCustomOauthClient: `/workspaces/current/tool-provider/builtin/${provider}/oauth/custom-client`,
|
||||
getCustomOAuthClientValues: `/workspaces/current/tool-provider/builtin/${provider}/oauth/custom-client`,
|
||||
deleteCustomOAuthClient: `/workspaces/current/tool-provider/builtin/${provider}/oauth/custom-client`,
|
||||
}
|
||||
}
|
||||
|
||||
if (category === AuthCategory.datasource) {
|
||||
return {
|
||||
getCredentialInfo: '',
|
||||
setDefaultCredential: `/auth/plugin/datasource/${provider}/default`,
|
||||
getCredentials: `/auth/plugin/datasource/${provider}`,
|
||||
addCredential: `/auth/plugin/datasource/${provider}`,
|
||||
updateCredential: `/auth/plugin/datasource/${provider}/update`,
|
||||
deleteCredential: `/auth/plugin/datasource/${provider}/delete`,
|
||||
getCredentialSchema: () => '',
|
||||
getOauthUrl: `/oauth/plugin/${provider}/datasource/get-authorization-url`,
|
||||
getOauthClientSchema: '',
|
||||
setCustomOauthClient: `/auth/plugin/datasource/${provider}/custom-client`,
|
||||
deleteCustomOAuthClient: `/auth/plugin/datasource/${provider}/custom-client`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getCredentialInfo: '',
|
||||
setDefaultCredential: '',
|
||||
getCredentials: '',
|
||||
addCredential: '',
|
||||
updateCredential: '',
|
||||
deleteCredential: '',
|
||||
getCredentialSchema: () => '',
|
||||
getOauthUrl: '',
|
||||
getOauthClientSchema: '',
|
||||
setCustomOauthClient: '',
|
||||
getCustomOAuthClientValues: '',
|
||||
deleteCustomOAuthClient: '',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import type { PluginPayload } from '../types'
|
||||
import {
|
||||
useDeletePluginCredentialHook,
|
||||
useSetPluginDefaultCredentialHook,
|
||||
useUpdatePluginCredentialHook,
|
||||
} from '../hooks/use-credential'
|
||||
|
||||
export const usePluginAuthAction = (
|
||||
pluginPayload: PluginPayload,
|
||||
onUpdate?: () => void,
|
||||
) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const pendingOperationCredentialId = useRef<string | null>(null)
|
||||
const [deleteCredentialId, setDeleteCredentialId] = useState<string | null>(null)
|
||||
const { mutateAsync: deletePluginCredential } = useDeletePluginCredentialHook(pluginPayload)
|
||||
const openConfirm = useCallback((credentialId?: string) => {
|
||||
if (credentialId)
|
||||
pendingOperationCredentialId.current = credentialId
|
||||
|
||||
setDeleteCredentialId(pendingOperationCredentialId.current)
|
||||
}, [])
|
||||
const closeConfirm = useCallback(() => {
|
||||
setDeleteCredentialId(null)
|
||||
pendingOperationCredentialId.current = null
|
||||
}, [])
|
||||
const [doingAction, setDoingAction] = useState(false)
|
||||
const doingActionRef = useRef(doingAction)
|
||||
const handleSetDoingAction = useCallback((doing: boolean) => {
|
||||
doingActionRef.current = doing
|
||||
setDoingAction(doing)
|
||||
}, [])
|
||||
const [editValues, setEditValues] = useState<Record<string, any> | null>(null)
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (doingActionRef.current)
|
||||
return
|
||||
if (!pendingOperationCredentialId.current) {
|
||||
setDeleteCredentialId(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
handleSetDoingAction(true)
|
||||
await deletePluginCredential({ credential_id: pendingOperationCredentialId.current })
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
})
|
||||
onUpdate?.()
|
||||
setDeleteCredentialId(null)
|
||||
pendingOperationCredentialId.current = null
|
||||
setEditValues(null)
|
||||
}
|
||||
finally {
|
||||
handleSetDoingAction(false)
|
||||
}
|
||||
}, [deletePluginCredential, onUpdate, notify, t, handleSetDoingAction])
|
||||
const handleEdit = useCallback((id: string, values: Record<string, any>) => {
|
||||
pendingOperationCredentialId.current = id
|
||||
setEditValues(values)
|
||||
}, [])
|
||||
const handleRemove = useCallback(() => {
|
||||
setDeleteCredentialId(pendingOperationCredentialId.current)
|
||||
}, [])
|
||||
const { mutateAsync: setPluginDefaultCredential } = useSetPluginDefaultCredentialHook(pluginPayload)
|
||||
const handleSetDefault = useCallback(async (id: string) => {
|
||||
if (doingActionRef.current)
|
||||
return
|
||||
try {
|
||||
handleSetDoingAction(true)
|
||||
await setPluginDefaultCredential(id)
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
})
|
||||
onUpdate?.()
|
||||
}
|
||||
finally {
|
||||
handleSetDoingAction(false)
|
||||
}
|
||||
}, [setPluginDefaultCredential, onUpdate, notify, t, handleSetDoingAction])
|
||||
const { mutateAsync: updatePluginCredential } = useUpdatePluginCredentialHook(pluginPayload)
|
||||
const handleRename = useCallback(async (payload: {
|
||||
credential_id: string
|
||||
name: string
|
||||
}) => {
|
||||
if (doingActionRef.current)
|
||||
return
|
||||
try {
|
||||
handleSetDoingAction(true)
|
||||
await updatePluginCredential(payload)
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
})
|
||||
onUpdate?.()
|
||||
}
|
||||
finally {
|
||||
handleSetDoingAction(false)
|
||||
}
|
||||
}, [updatePluginCredential, notify, t, handleSetDoingAction, onUpdate])
|
||||
|
||||
return {
|
||||
doingAction,
|
||||
handleSetDoingAction,
|
||||
openConfirm,
|
||||
closeConfirm,
|
||||
deleteCredentialId,
|
||||
setDeleteCredentialId,
|
||||
handleConfirm,
|
||||
editValues,
|
||||
setEditValues,
|
||||
handleEdit,
|
||||
handleRemove,
|
||||
handleSetDefault,
|
||||
handleRename,
|
||||
pendingOperationCredentialId,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import {
|
||||
useGetPluginCredentialInfoHook,
|
||||
useInvalidPluginCredentialInfoHook,
|
||||
} from './use-credential'
|
||||
import { CredentialTypeEnum } from '../types'
|
||||
import type { PluginPayload } from '../types'
|
||||
|
||||
export const usePluginAuth = (pluginPayload: PluginPayload, enable?: boolean) => {
|
||||
const { data } = useGetPluginCredentialInfoHook(pluginPayload, enable)
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
const isAuthorized = !!data?.credentials.length
|
||||
const canOAuth = data?.supported_credential_types.includes(CredentialTypeEnum.OAUTH2)
|
||||
const canApiKey = data?.supported_credential_types.includes(CredentialTypeEnum.API_KEY)
|
||||
const invalidPluginCredentialInfo = useInvalidPluginCredentialInfoHook(pluginPayload)
|
||||
|
||||
return {
|
||||
isAuthorized,
|
||||
canOAuth,
|
||||
canApiKey,
|
||||
credentials: data?.credentials || [],
|
||||
disabled: !isCurrentWorkspaceManager,
|
||||
notAllowCustomCredential: data?.allow_custom_token === false,
|
||||
invalidPluginCredentialInfo,
|
||||
}
|
||||
}
|
||||
12
dify/web/app/components/plugins/plugin-auth/index.tsx
Normal file
12
dify/web/app/components/plugins/plugin-auth/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export { default as PluginAuth } from './plugin-auth'
|
||||
export { default as Authorized } from './authorized'
|
||||
export { default as AuthorizedInNode } from './authorized-in-node'
|
||||
export { default as PluginAuthInAgent } from './plugin-auth-in-agent'
|
||||
export { usePluginAuth } from './hooks/use-plugin-auth'
|
||||
export { default as PluginAuthInDataSourceNode } from './plugin-auth-in-datasource-node'
|
||||
export { default as AuthorizedInDataSourceNode } from './authorized-in-data-source-node'
|
||||
export { default as AddOAuthButton } from './authorize/add-oauth-button'
|
||||
export { default as AddApiKeyButton } from './authorize/add-api-key-button'
|
||||
export { default as ApiKeyModal } from './authorize/api-key-modal'
|
||||
export * from './hooks/use-plugin-auth-action'
|
||||
export * from './types'
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Authorize from './authorize'
|
||||
import Authorized from './authorized'
|
||||
import type {
|
||||
Credential,
|
||||
PluginPayload,
|
||||
} from './types'
|
||||
import { usePluginAuth } from './hooks/use-plugin-auth'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type PluginAuthInAgentProps = {
|
||||
pluginPayload: PluginPayload
|
||||
credentialId?: string
|
||||
onAuthorizationItemClick?: (id: string) => void
|
||||
}
|
||||
const PluginAuthInAgent = ({
|
||||
pluginPayload,
|
||||
credentialId,
|
||||
onAuthorizationItemClick,
|
||||
}: PluginAuthInAgentProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const {
|
||||
isAuthorized,
|
||||
canOAuth,
|
||||
canApiKey,
|
||||
credentials,
|
||||
disabled,
|
||||
invalidPluginCredentialInfo,
|
||||
notAllowCustomCredential,
|
||||
} = usePluginAuth(pluginPayload, true)
|
||||
|
||||
const extraAuthorizationItems: Credential[] = [
|
||||
{
|
||||
id: '__workspace_default__',
|
||||
name: t('plugin.auth.workspaceDefault'),
|
||||
provider: '',
|
||||
is_default: !credentialId,
|
||||
isWorkspaceDefault: true,
|
||||
},
|
||||
]
|
||||
|
||||
const handleAuthorizationItemClick = useCallback((id: string) => {
|
||||
onAuthorizationItemClick?.(id)
|
||||
setIsOpen(false)
|
||||
}, [
|
||||
onAuthorizationItemClick,
|
||||
setIsOpen,
|
||||
])
|
||||
|
||||
const renderTrigger = useCallback((isOpen?: boolean) => {
|
||||
let label = ''
|
||||
let removed = false
|
||||
let unavailable = false
|
||||
let color = 'green'
|
||||
if (!credentialId) {
|
||||
label = t('plugin.auth.workspaceDefault')
|
||||
}
|
||||
else {
|
||||
const credential = credentials.find(c => c.id === credentialId)
|
||||
label = credential ? credential.name : t('plugin.auth.authRemoved')
|
||||
removed = !credential
|
||||
unavailable = !!credential?.not_allowed_to_use && !credential?.from_enterprise
|
||||
if (removed)
|
||||
color = 'red'
|
||||
else if (unavailable)
|
||||
color = 'gray'
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'w-full',
|
||||
isOpen && 'bg-components-button-secondary-bg-hover',
|
||||
removed && 'text-text-destructive',
|
||||
)}>
|
||||
<Indicator
|
||||
className='mr-2'
|
||||
color={color as any}
|
||||
/>
|
||||
{label}
|
||||
{
|
||||
unavailable && t('plugin.auth.unavailable')
|
||||
}
|
||||
<RiArrowDownSLine className='ml-0.5 h-4 w-4' />
|
||||
</Button>
|
||||
)
|
||||
}, [credentialId, credentials, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
!isAuthorized && (
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
isAuthorized && (
|
||||
<Authorized
|
||||
pluginPayload={pluginPayload}
|
||||
credentials={credentials}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled}
|
||||
disableSetDefault
|
||||
onItemClick={handleAuthorizationItemClick}
|
||||
extraAuthorizationItems={extraAuthorizationItems}
|
||||
showItemSelectedIcon
|
||||
renderTrigger={renderTrigger}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
selectedCredentialId={credentialId || '__workspace_default__'}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(PluginAuthInAgent)
|
||||
@@ -0,0 +1,39 @@
|
||||
import { memo } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
type PluginAuthInDataSourceNodeProps = {
|
||||
children?: ReactNode
|
||||
isAuthorized?: boolean
|
||||
onJumpToDataSourcePage: () => void
|
||||
}
|
||||
const PluginAuthInDataSourceNode = ({
|
||||
children,
|
||||
isAuthorized,
|
||||
onJumpToDataSourcePage,
|
||||
}: PluginAuthInDataSourceNodeProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<>
|
||||
{
|
||||
!isAuthorized && (
|
||||
<div className='px-4 pb-2'>
|
||||
<Button
|
||||
className='w-full'
|
||||
variant='primary'
|
||||
onClick={onJumpToDataSourcePage}
|
||||
>
|
||||
<RiAddLine className='mr-1 h-4 w-4' />
|
||||
{t('common.integrations.connect')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{isAuthorized && children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(PluginAuthInDataSourceNode)
|
||||
62
dify/web/app/components/plugins/plugin-auth/plugin-auth.tsx
Normal file
62
dify/web/app/components/plugins/plugin-auth/plugin-auth.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { memo } from 'react'
|
||||
import Authorize from './authorize'
|
||||
import Authorized from './authorized'
|
||||
import type { PluginPayload } from './types'
|
||||
import { usePluginAuth } from './hooks/use-plugin-auth'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type PluginAuthProps = {
|
||||
pluginPayload: PluginPayload
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
const PluginAuth = ({
|
||||
pluginPayload,
|
||||
children,
|
||||
className,
|
||||
}: PluginAuthProps) => {
|
||||
const {
|
||||
isAuthorized,
|
||||
canOAuth,
|
||||
canApiKey,
|
||||
credentials,
|
||||
disabled,
|
||||
invalidPluginCredentialInfo,
|
||||
notAllowCustomCredential,
|
||||
} = usePluginAuth(pluginPayload, !!pluginPayload.provider)
|
||||
|
||||
return (
|
||||
<div className={cn(!isAuthorized && className)}>
|
||||
{
|
||||
!isAuthorized && (
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
isAuthorized && !children && (
|
||||
<Authorized
|
||||
pluginPayload={pluginPayload}
|
||||
credentials={credentials}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
disabled={disabled}
|
||||
onUpdate={invalidPluginCredentialInfo}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
isAuthorized && children
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(PluginAuth)
|
||||
36
dify/web/app/components/plugins/plugin-auth/types.ts
Normal file
36
dify/web/app/components/plugins/plugin-auth/types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { CollectionType } from '../../tools/types'
|
||||
import type { PluginDetail } from '../types'
|
||||
|
||||
export type { AddApiKeyButtonProps } from './authorize/add-api-key-button'
|
||||
export type { AddOAuthButtonProps } from './authorize/add-oauth-button'
|
||||
|
||||
export enum AuthCategory {
|
||||
tool = 'tool',
|
||||
datasource = 'datasource',
|
||||
model = 'model',
|
||||
trigger = 'trigger',
|
||||
}
|
||||
|
||||
export type PluginPayload = {
|
||||
category: AuthCategory
|
||||
provider: string
|
||||
providerType?: CollectionType | string
|
||||
detail?: PluginDetail
|
||||
}
|
||||
|
||||
export enum CredentialTypeEnum {
|
||||
OAUTH2 = 'oauth2',
|
||||
API_KEY = 'api-key',
|
||||
}
|
||||
|
||||
export type Credential = {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
credential_type?: CredentialTypeEnum
|
||||
is_default: boolean
|
||||
credentials?: Record<string, any>
|
||||
isWorkspaceDefault?: boolean
|
||||
from_enterprise?: boolean
|
||||
not_allowed_to_use?: boolean
|
||||
}
|
||||
10
dify/web/app/components/plugins/plugin-auth/utils.ts
Normal file
10
dify/web/app/components/plugins/plugin-auth/utils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const transformFormSchemasSecretInput = (isPristineSecretInputNames: string[], values: Record<string, any>) => {
|
||||
const transformedValues: Record<string, any> = { ...values }
|
||||
|
||||
isPristineSecretInputNames.forEach((name) => {
|
||||
if (transformedValues[name])
|
||||
transformedValues[name] = '[__HIDDEN__]'
|
||||
})
|
||||
|
||||
return transformedValues
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ToolItem from '@/app/components/tools/provider/tool-item'
|
||||
import {
|
||||
useAllToolProviders,
|
||||
useBuiltinTools,
|
||||
} from '@/service/use-tools'
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
|
||||
type Props = {
|
||||
detail: PluginDetail
|
||||
}
|
||||
|
||||
const ActionList = ({
|
||||
detail,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const providerBriefInfo = detail.declaration?.tool?.identity
|
||||
const providerKey = providerBriefInfo ? `${detail.plugin_id}/${providerBriefInfo.name}` : ''
|
||||
const { data: collectionList = [] } = useAllToolProviders()
|
||||
const provider = useMemo(() => {
|
||||
return collectionList.find(collection => collection.name === providerKey)
|
||||
}, [collectionList, providerKey])
|
||||
const { data } = useBuiltinTools(providerKey)
|
||||
|
||||
if (!providerKey || !data || !provider)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className='px-4 pb-4 pt-2'>
|
||||
<div className='mb-1 py-1'>
|
||||
<div className='system-sm-semibold-uppercase mb-1 flex h-6 items-center justify-between text-text-secondary'>
|
||||
{t('plugin.detailPanel.actionNum', { num: data.length, action: data.length > 1 ? 'actions' : 'action' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{data.map(tool => (
|
||||
<ToolItem
|
||||
key={`${detail.plugin_id}${tool.name}`}
|
||||
disabled={false}
|
||||
collection={provider}
|
||||
tool={tool}
|
||||
isBuiltIn={true}
|
||||
isModel={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionList
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import StrategyItem from '@/app/components/plugins/plugin-detail-panel/strategy-item'
|
||||
import {
|
||||
useStrategyProviderDetail,
|
||||
} from '@/service/use-strategy'
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
|
||||
type Props = {
|
||||
detail: PluginDetail
|
||||
}
|
||||
|
||||
const AgentStrategyList = ({
|
||||
detail,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const providerBriefInfo = detail.declaration.agent_strategy.identity
|
||||
const providerKey = `${detail.plugin_id}/${providerBriefInfo.name}`
|
||||
const { data: strategyProviderDetail } = useStrategyProviderDetail(providerKey)
|
||||
|
||||
const providerDetail = useMemo(() => {
|
||||
return {
|
||||
...strategyProviderDetail?.declaration.identity,
|
||||
tenant_id: detail.tenant_id,
|
||||
}
|
||||
}, [detail.tenant_id, strategyProviderDetail?.declaration.identity])
|
||||
|
||||
const strategyList = useMemo(() => {
|
||||
if (!strategyProviderDetail)
|
||||
return []
|
||||
|
||||
return strategyProviderDetail.declaration.strategies
|
||||
}, [strategyProviderDetail])
|
||||
|
||||
if (!strategyProviderDetail)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className='px-4 pb-4 pt-2'>
|
||||
<div className='mb-1 py-1'>
|
||||
<div className='system-sm-semibold-uppercase mb-1 flex h-6 items-center justify-between text-text-secondary'>
|
||||
{t('plugin.detailPanel.strategyNum', { num: strategyList.length, strategy: strategyList.length > 1 ? 'strategies' : 'strategy' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{strategyList.map(strategyDetail => (
|
||||
<StrategyItem
|
||||
key={`${strategyDetail.identity.provider}${strategyDetail.identity.name}`}
|
||||
provider={providerDetail as any}
|
||||
detail={strategyDetail}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AgentStrategyList
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { PortalSelect } from '@/app/components/base/select'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
|
||||
type Props = {
|
||||
inputsForms: any[]
|
||||
inputs: Record<string, any>
|
||||
inputsRef: any
|
||||
onFormChange: (value: Record<string, any>) => void
|
||||
}
|
||||
const AppInputsForm = ({
|
||||
inputsForms,
|
||||
inputs,
|
||||
inputsRef,
|
||||
onFormChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleFormChange = useCallback((variable: string, value: any) => {
|
||||
onFormChange({
|
||||
...inputsRef.current,
|
||||
[variable]: value,
|
||||
})
|
||||
}, [onFormChange, inputsRef])
|
||||
|
||||
const renderField = (form: any) => {
|
||||
const {
|
||||
label,
|
||||
variable,
|
||||
options,
|
||||
} = form
|
||||
if (form.type === InputVarType.textInput) {
|
||||
return (
|
||||
<Input
|
||||
value={inputs[variable] || ''}
|
||||
onChange={e => handleFormChange(variable, e.target.value)}
|
||||
placeholder={label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (form.type === InputVarType.number) {
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={inputs[variable] || ''}
|
||||
onChange={e => handleFormChange(variable, e.target.value)}
|
||||
placeholder={label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (form.type === InputVarType.paragraph) {
|
||||
return (
|
||||
<Textarea
|
||||
value={inputs[variable] || ''}
|
||||
onChange={e => handleFormChange(variable, e.target.value)}
|
||||
placeholder={label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (form.type === InputVarType.select) {
|
||||
return (
|
||||
<PortalSelect
|
||||
popupClassName="w-[356px] z-[1050]"
|
||||
value={inputs[variable] || ''}
|
||||
items={options.map((option: string) => ({ value: option, name: option }))}
|
||||
onSelect={item => handleFormChange(variable, item.value as string)}
|
||||
placeholder={label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (form.type === InputVarType.singleFile) {
|
||||
return (
|
||||
<FileUploaderInAttachmentWrapper
|
||||
value={inputs[variable] ? [inputs[variable]] : []}
|
||||
onChange={files => handleFormChange(variable, files[0])}
|
||||
fileConfig={{
|
||||
allowed_file_types: form.allowed_file_types,
|
||||
allowed_file_extensions: form.allowed_file_extensions,
|
||||
allowed_file_upload_methods: form.allowed_file_upload_methods,
|
||||
number_limits: 1,
|
||||
fileUploadConfig: (form as any).fileUploadConfig,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (form.type === InputVarType.multiFiles) {
|
||||
return (
|
||||
<FileUploaderInAttachmentWrapper
|
||||
value={inputs[variable]}
|
||||
onChange={files => handleFormChange(variable, files)}
|
||||
fileConfig={{
|
||||
allowed_file_types: form.allowed_file_types,
|
||||
allowed_file_extensions: form.allowed_file_extensions,
|
||||
allowed_file_upload_methods: form.allowed_file_upload_methods,
|
||||
number_limits: form.max_length,
|
||||
fileUploadConfig: (form as any).fileUploadConfig,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!inputsForms.length)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4 px-4 py-2'>
|
||||
{inputsForms.map(form => (
|
||||
<div key={form.variable}>
|
||||
<div className='system-sm-semibold mb-1 flex h-6 items-center gap-1 text-text-secondary'>
|
||||
<div className='truncate'>{form.label}</div>
|
||||
{!form.required && <span className='system-xs-regular text-text-tertiary'>{t('workflow.panel.optional')}</span>}
|
||||
</div>
|
||||
{renderField(form)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppInputsForm
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
import React, { useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import AppInputsForm from '@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-form'
|
||||
import { useAppDetail } from '@/service/use-apps'
|
||||
import { useAppWorkflow } from '@/service/use-workflow'
|
||||
import { useFileUploadConfig } from '@/service/use-common'
|
||||
import { AppModeEnum, Resolution } from '@/types/app'
|
||||
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
import type { App } from '@/types/app'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { BlockEnum, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
value?: {
|
||||
app_id: string
|
||||
inputs: Record<string, any>
|
||||
}
|
||||
appDetail: App
|
||||
onFormChange: (value: Record<string, any>) => void
|
||||
}
|
||||
|
||||
const AppInputsPanel = ({
|
||||
value,
|
||||
appDetail,
|
||||
onFormChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const inputsRef = useRef<any>(value?.inputs || {})
|
||||
const isBasicApp = appDetail.mode !== AppModeEnum.ADVANCED_CHAT && appDetail.mode !== AppModeEnum.WORKFLOW
|
||||
const { data: fileUploadConfig } = useFileUploadConfig()
|
||||
const { data: currentApp, isFetching: isAppLoading } = useAppDetail(appDetail.id)
|
||||
const { data: currentWorkflow, isFetching: isWorkflowLoading } = useAppWorkflow(isBasicApp ? '' : appDetail.id)
|
||||
const isLoading = isAppLoading || isWorkflowLoading
|
||||
|
||||
const basicAppFileConfig = useMemo(() => {
|
||||
let fileConfig: FileUpload
|
||||
if (isBasicApp)
|
||||
fileConfig = currentApp?.model_config?.file_upload as FileUpload
|
||||
else
|
||||
fileConfig = currentWorkflow?.features?.file_upload as FileUpload
|
||||
return {
|
||||
image: {
|
||||
detail: fileConfig?.image?.detail || Resolution.high,
|
||||
enabled: !!fileConfig?.image?.enabled,
|
||||
number_limits: fileConfig?.image?.number_limits || 3,
|
||||
transfer_methods: fileConfig?.image?.transfer_methods || ['local_file', 'remote_url'],
|
||||
},
|
||||
enabled: !!(fileConfig?.enabled || fileConfig?.image?.enabled),
|
||||
allowed_file_types: fileConfig?.allowed_file_types || [SupportUploadFileTypes.image],
|
||||
allowed_file_extensions: fileConfig?.allowed_file_extensions || [...FILE_EXTS[SupportUploadFileTypes.image]].map(ext => `.${ext}`),
|
||||
allowed_file_upload_methods: fileConfig?.allowed_file_upload_methods || fileConfig?.image?.transfer_methods || ['local_file', 'remote_url'],
|
||||
number_limits: fileConfig?.number_limits || fileConfig?.image?.number_limits || 3,
|
||||
}
|
||||
}, [currentApp?.model_config?.file_upload, currentWorkflow?.features?.file_upload, isBasicApp])
|
||||
|
||||
const inputFormSchema = useMemo(() => {
|
||||
if (!currentApp)
|
||||
return []
|
||||
let inputFormSchema = []
|
||||
if (isBasicApp) {
|
||||
inputFormSchema = currentApp.model_config?.user_input_form?.filter((item: any) => !item.external_data_tool).map((item: any) => {
|
||||
if (item.paragraph) {
|
||||
return {
|
||||
...item.paragraph,
|
||||
type: 'paragraph',
|
||||
required: false,
|
||||
}
|
||||
}
|
||||
if (item.number) {
|
||||
return {
|
||||
...item.number,
|
||||
type: 'number',
|
||||
required: false,
|
||||
}
|
||||
}
|
||||
if (item.checkbox) {
|
||||
return {
|
||||
...item.checkbox,
|
||||
type: 'checkbox',
|
||||
required: false,
|
||||
}
|
||||
}
|
||||
if (item.select) {
|
||||
return {
|
||||
...item.select,
|
||||
type: 'select',
|
||||
required: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (item['file-list']) {
|
||||
return {
|
||||
...item['file-list'],
|
||||
type: 'file-list',
|
||||
required: false,
|
||||
fileUploadConfig,
|
||||
}
|
||||
}
|
||||
|
||||
if (item.file) {
|
||||
return {
|
||||
...item.file,
|
||||
type: 'file',
|
||||
required: false,
|
||||
fileUploadConfig,
|
||||
}
|
||||
}
|
||||
|
||||
if (item.json_object) {
|
||||
return {
|
||||
...item.json_object,
|
||||
type: 'json_object',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...item['text-input'],
|
||||
type: 'text-input',
|
||||
required: false,
|
||||
}
|
||||
}) || []
|
||||
}
|
||||
else {
|
||||
const startNode = currentWorkflow?.graph?.nodes.find(node => node.data.type === BlockEnum.Start) as any
|
||||
inputFormSchema = startNode?.data.variables.map((variable: any) => {
|
||||
if (variable.type === InputVarType.multiFiles) {
|
||||
return {
|
||||
...variable,
|
||||
required: false,
|
||||
fileUploadConfig,
|
||||
}
|
||||
}
|
||||
|
||||
if (variable.type === InputVarType.singleFile) {
|
||||
return {
|
||||
...variable,
|
||||
required: false,
|
||||
fileUploadConfig,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...variable,
|
||||
required: false,
|
||||
}
|
||||
}) || []
|
||||
}
|
||||
if ((currentApp.mode === AppModeEnum.COMPLETION || currentApp.mode === AppModeEnum.WORKFLOW) && basicAppFileConfig.enabled) {
|
||||
inputFormSchema.push({
|
||||
label: 'Image Upload',
|
||||
variable: '#image#',
|
||||
type: InputVarType.singleFile,
|
||||
required: false,
|
||||
...basicAppFileConfig,
|
||||
fileUploadConfig,
|
||||
})
|
||||
}
|
||||
return inputFormSchema || []
|
||||
}, [basicAppFileConfig, currentApp, currentWorkflow, fileUploadConfig, isBasicApp])
|
||||
|
||||
const handleFormChange = (value: Record<string, any>) => {
|
||||
inputsRef.current = value
|
||||
onFormChange(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex max-h-[240px] flex-col rounded-b-2xl border-t border-divider-subtle pb-4')}>
|
||||
{isLoading && <div className='pt-3'><Loading type='app' /></div>}
|
||||
{!isLoading && (
|
||||
<div className='system-sm-semibold mb-2 mt-3 flex h-6 shrink-0 items-center px-4 text-text-secondary'>{t('app.appSelector.params')}</div>
|
||||
)}
|
||||
{!isLoading && !inputFormSchema.length && (
|
||||
<div className='flex h-16 flex-col items-center justify-center'>
|
||||
<div className='system-sm-regular text-text-tertiary'>{t('app.appSelector.noParams')}</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !!inputFormSchema.length && (
|
||||
<div className='grow overflow-y-auto'>
|
||||
<AppInputsForm
|
||||
inputs={value?.inputs || {}}
|
||||
inputsRef={inputsRef}
|
||||
inputsForms={inputFormSchema}
|
||||
onFormChange={handleFormChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppInputsPanel
|
||||
@@ -0,0 +1,196 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useEffect, useRef } from 'react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import type {
|
||||
OffsetOptions,
|
||||
Placement,
|
||||
} from '@floating-ui/react'
|
||||
import Input from '@/app/components/base/input'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { type App, AppModeEnum } from '@/types/app'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type Props = {
|
||||
scope: string
|
||||
disabled: boolean
|
||||
trigger: React.ReactNode
|
||||
placement?: Placement
|
||||
offset?: OffsetOptions
|
||||
isShow: boolean
|
||||
onShowChange: (isShow: boolean) => void
|
||||
onSelect: (app: App) => void
|
||||
apps: App[]
|
||||
isLoading: boolean
|
||||
hasMore: boolean
|
||||
onLoadMore: () => void
|
||||
searchText: string
|
||||
onSearchChange: (text: string) => void
|
||||
}
|
||||
|
||||
const AppPicker: FC<Props> = ({
|
||||
scope: _scope,
|
||||
disabled,
|
||||
trigger,
|
||||
placement = 'right-start',
|
||||
offset = 0,
|
||||
isShow,
|
||||
onShowChange,
|
||||
onSelect,
|
||||
apps,
|
||||
isLoading,
|
||||
hasMore,
|
||||
onLoadMore,
|
||||
searchText,
|
||||
onSearchChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const observerTarget = useRef<HTMLDivElement>(null)
|
||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||
const loadingRef = useRef(false)
|
||||
|
||||
const handleIntersection = useCallback((entries: IntersectionObserverEntry[]) => {
|
||||
const target = entries[0]
|
||||
if (!target.isIntersecting || loadingRef.current || !hasMore || isLoading) return
|
||||
|
||||
loadingRef.current = true
|
||||
onLoadMore()
|
||||
// Reset loading state
|
||||
setTimeout(() => {
|
||||
loadingRef.current = false
|
||||
}, 500)
|
||||
}, [hasMore, isLoading, onLoadMore])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isShow) {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect()
|
||||
observerRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let mutationObserver: MutationObserver | null = null
|
||||
|
||||
const setupIntersectionObserver = () => {
|
||||
if (!observerTarget.current) return
|
||||
|
||||
// Create new observer
|
||||
observerRef.current = new IntersectionObserver(handleIntersection, {
|
||||
root: null,
|
||||
rootMargin: '100px',
|
||||
threshold: 0.1,
|
||||
})
|
||||
|
||||
observerRef.current.observe(observerTarget.current)
|
||||
}
|
||||
|
||||
// Set up MutationObserver to watch DOM changes
|
||||
mutationObserver = new MutationObserver((_mutations) => {
|
||||
if (observerTarget.current) {
|
||||
setupIntersectionObserver()
|
||||
mutationObserver?.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
// Watch body changes since Portal adds content to body
|
||||
mutationObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
|
||||
// If element exists, set up IntersectionObserver directly
|
||||
if (observerTarget.current)
|
||||
setupIntersectionObserver()
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect()
|
||||
observerRef.current = null
|
||||
}
|
||||
mutationObserver?.disconnect()
|
||||
}
|
||||
}, [isShow, handleIntersection])
|
||||
|
||||
const getAppType = (app: App) => {
|
||||
switch (app.mode) {
|
||||
case AppModeEnum.ADVANCED_CHAT:
|
||||
return 'chatflow'
|
||||
case AppModeEnum.AGENT_CHAT:
|
||||
return 'agent'
|
||||
case AppModeEnum.CHAT:
|
||||
return 'chat'
|
||||
case AppModeEnum.COMPLETION:
|
||||
return 'completion'
|
||||
case AppModeEnum.WORKFLOW:
|
||||
return 'workflow'
|
||||
}
|
||||
}
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
if (disabled) return
|
||||
onShowChange(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement={placement}
|
||||
offset={offset}
|
||||
open={isShow}
|
||||
onOpenChange={onShowChange}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={handleTriggerClick}
|
||||
>
|
||||
{trigger}
|
||||
</PortalToFollowElemTrigger>
|
||||
|
||||
<PortalToFollowElemContent className='z-[1000]'>
|
||||
<div className="relative flex max-h-[400px] min-h-20 w-[356px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
|
||||
<div className='p-2 pb-1'>
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
value={searchText}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
onClear={() => onSearchChange('')}
|
||||
/>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto p-1'>
|
||||
{apps.map(app => (
|
||||
<div
|
||||
key={app.id}
|
||||
className='flex cursor-pointer items-center gap-3 rounded-lg py-1 pl-2 pr-3 hover:bg-state-base-hover'
|
||||
onClick={() => onSelect(app)}
|
||||
>
|
||||
<AppIcon
|
||||
className='shrink-0'
|
||||
size='xs'
|
||||
iconType={app.icon_type}
|
||||
icon={app.icon}
|
||||
background={app.icon_background}
|
||||
imageUrl={app.icon_url}
|
||||
/>
|
||||
<div title={app.name} className='system-sm-medium grow text-components-input-text-filled'>{app.name}</div>
|
||||
<div className='system-2xs-medium-uppercase shrink-0 text-text-tertiary'>{getAppType(app)}</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={observerTarget} className='h-4 w-full'>
|
||||
{isLoading && (
|
||||
<div className='flex justify-center py-2'>
|
||||
<div className='text-sm text-gray-500'>{t('common.loading')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AppPicker)
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
} from '@remixicon/react'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import type { App } from '@/types/app'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
appDetail?: App
|
||||
}
|
||||
|
||||
const AppTrigger = ({
|
||||
open,
|
||||
appDetail,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={cn(
|
||||
'group flex cursor-pointer items-center rounded-lg bg-components-input-bg-normal p-2 pl-3 hover:bg-state-base-hover-alt',
|
||||
open && 'bg-state-base-hover-alt',
|
||||
appDetail && 'py-1.5 pl-1.5',
|
||||
)}>
|
||||
{appDetail && (
|
||||
<AppIcon
|
||||
className='mr-2'
|
||||
size='xs'
|
||||
iconType={appDetail.icon_type}
|
||||
icon={appDetail.icon}
|
||||
background={appDetail.icon_background}
|
||||
imageUrl={appDetail.icon_url}
|
||||
/>
|
||||
)}
|
||||
{appDetail && (
|
||||
<div title={appDetail.name} className='system-sm-medium grow text-components-input-text-filled'>{appDetail.name}</div>
|
||||
)}
|
||||
{!appDetail && (
|
||||
<div className='system-sm-regular grow truncate text-components-input-text-placeholder'>{t('app.appSelector.placeholder')}</div>
|
||||
)}
|
||||
<RiArrowDownSLine className={cn('ml-0.5 h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppTrigger
|
||||
@@ -0,0 +1,210 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import AppTrigger from '@/app/components/plugins/plugin-detail-panel/app-selector/app-trigger'
|
||||
import AppPicker from '@/app/components/plugins/plugin-detail-panel/app-selector/app-picker'
|
||||
import AppInputsPanel from '@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel'
|
||||
import type { App } from '@/types/app'
|
||||
import type {
|
||||
OffsetOptions,
|
||||
Placement,
|
||||
} from '@floating-ui/react'
|
||||
import useSWRInfinite from 'swr/infinite'
|
||||
import { fetchAppList } from '@/service/apps'
|
||||
import type { AppListResponse } from '@/models/app'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const getKey = (
|
||||
pageIndex: number,
|
||||
previousPageData: AppListResponse,
|
||||
searchText: string,
|
||||
) => {
|
||||
if (pageIndex === 0 || (previousPageData && previousPageData.has_more)) {
|
||||
const params: any = {
|
||||
url: 'apps',
|
||||
params: {
|
||||
page: pageIndex + 1,
|
||||
limit: PAGE_SIZE,
|
||||
name: searchText,
|
||||
},
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type Props = {
|
||||
value?: {
|
||||
app_id: string
|
||||
inputs: Record<string, any>
|
||||
files?: any[]
|
||||
}
|
||||
scope?: string
|
||||
disabled?: boolean
|
||||
placement?: Placement
|
||||
offset?: OffsetOptions
|
||||
onSelect: (app: {
|
||||
app_id: string
|
||||
inputs: Record<string, any>
|
||||
files?: any[]
|
||||
}) => void
|
||||
supportAddCustomTool?: boolean
|
||||
}
|
||||
|
||||
const AppSelector: FC<Props> = ({
|
||||
value,
|
||||
scope,
|
||||
disabled,
|
||||
placement = 'bottom',
|
||||
offset = 4,
|
||||
onSelect,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isShow, onShowChange] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
|
||||
const { data, isLoading, setSize } = useSWRInfinite(
|
||||
(pageIndex: number, previousPageData: AppListResponse) => getKey(pageIndex, previousPageData, searchText),
|
||||
fetchAppList,
|
||||
{
|
||||
revalidateFirstPage: true,
|
||||
shouldRetryOnError: false,
|
||||
dedupingInterval: 500,
|
||||
errorRetryCount: 3,
|
||||
},
|
||||
)
|
||||
|
||||
const displayedApps = useMemo(() => {
|
||||
if (!data) return []
|
||||
return data.flatMap(({ data: apps }) => apps)
|
||||
}, [data])
|
||||
|
||||
const hasMore = data?.at(-1)?.has_more ?? true
|
||||
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (isLoadingMore || !hasMore) return
|
||||
|
||||
setIsLoadingMore(true)
|
||||
try {
|
||||
await setSize((size: number) => size + 1)
|
||||
}
|
||||
finally {
|
||||
// Add a small delay to ensure state updates are complete
|
||||
setTimeout(() => {
|
||||
setIsLoadingMore(false)
|
||||
}, 300)
|
||||
}
|
||||
}, [isLoadingMore, hasMore, setSize])
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
if (disabled) return
|
||||
onShowChange(true)
|
||||
}
|
||||
|
||||
const [isShowChooseApp, setIsShowChooseApp] = useState(false)
|
||||
const handleSelectApp = (app: App) => {
|
||||
const clearValue = app.id !== value?.app_id
|
||||
const appValue = {
|
||||
app_id: app.id,
|
||||
inputs: clearValue ? {} : value?.inputs || {},
|
||||
files: clearValue ? [] : value?.files || [],
|
||||
}
|
||||
onSelect(appValue)
|
||||
setIsShowChooseApp(false)
|
||||
}
|
||||
|
||||
const handleFormChange = (inputs: Record<string, any>) => {
|
||||
const newFiles = inputs['#image#']
|
||||
delete inputs['#image#']
|
||||
const newValue = {
|
||||
app_id: value?.app_id || '',
|
||||
inputs,
|
||||
files: newFiles ? [newFiles] : value?.files || [],
|
||||
}
|
||||
onSelect(newValue)
|
||||
}
|
||||
|
||||
const formattedValue = useMemo(() => {
|
||||
return {
|
||||
app_id: value?.app_id || '',
|
||||
inputs: {
|
||||
...value?.inputs,
|
||||
...(value?.files?.length ? { '#image#': value.files[0] } : {}),
|
||||
},
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const currentAppInfo = useMemo(() => {
|
||||
if (!displayedApps || !value)
|
||||
return undefined
|
||||
return displayedApps.find(app => app.id === value.app_id)
|
||||
}, [displayedApps, value])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PortalToFollowElem
|
||||
placement={placement}
|
||||
offset={offset}
|
||||
open={isShow}
|
||||
onOpenChange={onShowChange}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
className='w-full'
|
||||
onClick={handleTriggerClick}
|
||||
>
|
||||
<AppTrigger
|
||||
open={isShow}
|
||||
appDetail={currentAppInfo}
|
||||
/>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[1000]'>
|
||||
<div className="relative min-h-20 w-[389px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
|
||||
<div className='flex flex-col gap-1 px-4 py-3'>
|
||||
<div className='system-sm-semibold flex h-6 items-center text-text-secondary'>{t('app.appSelector.label')}</div>
|
||||
<AppPicker
|
||||
placement='bottom'
|
||||
offset={offset}
|
||||
trigger={
|
||||
<AppTrigger
|
||||
open={isShowChooseApp}
|
||||
appDetail={currentAppInfo}
|
||||
/>
|
||||
}
|
||||
isShow={isShowChooseApp}
|
||||
onShowChange={setIsShowChooseApp}
|
||||
disabled={false}
|
||||
onSelect={handleSelectApp}
|
||||
scope={scope || 'all'}
|
||||
apps={displayedApps}
|
||||
isLoading={isLoading || isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
searchText={searchText}
|
||||
onSearchChange={setSearchText}
|
||||
/>
|
||||
</div>
|
||||
{/* app inputs config panel */}
|
||||
{currentAppInfo && (
|
||||
<AppInputsPanel
|
||||
value={formattedValue}
|
||||
appDetail={currentAppInfo}
|
||||
onFormChange={handleFormChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AppSelector)
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
// import { useAppContext } from '@/context/app-context'
|
||||
// import Button from '@/app/components/base/button'
|
||||
// import Toast from '@/app/components/base/toast'
|
||||
// import Indicator from '@/app/components/header/indicator'
|
||||
// import ToolItem from '@/app/components/tools/provider/tool-item'
|
||||
// import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials'
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { useDataSourceList } from '@/service/use-pipeline'
|
||||
import { transformDataSourceToTool } from '@/app/components/workflow/block-selector/utils'
|
||||
|
||||
type Props = {
|
||||
detail: PluginDetail
|
||||
}
|
||||
|
||||
const ActionList = ({
|
||||
detail,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
// const { isCurrentWorkspaceManager } = useAppContext()
|
||||
// const providerBriefInfo = detail.declaration.datasource?.identity
|
||||
// const providerKey = `${detail.plugin_id}/${providerBriefInfo?.name}`
|
||||
const { data: dataSourceList } = useDataSourceList(true)
|
||||
const provider = useMemo(() => {
|
||||
const result = dataSourceList?.find(collection => collection.plugin_id === detail.plugin_id)
|
||||
|
||||
if (result)
|
||||
return transformDataSourceToTool(result)
|
||||
}, [detail.plugin_id, dataSourceList])
|
||||
const data: any = []
|
||||
// const { data } = useBuiltinTools(providerKey)
|
||||
|
||||
// const [showSettingAuth, setShowSettingAuth] = useState(false)
|
||||
|
||||
// const handleCredentialSettingUpdate = () => {
|
||||
// Toast.notify({
|
||||
// type: 'success',
|
||||
// message: t('common.api.actionSuccess'),
|
||||
// })
|
||||
// setShowSettingAuth(false)
|
||||
// }
|
||||
|
||||
// const { mutate: updatePermission, isPending } = useUpdateProviderCredentials({
|
||||
// onSuccess: handleCredentialSettingUpdate,
|
||||
// })
|
||||
|
||||
// const { mutate: removePermission } = useRemoveProviderCredentials({
|
||||
// onSuccess: handleCredentialSettingUpdate,
|
||||
// })
|
||||
|
||||
if (!data || !provider)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className='px-4 pb-4 pt-2'>
|
||||
<div className='mb-1 py-1'>
|
||||
<div className='system-sm-semibold-uppercase mb-1 flex h-6 items-center justify-between text-text-secondary'>
|
||||
{t('plugin.detailPanel.actionNum', { num: data.length, action: data.length > 1 ? 'actions' : 'action' })}
|
||||
{/* {provider.is_team_authorization && provider.allow_delete && (
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='small'
|
||||
onClick={() => setShowSettingAuth(true)}
|
||||
disabled={!isCurrentWorkspaceManager}
|
||||
>
|
||||
<Indicator className='mr-2' color={'green'} />
|
||||
{t('tools.auth.authorized')}
|
||||
</Button>
|
||||
)} */}
|
||||
</div>
|
||||
{/* {!provider.is_team_authorization && provider.allow_delete && (
|
||||
<Button
|
||||
variant='primary'
|
||||
className='w-full'
|
||||
onClick={() => setShowSettingAuth(true)}
|
||||
disabled={!isCurrentWorkspaceManager}
|
||||
>{t('workflow.nodes.tool.authorize')}</Button>
|
||||
)} */}
|
||||
</div>
|
||||
{/* <div className='flex flex-col gap-2'>
|
||||
{data.map(tool => (
|
||||
<ToolItem
|
||||
key={`${detail.plugin_id}${tool.name}`}
|
||||
disabled={false}
|
||||
collection={provider}
|
||||
tool={tool}
|
||||
isBuiltIn={true}
|
||||
isModel={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{showSettingAuth && (
|
||||
<ConfigCredential
|
||||
collection={provider}
|
||||
onCancel={() => setShowSettingAuth(false)}
|
||||
onSaved={async value => updatePermission({
|
||||
providerName: provider.name,
|
||||
credentials: value,
|
||||
})}
|
||||
onRemove={async () => removePermission(provider.name)}
|
||||
isSaving={isPending}
|
||||
/>
|
||||
)} */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionList
|
||||
@@ -0,0 +1,392 @@
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import { Github } from '@/app/components/base/icons/src/public/common'
|
||||
import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { AuthCategory, PluginAuth } from '@/app/components/plugins/plugin-auth'
|
||||
import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown'
|
||||
import PluginInfo from '@/app/components/plugins/plugin-page/plugin-info'
|
||||
import UpdateFromMarketplace from '@/app/components/plugins/update-plugin/from-market-place'
|
||||
import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useGetLanguage, useI18N } from '@/context/i18n'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { uninstallPlugin } from '@/service/plugins'
|
||||
import { useAllToolProviders, useInvalidateAllToolProviders } from '@/service/use-tools'
|
||||
import cn from '@/utils/classnames'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import {
|
||||
RiArrowLeftRightLine,
|
||||
RiBugLine,
|
||||
RiCloseLine,
|
||||
RiHardDrive3Line,
|
||||
} from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useTheme } from 'next-themes'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Verified from '../base/badges/verified'
|
||||
import { AutoUpdateLine } from '../../base/icons/src/vender/system'
|
||||
import DeprecationNotice from '../base/deprecation-notice'
|
||||
import Icon from '../card/base/card-icon'
|
||||
import Description from '../card/base/description'
|
||||
import OrgInfo from '../card/base/org-info'
|
||||
import Title from '../card/base/title'
|
||||
import { useGitHubReleases } from '../install-plugin/hooks'
|
||||
import useReferenceSetting from '../plugin-page/use-reference-setting'
|
||||
import { AUTO_UPDATE_MODE } from '../reference-setting-modal/auto-update-setting/types'
|
||||
import { convertUTCDaySecondsToLocalSeconds, timeOfDayToDayjs } from '../reference-setting-modal/auto-update-setting/utils'
|
||||
import type { PluginDetail } from '../types'
|
||||
import { PluginCategoryEnum, PluginSource } from '../types'
|
||||
|
||||
const i18nPrefix = 'plugin.action'
|
||||
|
||||
type Props = {
|
||||
detail: PluginDetail
|
||||
isReadmeView?: boolean
|
||||
onHide?: () => void
|
||||
onUpdate?: (isDelete?: boolean) => void
|
||||
}
|
||||
|
||||
const DetailHeader = ({
|
||||
detail,
|
||||
isReadmeView = false,
|
||||
onHide,
|
||||
onUpdate,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { userProfile: { timezone } } = useAppContext()
|
||||
|
||||
const { theme } = useTheme()
|
||||
const locale = useGetLanguage()
|
||||
const { locale: currentLocale } = useI18N()
|
||||
const { checkForUpdates, fetchReleases } = useGitHubReleases()
|
||||
const { setShowUpdatePluginModal } = useModalContext()
|
||||
const { refreshModelProviders } = useProviderContext()
|
||||
const invalidateAllToolProviders = useInvalidateAllToolProviders()
|
||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
|
||||
const {
|
||||
id,
|
||||
source,
|
||||
tenant_id,
|
||||
version,
|
||||
latest_unique_identifier,
|
||||
latest_version,
|
||||
meta,
|
||||
plugin_id,
|
||||
status,
|
||||
deprecated_reason,
|
||||
alternative_plugin_id,
|
||||
} = detail
|
||||
|
||||
const { author, category, name, label, description, icon, verified, tool } = detail.declaration || detail
|
||||
const isTool = category === PluginCategoryEnum.tool
|
||||
const providerBriefInfo = tool?.identity
|
||||
const providerKey = `${plugin_id}/${providerBriefInfo?.name}`
|
||||
const { data: collectionList = [] } = useAllToolProviders(isTool)
|
||||
const provider = useMemo(() => {
|
||||
return collectionList.find(collection => collection.name === providerKey)
|
||||
}, [collectionList, providerKey])
|
||||
const isFromGitHub = source === PluginSource.github
|
||||
const isFromMarketplace = source === PluginSource.marketplace
|
||||
|
||||
const [isShow, setIsShow] = useState(false)
|
||||
const [targetVersion, setTargetVersion] = useState({
|
||||
version: latest_version,
|
||||
unique_identifier: latest_unique_identifier,
|
||||
})
|
||||
const hasNewVersion = useMemo(() => {
|
||||
if (isFromMarketplace)
|
||||
return !!latest_version && latest_version !== version
|
||||
|
||||
return false
|
||||
}, [isFromMarketplace, latest_version, version])
|
||||
|
||||
const detailUrl = useMemo(() => {
|
||||
if (isFromGitHub)
|
||||
return `https://github.com/${meta!.repo}`
|
||||
if (isFromMarketplace)
|
||||
return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: currentLocale, theme })
|
||||
return ''
|
||||
}, [author, isFromGitHub, isFromMarketplace, meta, name, theme])
|
||||
|
||||
const [isShowUpdateModal, {
|
||||
setTrue: showUpdateModal,
|
||||
setFalse: hideUpdateModal,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const { referenceSetting } = useReferenceSetting()
|
||||
const { auto_upgrade: autoUpgradeInfo } = referenceSetting || {}
|
||||
const isAutoUpgradeEnabled = useMemo(() => {
|
||||
if (!enable_marketplace)
|
||||
return false
|
||||
if (!autoUpgradeInfo || !isFromMarketplace)
|
||||
return false
|
||||
if (autoUpgradeInfo.strategy_setting === 'disabled')
|
||||
return false
|
||||
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.update_all)
|
||||
return true
|
||||
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.partial && autoUpgradeInfo.include_plugins.includes(plugin_id))
|
||||
return true
|
||||
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.exclude && !autoUpgradeInfo.exclude_plugins.includes(plugin_id))
|
||||
return true
|
||||
return false
|
||||
}, [autoUpgradeInfo, plugin_id, isFromMarketplace])
|
||||
|
||||
const [isDowngrade, setIsDowngrade] = useState(false)
|
||||
const handleUpdate = async (isDowngrade?: boolean) => {
|
||||
if (isFromMarketplace) {
|
||||
setIsDowngrade(!!isDowngrade)
|
||||
showUpdateModal()
|
||||
return
|
||||
}
|
||||
|
||||
const owner = meta!.repo.split('/')[0] || author
|
||||
const repo = meta!.repo.split('/')[1] || name
|
||||
const fetchedReleases = await fetchReleases(owner, repo)
|
||||
if (fetchedReleases.length === 0) return
|
||||
const { needUpdate, toastProps } = checkForUpdates(fetchedReleases, meta!.version)
|
||||
Toast.notify(toastProps)
|
||||
if (needUpdate) {
|
||||
setShowUpdatePluginModal({
|
||||
onSaveCallback: () => {
|
||||
onUpdate?.()
|
||||
},
|
||||
payload: {
|
||||
type: PluginSource.github,
|
||||
category: detail.declaration.category,
|
||||
github: {
|
||||
originalPackageInfo: {
|
||||
id: detail.plugin_unique_identifier,
|
||||
repo: meta!.repo,
|
||||
version: meta!.version,
|
||||
package: meta!.package,
|
||||
releases: fetchedReleases,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdatedFromMarketplace = () => {
|
||||
onUpdate?.()
|
||||
hideUpdateModal()
|
||||
}
|
||||
|
||||
const [isShowPluginInfo, {
|
||||
setTrue: showPluginInfo,
|
||||
setFalse: hidePluginInfo,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const [isShowDeleteConfirm, {
|
||||
setTrue: showDeleteConfirm,
|
||||
setFalse: hideDeleteConfirm,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const [deleting, {
|
||||
setTrue: showDeleting,
|
||||
setFalse: hideDeleting,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
showDeleting()
|
||||
const res = await uninstallPlugin(id)
|
||||
hideDeleting()
|
||||
if (res.success) {
|
||||
hideDeleteConfirm()
|
||||
onUpdate?.(true)
|
||||
if (PluginCategoryEnum.model.includes(category))
|
||||
refreshModelProviders()
|
||||
if (PluginCategoryEnum.tool.includes(category))
|
||||
invalidateAllToolProviders()
|
||||
}
|
||||
}, [showDeleting, id, hideDeleting, hideDeleteConfirm, onUpdate, category, refreshModelProviders, invalidateAllToolProviders])
|
||||
|
||||
return (
|
||||
<div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3', isReadmeView && 'border-b-0 bg-transparent p-0')}>
|
||||
<div className="flex">
|
||||
<div className={cn('overflow-hidden rounded-xl border border-components-panel-border-subtle', isReadmeView && 'bg-components-panel-bg')}>
|
||||
<Icon src={icon.startsWith('http') ? icon : `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenant_id}&filename=${icon}`} />
|
||||
</div>
|
||||
<div className="ml-3 w-0 grow">
|
||||
<div className="flex h-5 items-center">
|
||||
<Title title={label[locale]} />
|
||||
{verified && !isReadmeView && <Verified className='ml-0.5 h-4 w-4' text={t('plugin.marketplace.verifiedTip')} />}
|
||||
{version && <PluginVersionPicker
|
||||
disabled={!isFromMarketplace || isReadmeView}
|
||||
isShow={isShow}
|
||||
onShowChange={setIsShow}
|
||||
pluginID={plugin_id}
|
||||
currentVersion={version}
|
||||
onSelect={(state) => {
|
||||
setTargetVersion(state)
|
||||
handleUpdate(state.isDowngrade)
|
||||
}}
|
||||
trigger={
|
||||
<Badge
|
||||
className={cn(
|
||||
'mx-1',
|
||||
isShow && 'bg-state-base-hover',
|
||||
(isShow || isFromMarketplace) && 'hover:bg-state-base-hover',
|
||||
)}
|
||||
uppercase={false}
|
||||
text={
|
||||
<>
|
||||
<div>{isFromGitHub ? meta!.version : version}</div>
|
||||
{isFromMarketplace && !isReadmeView && <RiArrowLeftRightLine className='ml-1 h-3 w-3 text-text-tertiary' />}
|
||||
</>
|
||||
}
|
||||
hasRedCornerMark={hasNewVersion}
|
||||
/>
|
||||
}
|
||||
/>}
|
||||
{/* Auto update info */}
|
||||
{isAutoUpgradeEnabled && !isReadmeView && (
|
||||
<Tooltip popupContent={t('plugin.autoUpdate.nextUpdateTime', { time: timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(autoUpgradeInfo?.upgrade_time_of_day || 0, timezone!)).format('hh:mm A') })}>
|
||||
{/* add a a div to fix tooltip hover not show problem */}
|
||||
<div>
|
||||
<Badge className='mr-1 cursor-pointer px-1'>
|
||||
<AutoUpdateLine className='size-3' />
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{(hasNewVersion || isFromGitHub) && (
|
||||
<Button variant='secondary-accent' size='small' className='!h-5' onClick={() => {
|
||||
if (isFromMarketplace) {
|
||||
setTargetVersion({
|
||||
version: latest_version,
|
||||
unique_identifier: latest_unique_identifier,
|
||||
})
|
||||
}
|
||||
handleUpdate()
|
||||
}}>{t('plugin.detailPanel.operation.update')}</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className='mb-1 flex h-4 items-center justify-between'>
|
||||
<div className='mt-0.5 flex items-center'>
|
||||
<OrgInfo
|
||||
packageNameClassName='w-auto'
|
||||
orgName={author}
|
||||
packageName={name?.includes('/') ? (name.split('/').pop() || '') : name}
|
||||
/>
|
||||
{source && <>
|
||||
<div className='system-xs-regular ml-1 mr-0.5 text-text-quaternary'>·</div>
|
||||
{source === PluginSource.marketplace && (
|
||||
<Tooltip popupContent={t('plugin.detailPanel.categoryTip.marketplace')} >
|
||||
<div><BoxSparkleFill className='h-3.5 w-3.5 text-text-tertiary hover:text-text-accent' /></div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{source === PluginSource.github && (
|
||||
<Tooltip popupContent={t('plugin.detailPanel.categoryTip.github')} >
|
||||
<div><Github className='h-3.5 w-3.5 text-text-secondary hover:text-text-primary' /></div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{source === PluginSource.local && (
|
||||
<Tooltip popupContent={t('plugin.detailPanel.categoryTip.local')} >
|
||||
<div><RiHardDrive3Line className='h-3.5 w-3.5 text-text-tertiary' /></div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{source === PluginSource.debugging && (
|
||||
<Tooltip popupContent={t('plugin.detailPanel.categoryTip.debugging')} >
|
||||
<div><RiBugLine className='h-3.5 w-3.5 text-text-tertiary hover:text-text-warning' /></div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isReadmeView && (
|
||||
<div className='flex gap-1'>
|
||||
<OperationDropdown
|
||||
source={source}
|
||||
onInfo={showPluginInfo}
|
||||
onCheckVersion={handleUpdate}
|
||||
onRemove={showDeleteConfirm}
|
||||
detailUrl={detailUrl}
|
||||
/>
|
||||
<ActionButton onClick={onHide}>
|
||||
<RiCloseLine className='h-4 w-4' />
|
||||
</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'
|
||||
/>
|
||||
)}
|
||||
{!isReadmeView && <Description className='mb-2 mt-3 h-auto' text={description[locale]} descriptionLineRows={2}></Description>}
|
||||
{
|
||||
category === PluginCategoryEnum.tool && !isReadmeView && (
|
||||
<PluginAuth
|
||||
pluginPayload={{
|
||||
provider: provider?.name || '',
|
||||
category: AuthCategory.tool,
|
||||
providerType: provider?.type || '',
|
||||
detail,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{isShowPluginInfo && (
|
||||
<PluginInfo
|
||||
repository={isFromGitHub ? meta?.repo : ''}
|
||||
release={version}
|
||||
packageName={meta?.package || ''}
|
||||
onHide={hidePluginInfo}
|
||||
/>
|
||||
)}
|
||||
{isShowDeleteConfirm && (
|
||||
<Confirm
|
||||
isShow
|
||||
title={t(`${i18nPrefix}.delete`)}
|
||||
content={
|
||||
<div>
|
||||
{t(`${i18nPrefix}.deleteContentLeft`)}<span className='system-md-semibold'>{label[locale]}</span>{t(`${i18nPrefix}.deleteContentRight`)}<br />
|
||||
</div>
|
||||
}
|
||||
onCancel={hideDeleteConfirm}
|
||||
onConfirm={handleDelete}
|
||||
isLoading={deleting}
|
||||
isDisabled={deleting}
|
||||
/>
|
||||
)}
|
||||
{
|
||||
isShowUpdateModal && (
|
||||
<UpdateFromMarketplace
|
||||
pluginId={plugin_id}
|
||||
payload={{
|
||||
category: detail.declaration.category,
|
||||
originalPackageInfo: {
|
||||
id: detail.plugin_unique_identifier,
|
||||
payload: detail.declaration,
|
||||
},
|
||||
targetPackageInfo: {
|
||||
id: targetVersion.unique_identifier,
|
||||
version: targetVersion.version,
|
||||
},
|
||||
}}
|
||||
onCancel={hideUpdateModal}
|
||||
onSave={handleUpdatedFromMarketplace}
|
||||
isShowDowngradeWarningModal={isDowngrade && isAutoUpgradeEnabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DetailHeader
|
||||
@@ -0,0 +1,222 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { RiClipboardLine, RiDeleteBinLine, RiEditLine, RiLoginCircleLine } from '@remixicon/react'
|
||||
import type { EndpointListItem, PluginDetail } from '../types'
|
||||
import EndpointModal from './endpoint-modal'
|
||||
import { NAME_FIELD } from './utils'
|
||||
import { addDefaultValue, toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
|
||||
import { CopyCheck } from '@/app/components/base/icons/src/vender/line/files'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
useDeleteEndpoint,
|
||||
useDisableEndpoint,
|
||||
useEnableEndpoint,
|
||||
useUpdateEndpoint,
|
||||
} from '@/service/use-endpoints'
|
||||
|
||||
type Props = {
|
||||
pluginDetail: PluginDetail
|
||||
data: EndpointListItem
|
||||
handleChange: () => void
|
||||
}
|
||||
|
||||
const EndpointCard = ({
|
||||
pluginDetail,
|
||||
data,
|
||||
handleChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const [active, setActive] = useState(data.enabled)
|
||||
const endpointID = data.id
|
||||
|
||||
// switch
|
||||
const [isShowDisableConfirm, {
|
||||
setTrue: showDisableConfirm,
|
||||
setFalse: hideDisableConfirm,
|
||||
}] = useBoolean(false)
|
||||
const { mutate: enableEndpoint } = useEnableEndpoint({
|
||||
onSuccess: async () => {
|
||||
await handleChange()
|
||||
},
|
||||
onError: () => {
|
||||
Toast.notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') })
|
||||
setActive(false)
|
||||
},
|
||||
})
|
||||
const { mutate: disableEndpoint } = useDisableEndpoint({
|
||||
onSuccess: async () => {
|
||||
await handleChange()
|
||||
hideDisableConfirm()
|
||||
},
|
||||
onError: () => {
|
||||
Toast.notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') })
|
||||
setActive(false)
|
||||
},
|
||||
})
|
||||
const handleSwitch = (state: boolean) => {
|
||||
if (state) {
|
||||
setActive(true)
|
||||
enableEndpoint(endpointID)
|
||||
}
|
||||
else {
|
||||
setActive(false)
|
||||
showDisableConfirm()
|
||||
}
|
||||
}
|
||||
|
||||
// delete
|
||||
const [isShowDeleteConfirm, {
|
||||
setTrue: showDeleteConfirm,
|
||||
setFalse: hideDeleteConfirm,
|
||||
}] = useBoolean(false)
|
||||
const { mutate: deleteEndpoint } = useDeleteEndpoint({
|
||||
onSuccess: async () => {
|
||||
await handleChange()
|
||||
hideDeleteConfirm()
|
||||
},
|
||||
onError: () => {
|
||||
Toast.notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') })
|
||||
},
|
||||
})
|
||||
|
||||
// update
|
||||
const [isShowEndpointModal, {
|
||||
setTrue: showEndpointModalConfirm,
|
||||
setFalse: hideEndpointModalConfirm,
|
||||
}] = useBoolean(false)
|
||||
const formSchemas = useMemo(() => {
|
||||
return toolCredentialToFormSchemas([NAME_FIELD, ...data.declaration.settings])
|
||||
}, [data.declaration.settings])
|
||||
const formValue = useMemo(() => {
|
||||
const formValue = {
|
||||
name: data.name,
|
||||
...data.settings,
|
||||
}
|
||||
return addDefaultValue(formValue, formSchemas)
|
||||
}, [data.name, data.settings, formSchemas])
|
||||
const { mutate: updateEndpoint } = useUpdateEndpoint({
|
||||
onSuccess: async () => {
|
||||
await handleChange()
|
||||
hideEndpointModalConfirm()
|
||||
},
|
||||
onError: () => {
|
||||
Toast.notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') })
|
||||
},
|
||||
})
|
||||
const handleUpdate = (state: Record<string, any>) => updateEndpoint({
|
||||
endpointID,
|
||||
state,
|
||||
})
|
||||
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const handleCopy = (value: string) => {
|
||||
copy(value)
|
||||
setIsCopied(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isCopied) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsCopied(false)
|
||||
}, 2000)
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [isCopied])
|
||||
|
||||
const CopyIcon = isCopied ? CopyCheck : RiClipboardLine
|
||||
|
||||
return (
|
||||
<div className='rounded-xl bg-background-section-burn p-0.5'>
|
||||
<div className='group rounded-[10px] border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-2.5 pl-3'>
|
||||
<div className='flex items-center'>
|
||||
<div className='system-md-semibold mb-1 flex h-6 grow items-center gap-1 text-text-secondary'>
|
||||
<RiLoginCircleLine className='h-4 w-4' />
|
||||
<div>{data.name}</div>
|
||||
</div>
|
||||
<div className='hidden items-center group-hover:flex'>
|
||||
<ActionButton onClick={showEndpointModalConfirm}>
|
||||
<RiEditLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
<ActionButton onClick={showDeleteConfirm} className='text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive'>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
{data.declaration.endpoints.filter(endpoint => !endpoint.hidden).map((endpoint, index) => (
|
||||
<div key={index} className='flex h-6 items-center'>
|
||||
<div className='system-xs-regular w-12 shrink-0 text-text-tertiary'>{endpoint.method}</div>
|
||||
<div className='group/item system-xs-regular flex grow items-center truncate text-text-secondary'>
|
||||
<div title={`${data.url}${endpoint.path}`} className='truncate'>{`${data.url}${endpoint.path}`}</div>
|
||||
<Tooltip popupContent={t(`common.operation.${isCopied ? 'copied' : 'copy'}`)} position='top'>
|
||||
<ActionButton className='ml-2 hidden shrink-0 group-hover/item:flex' onClick={() => handleCopy(`${data.url}${endpoint.path}`)}>
|
||||
<CopyIcon className='h-3.5 w-3.5 text-text-tertiary' />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='flex items-center justify-between p-2 pl-3'>
|
||||
{active && (
|
||||
<div className='system-xs-semibold-uppercase flex items-center gap-1 text-util-colors-green-green-600'>
|
||||
<Indicator color='green' />
|
||||
{t('plugin.detailPanel.serviceOk')}
|
||||
</div>
|
||||
)}
|
||||
{!active && (
|
||||
<div className='system-xs-semibold-uppercase flex items-center gap-1 text-text-tertiary'>
|
||||
<Indicator color='gray' />
|
||||
{t('plugin.detailPanel.disabled')}
|
||||
</div>
|
||||
)}
|
||||
<Switch
|
||||
className='ml-3'
|
||||
defaultValue={active}
|
||||
onChange={handleSwitch}
|
||||
size='sm'
|
||||
/>
|
||||
</div>
|
||||
{isShowDisableConfirm && (
|
||||
<Confirm
|
||||
isShow
|
||||
title={t('plugin.detailPanel.endpointDisableTip')}
|
||||
content={<div>{t('plugin.detailPanel.endpointDisableContent', { name: data.name })}</div>}
|
||||
onCancel={() => {
|
||||
hideDisableConfirm()
|
||||
setActive(true)
|
||||
}}
|
||||
onConfirm={() => disableEndpoint(endpointID)}
|
||||
/>
|
||||
)}
|
||||
{isShowDeleteConfirm && (
|
||||
<Confirm
|
||||
isShow
|
||||
title={t('plugin.detailPanel.endpointDeleteTip')}
|
||||
content={<div>{t('plugin.detailPanel.endpointDeleteContent', { name: data.name })}</div>}
|
||||
onCancel={hideDeleteConfirm}
|
||||
onConfirm={() => deleteEndpoint(endpointID)}
|
||||
/>
|
||||
)}
|
||||
{isShowEndpointModal && (
|
||||
<EndpointModal
|
||||
formSchemas={formSchemas as any}
|
||||
defaultValues={formValue}
|
||||
onCancel={hideEndpointModalConfirm}
|
||||
onSaved={handleUpdate}
|
||||
pluginDetail={pluginDetail}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EndpointCard
|
||||
@@ -0,0 +1,121 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import {
|
||||
RiAddLine,
|
||||
RiApps2AddLine,
|
||||
RiBookOpenLine,
|
||||
} from '@remixicon/react'
|
||||
import EndpointModal from './endpoint-modal'
|
||||
import EndpointCard from './endpoint-card'
|
||||
import { NAME_FIELD } from './utils'
|
||||
import { toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import {
|
||||
useCreateEndpoint,
|
||||
useEndpointList,
|
||||
useInvalidateEndpointList,
|
||||
} from '@/service/use-endpoints'
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
detail: PluginDetail
|
||||
}
|
||||
const EndpointList = ({ detail }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const pluginUniqueID = detail.plugin_unique_identifier
|
||||
const declaration = detail.declaration.endpoint
|
||||
const showTopBorder = detail.declaration.tool
|
||||
const { data } = useEndpointList(detail.plugin_id)
|
||||
const invalidateEndpointList = useInvalidateEndpointList()
|
||||
|
||||
const [isShowEndpointModal, {
|
||||
setTrue: showEndpointModal,
|
||||
setFalse: hideEndpointModal,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const formSchemas = useMemo(() => {
|
||||
return toolCredentialToFormSchemas([NAME_FIELD, ...declaration.settings])
|
||||
}, [declaration.settings])
|
||||
|
||||
const { mutate: createEndpoint } = useCreateEndpoint({
|
||||
onSuccess: async () => {
|
||||
await invalidateEndpointList(detail.plugin_id)
|
||||
hideEndpointModal()
|
||||
},
|
||||
onError: () => {
|
||||
Toast.notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') })
|
||||
},
|
||||
})
|
||||
|
||||
const handleCreate = (state: Record<string, any>) => createEndpoint({
|
||||
pluginUniqueID,
|
||||
state,
|
||||
})
|
||||
|
||||
if (!data)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className={cn('border-divider-subtle px-4 py-2', showTopBorder && 'border-t')}>
|
||||
<div className='system-sm-semibold-uppercase mb-1 flex h-6 items-center justify-between text-text-secondary'>
|
||||
<div className='flex items-center gap-0.5'>
|
||||
{t('plugin.detailPanel.endpoints')}
|
||||
<Tooltip
|
||||
position='right'
|
||||
popupClassName='w-[240px] p-4 rounded-xl bg-components-panel-bg-blur border-[0.5px] border-components-panel-border'
|
||||
popupContent={
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle'>
|
||||
<RiApps2AddLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>{t('plugin.detailPanel.endpointsTip')}</div>
|
||||
<a
|
||||
href={docLink('/plugins/schema-definition/endpoint')}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<div className='system-xs-regular inline-flex cursor-pointer items-center gap-1 text-text-accent'>
|
||||
<RiBookOpenLine className='h-3 w-3' />
|
||||
{t('plugin.detailPanel.endpointsDocLink')}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ActionButton onClick={showEndpointModal}>
|
||||
<RiAddLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</div>
|
||||
{data.endpoints.length === 0 && (
|
||||
<div className='system-xs-regular mb-1 flex justify-center rounded-[10px] bg-background-section p-3 text-text-tertiary'>{t('plugin.detailPanel.endpointsEmpty')}</div>
|
||||
)}
|
||||
<div className='flex flex-col gap-2'>
|
||||
{data.endpoints.map((item, index) => (
|
||||
<EndpointCard
|
||||
key={index}
|
||||
data={item}
|
||||
handleChange={() => invalidateEndpointList(detail.plugin_id)}
|
||||
pluginDetail={detail}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{isShowEndpointModal && (
|
||||
<EndpointModal
|
||||
formSchemas={formSchemas as any}
|
||||
onCancel={hideEndpointModal}
|
||||
onSaved={handleCreate}
|
||||
pluginDetail={detail}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EndpointList
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user