This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View 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 }
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View 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}` : ''
}