From df8706983beb7600bd550f3c4c63392fa222af1d Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 3 Feb 2026 15:19:02 +0800 Subject: [PATCH 1/2] feat(web): var-aggregator add group_type ; docs(web): add comments --- .../Properties/GroupVariableList/index.tsx | 193 ++++++++---- web/src/views/Workflow/constant.ts | 109 +++++-- .../views/Workflow/hooks/useWorkflowGraph.ts | 284 ++++++++++++------ 3 files changed, 411 insertions(+), 175 deletions(-) diff --git a/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx b/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx index 81eac38e..a9b67dac 100644 --- a/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx +++ b/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx @@ -1,30 +1,87 @@ -import { type FC } from 'react' +/* + * @Author: ZhaoYing + * @Date: 2026-02-03 15:17:39 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-03 15:17:39 + */ +import { useEffect, type FC } from 'react' import { useTranslation } from 'react-i18next'; import { Form, Input, Button, Row, Col } from 'antd' import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin' import VariableSelect from '../VariableSelect' +/** + * Props for GroupVariableList component + */ interface GroupVariableListProps { + /** Current value - array of key-value pairs for grouped variables */ value?: Array<{ key: string; value: string[]; }>; + /** Form field name */ name: string; + /** Available variable options for selection */ options: Suggestion[]; + /** Whether user can add custom groups */ isCanAdd: boolean; + /** Size of form controls */ size: 'small' | 'middle' } +/** + * GroupVariableList component + * Manages grouped variable selection for var-aggregator node + * Supports two modes: + * 1. Simple mode (isCanAdd=false): Single variable list with type inference + * 2. Advanced mode (isCanAdd=true): Multiple named groups with type inference per group + * @param props - Component props + */ const GroupVariableList: FC = ({ name, options = [], isCanAdd = false, size = "small" }) => { + // Hooks const { t } = useTranslation(); const form = Form.useFormInstance(); + + // Get current form value const value = form.getFieldValue(name) || []; - console.log('GroupVariableList', value) + /** + * Reset group_type when mode changes + */ + useEffect(() => { + form.setFieldValue('group_type', {}) + }, [isCanAdd]) + /** + * Auto-infer and set data types based on selected variables + * In simple mode: Sets single output type + * In advanced mode: Sets type for each group + */ + useEffect(() => { + if (!isCanAdd && value[0]) { + const firstVariable = options.find(opt => `{{${opt.value}}}` === value[0]); + if (firstVariable) { + form.setFieldValue(['group_type', 'output'], firstVariable.dataType); + } + } else if (isCanAdd) { + value.forEach((item: any, index: number) => { + if (item?.value?.[0]) { + const firstVariable = options.find(opt => `{{${opt.value}}}` === item.value[0]); + if (firstVariable) { + form.setFieldValue(['group_type', index], firstVariable.dataType); + } + } + }); + } + }, [isCanAdd, options, value, form]) + + /** + * Simple mode rendering + * Single variable list with automatic type filtering + */ if (!isCanAdd) { // Filter options based on first variable's dataType if value exists let filteredOptions = options; @@ -53,77 +110,85 @@ const GroupVariableList: FC = ({ size={size} /> + ) } + /** + * Advanced mode rendering + * Multiple named groups with individual variable lists + */ return ( - - {(fields, { add, remove }) => ( - <> - {fields.map(({ key, name, ...restField }) => { - return ( -
- - - - {isCanAdd ? : t('workflow.config.var-aggregator.variable')} - - + <> + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...restField }) => { + return ( +
+ + + + {isCanAdd ? : t('workflow.config.var-aggregator.variable')} + + - {isCanAdd && -
remove(name)} - >
- } -
+ {isCanAdd && +
remove(name)} + >
+ } + - - { - const currentGroupValue = value[name]?.value || []; - if (currentGroupValue.length > 0) { - const firstVariableValue = currentGroupValue[0]; - const firstVariable = options.find(opt => `{{${opt.value}}}` === firstVariableValue); - if (firstVariable) { - return options.filter(opt => opt.dataType === firstVariable.dataType); + + { + const currentGroupValue = value[name]?.value || []; + if (currentGroupValue.length > 0) { + const firstVariableValue = currentGroupValue[0]; + const firstVariable = options.find(opt => `{{${opt.value}}}` === firstVariableValue); + if (firstVariable) { + return options.filter(opt => opt.dataType === firstVariable.dataType); + } } + return options; + })() } - return options; - })() - } - mode="multiple" - size={size} - /> - -
- ) - })} + mode="multiple" + size={size} + /> + +
+ ) + })} - {isCanAdd && } - - )} -
+ {isCanAdd && } + + )} + + + ) } diff --git a/web/src/views/Workflow/constant.ts b/web/src/views/Workflow/constant.ts index 64bb5757..aa49445a 100644 --- a/web/src/views/Workflow/constant.ts +++ b/web/src/views/Workflow/constant.ts @@ -1,3 +1,9 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-02-03 15:06:18 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-03 15:10:19 + */ import LoopNode from './components/Nodes/LoopNode'; import NormalNode from './components/Nodes/NormalNode'; import ConditionNode from './components/Nodes/ConditionNode'; @@ -9,33 +15,33 @@ import type { ReactShapeConfig } from '@antv/x6-react-shape'; // Import workflow icons import startIcon from '@/assets/images/workflow/start.png'; import endIcon from '@/assets/images/workflow/end.png'; -import answerIcon from '@/assets/images/workflow/answer.png'; +// import answerIcon from '@/assets/images/workflow/answer.png'; import llmIcon from '@/assets/images/workflow/llm.png'; -import modelSelectionIcon from '@/assets/images/workflow/model_selection.png'; -import modelVotingIcon from '@/assets/images/workflow/model_voting.png'; +// import modelSelectionIcon from '@/assets/images/workflow/model_selection.png'; +// import modelVotingIcon from '@/assets/images/workflow/model_voting.png'; import ragIcon from '@/assets/images/workflow/rag.png'; -import classificationIcon from '@/assets/images/workflow/classification.png'; +// import classificationIcon from '@/assets/images/workflow/classification.png'; import parameterExtractionIcon from '@/assets/images/workflow/parameter_extraction.png'; -import taskPlanningIcon from '@/assets/images/workflow/task_planning.png'; -import reasoningControlIcon from '@/assets/images/workflow/reasoning_control.png'; -import selfReflectionIcon from '@/assets/images/workflow/self_reflection.png'; -import memoryEnhancementIcon from '@/assets/images/workflow/memory_enhancement.png'; -import agentSchedulingIcon from '@/assets/images/workflow/agent_scheduling.png'; -import agentCollaborationIcon from '@/assets/images/workflow/agent_collaboration.png'; -import agentArbitrationIcon from '@/assets/images/workflow/agent_arbitration.png'; +// import taskPlanningIcon from '@/assets/images/workflow/task_planning.png'; +// import reasoningControlIcon from '@/assets/images/workflow/reasoning_control.png'; +// import selfReflectionIcon from '@/assets/images/workflow/self_reflection.png'; +// import memoryEnhancementIcon from '@/assets/images/workflow/memory_enhancement.png'; +// import agentSchedulingIcon from '@/assets/images/workflow/agent_scheduling.png'; +// import agentCollaborationIcon from '@/assets/images/workflow/agent_collaboration.png'; +// import agentArbitrationIcon from '@/assets/images/workflow/agent_arbitration.png'; import conditionIcon from '@/assets/images/workflow/condition.png'; import iterationIcon from '@/assets/images/workflow/iteration.png'; import loopIcon from '@/assets/images/workflow/loop.png'; -import parallelIcon from '@/assets/images/workflow/parallel.png'; +// import parallelIcon from '@/assets/images/workflow/parallel.png'; import aggregatorIcon from '@/assets/images/workflow/aggregator.png'; import httpRequestIcon from '@/assets/images/workflow/http_request.png'; import toolsIcon from '@/assets/images/workflow/tools.png'; import codeExecutionIcon from '@/assets/images/workflow/code_execution.png'; import templateRenderingIcon from '@/assets/images/workflow/template_rendering.png'; -import sensitiveDetectionIcon from '@/assets/images/workflow/sensitive_detection.png'; -import outputAuditIcon from '@/assets/images/workflow/output_audit.png'; -import selfOptimizationIcon from '@/assets/images/workflow/self_optimization.png'; -import processEvolutionIcon from '@/assets/images/workflow/process_evolution.png'; +// import sensitiveDetectionIcon from '@/assets/images/workflow/sensitive_detection.png'; +// import outputAuditIcon from '@/assets/images/workflow/output_audit.png'; +// import selfOptimizationIcon from '@/assets/images/workflow/self_optimization.png'; +// import processEvolutionIcon from '@/assets/images/workflow/process_evolution.png'; import questionClassifierIcon from '@/assets/images/workflow/question-classifier.png' import breakIcon from '@/assets/images/workflow/break.png' import assignerIcon from '@/assets/images/workflow/assigner.png' @@ -47,6 +53,10 @@ import { memoryConfigListUrl } from '@/api/memory' import { getModelListUrl } from '@/api/models' import type { NodeLibrary } from './types' +/** + * Workflow node library configuration + * Defines all available node types, their icons, and configuration schemas + */ export const nodeLibrary: NodeLibrary[] = [ { category: "coreNode", @@ -300,7 +310,7 @@ export const nodeLibrary: NodeLibrary[] = [ dependsOn: 'parallel', dependsOnValue: true }, - flatten: { // 扁平化输出 + flatten: { // Flatten output type: 'switch', defaultValue: false }, @@ -345,6 +355,9 @@ export const nodeLibrary: NodeLibrary[] = [ group_variables: { type: 'groupVariableList', defaultValue: [], + }, + group_type: { + type: 'define', } } }, @@ -490,7 +503,10 @@ export const nodeLibrary: NodeLibrary[] = [ // }, ]; -// 节点注册库 +/** + * Node registration library for X6 graph + * Maps node shapes to their React components + */ export const nodeRegisterLibrary: ReactShapeConfig[] = [ { shape: 'loop-node', @@ -530,21 +546,39 @@ export const nodeRegisterLibrary: ReactShapeConfig[] = [ }, ]; +/** + * Port configuration interface + */ interface PortsConfig { + /** Port group metadata */ groups?: GroupMetadata; + /** Port item metadata array */ items?: PortMetadata[]; } +/** + * Node configuration interface + */ interface NodeConfig { + /** Node width in pixels */ width: number; + /** Node height in pixels */ height: number; + /** Node shape type */ shape: string; + /** Port configuration */ ports?: PortsConfig; } +/** Edge color for normal state */ export const edge_color = '#155EEF'; +/** Edge color for selected state */ export const edge_selected_color = '#4DA8FF' -// 统一的端口 markup 配置 + +/** + * Unified port markup configuration + * Defines SVG elements for port rendering + */ export const portMarkup = [ { tagName: 'circle', @@ -556,7 +590,10 @@ export const portMarkup = [ }, ]; -// 统一的端口属性配置 +/** + * Unified port attributes configuration + * Defines visual styling for ports + */ export const portAttrs = { body: { r: 6, @@ -576,20 +613,34 @@ export const portAttrs = { } } -// 统一的端口组配置 +/** + * Unified port group configuration + * Defines port positions and attributes for different sides + */ const defaultPortGroups = { // top: { position: 'top', markup: portMarkup, attrs: portAttrs }, right: { position: 'right', markup: portMarkup, attrs: portAttrs }, // bottom: { position: 'bottom', markup: portMarkup, attrs: portAttrs }, left: { position: 'left', markup: portMarkup, attrs: portAttrs }, } +/** + * Default port items for standard nodes + */ const defaultPortItems = [ // { group: 'top' }, { group: 'right' }, // { group: 'bottom' }, { group: 'left' } ]; +/** + * Port position arguments + */ export const portArgs = { dy: 18 } + +/** + * Graph node library configuration + * Maps node types to their visual and structural properties + */ export const graphNodeLibrary: Record = { iteration: { width: 240, @@ -701,21 +752,33 @@ export const graphNodeLibrary: Record = { } +/** + * Output variable configuration interface + */ export interface OutputVariable { + /** Default output variables */ default?: Array<{ name: string; type: string; }>; + /** Dynamically defined variable keys */ define?: string[]; + /** System-level output variables */ sys?: Array<{ name: string; type: string; }>; + /** Error-related output variables */ error?: Array<{ name: string; type: string; }>; } + +/** + * Output variable definitions for each node type + * Specifies what variables each node produces + */ export const outputVariable: { [key: string]: OutputVariable } = { start: { sys: [ @@ -806,6 +869,10 @@ export const outputVariable: { [key: string]: OutputVariable } = { }, } +/** + * Default edge attributes configuration + * Defines visual styling for edges/connections + */ export const edgeAttrs = { attrs: { line: { diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index 774b3f7b..68d08aaa 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -1,48 +1,90 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-02-03 15:17:48 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-03 15:17:48 + */ import { useRef, useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { App } from 'antd' import { Graph, Node, MiniMap, Snapline, Clipboard, Keyboard, type Edge } from '@antv/x6'; import { register } from '@antv/x6-react-shape'; +import type { PortMetadata } from '@antv/x6/lib/model/port'; import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portArgs } from '../constant'; import type { WorkflowConfig, NodeProperties, ChatVariable } from '../types'; import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application' -import type { PortMetadata } from '@antv/x6/lib/model/port'; +/** + * Props for useWorkflowGraph hook + */ export interface UseWorkflowGraphProps { + /** Reference to the main graph container element */ containerRef: React.RefObject; + /** Reference to the minimap container element */ miniMapRef: React.RefObject; } +/** + * Return type for useWorkflowGraph hook + */ export interface UseWorkflowGraphReturn { + /** Current workflow configuration */ config: WorkflowConfig | null; + /** Function to update workflow configuration */ setConfig: React.Dispatch>; + /** Reference to the X6 graph instance */ graphRef: React.MutableRefObject; + /** Currently selected node */ selectedNode: Node | null; + /** Function to update selected node */ setSelectedNode: React.Dispatch>; + /** Current zoom level of the graph */ zoomLevel: number; + /** Function to update zoom level */ setZoomLevel: React.Dispatch>; + /** Whether hand/pan mode is enabled */ isHandMode: boolean; + /** Function to toggle hand mode */ setIsHandMode: React.Dispatch>; + /** Handler for dropping nodes onto canvas */ onDrop: (event: React.DragEvent) => void; + /** Handler for clicking blank canvas area */ blankClick: () => void; + /** Handler for delete keyboard event */ deleteEvent: () => boolean | void; + /** Handler for copy keyboard event */ copyEvent: () => boolean | void; + /** Handler for paste keyboard event */ parseEvent: () => boolean | void; + /** Function to save workflow configuration */ handleSave: (flag?: boolean) => Promise; + /** Chat variables for workflow */ chatVariables: ChatVariable[]; + /** Function to update chat variables */ setChatVariables: React.Dispatch>; } +/** + * Custom hook for managing workflow graph + * Handles graph initialization, node/edge operations, and workflow configuration + * @param props - Hook props containing container references + * @returns Object containing graph state and handlers + */ export const useWorkflowGraph = ({ containerRef, miniMapRef, }: UseWorkflowGraphProps): UseWorkflowGraphReturn => { + // Hooks const { id } = useParams(); const { message } = App.useApp(); const { t } = useTranslation() + + // Refs const graphRef = useRef(); + + // State const [selectedNode, setSelectedNode] = useState(null); const [zoomLevel, setZoomLevel] = useState(1); const [isHandMode, setIsHandMode] = useState(true); @@ -52,6 +94,9 @@ export const useWorkflowGraph = ({ useEffect(() => { getConfig() }, [id]) + /** + * Fetch workflow configuration from API + */ const getConfig = () => { if (!id) return getWorkflowConfig(id) @@ -73,6 +118,9 @@ export const useWorkflowGraph = ({ initWorkflow() }, [config, graphRef.current]) + /** + * Initialize workflow graph with nodes and edges from configuration + */ const initWorkflow = () => { if (!config || !graphRef.current) return const { nodes, edges } = config @@ -129,7 +177,7 @@ export const useWorkflowGraph = ({ ...position, } - // 如果是if-else节点,根据cases动态生成端口 + // Generate ports dynamically for if-else node based on cases if (type === 'if-else' && config.cases && Array.isArray(config.cases)) { const caseCount = config.cases.length; const totalPorts = caseCount + 1; // IF/ELIF + ELSE @@ -141,7 +189,7 @@ export const useWorkflowGraph = ({ { group: 'right', id: 'CASE1', args: portArgs, attrs: { text: { text: 'IF', fontSize: 12, fill: '#5B6167' }} } ]; - // 添加 ELIF 端口 + // Add ELIF ports for (let i = 1; i < caseCount; i++) { portItems.push({ group: 'right', @@ -151,7 +199,7 @@ export const useWorkflowGraph = ({ }); } - // 添加 ELSE 端口 + // Add ELSE port portItems.push({ group: 'right', id: `CASE${caseCount + 1}`, @@ -170,7 +218,7 @@ export const useWorkflowGraph = ({ nodeConfig.height = newHeight; } - // 如果是question-classifier节点,根据categories动态生成端口 + // Generate ports dynamically for question-classifier node based on categories if (type === 'question-classifier' && config.categories && Array.isArray(config.categories)) { const categoryCount = config.categories.length; const baseHeight = 88; @@ -180,7 +228,7 @@ export const useWorkflowGraph = ({ { group: 'left' } ]; - // 添加分类端口 + // Add category ports config.categories.forEach((_category: any, index: number) => { portItems.push({ group: 'right', @@ -201,7 +249,7 @@ export const useWorkflowGraph = ({ nodeConfig.height = newHeight; } - // 如果是http-request节点,检查error_handle.method配置 + // Check error_handle.method config for http-request node if (type === 'http-request' && (config as any).error_handle?.method === 'branch') { nodeConfig.ports = { groups: { @@ -219,14 +267,14 @@ export const useWorkflowGraph = ({ return nodeConfig }) - // 分离父节点和子节点 + // Separate parent nodes and child nodes const parentNodes = nodeList.filter(node => !node.data.cycle) const childNodes = nodeList.filter(node => node.data.cycle) - // 先添加父节点 + // Add parent nodes first graphRef.current?.addNodes(parentNodes) - // 然后处理子节点,使用addChild添加到对应的父节点 + // Then process child nodes, use addChild to add to corresponding parent node childNodes.forEach(childNode => { const cycleId = childNode.data.cycle if (cycleId) { @@ -240,7 +288,7 @@ export const useWorkflowGraph = ({ } }) - // 调整父节点大小以适应子节点 + // Adjust parent node size to fit child nodes setTimeout(() => { const parentNodesWithChildren = parentNodes.filter(parentNode => { const parentId = parentNode.data.id @@ -274,7 +322,7 @@ export const useWorkflowGraph = ({ }, 100) } if (edges.length) { - // 去重处理:对于if-else和question-classifier节点,不同连接桩允许连接到相同节点 + // Deduplication: For if-else and question-classifier nodes, different ports can connect to same node const uniqueEdges = edges.filter((edge, index, arr) => { return arr.findIndex(e => { const sourceCell = graphRef.current?.getCellById(e.source); @@ -282,10 +330,10 @@ export const useWorkflowGraph = ({ const isMultiPortNode = sourceType === 'question-classifier' || sourceType === 'if-else'; if (isMultiPortNode) { - // 多端口节点需要同时比较source、target和label + // Multi-port nodes need to compare source, target and label return e.source === edge.source && e.target === edge.target && e.label === edge.label; } else { - // 其他节点只比较source和target + // Other nodes only compare source and target return e.source === edge.source && e.target === edge.target; } }) === index; @@ -302,16 +350,16 @@ export const useWorkflowGraph = ({ let sourcePort = sourcePorts.find((port: any) => port.group === 'right')?.id || 'right'; - // 如果是if-else节点且有label,根据label匹配对应的端口 + // If if-else node has label, match corresponding port by label if (sourceCell.getData()?.type === 'if-else' && label) { - // 查找匹配的端口ID + // Find matching port ID const matchingPort = sourcePorts.find((port: any) => port.id === label); if (matchingPort) { sourcePort = label; } } - // 如果是question-classifier节点且有label,根据label匹配对应的端口 + // If question-classifier node has label, match corresponding port by label if (sourceCell.getData()?.type === 'question-classifier' && label) { const matchingPort = sourcePorts.find((port: any) => port.id === label); if (matchingPort) { @@ -319,7 +367,7 @@ export const useWorkflowGraph = ({ } } - // 如果是http-request节点且有label,根据label匹配对应的端口 + // If http-request node has label, match corresponding port by label if (sourceCell.getData()?.type === 'http-request' && label) { const matchingPort = sourcePorts.find((port: any) => port.id === label); if (matchingPort) { @@ -348,7 +396,7 @@ export const useWorkflowGraph = ({ graphRef.current.addEdges(edgeList.filter(vo => vo !== null)) } - // 初始化完成后,将节点展示在可视区域内 + // Initialize after completion, display nodes in visible area if (nodes.length > 0 || edges.length > 0) { setTimeout(() => { if (graphRef.current) { @@ -357,7 +405,9 @@ export const useWorkflowGraph = ({ }, 200) } } - // 使用插件 + /** + * Setup X6 graph plugins (MiniMap, Snapline, Clipboard, Keyboard) + */ const setupPlugins = () => { if (!graphRef.current || !miniMapRef.current) return; // 添加小地图 @@ -395,9 +445,12 @@ export const useWorkflowGraph = ({ // ports[i].style.visibility = show ? 'visible' : 'hidden'; // } // }; - // 节点选择事件 + /** + * Handle node click event + * @param node - Clicked node + */ const nodeClick = ({ node }: { node: Node }) => { - // 忽略 add-node 类型的节点点击 + // Ignore add-node type node clicks if (node.getData()?.type === 'add-node' || node.getData().type === 'break' || node.getData().type === 'cycle-start') { setSelectedNode(null) return; @@ -420,12 +473,17 @@ export const useWorkflowGraph = ({ }); setSelectedNode(node); }; - // 连线选择事件 + /** + * Handle edge click event + * @param edge - Clicked edge + */ const edgeClick = ({ edge }: { edge: Edge }) => { edge.setAttrByPath('line/stroke', edge_selected_color); clearNodeSelect(); }; - // 清空选中节点 + /** + * Clear all selected nodes + */ const clearNodeSelect = () => { const nodes = graphRef.current?.getNodes(); @@ -440,44 +498,54 @@ export const useWorkflowGraph = ({ }); setSelectedNode(null); }; - // 清空选中连线 + /** + * Clear all selected edges + */ const clearEdgeSelect = () => { graphRef.current?.getEdges().forEach(e => { e.setAttrByPath('line/stroke', edge_color); e.setAttrByPath('line/strokeWidth', 1); }); }; - // 画布点击事件,取消选择 + /** + * Handle blank canvas click - deselect all + */ const blankClick = () => { clearNodeSelect(); clearEdgeSelect(); graphRef.current?.cleanSelection(); }; - // 画布缩放事件 + /** + * Handle canvas scale/zoom event + * @param sx - Scale factor on x-axis + */ const scaleEvent = ({ sx }: { sx: number }) => { setZoomLevel(sx); }; - // 节点移动事件 + /** + * Handle node moved event - restrict child nodes within parent bounds + * @param node - Moved node + */ const nodeMoved = ({ node }: { node: Node }) => { const cycle = node.getData()?.cycle; if (cycle) { const parentNode = graphRef.current!.getNodes().find(n => n.id === cycle); if (parentNode?.getData()?.isGroup) { - // 获取父节点和子节点的边界框 + // Get parent node and child node bounding boxes const parentBBox = parentNode.getBBox(); const childBBox = node.getBBox(); - // 计算父节点的内边距 + // Calculate parent node padding const padding = 24; const headerHeight = 50; - // 计算子节点允许的最小和最大位置 + // Calculate minimum and maximum positions allowed for child node const minX = parentBBox.x + padding; const minY = parentBBox.y + padding + headerHeight; const maxX = parentBBox.x + parentBBox.width - padding - childBBox.width; const maxY = parentBBox.y + parentBBox.height - padding - childBBox.height; - // 限制子节点在父节点内移动 + // Restrict child node movement within parent node let newX = childBBox.x; let newY = childBBox.y; @@ -486,14 +554,17 @@ export const useWorkflowGraph = ({ if (newX > maxX) newX = maxX; if (newY > maxY) newY = maxY; - // 如果子节点位置被限制,更新其位置 + // If child node position is restricted, update its position if (newX !== childBBox.x || newY !== childBBox.y) { node.setPosition(newX, newY); } } } }; - // 复制快捷键事件 + /** + * Handle copy keyboard shortcut (Ctrl+C / Cmd+C) + * @returns false to prevent default behavior + */ const copyEvent = () => { if (!graphRef.current) return false; const selectedNodes = graphRef.current.getNodes().filter(node => node.getData()?.isSelected); @@ -502,7 +573,10 @@ export const useWorkflowGraph = ({ } return false; }; - // 粘贴快捷键事件 + /** + * Handle paste keyboard shortcut (Ctrl+V / Cmd+V) + * @returns false to prevent default behavior + */ const parseEvent = () => { if (!graphRef.current?.isClipboardEmpty()) { graphRef.current?.paste({ offset: 32 }); @@ -510,7 +584,11 @@ export const useWorkflowGraph = ({ } return false; }; - // 删除选中的节点和连线事件 + /** + * Handle delete keyboard shortcut + * Removes selected nodes, edges, and handles parent-child relationships + * @returns false to prevent default behavior + */ const deleteEvent = () => { if (!graphRef.current) return; const nodes = graphRef.current?.getNodes(); @@ -519,16 +597,16 @@ export const useWorkflowGraph = ({ const nodesToDelete: Node[] = []; const parentNodesToUpdate: Node[] = []; - // 首先收集所有选中的节点,但排除默认子节点 + // First collect all selected nodes, but exclude default child nodes nodes?.forEach(node => { const data = node.getData(); - // 如果节点是默认子节点,不允许单独删除 + // If node is default child node, do not allow individual deletion if (data.isSelected && !data.isDefault) { nodesToDelete.push(node); } }); - // 收集与选中节点相关的连线 + // Collect edges related to selected nodes edges?.forEach(edge => { const attrs = edge.getAttrs() if (attrs.line.stroke === edge_selected_color) { @@ -545,35 +623,35 @@ export const useWorkflowGraph = ({ } }) - // 对于每个选中的节点 + // For each selected node if (nodesToDelete.length > 0) { nodesToDelete.forEach(nodeToDelete => { - // 检查是否为子节点 + // Check if it's a child node const nodeData = nodeToDelete.getData(); if (nodeData.cycle) { - // 找到对应的父节点 + // Find corresponding parent node const parentNode = nodes?.find(n => n.id === nodeData.cycle); if (parentNode) { - // 使用removeChild方法删除子节点 + // Use removeChild method to delete child node parentNode.removeChild(nodeToDelete); parentNodesToUpdate.push(parentNode); } - // 将子节点添加到删除列表 + // Add child node to deletion list cells.push(nodeToDelete); } - // 检查是否为 LoopNode、IterationNode 或 SubGraphNode + // Check if it's LoopNode, IterationNode or SubGraphNode else if (nodeToDelete.shape === 'loop-node' || nodeToDelete.shape === 'iteration-node' || nodeToDelete.shape === 'subgraph-node') { - // 查找所有 cycle 为当前节点 id 的子节点 + // Find all child nodes with cycle equal to current node id nodes?.forEach(node => { const data = node.getData(); if (data.cycle === nodeToDelete.id || data.cycle === nodeToDelete.getData()?.id) { cells.push(node); } }); - // 添加父节点到删除列表 + // Add parent node to deletion list cells.push(nodeToDelete); } - // 普通节点 + // Normal node else { cells.push(nodeToDelete); } @@ -581,25 +659,29 @@ export const useWorkflowGraph = ({ blankClick(); } - // 删除所有收集的节点和连线 + // Delete all collected nodes and edges if (cells.length > 0) { graphRef.current?.removeCells(cells); } return false; }; - // 调整画布大小 + /** + * Handle window resize event + */ const handleResize = () => { if (containerRef.current && graphRef.current) { graphRef.current.resize(containerRef.current.offsetWidth, containerRef.current.offsetHeight); } }; - // 初始化 + /** + * Initialize X6 graph with configuration and event listeners + */ const init = () => { if (!containerRef.current || !miniMapRef.current) return; - // 注册React形状 + // Register React shapes nodeRegisterLibrary.forEach((item) => { register(item); }); @@ -616,8 +698,8 @@ export const useWorkflowGraph = ({ type: 'dot', size: 10, args: { - color: '#939AB1', // 网点颜色 - thickness: 1, // 网点大小 + color: '#939AB1', // Grid dot color + thickness: 1, // Grid dot size } }, panning: isHandMode, @@ -649,32 +731,32 @@ export const useWorkflowGraph = ({ validateConnection({ sourceCell, targetCell, targetMagnet }) { if (!targetMagnet) return false; - // 节点不能与自己连线 + // Node cannot connect to itself if (sourceCell?.id === targetCell?.id) return false; const sourceType = sourceCell?.getData()?.type; const targetType = targetCell?.getData()?.type; - // 开始节点不能作为连线的终点 + // Start node cannot be connection target if (targetType === 'start') return false; - // 结束节点不能作为连线的起点 + // End node cannot be connection source if (sourceType === 'end') return false; - // 获取源节点和目标节点的父节点ID + // Get source node and target node parent IDs const sourceParentId = sourceCell?.getData()?.cycle; const targetParentId = targetCell?.getData()?.cycle; - // 验证父子节点关系: - // 1. 如果两个节点都有父节点ID,必须相同才能连线 - // 2. 如果两个都没有父节点ID,可以正常连线 - // 3. 如果一个有父节点,一个没有,不能连线 + // Validate parent-child relationship: + // 1. If both nodes have parent IDs, they must be same to connect + // 2. If both have no parent ID, can connect normally + // 3. If one has parent, one doesn't, cannot connect console.log('sourceParentId', sourceParentId, targetParentId) if (sourceParentId && targetParentId) { - // 同一父节点下的子节点可以互相连线 + // Child nodes under same parent can connect to each other return sourceParentId === targetParentId; } else if (sourceParentId || targetParentId) { - // 一个有父节点,一个没有,不能连线 + // One has parent, one doesn't, cannot connect return false; } @@ -710,26 +792,26 @@ export const useWorkflowGraph = ({ }, }, }); - // 使用插件 + // Use plugins setupPlugins(); - // 监听连线mouseleave事件 + // Listen to edge mouseleave event graphRef.current.on('edge:mouseleave', ({ edge }: { edge: Edge }) => { if (edge.getAttrByPath('line/stroke') !== edge_selected_color) { edge.setAttrByPath('line/stroke', edge_color); edge.setAttrByPath('line/strokeWidth', 1); } }); - // 监听节点选择事件 + // Listen to node selection event graphRef.current.on('node:click', nodeClick); - // 监听连线选择事件 + // Listen to edge selection event graphRef.current.on('edge:click', edgeClick); - // 监听连接桩点击事件 + // Listen to port click event graphRef.current.on('node:port:click', ({ e, node, port }: { e: MouseEvent, node: Node, port: string }) => { e.stopPropagation(); const portElement = e.target as HTMLElement; const rect = portElement.getBoundingClientRect(); - // 创建临时的popover触发元素 + // Create temporary popover trigger element const tempDiv = document.createElement('div'); tempDiv.style.position = 'fixed'; tempDiv.style.left = rect.left + 'px'; @@ -739,23 +821,23 @@ export const useWorkflowGraph = ({ tempDiv.style.zIndex = '9999'; document.body.appendChild(tempDiv); - // 触发自定义事件来显示节点选择popover + // Trigger custom event to show node selection popover const customEvent = new CustomEvent('port:click', { detail: { node, port, element: tempDiv, rect } }); window.dispatchEvent(customEvent); }); - // 监听画布点击事件,取消选择 + // Listen to canvas click event, cancel selection graphRef.current.on('blank:click', blankClick); - // 监听缩放事件 + // Listen to zoom event graphRef.current.on('scale', scaleEvent); - // 监听节点移动事件 + // Listen to node move event graphRef.current.on('node:moved', nodeMoved); - // 监听复制键盘事件 + // Listen to copy keyboard event graphRef.current.bindKey(['ctrl+c', 'cmd+c'], copyEvent); - // 监听粘贴键盘事件 + // Listen to paste keyboard event graphRef.current.bindKey(['ctrl+v', 'cmd+v'], parseEvent); - // 删除选中的节点和连线 + // Delete selected nodes and edges graphRef.current.bindKey(['ctrl+d', 'cmd+d', 'delete', 'backspace'], deleteEvent); }; @@ -771,6 +853,11 @@ export const useWorkflowGraph = ({ }; }, []); + /** + * Handle node drop event from drag-and-drop + * Creates new node at drop position + * @param event - React drag event + */ const onDrop = (event: React.DragEvent) => { if (!graphRef.current) return; event.preventDefault(); @@ -780,13 +867,13 @@ export const useWorkflowGraph = ({ const point = graphRef.current.clientToLocal(event.clientX, event.clientY); - // 获取节点库中的原始配置,避免config数据串联 + // Get original config from node library to avoid config data chaining let nodeLibraryConfig = [...nodeLibrary] .flatMap(category => category.nodes) .find(n => n.type === dragData.type); nodeLibraryConfig = JSON.parse(JSON.stringify({ config: {}, ...nodeLibraryConfig })) as NodeProperties - // 创建干净的节点数据,只保留必要的字段 + // Create clean node data, only keep necessary fields const cleanNodeData = { id: `${dragData.type.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, name: t(`workflow.${dragData.type}`), @@ -802,7 +889,7 @@ export const useWorkflowGraph = ({ data: { ...cleanNodeData, isGroup: true }, }); } else if (dragData.type === 'if-else') { - // 创建条件节点 + // Create condition node graphRef.current.addNode({ ...graphNodeLibrary[dragData.type], x: point.x - 100, @@ -811,7 +898,7 @@ export const useWorkflowGraph = ({ data: { ...cleanNodeData }, }); } else { - // 普通节点创建,不支持拖拽到循环节点内 + // Normal node creation, does not support dragging into loop node graphRef.current.addNode({ ...(graphNodeLibrary[dragData.type] || graphNodeLibrary.default), x: point.x - 60, @@ -821,7 +908,12 @@ export const useWorkflowGraph = ({ }); } }; - // 保存workflow配置 + /** + * Save workflow configuration to backend + * Serializes graph state (nodes, edges, variables) and sends to API + * @param flag - Whether to show success message (default: true) + * @returns Promise that resolves when save is complete + */ const handleSave = (flag = true) => { if (!graphRef.current || !config) return Promise.resolve() return new Promise((resolve, reject) => { @@ -869,6 +961,18 @@ export const useWorkflowGraph = ({ }) } itemConfig[key] = group_variables + } else if (data.config[key] && 'defaultValue' in data.config[key] && key === 'group_type') { + let group = data.config.group.defaultValue + let group_type = group ? {} : data.config[key].defaultValue + let group_variables = data.config.group_variables.defaultValue + + if (group) { + group_variables.forEach((item: any, index: number) => { + group_type[item.key] = data.config[key].defaultValue[index] || data.config[key].defaultValue[item.key] + }) + } + + itemConfig[key] = group_type } else if (data.type === 'http-request' && (key === 'headers' || key === 'params') && data.config[key] && 'defaultValue' in data.config[key]) { const value = data.config[key].defaultValue itemConfig[key] = {} @@ -897,7 +1001,7 @@ export const useWorkflowGraph = ({ id: data.id || node.id, type: data.type, name: data.name, - cycle: data.cycle, // 保存cycle参数 + cycle: data.cycle, // Save cycle parameter position: { x: position.x, y: position.y, @@ -910,13 +1014,13 @@ export const useWorkflowGraph = ({ const targetCell = graphRef.current?.getCellById(edge.getTargetCellId()); const sourcePortId = edge.getSourcePortId(); - // 过滤无效连线:源节点或目标节点不存在,或者是add-node类型 + // Filter invalid edges: source or target node doesn't exist, or is add-node type if (!sourceCell?.getData()?.id || !targetCell?.getData()?.id || sourceCell?.getData()?.type === 'add-node' || targetCell?.getData()?.type === 'add-node') { return null; } - // 如果是if-else节点的右侧端口连线,添加label + // If if-else node right port connection, add label if (sourceCell?.getData()?.type === 'if-else' && sourcePortId?.startsWith('CASE')) { return { source: sourceCell.getData().id, @@ -925,7 +1029,7 @@ export const useWorkflowGraph = ({ }; } - // 如果是question-classifier节点的右侧端口连线,添加label + // If question-classifier node right port connection, add label if (sourceCell?.getData()?.type === 'question-classifier' && sourcePortId?.startsWith('CASE')) { return { source: sourceCell.getData().id, @@ -934,7 +1038,7 @@ export const useWorkflowGraph = ({ }; } - // 如果是http-request节点的右侧端口连线,添加label + // If http-request node right port connection, add label if (sourceCell?.getData()?.type === 'http-request') { if (sourcePortId === 'ERROR') { return { @@ -958,7 +1062,7 @@ export const useWorkflowGraph = ({ }) .filter(edge => edge !== null) .filter((edge, index, arr) => { - // 去重:对于if-else和question-classifier节点,不同连接桩允许连接到相同节点 + // Deduplication: For if-else and question-classifier nodes, different ports can connect to same node return arr.findIndex(e => { if (!e || !edge) return false; const sourceCell = graphRef.current?.getCellById(e.source); @@ -966,10 +1070,10 @@ export const useWorkflowGraph = ({ const isMultiPortNode = sourceType === 'question-classifier' || sourceType === 'if-else'; if (isMultiPortNode) { - // 多端口节点需要同时比较source、target和label + // Multi-port nodes need to compare source, target and label return e.source === edge.source && e.target === edge.target && e.label === edge.label; } else { - // 其他节点只比较source和target + // Other nodes only compare source and target return e.source === edge.source && e.target === edge.target; } }) === index; From be01f1869edd40fcb564df171cd7ffbf836d097e Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 3 Feb 2026 15:40:18 +0800 Subject: [PATCH 2/2] feat(web): iteration add output_type ; docs(web): add comments --- .../components/Properties/VariableSelect.tsx | 41 +++++++++++- .../Workflow/components/Properties/index.tsx | 62 +++++++++++++++++-- web/src/views/Workflow/constant.ts | 5 +- 3 files changed, 99 insertions(+), 9 deletions(-) diff --git a/web/src/views/Workflow/components/Properties/VariableSelect.tsx b/web/src/views/Workflow/components/Properties/VariableSelect.tsx index f56180a0..7ae4eff5 100644 --- a/web/src/views/Workflow/components/Properties/VariableSelect.tsx +++ b/web/src/views/Workflow/components/Properties/VariableSelect.tsx @@ -1,18 +1,36 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-02-03 15:40:13 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-03 15:40:13 + */ import { type FC } from 'react' import clsx from 'clsx'; import { Select, type SelectProps } from 'antd' import type { Suggestion } from '../Editor/plugin/AutocompletePlugin' type LabelRender = SelectProps['labelRender']; +/** + * Props for VariableSelect component + */ interface VariableSelectProps extends SelectProps { + /** Available variable options */ options: Suggestion[]; + /** Current selected value */ value?: string; - onChange?: (value: string) => void; + /** Whether to show clear button */ allowClear?: boolean; + /** Filter out boolean type variables */ filterBooleanType?: boolean; + /** Size of the select component */ size?: 'small' | 'middle' | 'large' } +/** + * VariableSelect component + * Custom select component for workflow variables with grouped options and custom rendering + * @param props - Component props + */ const VariableSelect: FC = ({ placeholder, options, @@ -24,9 +42,19 @@ const VariableSelect: FC = ({ ...resetPorps }) => { - const handleChange = (value: string) => { - onChange?.(value); + /** + * Handle value change and pass selected option to parent + * @param value - Selected value + */ + const handleChange: SelectProps['onChange'] = (value: string) => { + const filterItem = options.find(option => `{{${option.value}}}` === value) + onChange?.(value, filterItem); } + /** + * Custom label renderer for selected value + * Displays node icon, name and variable label + * @param props - Label render props + */ const labelRender: LabelRender = (props) => { const { value } = props const filterOption = filteredOptions.find(vo => `{{${vo.value}}}` === value) @@ -57,10 +85,14 @@ const VariableSelect: FC = ({ } return null } + // Filter options based on boolean type if needed const filteredOptions = filterBooleanType ? options.filter(option => option.dataType !== 'boolean') : options; + /** + * Group suggestions by node ID + */ const groupedSuggestions = filteredOptions.reduce((groups: Record, suggestion) => { const { nodeData } = suggestion const nodeId = nodeData.id as string; @@ -71,6 +103,9 @@ const VariableSelect: FC = ({ return groups; }, {}); + /** + * Format grouped options for Select component + */ const groupedOptions = Object.entries(groupedSuggestions).map(([_nodeId, suggestions]) => ({ label: suggestions[0].nodeData.name, options: suggestions.map(s => ({ diff --git a/web/src/views/Workflow/components/Properties/index.tsx b/web/src/views/Workflow/components/Properties/index.tsx index aa757275..59860005 100644 --- a/web/src/views/Workflow/components/Properties/index.tsx +++ b/web/src/views/Workflow/components/Properties/index.tsx @@ -1,3 +1,9 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-02-03 15:39:59 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-03 15:39:59 + */ import { type FC, useEffect, useState, useMemo } from "react"; import clsx from 'clsx' import { useTranslation } from 'react-i18next' @@ -31,17 +37,35 @@ import RbSlider from './RbSlider' import JinjaRender from './JinjaRender' import CodeExecution from './CodeExecution' +/** + * Props for Properties component + */ interface PropertiesProps { + /** Currently selected node */ selectedNode?: Node | null; + /** Function to update selected node */ setSelectedNode: (node: Node | null) => void; + /** Reference to graph instance */ graphRef: React.MutableRefObject; + /** Handler for blank canvas click */ blankClick: () => void; + /** Handler for delete event */ deleteEvent: () => void; + /** Handler for copy event */ copyEvent: () => void; + /** Handler for paste event */ parseEvent: () => void; + /** Workflow configuration */ config?: any; + /** Chat variables */ chatVariables: ChatVariable[]; } + +/** + * Properties panel component + * Displays and manages configuration for selected workflow node + * @param props - Component props + */ const Properties: FC = ({ selectedNode, graphRef, @@ -83,6 +107,10 @@ const Properties: FC = ({ } }, [selectedNode, form]) + /** + * Update node label in graph + * @param newLabel - New label text + */ const updateNodeLabel = (newLabel: string) => { if (selectedNode && form) { const nodeData = selectedNode.data as NodeProperties; @@ -107,8 +135,6 @@ const Properties: FC = ({ })) } - - Object.keys(values).forEach(key => { if (selectedNode.data?.config?.[key]) { // Create a deep copy to avoid reference sharing between nodes @@ -131,7 +157,12 @@ const Properties: FC = ({ - // Filter out boolean type variables for loop and llm nodes + /** + * Get filtered variable list based on node type and config key + * @param nodeType - Type of the node + * @param key - Configuration key + * @returns Filtered variable list + */ const getFilteredVariableList = (nodeType?: string, key?: string) => { // Check if current node is a child of iteration node const parentIterationNode = selectedNode ? (() => { @@ -321,15 +352,33 @@ const Properties: FC = ({ console.log('values', values) + /** + * Get current node output variables + */ const currentNodeVariables = useMemo(() => { if (!selectedNode) return [] return getCurrentNodeVariables(selectedNode?.getData(), values) }, [selectedNode?.getData(), values]) const [outputCollapsed, setOutputCollapsed] = useState(true) + /** + * Toggle output section collapsed state + */ const handleToggle = () => { setOutputCollapsed((prev: boolean) => !prev) } + + /** + * Handle variable list change and update output type for iteration nodes + * @param _value - Selected value + * @param option - Selected option + * @param key - Configuration key + */ + const handleChangeVariableList = (_value: string, option: any, key: string) => { + if (selectedNode?.data?.type === 'iteration' && key === 'output') { + form.setFieldValue('output_type', option?.dataType) + } + } console.log('variableList', variableList, currentNodeVariables) return ( @@ -422,6 +471,9 @@ const Properties: FC = ({ ) } + if (selectedNode?.data?.type === 'iteration' && key === 'output_type') { + return (