mirror of
https://github.com/langgenius/dify.git
synced 2026-03-10 01:46:14 +08:00
feat(workflow): open install bundle from checklist and strict marketplace parsing
This commit is contained in:
@ -270,6 +270,24 @@ describe('useInstallMultiState', () => {
|
||||
|
||||
// ==================== Error Handling ====================
|
||||
describe('Error Handling', () => {
|
||||
it('should mark marketplace index as error when identifier is missing', async () => {
|
||||
const params = createDefaultParams({
|
||||
allPlugins: [
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
version: '1.0.0',
|
||||
},
|
||||
} as GitHubItemAndMarketPlaceDependency,
|
||||
] as Dependency[],
|
||||
})
|
||||
const { result } = renderHook(() => useInstallMultiState(params))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.errorIndexes).toContain(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should mark all marketplace indexes as errors on fetch failure', async () => {
|
||||
mockMarketplaceError = new Error('Fetch failed')
|
||||
|
||||
|
||||
@ -14,14 +14,30 @@ type UseInstallMultiStateParams = {
|
||||
onLoadedAllPlugin: (installedInfo: Record<string, VersionInfo>) => void
|
||||
}
|
||||
|
||||
type MarketplacePluginInfo = {
|
||||
organization: string
|
||||
plugin: string
|
||||
version?: string
|
||||
}
|
||||
|
||||
export function getPluginKey(plugin: Plugin | undefined): string {
|
||||
return `${plugin?.org || plugin?.author}/${plugin?.name}`
|
||||
}
|
||||
|
||||
function parseMarketplaceIdentifier(identifier: string) {
|
||||
const [orgPart, nameAndVersionPart] = identifier.split('@')[0].split('/')
|
||||
const [name, version] = nameAndVersionPart.split(':')
|
||||
return { organization: orgPart, plugin: name, version }
|
||||
function parseMarketplaceIdentifier(identifier?: string): MarketplacePluginInfo | null {
|
||||
if (!identifier)
|
||||
return null
|
||||
|
||||
const withoutHash = identifier.split('@')[0]
|
||||
const [organization, nameAndVersionPart] = withoutHash.split('/')
|
||||
if (!organization || !nameAndVersionPart)
|
||||
return null
|
||||
|
||||
const [plugin, version] = nameAndVersionPart.split(':')
|
||||
if (!plugin)
|
||||
return null
|
||||
|
||||
return { organization, plugin, version }
|
||||
}
|
||||
|
||||
function initPluginsFromDependencies(allPlugins: Dependency[]): (Plugin | undefined)[] {
|
||||
@ -61,67 +77,67 @@ export function useInstallMultiState({
|
||||
}, [])
|
||||
}, [allPlugins])
|
||||
|
||||
// Marketplace data fetching: by unique identifier and by meta info
|
||||
const { marketplacePayloadByIdentifier, invalidMarketplaceIndexes } = useMemo(() => {
|
||||
return marketplacePlugins.reduce<{
|
||||
marketplacePayloadByIdentifier: MarketplacePluginInfo[]
|
||||
invalidMarketplaceIndexes: number[]
|
||||
}>((acc, d, marketplaceIndex) => {
|
||||
const parsedIdentifier = parseMarketplaceIdentifier(
|
||||
d.value.marketplace_plugin_unique_identifier || d.value.plugin_unique_identifier,
|
||||
)
|
||||
const dslIndex = marketPlaceInDSLIndex[marketplaceIndex]
|
||||
if (parsedIdentifier)
|
||||
acc.marketplacePayloadByIdentifier.push(parsedIdentifier)
|
||||
else if (dslIndex !== undefined)
|
||||
acc.invalidMarketplaceIndexes.push(dslIndex)
|
||||
return acc
|
||||
}, {
|
||||
marketplacePayloadByIdentifier: [],
|
||||
invalidMarketplaceIndexes: [],
|
||||
})
|
||||
}, [marketPlaceInDSLIndex, marketplacePlugins])
|
||||
|
||||
// Marketplace data fetching: by unique identifier
|
||||
const {
|
||||
isLoading: isFetchingById,
|
||||
data: infoGetById,
|
||||
error: infoByIdError,
|
||||
} = useFetchPluginsInMarketPlaceByInfo(
|
||||
marketplacePlugins.map(d => parseMarketplaceIdentifier(d.value.marketplace_plugin_unique_identifier!)),
|
||||
)
|
||||
|
||||
const {
|
||||
isLoading: isFetchingByMeta,
|
||||
data: infoByMeta,
|
||||
error: infoByMetaError,
|
||||
} = useFetchPluginsInMarketPlaceByInfo(
|
||||
marketplacePlugins.map(d => d.value!),
|
||||
marketplacePayloadByIdentifier,
|
||||
)
|
||||
|
||||
// Derive marketplace plugin data and errors from API responses
|
||||
const { marketplacePluginMap, marketplaceErrorIndexes } = useMemo(() => {
|
||||
const pluginMap = new Map<number, Plugin>()
|
||||
const errorSet = new Set<number>()
|
||||
const errorSet = new Set<number>(invalidMarketplaceIndexes)
|
||||
|
||||
// Process "by ID" response
|
||||
if (!isFetchingById && infoGetById?.data.list) {
|
||||
const sortedList = marketplacePlugins.map((d) => {
|
||||
const id = d.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 pluginById = new Map(
|
||||
infoGetById.data.list.map(item => [item.plugin.plugin_id, item.plugin]),
|
||||
)
|
||||
|
||||
marketPlaceInDSLIndex.forEach((index, i) => {
|
||||
if (sortedList[i]) {
|
||||
const dependency = marketplacePlugins[i]
|
||||
const pluginId = (dependency?.value.marketplace_plugin_unique_identifier || dependency?.value.plugin_unique_identifier)?.split(':')[0]
|
||||
const pluginInfo = pluginId ? pluginById.get(pluginId) : undefined
|
||||
if (pluginInfo) {
|
||||
pluginMap.set(index, {
|
||||
...sortedList[i],
|
||||
version: sortedList[i]!.version || sortedList[i]!.latest_version,
|
||||
...pluginInfo,
|
||||
from: dependency.type,
|
||||
version: pluginInfo.version || pluginInfo.latest_version,
|
||||
})
|
||||
}
|
||||
else { errorSet.add(index) }
|
||||
})
|
||||
}
|
||||
|
||||
// Process "by meta" response (may overwrite "by ID" results)
|
||||
if (!isFetchingByMeta && infoByMeta?.data.list) {
|
||||
const payloads = infoByMeta.data.list
|
||||
marketPlaceInDSLIndex.forEach((index, i) => {
|
||||
if (payloads[i]) {
|
||||
const item = payloads[i]
|
||||
pluginMap.set(index, {
|
||||
...item.plugin,
|
||||
plugin_id: item.version.unique_identifier,
|
||||
} as Plugin)
|
||||
}
|
||||
else { errorSet.add(index) }
|
||||
})
|
||||
}
|
||||
|
||||
// Mark all marketplace indexes as errors on fetch failure
|
||||
if (infoByMetaError || infoByIdError)
|
||||
if (infoByIdError)
|
||||
marketPlaceInDSLIndex.forEach(index => errorSet.add(index))
|
||||
|
||||
return { marketplacePluginMap: pluginMap, marketplaceErrorIndexes: errorSet }
|
||||
}, [isFetchingById, isFetchingByMeta, infoGetById, infoByMeta, infoByMetaError, infoByIdError, marketPlaceInDSLIndex, marketplacePlugins])
|
||||
}, [invalidMarketplaceIndexes, isFetchingById, infoGetById, infoByIdError, marketPlaceInDSLIndex, marketplacePlugins])
|
||||
|
||||
// GitHub-fetched plugins and errors (imperative state from child callbacks)
|
||||
const [githubPluginMap, setGithubPluginMap] = useState<Map<number, Plugin>>(() => new Map())
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
import type { ChecklistItem } from '../../hooks/use-checklist'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { useStore as usePluginDependencyStore } from '../../plugin-dependency/store'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { ChecklistPluginGroup } from './plugin-group'
|
||||
|
||||
const createChecklistItem = (overrides: Partial<ChecklistItem> = {}): ChecklistItem => ({
|
||||
id: 'node-1',
|
||||
type: BlockEnum.Tool,
|
||||
title: 'Tool Node',
|
||||
errorMessages: [],
|
||||
canNavigate: false,
|
||||
isPluginMissing: true,
|
||||
pluginUniqueIdentifier: 'langgenius/test-plugin:1.0.0@sha256',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ChecklistPluginGroup', () => {
|
||||
beforeEach(() => {
|
||||
usePluginDependencyStore.setState({ dependencies: [] })
|
||||
})
|
||||
|
||||
it('should set marketplace dependencies when install button is clicked', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
createChecklistItem({ id: 'node-1', pluginUniqueIdentifier: 'langgenius/test-plugin:1.0.0@sha256' }),
|
||||
createChecklistItem({ id: 'node-2', pluginUniqueIdentifier: 'langgenius/test-plugin:1.0.0@sha256' }),
|
||||
createChecklistItem({ id: 'node-3', pluginUniqueIdentifier: 'langgenius/another-plugin:2.0.0@sha256' }),
|
||||
]
|
||||
|
||||
render(<ChecklistPluginGroup items={items} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(usePluginDependencyStore.getState().dependencies).toEqual([
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
marketplace_plugin_unique_identifier: 'langgenius/test-plugin:1.0.0@sha256',
|
||||
plugin_unique_identifier: 'langgenius/test-plugin:1.0.0@sha256',
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
marketplace_plugin_unique_identifier: 'langgenius/another-plugin:2.0.0@sha256',
|
||||
plugin_unique_identifier: 'langgenius/another-plugin:2.0.0@sha256',
|
||||
version: '2.0.0',
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should keep install button disabled when no identifier is available', () => {
|
||||
render(<ChecklistPluginGroup items={[createChecklistItem({ pluginUniqueIdentifier: undefined })]} />)
|
||||
|
||||
const installButton = screen.getByRole('button')
|
||||
expect(installButton).toBeDisabled()
|
||||
|
||||
fireEvent.click(installButton)
|
||||
expect(usePluginDependencyStore.getState().dependencies).toEqual([])
|
||||
})
|
||||
})
|
||||
@ -1,49 +1,57 @@
|
||||
import type { MouseEventHandler } from 'react'
|
||||
import type { ChecklistItem } from '../../hooks/use-checklist'
|
||||
import type { BlockEnum } from '../../types'
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import type { Dependency } from '@/app/components/plugins/types'
|
||||
import { memo, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status'
|
||||
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
|
||||
import BlockIcon from '../../block-icon'
|
||||
import { useStore as usePluginDependencyStore } from '../../plugin-dependency/store'
|
||||
import { ItemIndicator } from './item-indicator'
|
||||
|
||||
function getVersionFromMarketplaceIdentifier(identifier: string): string | undefined {
|
||||
const withoutHash = identifier.split('@')[0]
|
||||
const [, version] = withoutHash.split(':')
|
||||
return version || undefined
|
||||
}
|
||||
|
||||
export const ChecklistPluginGroup = memo(({
|
||||
items,
|
||||
}: {
|
||||
items: ChecklistItem[]
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const install = useInstallPackageFromMarketPlace()
|
||||
const [installing, setInstalling] = useState(false)
|
||||
|
||||
const identifiers = useMemo(
|
||||
() => items
|
||||
.map(i => i.pluginUniqueIdentifier)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
() => Array.from(
|
||||
new Set(
|
||||
items
|
||||
.map(i => i.pluginUniqueIdentifier)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
),
|
||||
[items],
|
||||
)
|
||||
|
||||
const handleInstallAll: MouseEventHandler = async (e) => {
|
||||
const dependencies = useMemo<Dependency[]>(() => {
|
||||
return identifiers.map((identifier) => {
|
||||
return {
|
||||
type: 'marketplace',
|
||||
value: {
|
||||
marketplace_plugin_unique_identifier: identifier,
|
||||
plugin_unique_identifier: identifier,
|
||||
version: getVersionFromMarketplaceIdentifier(identifier),
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [identifiers])
|
||||
|
||||
const handleInstallAll: MouseEventHandler = (e) => {
|
||||
e.stopPropagation()
|
||||
if (installing || identifiers.length === 0)
|
||||
if (dependencies.length === 0)
|
||||
return
|
||||
setInstalling(true)
|
||||
for (const id of identifiers) {
|
||||
try {
|
||||
const response = await install.mutateAsync(id)
|
||||
if (response?.task_id) {
|
||||
const { check } = checkTaskStatus()
|
||||
await check({ taskId: response.task_id, pluginUniqueIdentifier: id })
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// continue installing remaining plugins
|
||||
}
|
||||
}
|
||||
setInstalling(false)
|
||||
install.reset()
|
||||
const { setDependencies } = usePluginDependencyStore.getState()
|
||||
setDependencies(dependencies)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -59,16 +67,9 @@ export const ChecklistPluginGroup = memo(({
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={handleInstallAll}
|
||||
disabled={installing || identifiers.length === 0}
|
||||
disabled={dependencies.length === 0}
|
||||
>
|
||||
{installing
|
||||
? (
|
||||
<>
|
||||
{t('nodes.agent.pluginInstaller.installing', { ns: 'workflow' })}
|
||||
<span className="i-ri-loader-2-line ml-1 size-3 animate-spin" />
|
||||
</>
|
||||
)
|
||||
: t('nodes.agent.pluginInstaller.install', { ns: 'workflow' })}
|
||||
{t('nodes.agent.pluginInstaller.install', { ns: 'workflow' })}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-1">
|
||||
|
||||
Reference in New Issue
Block a user