mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 17:08:03 +08:00
feat(web): add LLM model plugin check to workflow checklist
Detect uninstalled model plugins for LLM nodes in the checklist and publish-gate. Migrate ChecklistItem.errorMessage to errorMessages[] so a single node can surface multiple validation issues at once. - Extract shared extractPluginId utility for checklist and prompt editor - Build installed-plugin Set (O(1) lookup) from ProviderContext - Remove short-circuit between checkValid and variable validation - Sync the same check into handleCheckBeforePublish - Adapt node-group, use-last-run, and test assertions
This commit is contained in:
@ -25,12 +25,12 @@ export const ChecklistNodeGroup = memo(({
|
||||
|
||||
const subItems = useMemo(() => {
|
||||
const items: ChecklistSubItem[] = []
|
||||
if (item.errorMessage)
|
||||
items.push({ key: 'error', message: item.errorMessage })
|
||||
for (let i = 0; i < item.errorMessages.length; i++)
|
||||
items.push({ key: `error-${i}`, message: item.errorMessages[i] })
|
||||
if (item.unConnected)
|
||||
items.push({ key: 'unconnected', message: t('common.needConnectTip', { ns: 'workflow' }) })
|
||||
return items
|
||||
}, [item.errorMessage, item.unConnected, t])
|
||||
}, [item.errorMessages, item.unConnected, t])
|
||||
|
||||
return (
|
||||
<div className="overflow-clip rounded-[10px] bg-components-panel-on-panel-item-bg">
|
||||
|
||||
@ -195,7 +195,7 @@ describe('useChecklist', () => {
|
||||
|
||||
const warning = result.current.find((item: ChecklistItem) => item.id === 'llm')
|
||||
expect(warning).toBeDefined()
|
||||
expect(warning!.errorMessage).toBe('Model not configured')
|
||||
expect(warning!.errorMessages).toContain('Model not configured')
|
||||
})
|
||||
|
||||
it('should report missing start node in workflow mode', () => {
|
||||
|
||||
@ -8,6 +8,7 @@ import type {
|
||||
CommonEdgeType,
|
||||
CommonNodeType,
|
||||
Edge,
|
||||
ModelConfig,
|
||||
Node,
|
||||
ValueSelector,
|
||||
} from '../types'
|
||||
@ -28,6 +29,7 @@ import { useModelList } from '@/app/components/header/account-setting/model-prov
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { MAX_TREE_DEPTH } from '@/config'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { fetchDatasets } from '@/service/datasets'
|
||||
import { useStrategyProviders } from '@/service/use-strategy'
|
||||
import {
|
||||
@ -56,6 +58,7 @@ import {
|
||||
getToolCheckParams,
|
||||
getValidTreeNodes,
|
||||
} from '../utils'
|
||||
import { extractPluginId } from '../utils/plugin'
|
||||
import { getTriggerCheckParams } from '../utils/trigger'
|
||||
import useNodesAvailableVarList, { useGetNodesAvailableVarList } from './use-nodes-available-var-list'
|
||||
|
||||
@ -65,7 +68,7 @@ export type ChecklistItem = {
|
||||
title: string
|
||||
toolIcon?: string | Emoji
|
||||
unConnected?: boolean
|
||||
errorMessage?: string
|
||||
errorMessages: string[]
|
||||
canNavigate: boolean
|
||||
disableGoTo?: boolean
|
||||
isPluginMissing?: boolean
|
||||
@ -100,6 +103,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
const getToolIcon = useGetToolIcon()
|
||||
const appMode = useAppStore.getState().appDetail?.mode
|
||||
const shouldCheckStartNode = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT
|
||||
const modelProviders = useProviderContextSelector(s => s.modelProviders)
|
||||
|
||||
const map = useNodesAvailableVarList(nodes)
|
||||
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
|
||||
@ -133,6 +137,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
const list: ChecklistItem[] = []
|
||||
const filteredNodes = nodes.filter(node => node.type === CUSTOM_NODE)
|
||||
const { validNodes } = getValidTreeNodes(filteredNodes, edges)
|
||||
const installedPluginIds = new Set(modelProviders.map(p => extractPluginId(p.provider)))
|
||||
|
||||
for (let i = 0; i < filteredNodes.length; i++) {
|
||||
const node = filteredNodes[i]
|
||||
@ -170,39 +175,44 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
const validator = nodesExtraData?.[node.data.type as BlockEnum]?.checkValid
|
||||
const isPluginMissing = PLUGIN_DEPENDENT_TYPES.includes(node.data.type as BlockEnum) && node.data._pluginInstallLocked
|
||||
|
||||
// Check if plugin is installed for plugin-dependent nodes first
|
||||
let errorMessage: string | undefined
|
||||
if (isPluginMissing)
|
||||
errorMessage = t('nodes.common.pluginNotInstalled', { ns: 'workflow' })
|
||||
else if (validator)
|
||||
errorMessage = validator(checkData, t, moreDataForCheckValid).errorMessage
|
||||
const errorMessages: string[] = []
|
||||
|
||||
if (!errorMessage) {
|
||||
const availableVars = map[node.id].availableVars
|
||||
|
||||
for (const variable of usedVars) {
|
||||
const isSpecialVars = isSpecialVar(variable[0])
|
||||
if (!isSpecialVars) {
|
||||
const usedNode = availableVars.find(v => v.nodeId === variable?.[0])
|
||||
if (usedNode) {
|
||||
const usedVar = usedNode.vars.find(v => v.variable === variable?.[1])
|
||||
if (!usedVar)
|
||||
errorMessage = t('errorMsg.invalidVariable', { ns: 'workflow' })
|
||||
}
|
||||
else {
|
||||
errorMessage = t('errorMsg.invalidVariable', { ns: 'workflow' })
|
||||
}
|
||||
}
|
||||
if (isPluginMissing) {
|
||||
errorMessages.push(t('nodes.common.pluginNotInstalled', { ns: 'workflow' }))
|
||||
}
|
||||
else {
|
||||
if (node.data.type === BlockEnum.LLM) {
|
||||
const modelProvider = (node.data as CommonNodeType<{ model?: ModelConfig }>).model?.provider
|
||||
if (modelProvider && !installedPluginIds.has(extractPluginId(modelProvider)))
|
||||
errorMessages.push(t('errorMsg.configureModel', { ns: 'workflow' }))
|
||||
}
|
||||
|
||||
if (validator) {
|
||||
const validationError = validator(checkData, t, moreDataForCheckValid).errorMessage
|
||||
if (validationError)
|
||||
errorMessages.push(validationError)
|
||||
}
|
||||
|
||||
const availableVars = map[node.id].availableVars
|
||||
let hasInvalidVar = false
|
||||
for (const variable of usedVars) {
|
||||
if (hasInvalidVar)
|
||||
break
|
||||
if (isSpecialVar(variable[0]))
|
||||
continue
|
||||
const usedNode = availableVars.find(v => v.nodeId === variable?.[0])
|
||||
if (!usedNode || !usedNode.vars.find(v => v.variable === variable?.[1]))
|
||||
hasInvalidVar = true
|
||||
}
|
||||
if (hasInvalidVar)
|
||||
errorMessages.push(t('errorMsg.invalidVariable', { ns: 'workflow' }))
|
||||
}
|
||||
|
||||
// Start nodes and Trigger nodes should not show unConnected error if they have validation errors
|
||||
// or if they are valid start nodes (even without incoming connections)
|
||||
const isStartNodeMeta = nodesExtraData?.[node.data.type as BlockEnum]?.metaData.isStart ?? false
|
||||
const canSkipConnectionCheck = shouldCheckStartNode ? isStartNodeMeta : true
|
||||
|
||||
const isUnconnected = !validNodes.find(n => n.id === node.id)
|
||||
const shouldShowError = errorMessage || (isUnconnected && !canSkipConnectionCheck)
|
||||
const shouldShowError = errorMessages.length > 0 || (isUnconnected && !canSkipConnectionCheck)
|
||||
|
||||
if (shouldShowError) {
|
||||
list.push({
|
||||
@ -211,7 +221,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
title: node.data.title,
|
||||
toolIcon,
|
||||
unConnected: isUnconnected && !canSkipConnectionCheck,
|
||||
errorMessage,
|
||||
errorMessages,
|
||||
canNavigate: !isPluginMissing,
|
||||
disableGoTo: isPluginMissing,
|
||||
isPluginMissing,
|
||||
@ -231,7 +241,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
id: 'start-node-required',
|
||||
type: BlockEnum.Start,
|
||||
title: t('panel.startNode', { ns: 'workflow' }),
|
||||
errorMessage: t('common.needStartNode', { ns: 'workflow' }),
|
||||
errorMessages: [t('common.needStartNode', { ns: 'workflow' })],
|
||||
canNavigate: false,
|
||||
})
|
||||
}
|
||||
@ -244,18 +254,15 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
list.push({
|
||||
id: `${type}-need-added`,
|
||||
type,
|
||||
// We don't have enough type info for t() here
|
||||
|
||||
title: t(`blocks.${type}` as I18nKeysWithPrefix<'workflow', 'blocks.'>, { ns: 'workflow' }),
|
||||
|
||||
errorMessage: t('common.needAdd', { ns: 'workflow', node: t(`blocks.${type}` as I18nKeysWithPrefix<'workflow', 'blocks.'>, { ns: 'workflow' }) }),
|
||||
errorMessages: [t('common.needAdd', { ns: 'workflow', node: t(`blocks.${type}` as I18nKeysWithPrefix<'workflow', 'blocks.'>, { ns: 'workflow' }) })],
|
||||
canNavigate: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return list
|
||||
}, [nodes, edges, shouldCheckStartNode, nodesExtraData, buildInTools, customTools, workflowTools, language, dataSourceList, triggerPlugins, getToolIcon, strategyProviders, getCheckData, t, map])
|
||||
}, [nodes, edges, shouldCheckStartNode, nodesExtraData, buildInTools, customTools, workflowTools, language, dataSourceList, triggerPlugins, getToolIcon, strategyProviders, getCheckData, t, map, modelProviders])
|
||||
|
||||
return needWarningNodes
|
||||
}
|
||||
@ -267,6 +274,7 @@ export const useChecklistBeforePublish = () => {
|
||||
const store = useStoreApi()
|
||||
const { nodesMap: nodesExtraData } = useNodesMetaData()
|
||||
const { data: strategyProviders } = useStrategyProviders()
|
||||
const modelProviders = useProviderContextSelector(s => s.modelProviders)
|
||||
const updateDatasetsDetail = useDatasetsDetailStore(s => s.updateDatasetsDetail)
|
||||
const updateTime = useRef(0)
|
||||
const workflowStore = useWorkflowStore()
|
||||
@ -341,6 +349,7 @@ export const useChecklistBeforePublish = () => {
|
||||
updateDatasetsDetail(datasetsDetail)
|
||||
}
|
||||
}
|
||||
const installedPluginIds = new Set(modelProviders.map(p => extractPluginId(p.provider)))
|
||||
const map = getNodesAvailableVarList(nodes)
|
||||
for (let i = 0; i < filteredNodes.length; i++) {
|
||||
const node = filteredNodes[i]
|
||||
@ -367,6 +376,15 @@ export const useChecklistBeforePublish = () => {
|
||||
else {
|
||||
usedVars = getNodeUsedVars(node).filter(v => v.length > 0)
|
||||
}
|
||||
|
||||
if (node.data.type === BlockEnum.LLM) {
|
||||
const modelProvider = (node.data as CommonNodeType<{ model?: ModelConfig }>).model?.provider
|
||||
if (modelProvider && !installedPluginIds.has(extractPluginId(modelProvider))) {
|
||||
notify({ type: 'error', message: `[${node.data.title}] ${t('errorMsg.configureModel', { ns: 'workflow' })}` })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const checkData = getCheckData(node.data, datasets)
|
||||
const { errorMessage } = nodesExtraData![node.data.type as BlockEnum].checkValid(checkData, t, moreDataForCheckValid)
|
||||
|
||||
@ -425,7 +443,7 @@ export const useChecklistBeforePublish = () => {
|
||||
}
|
||||
|
||||
return true
|
||||
}, [store, workflowStore, getNodesAvailableVarList, shouldCheckStartNode, nodesExtraData, notify, t, updateDatasetsDetail, buildInTools, customTools, workflowTools, language, getCheckData, strategyProviders])
|
||||
}, [store, workflowStore, getNodesAvailableVarList, shouldCheckStartNode, nodesExtraData, notify, t, updateDatasetsDetail, buildInTools, customTools, workflowTools, language, getCheckData, strategyProviders, modelProviders])
|
||||
|
||||
return {
|
||||
handleCheckBeforePublish,
|
||||
|
||||
@ -159,10 +159,10 @@ const useLastRun = <T>({
|
||||
if (!warningForNode)
|
||||
return false
|
||||
|
||||
if (warningForNode.unConnected && !warningForNode.errorMessage)
|
||||
if (warningForNode.unConnected && warningForNode.errorMessages.length === 0)
|
||||
return false
|
||||
|
||||
const message = warningForNode.errorMessage || 'This node has unresolved checklist issues'
|
||||
const message = warningForNode.errorMessages[0] || 'This node has unresolved checklist issues'
|
||||
Toast.notify({ type: 'error', message })
|
||||
return true
|
||||
}, [warningNodes, id])
|
||||
|
||||
4
web/app/components/workflow/utils/plugin.ts
Normal file
4
web/app/components/workflow/utils/plugin.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export function extractPluginId(provider: string): string {
|
||||
const parts = provider.split('/')
|
||||
return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : provider
|
||||
}
|
||||
Reference in New Issue
Block a user