diff --git a/web/src/assets/images/workflow/checkList.svg b/web/src/assets/images/workflow/checkList.svg new file mode 100644 index 00000000..169743dc --- /dev/null +++ b/web/src/assets/images/workflow/checkList.svg @@ -0,0 +1,16 @@ + + + 参与 + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/workflow/features.svg b/web/src/assets/images/workflow/features.svg index 2ff48584..bd31b107 100644 --- a/web/src/assets/images/workflow/features.svg +++ b/web/src/assets/images/workflow/features.svg @@ -1,12 +1,14 @@ 参与 - - + + - - + + + + diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index d54f0d25..8f88b561 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1396,6 +1396,43 @@ export const en = { pleaseUploadFile: 'Please upload file', setting: 'Settings', features: 'Conversation Features', + checkList: 'Check List', + checkListDesc: 'Ensure all issues are resolved before publishing', + checkListEmpty: 'No issues found', + notConnected: 'This node is not connected to other nodes', + goto: 'Go to', + cannotBeEmpty: 'cannot be empty', + checkListErrors: { + 'llm.model_id': 'Model', + 'llm.messages': 'Messages', + 'end.output': 'Output', + 'knowledge-retrieval.knowledge_retrieval': 'Knowledge bases', + 'parameter-extractor.model_id': 'Model', + 'parameter-extractor.text': 'Input variable', + 'parameter-extractor.params': 'Params', + 'memory-read.message': 'Message', + 'memory-read.config_id': 'Memory config', + 'memory-read.search_switch': 'Search mode', + 'memory-write.messages': 'Messages', + 'memory-write.config_id': 'Memory config', + 'if-else.cases': 'Condition', + 'question-classifier.model_id': 'Model', + 'question-classifier.input_variable': 'Input variable', + 'question-classifier.categories': 'Categories', + 'iteration.input': 'Input variable', + 'iteration.output': 'Output variable', + 'var-aggregator.group_variables': 'Variables', + 'assigner.assignments': 'Variables', + 'http-request.url': 'API URL', + 'http-request.body.data': 'Binary file variable', + 'code.input_variables': 'Input variables', + 'code.code': 'Code', + 'code.output_variables': 'Output variables', + 'jinja-render.mapping': 'Input variables', + 'jinja-render.template': 'Template', + 'document-extractor.file_selector': 'File variable', + 'list-operator.input_list': 'Input list', + }, file_upload: 'File Upload', file_upload_desc: 'The chat input box supports file uploads. Types include images, documents, and other types', settings: 'File Upload Settings', @@ -2442,7 +2479,45 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re iteration: 'Iteration', input_cycle_vars: 'Initial Loop Variables', output_cycle_vars: 'Final Loop Variables', - } + }, + sureReplace: '确认替换', + checkList: 'Check List', + checkListDesc: 'Ensure all issues are resolved before publishing', + checkListEmpty: 'No issues found', + notConnected: 'This node is not connected to other nodes', + goto: 'Go to', + cannotBeEmpty: 'cannot be empty', + checkListErrors: { + 'llm.model_id': 'Model', + 'llm.messages': 'Messages', + 'end.output': 'Output', + 'knowledge-retrieval.knowledge_retrieval': 'Knowledge bases', + 'parameter-extractor.model_id': 'Model', + 'parameter-extractor.text': 'Input variable', + 'parameter-extractor.params': 'Params', + 'memory-read.message': 'Message', + 'memory-read.config_id': 'Memory config', + 'memory-read.search_switch': 'Search mode', + 'memory-write.messages': 'Messages', + 'memory-write.config_id': 'Memory config', + 'if-else.cases': 'Condition', + 'question-classifier.model_id': 'Model', + 'question-classifier.input_variable': 'Input variable', + 'question-classifier.categories': 'Categories', + 'iteration.input': 'Input variable', + 'iteration.output': 'Output variable', + 'var-aggregator.group_variables': 'Variables', + 'assigner.assignments': 'Variables', + 'http-request.url': 'API URL', + 'http-request.body.data': 'Binary file variable', + 'code.input_variables': 'Input variables', + 'code.code': 'Code', + 'code.output_variables': 'Output variables', + 'jinja-render.mapping': 'Input variables', + 'jinja-render.template': 'Template', + 'document-extractor.file_selector': 'File variable', + 'list-operator.input_list': 'Input list', + }, }, emotionEngine: { emotionEngineConfig: 'Emotion Engine Configuration', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 5b46cb48..e521d5fa 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -2445,6 +2445,43 @@ export const zh = { output_cycle_vars: '最终循环变量', }, sureReplace: '确认替换', + checkList: '检查清单', + checkListDesc: '发布前确保所有问题均已解决', + checkListEmpty: '没有发现问题', + notConnected: '此节点尚未连接到其他节点', + goto: '转到', + cannotBeEmpty: '不能为空', + checkListErrors: { + 'llm.model_id': '模型', + 'llm.messages': '提示词', + 'end.output': '回复', + 'knowledge-retrieval.knowledge_retrieval': '知识库', + 'parameter-extractor.model_id': '模型', + 'parameter-extractor.text': '输入变量', + 'parameter-extractor.params': '提取参数', + 'memory-read.message': '消息', + 'memory-read.config_id': '记忆配置', + 'memory-read.search_switch': '检索模式', + 'memory-write.messages': '消息', + 'memory-write.config_id': '记忆配置', + 'if-else.cases': '条件', + 'question-classifier.model_id': '模型', + 'question-classifier.input_variable': '输入变量', + 'question-classifier.categories': '分类', + 'iteration.input': '输入变量', + 'iteration.output': '输出变量', + 'var-aggregator.group_variables': '变量', + 'assigner.assignments': '变量', + 'http-request.url': 'API URL', + 'http-request.body.data': 'binary文件类型变量', + 'code.input_variables': '输入变量', + 'code.code': '代码', + 'code.output_variables': '输出变量', + 'jinja-render.mapping': '输入变量', + 'jinja-render.template': '模板', + 'document-extractor.file_selector': '文件变量', + 'list-operator.input_list': '输入变量', + }, }, emotionEngine: { emotionEngineConfig: '情感引擎配置', diff --git a/web/src/views/ApplicationConfig/components/ConfigHeader.tsx b/web/src/views/ApplicationConfig/components/ConfigHeader.tsx index d77ae27c..b2e1e36b 100644 --- a/web/src/views/ApplicationConfig/components/ConfigHeader.tsx +++ b/web/src/views/ApplicationConfig/components/ConfigHeader.tsx @@ -4,7 +4,7 @@ * @Last Modified by: ZhaoYing * @Last Modified time: 2026-04-07 16:28:33 */ -import { type FC, useRef, useMemo, useCallback } from 'react'; +import { type FC, useRef, useMemo } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { Tabs, Dropdown, Flex, Popover } from 'antd'; import type { MenuProps } from 'antd'; @@ -18,6 +18,7 @@ import type { CopyModalRef, AgentRef, ClusterRef, WorkflowRef, FeaturesConfigFor import { deleteApplication, appExport } from '@/api/application' import CopyModal from './CopyModal' import PageHeader from '@/components/Layout/PageHeader' +import CheckList from '@/views/Workflow/components/CheckList' /** * Tab keys for application configuration @@ -206,6 +207,7 @@ const ConfigHeader: FC = ({ } extra={application?.type === 'workflow' && source !== 'sharing' && activeTab === 'arrangement' ? +
void; handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void; + nodeClick: ({ node }: { node: Node }) => void; } /** diff --git a/web/src/views/ModelManagement/List.tsx b/web/src/views/ModelManagement/List.tsx index 10026aa5..7342ecd1 100644 --- a/web/src/views/ModelManagement/List.tsx +++ b/web/src/views/ModelManagement/List.tsx @@ -106,6 +106,10 @@ const ModelList = forwardRef void; handleEdit: (vo?: ModelListItem) => void; handleCloseConfig?: () => void; + query?: any; } /** * Model list detail drawer component */ -const ModelListDetail = forwardRef(({ refresh, handleEdit, handleCloseConfig }, ref) => { +const ModelListDetail = forwardRef(({ refresh, handleEdit, handleCloseConfig, query }, ref) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); const [data, setData] = useState({} as ProviderModelItem) @@ -58,7 +59,8 @@ const ModelListDetail = forwardRef(({ if (!vo.provider) return getModelNewList({ - provider: vo.provider + provider: vo.provider, + ...query, }) .then(res => { const response = res as ProviderModelItem[] diff --git a/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx b/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx index d718b2eb..604195f7 100644 --- a/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx +++ b/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx @@ -52,7 +52,7 @@ const types = [ 'number', 'boolean', 'object', - // 'file', + 'file', 'array[file]', 'array[string]', 'array[number]', diff --git a/web/src/views/Workflow/components/CheckList/index.tsx b/web/src/views/Workflow/components/CheckList/index.tsx new file mode 100644 index 00000000..fe627e03 --- /dev/null +++ b/web/src/views/Workflow/components/CheckList/index.tsx @@ -0,0 +1,285 @@ +import { type FC, useState, useCallback, useEffect, useRef } from 'react' +import { Popover, Flex } from 'antd' +import { WarningFilled } from '@ant-design/icons' +import { useTranslation } from 'react-i18next' +import { Node } from '@antv/x6'; + +import type { WorkflowRef } from '@/views/ApplicationConfig/types' +import { nodeLibrary } from '../../constant' +import { getToolMethods } from '@/api/tools' +import RbDrawer from '@/components/RbDrawer' + +interface CheckListProps { + workflowRef: React.RefObject +} + +interface CheckError { + key: string + message: string +} + +interface NodeCheckResult { + id: string + name: string + type: string + icon: string + errors: CheckError[] +} + +const allNodes = nodeLibrary.flatMap(c => c.nodes) +const nodeIconMap: Record = Object.fromEntries(allNodes.map(n => [n.type, n.icon])) +const nodeConfigMap: Record> = Object.fromEntries( + allNodes.filter(n => n.config).map(n => [n.type, n.config!]) +) + +// Special validators for fields that need deeper checks beyond simple empty check +const specialValidators: Record boolean> = { + // llm.messages: at least one message with non-empty content + 'llm.messages': (val: any[]) => !Array.isArray(val) || !val.some(m => m?.content && String(m.content).trim()), + // knowledge-retrieval.knowledge_retrieval: knowledge_bases array must be non-empty + 'knowledge-retrieval.knowledge_retrieval': (val: any) => !(val?.knowledge_bases?.length > 0), + 'memory-write.messages': (val: any[]) => !Array.isArray(val) || !val.some(m => m?.content && String(m.content).trim()), + // if-else.cases: every case must have at least one expression, and every expression must be fully set + 'if-else.cases': (val: any[]) => { + if (!Array.isArray(val) || !val.length) return true + return val.some(c => { + if (!c?.expressions?.length) return true + return c.expressions.some((expr: any) => { + if (!expr?.left) return true + if (['not_empty', 'empty'].includes(expr.operator)) return false + return !(!!expr.left && (!!expr.right || typeof expr.right === 'boolean' || typeof expr.right === 'number')) + }) + }) + }, + // question-classifier.categories: every category must have a value + 'question-classifier.categories': (val: any[]) => !Array.isArray(val) || !val.some(c => c?.class_name && String(c.class_name).trim()), + // var-aggregator.group_variables: must be non-empty array + 'var-aggregator.group_variables': (val: any[]) => !Array.isArray(val) || !val.length, + // assigner.assignments: every item needs variable_selector + operation; value required unless operation is 'clear' + 'assigner.assignments': (val: any[]) => { + if (!Array.isArray(val) || !val.length) return false + return val.some(a => { + if (!a?.variable_selector || !a?.operation) return true + if (a.operation === 'clear') return false + return a.value === undefined || a.value === null || a.value === '' + }) + }, + // http-request.body: binary content_type requires data + 'http-request.body': (val: any) => val?.content_type === 'binary' && !val?.data, + // tool.tool_parameters: validated async via API, placeholder always returns false + 'tool.tool_parameters': () => false, + // code.input_variables: if non-empty, every item must have both name and variable + 'code.input_variables': (val: any[]) => Array.isArray(val) && val.length > 0 && val.some(v => !v?.name || !v?.variable), + // code.output_variables: must be non-empty + 'code.output_variables': (val: any[]) => !Array.isArray(val) || !val.length, + // jinja-render.mapping: if non-empty, every item must have a name + 'jinja-render.mapping': (val: any[]) => Array.isArray(val) && val.length > 0 && val.some(v => !v?.name || !v?.value), +} + +function isEmpty(val: any): boolean { + console.log('validateNode isEmpty', val, val === undefined || val === null || val === '') + if (val === undefined || val === null || val === '') return true + if (Array.isArray(val)) return val.length === 0 + return false +} + +function validateNode(type: string, config: Record): CheckError[] { + const errors: CheckError[] = [] + const nodeConfig = nodeConfigMap[type] + if (!nodeConfig) return errors + + const get = (key: string) => config[key]?.defaultValue + + Object.entries(nodeConfig).forEach(([field, fieldConfig]) => { + if (!fieldConfig?.required) return + const val = get(field) + const specialKey = `${type}.${field}` + const specialValidator = specialValidators[specialKey] + const isInvalid = specialValidator ? specialValidator(val) : isEmpty(val) + console.log('validateNode', val, specialKey, specialValidator, isEmpty(val)) + if (isInvalid) errors.push({ key: specialKey, message: '' }) + }) + + // http-request body.data (binary) — not a top-level required field, check separately + if (type === 'http-request') { + const body = get('body') + if (body?.content_type === 'binary' && !body?.data) { + errors.push({ key: 'http-request.body.data', message: '' }) + } + } + + // console.log('nodeConfig', nodeConfigMap, nodeConfig, errors) + return errors +} + +const CheckList: FC = ({ workflowRef }) => { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [results, setResults] = useState([]) + const timerRef = useRef>() + + const runCheck = useCallback(async () => { + const graph = workflowRef.current?.graphRef?.current + if (!graph) return [] + + const nodes = graph.getNodes() + const edges = graph.getEdges() + const sourceIds = new Set() + const targetIds = new Set() + // child-to-child edges within same parent (cycle) + const childTargetIds = new Set() + edges.forEach(e => { + sourceIds.add(e.getSourceCellId()) + targetIds.add(e.getTargetCellId()) + const srcData = graph.getCellById(e.getSourceCellId())?.getData() + const tgtData = graph.getCellById(e.getTargetCellId())?.getData() + if (srcData?.cycle && tgtData?.cycle && srcData.cycle === tgtData.cycle) { + childTargetIds.add(e.getTargetCellId()) + } + }) + + const checked: NodeCheckResult[] = [] + for (const node of nodes) { + const data = node.getData() + if (!data || ['add-node', 'notes', 'cycle-start', 'break'].includes(data.type)) continue + + const errors: CheckError[] = [] + + + // Check connectivity + const isChildNode = !!data.cycle + const hasIncoming = isChildNode ? childTargetIds.has(node.id) : !['start', 'cycle-start'].includes(data.type) ? targetIds.has(node.id) : true + if (!hasIncoming) { + errors.push({ key: 'notConnected', message: t('workflow.notConnected') }) + } + + // Validate config + const configErrors = validateNode(data.type, data.config ?? {}) + configErrors.forEach(e => { + errors.push({ key: e.key, message: `${t(`workflow.checkListErrors.${e.key}`)} ${t('workflow.cannotBeEmpty')}`.trim() }) + }) + + // Tool node: fetch parameters via API and check required fields + if (data.type === 'tool') { + const toolId = data.config?.tool_id?.defaultValue ?? data.config?.tool_id + const toolParameters = data.config?.tool_parameters?.defaultValue ?? data.config?.tool_parameters ?? {} + if (toolId) { + try { + const methods = await getToolMethods(toolId) as Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }> + const operation = toolParameters?.operation + const method = operation ? methods.find(m => m.name === operation) : methods[0] + if (method) { + const missingParams = method.parameters.filter(p => p.required && (toolParameters[p.name] === undefined || toolParameters[p.name] === null || toolParameters[p.name] === '')) + missingParams.forEach(p => errors.push({ key: 'tool.tool_parameters', message: `${p.name} ${t('workflow.cannotBeEmpty')}` })) + } + } catch { + // ignore API errors + } + } + } + + if (errors.length) { + checked.push({ + id: node.id, + name: data.name || t(`workflow.${data.type}`), + type: data.type, + icon: nodeIconMap[data.type] ?? '', + errors, + }) + } + } + + return checked + }, [workflowRef.current?.graphRef?.current, t]) + + const scheduleCheck = useCallback(() => { + clearTimeout(timerRef.current) + timerRef.current = setTimeout(async () => { + setResults(await runCheck()) + }, 500) + }, [runCheck]) + + useEffect(() => { + const graph = workflowRef.current?.graphRef?.current + if (!graph) return + const events = ['node:added', 'node:removed', 'node:change:data', 'edge:added', 'edge:removed'] + events.forEach(e => graph.on(e, scheduleCheck)) + scheduleCheck() + return () => { + events.forEach(e => graph.off(e, scheduleCheck)) + clearTimeout(timerRef.current) + } + }, [workflowRef.current?.graphRef?.current]) + + const handleOpen = () => { + setOpen(true) + } + + const focusNode = (id: string) => { + const graph = workflowRef.current?.graphRef?.current + if (!graph) return + const node = graph.getCellById(id) + if (node) { + workflowRef.current?.nodeClick({node} as { node: Node }) + } + setOpen(false) + } + + return ( + <> + +
+
+ {results.length > 0 && ( + + {results.reduce((sum, n) => sum + n.errors.length, 0)} + + )} +
+ + + {t('workflow.checkList')}{results.length > 0 ? `(${results.reduce((sum, n) => sum + n.errors.length, 0)})` : ''} + + } + open={open} + onClose={() => setOpen(false)} + width={360} + styles={{ body: { padding: '12px 16px' } }} + > +

{t('workflow.checkListDesc')}

+ {results.length === 0 + ?
{t('workflow.checkListEmpty')}
+ : + {results.map(node => ( +
+ +
+ {node.name} + focusNode(node.id)} + > + {t('workflow.goto')} → + + + + + {node.errors.map((err, i) => ( + + + {err.message} + + ))} + +
+ ))} +
+ } + + + ) +} + +export default CheckList diff --git a/web/src/views/Workflow/components/Properties/CodeExecution/OutputList.tsx b/web/src/views/Workflow/components/Properties/CodeExecution/OutputList.tsx index 6bb12d9b..0080c493 100644 --- a/web/src/views/Workflow/components/Properties/CodeExecution/OutputList.tsx +++ b/web/src/views/Workflow/components/Properties/CodeExecution/OutputList.tsx @@ -27,7 +27,8 @@ const OutputList: FC = ({ label, name, extra }) => { <>
- {label} + + *{label}
diff --git a/web/src/views/Workflow/components/Properties/ConditionList/index.tsx b/web/src/views/Workflow/components/Properties/ConditionList/index.tsx index d484da09..cad32e37 100644 --- a/web/src/views/Workflow/components/Properties/ConditionList/index.tsx +++ b/web/src/views/Workflow/components/Properties/ConditionList/index.tsx @@ -58,7 +58,7 @@ const ConditionList: FC = ({ const { t } = useTranslation(); const form = Form.useFormInstance(); - const handleLeftFieldChange = (index: number, newValue: string) => { + const handleLeftFieldChange = (index: number, newValue?: string | string[]) => { form.setFieldsValue({ [parentName]: { expressions: { diff --git a/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx b/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx index 53714327..a02549bd 100644 --- a/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx +++ b/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx @@ -87,7 +87,9 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an return ( <> -
API
+
+ *API +