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:
yyh
2026-03-09 16:16:16 +08:00
parent 0c17823c8b
commit 5cee7cf8ce
8 changed files with 65 additions and 45 deletions

View File

@ -1,12 +1,8 @@
import type { WorkflowNodesMap } from '@/app/components/base/prompt-editor/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { extractPluginId } from '@/app/components/workflow/utils/plugin'
import { useProviderContextSelector } from '@/context/provider-context'
function extractPluginId(provider: string): string {
const parts = provider.split('/')
return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : provider
}
export function useLlmModelPluginInstalled(
nodeId: string,
workflowNodesMap: WorkflowNodesMap | undefined,

View File

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

View File

@ -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', () => {

View File

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

View File

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

View File

@ -0,0 +1,4 @@
export function extractPluginId(provider: string): string {
const parts = provider.split('/')
return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : provider
}

View File

@ -299,6 +299,7 @@
"error.operations.updatingWorkflow": "updating workflow",
"error.startNodeRequired": "Please add a start node first before {{operation}}",
"errorMsg.authRequired": "Authorization is required",
"errorMsg.configureModel": "Configure a model",
"errorMsg.fieldRequired": "{{field}} is required",
"errorMsg.fields.code": "Code",
"errorMsg.fields.model": "Model",

View File

@ -299,6 +299,7 @@
"error.operations.updatingWorkflow": "更新工作流",
"error.startNodeRequired": "请先添加开始节点,然后再{{operation}}",
"errorMsg.authRequired": "请先授权",
"errorMsg.configureModel": "请配置模型",
"errorMsg.fieldRequired": "{{field}} 不能为空",
"errorMsg.fields.code": "代码",
"errorMsg.fields.model": "模型",