Merge pull request #286 from SuanmoSuanyangTechnology/feature/workflow_variable_zy

Feature/workflow variable zy
This commit is contained in:
yingzhao
2026-02-03 15:42:07 +08:00
committed by GitHub
5 changed files with 509 additions and 183 deletions

View File

@@ -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 { useTranslation } from 'react-i18next';
import { Form, Input, Button, Row, Col } from 'antd' import { Form, Input, Button, Row, Col } from 'antd'
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin' import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin'
import VariableSelect from '../VariableSelect' import VariableSelect from '../VariableSelect'
/**
* Props for GroupVariableList component
*/
interface GroupVariableListProps { interface GroupVariableListProps {
/** Current value - array of key-value pairs for grouped variables */
value?: Array<{ key: string; value: string[]; }>; value?: Array<{ key: string; value: string[]; }>;
/** Form field name */
name: string; name: string;
/** Available variable options for selection */
options: Suggestion[]; options: Suggestion[];
/** Whether user can add custom groups */
isCanAdd: boolean; isCanAdd: boolean;
/** Size of form controls */
size: 'small' | 'middle' 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<GroupVariableListProps> = ({ const GroupVariableList: FC<GroupVariableListProps> = ({
name, name,
options = [], options = [],
isCanAdd = false, isCanAdd = false,
size = "small" size = "small"
}) => { }) => {
// Hooks
const { t } = useTranslation(); const { t } = useTranslation();
const form = Form.useFormInstance(); const form = Form.useFormInstance();
// Get current form value
const value = form.getFieldValue(name) || []; 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) { if (!isCanAdd) {
// Filter options based on first variable's dataType if value exists // Filter options based on first variable's dataType if value exists
let filteredOptions = options; let filteredOptions = options;
@@ -53,77 +110,85 @@ const GroupVariableList: FC<GroupVariableListProps> = ({
size={size} size={size}
/> />
</Form.Item> </Form.Item>
<Form.Item name={['group_type', 'output']} hidden></Form.Item>
</div> </div>
) )
} }
/**
* Advanced mode rendering
* Multiple named groups with individual variable lists
*/
return ( return (
<Form.List name={name}> <>
{(fields, { add, remove }) => ( <Form.List name={name}>
<> {(fields, { add, remove }) => (
{fields.map(({ key, name, ...restField }) => { <>
return ( {fields.map(({ key, name, ...restField }) => {
<div key={key} className="rb:mb-4"> return (
<Row gutter={12} className="rb:mb-2!"> <div key={key} className="rb:mb-4">
<Col span={12}> <Row gutter={12} className="rb:mb-2!">
<Form.Item <Col span={12}>
{...restField} <Form.Item
name={isCanAdd ? [name, 'key'] : undefined} {...restField}
rules={[ name={isCanAdd ? [name, 'key'] : undefined}
{ pattern: /^[a-zA-Z_][a-zA-Z0-9_]*$/, message: t('workflow.config.var-aggregator.invalidVariableName') }, rules={[
]} { pattern: /^[a-zA-Z_][a-zA-Z0-9_]*$/, message: t('workflow.config.var-aggregator.invalidVariableName') },
noStyle ]}
> noStyle
{isCanAdd ? <Input placeholder={t('common.pleaseEnter')} size={size} /> : t('workflow.config.var-aggregator.variable')} >
</Form.Item> {isCanAdd ? <Input placeholder={t('common.pleaseEnter')} size={size} /> : t('workflow.config.var-aggregator.variable')}
</Col> </Form.Item>
</Col>
{isCanAdd && <Col span={12} className="rb:flex! rb:items-center rb:justify-end"> {isCanAdd && <Col span={12} className="rb:flex! rb:items-center rb:justify-end">
<div <div
className="rb:ml-1 rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/workflow/deleteBg.svg')] rb:hover:bg-[url('@/assets/images/workflow/deleteBg_hover.svg')]" className="rb:ml-1 rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/workflow/deleteBg.svg')] rb:hover:bg-[url('@/assets/images/workflow/deleteBg_hover.svg')]"
onClick={() => remove(name)} onClick={() => remove(name)}
></div> ></div>
</Col>} </Col>}
</Row> </Row>
<Form.Item <Form.Item
{...restField} {...restField}
name={[name, 'value']} name={[name, 'value']}
noStyle noStyle
> >
<VariableSelect <VariableSelect
placeholder={t('common.pleaseSelect')} placeholder={t('common.pleaseSelect')}
options={(() => { options={(() => {
const currentGroupValue = value[name]?.value || []; const currentGroupValue = value[name]?.value || [];
if (currentGroupValue.length > 0) { if (currentGroupValue.length > 0) {
const firstVariableValue = currentGroupValue[0]; const firstVariableValue = currentGroupValue[0];
const firstVariable = options.find(opt => `{{${opt.value}}}` === firstVariableValue); const firstVariable = options.find(opt => `{{${opt.value}}}` === firstVariableValue);
if (firstVariable) { if (firstVariable) {
return options.filter(opt => opt.dataType === firstVariable.dataType); return options.filter(opt => opt.dataType === firstVariable.dataType);
}
} }
return options;
})()
} }
return options; mode="multiple"
})() size={size}
} />
mode="multiple" </Form.Item>
size={size} </div>
/> )
</Form.Item> })}
</div>
)
})}
{isCanAdd && <Button {isCanAdd && <Button
type="dashed" type="dashed"
block block
size="middle" size="middle"
className="rb:text-[12px]!" className="rb:text-[12px]!"
onClick={() => add({ key: `Group${fields.length + 1}` })} onClick={() => add({ key: `Group${fields.length + 1}` })}
> >
+ {t('workflow.config.var-aggregator.addGroup')} + {t('workflow.config.var-aggregator.addGroup')}
</Button>} </Button>}
</> </>
)} )}
</Form.List> </Form.List>
<Form.Item name={['group_type']} hidden></Form.Item>
</>
) )
} }

View File

@@ -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 { type FC } from 'react'
import clsx from 'clsx'; import clsx from 'clsx';
import { Select, type SelectProps } from 'antd' import { Select, type SelectProps } from 'antd'
import type { Suggestion } from '../Editor/plugin/AutocompletePlugin' import type { Suggestion } from '../Editor/plugin/AutocompletePlugin'
type LabelRender = SelectProps['labelRender']; type LabelRender = SelectProps['labelRender'];
/**
* Props for VariableSelect component
*/
interface VariableSelectProps extends SelectProps { interface VariableSelectProps extends SelectProps {
/** Available variable options */
options: Suggestion[]; options: Suggestion[];
/** Current selected value */
value?: string; value?: string;
onChange?: (value: string) => void; /** Whether to show clear button */
allowClear?: boolean; allowClear?: boolean;
/** Filter out boolean type variables */
filterBooleanType?: boolean; filterBooleanType?: boolean;
/** Size of the select component */
size?: 'small' | 'middle' | 'large' 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<VariableSelectProps> = ({ const VariableSelect: FC<VariableSelectProps> = ({
placeholder, placeholder,
options, options,
@@ -24,9 +42,19 @@ const VariableSelect: FC<VariableSelectProps> = ({
...resetPorps ...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 labelRender: LabelRender = (props) => {
const { value } = props const { value } = props
const filterOption = filteredOptions.find(vo => `{{${vo.value}}}` === value) const filterOption = filteredOptions.find(vo => `{{${vo.value}}}` === value)
@@ -57,10 +85,14 @@ const VariableSelect: FC<VariableSelectProps> = ({
} }
return null return null
} }
// Filter options based on boolean type if needed
const filteredOptions = filterBooleanType const filteredOptions = filterBooleanType
? options.filter(option => option.dataType !== 'boolean') ? options.filter(option => option.dataType !== 'boolean')
: options; : options;
/**
* Group suggestions by node ID
*/
const groupedSuggestions = filteredOptions.reduce((groups: Record<string, any[]>, suggestion) => { const groupedSuggestions = filteredOptions.reduce((groups: Record<string, any[]>, suggestion) => {
const { nodeData } = suggestion const { nodeData } = suggestion
const nodeId = nodeData.id as string; const nodeId = nodeData.id as string;
@@ -71,6 +103,9 @@ const VariableSelect: FC<VariableSelectProps> = ({
return groups; return groups;
}, {}); }, {});
/**
* Format grouped options for Select component
*/
const groupedOptions = Object.entries(groupedSuggestions).map(([_nodeId, suggestions]) => ({ const groupedOptions = Object.entries(groupedSuggestions).map(([_nodeId, suggestions]) => ({
label: suggestions[0].nodeData.name, label: suggestions[0].nodeData.name,
options: suggestions.map(s => ({ options: suggestions.map(s => ({

View File

@@ -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 { type FC, useEffect, useState, useMemo } from "react";
import clsx from 'clsx' import clsx from 'clsx'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -31,17 +37,35 @@ import RbSlider from './RbSlider'
import JinjaRender from './JinjaRender' import JinjaRender from './JinjaRender'
import CodeExecution from './CodeExecution' import CodeExecution from './CodeExecution'
/**
* Props for Properties component
*/
interface PropertiesProps { interface PropertiesProps {
/** Currently selected node */
selectedNode?: Node | null; selectedNode?: Node | null;
/** Function to update selected node */
setSelectedNode: (node: Node | null) => void; setSelectedNode: (node: Node | null) => void;
/** Reference to graph instance */
graphRef: React.MutableRefObject<Graph | undefined>; graphRef: React.MutableRefObject<Graph | undefined>;
/** Handler for blank canvas click */
blankClick: () => void; blankClick: () => void;
/** Handler for delete event */
deleteEvent: () => void; deleteEvent: () => void;
/** Handler for copy event */
copyEvent: () => void; copyEvent: () => void;
/** Handler for paste event */
parseEvent: () => void; parseEvent: () => void;
/** Workflow configuration */
config?: any; config?: any;
/** Chat variables */
chatVariables: ChatVariable[]; chatVariables: ChatVariable[];
} }
/**
* Properties panel component
* Displays and manages configuration for selected workflow node
* @param props - Component props
*/
const Properties: FC<PropertiesProps> = ({ const Properties: FC<PropertiesProps> = ({
selectedNode, selectedNode,
graphRef, graphRef,
@@ -83,6 +107,10 @@ const Properties: FC<PropertiesProps> = ({
} }
}, [selectedNode, form]) }, [selectedNode, form])
/**
* Update node label in graph
* @param newLabel - New label text
*/
const updateNodeLabel = (newLabel: string) => { const updateNodeLabel = (newLabel: string) => {
if (selectedNode && form) { if (selectedNode && form) {
const nodeData = selectedNode.data as NodeProperties; const nodeData = selectedNode.data as NodeProperties;
@@ -107,8 +135,6 @@ const Properties: FC<PropertiesProps> = ({
})) }))
} }
Object.keys(values).forEach(key => { Object.keys(values).forEach(key => {
if (selectedNode.data?.config?.[key]) { if (selectedNode.data?.config?.[key]) {
// Create a deep copy to avoid reference sharing between nodes // Create a deep copy to avoid reference sharing between nodes
@@ -131,7 +157,12 @@ const Properties: FC<PropertiesProps> = ({
// 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) => { const getFilteredVariableList = (nodeType?: string, key?: string) => {
// Check if current node is a child of iteration node // Check if current node is a child of iteration node
const parentIterationNode = selectedNode ? (() => { const parentIterationNode = selectedNode ? (() => {
@@ -321,15 +352,33 @@ const Properties: FC<PropertiesProps> = ({
console.log('values', values) console.log('values', values)
/**
* Get current node output variables
*/
const currentNodeVariables = useMemo(() => { const currentNodeVariables = useMemo(() => {
if (!selectedNode) return [] if (!selectedNode) return []
return getCurrentNodeVariables(selectedNode?.getData(), values) return getCurrentNodeVariables(selectedNode?.getData(), values)
}, [selectedNode?.getData(), values]) }, [selectedNode?.getData(), values])
const [outputCollapsed, setOutputCollapsed] = useState(true) const [outputCollapsed, setOutputCollapsed] = useState(true)
/**
* Toggle output section collapsed state
*/
const handleToggle = () => { const handleToggle = () => {
setOutputCollapsed((prev: boolean) => !prev) 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) console.log('variableList', variableList, currentNodeVariables)
return ( return (
@@ -422,6 +471,9 @@ const Properties: FC<PropertiesProps> = ({
</Form.Item> </Form.Item>
) )
} }
if (selectedNode?.data?.type === 'iteration' && key === 'output_type') {
return (<Form.Item key={key} name={key} hidden />)
}
if (config.type === 'define') { if (config.type === 'define') {
return null return null
} }
@@ -628,8 +680,8 @@ const Properties: FC<PropertiesProps> = ({
); );
} }
return baseVariableList; return baseVariableList;
})() })()}
} onChange={(value, option) => handleChangeVariableList(value, option, key)}
size="small" size="small"
/> />
: config.type === 'switch' : config.type === 'switch'

View File

@@ -1,3 +1,9 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 15:06:18
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 15:25:25
*/
import LoopNode from './components/Nodes/LoopNode'; import LoopNode from './components/Nodes/LoopNode';
import NormalNode from './components/Nodes/NormalNode'; import NormalNode from './components/Nodes/NormalNode';
import ConditionNode from './components/Nodes/ConditionNode'; import ConditionNode from './components/Nodes/ConditionNode';
@@ -9,33 +15,33 @@ import type { ReactShapeConfig } from '@antv/x6-react-shape';
// Import workflow icons // Import workflow icons
import startIcon from '@/assets/images/workflow/start.png'; import startIcon from '@/assets/images/workflow/start.png';
import endIcon from '@/assets/images/workflow/end.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 llmIcon from '@/assets/images/workflow/llm.png';
import modelSelectionIcon from '@/assets/images/workflow/model_selection.png'; // import modelSelectionIcon from '@/assets/images/workflow/model_selection.png';
import modelVotingIcon from '@/assets/images/workflow/model_voting.png'; // import modelVotingIcon from '@/assets/images/workflow/model_voting.png';
import ragIcon from '@/assets/images/workflow/rag.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 parameterExtractionIcon from '@/assets/images/workflow/parameter_extraction.png';
import taskPlanningIcon from '@/assets/images/workflow/task_planning.png'; // import taskPlanningIcon from '@/assets/images/workflow/task_planning.png';
import reasoningControlIcon from '@/assets/images/workflow/reasoning_control.png'; // import reasoningControlIcon from '@/assets/images/workflow/reasoning_control.png';
import selfReflectionIcon from '@/assets/images/workflow/self_reflection.png'; // import selfReflectionIcon from '@/assets/images/workflow/self_reflection.png';
import memoryEnhancementIcon from '@/assets/images/workflow/memory_enhancement.png'; // import memoryEnhancementIcon from '@/assets/images/workflow/memory_enhancement.png';
import agentSchedulingIcon from '@/assets/images/workflow/agent_scheduling.png'; // import agentSchedulingIcon from '@/assets/images/workflow/agent_scheduling.png';
import agentCollaborationIcon from '@/assets/images/workflow/agent_collaboration.png'; // import agentCollaborationIcon from '@/assets/images/workflow/agent_collaboration.png';
import agentArbitrationIcon from '@/assets/images/workflow/agent_arbitration.png'; // import agentArbitrationIcon from '@/assets/images/workflow/agent_arbitration.png';
import conditionIcon from '@/assets/images/workflow/condition.png'; import conditionIcon from '@/assets/images/workflow/condition.png';
import iterationIcon from '@/assets/images/workflow/iteration.png'; import iterationIcon from '@/assets/images/workflow/iteration.png';
import loopIcon from '@/assets/images/workflow/loop.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 aggregatorIcon from '@/assets/images/workflow/aggregator.png';
import httpRequestIcon from '@/assets/images/workflow/http_request.png'; import httpRequestIcon from '@/assets/images/workflow/http_request.png';
import toolsIcon from '@/assets/images/workflow/tools.png'; import toolsIcon from '@/assets/images/workflow/tools.png';
import codeExecutionIcon from '@/assets/images/workflow/code_execution.png'; import codeExecutionIcon from '@/assets/images/workflow/code_execution.png';
import templateRenderingIcon from '@/assets/images/workflow/template_rendering.png'; import templateRenderingIcon from '@/assets/images/workflow/template_rendering.png';
import sensitiveDetectionIcon from '@/assets/images/workflow/sensitive_detection.png'; // import sensitiveDetectionIcon from '@/assets/images/workflow/sensitive_detection.png';
import outputAuditIcon from '@/assets/images/workflow/output_audit.png'; // import outputAuditIcon from '@/assets/images/workflow/output_audit.png';
import selfOptimizationIcon from '@/assets/images/workflow/self_optimization.png'; // import selfOptimizationIcon from '@/assets/images/workflow/self_optimization.png';
import processEvolutionIcon from '@/assets/images/workflow/process_evolution.png'; // import processEvolutionIcon from '@/assets/images/workflow/process_evolution.png';
import questionClassifierIcon from '@/assets/images/workflow/question-classifier.png' import questionClassifierIcon from '@/assets/images/workflow/question-classifier.png'
import breakIcon from '@/assets/images/workflow/break.png' import breakIcon from '@/assets/images/workflow/break.png'
import assignerIcon from '@/assets/images/workflow/assigner.png' import assignerIcon from '@/assets/images/workflow/assigner.png'
@@ -47,6 +53,10 @@ import { memoryConfigListUrl } from '@/api/memory'
import { getModelListUrl } from '@/api/models' import { getModelListUrl } from '@/api/models'
import type { NodeLibrary } from './types' import type { NodeLibrary } from './types'
/**
* Workflow node library configuration
* Defines all available node types, their icons, and configuration schemas
*/
export const nodeLibrary: NodeLibrary[] = [ export const nodeLibrary: NodeLibrary[] = [
{ {
category: "coreNode", category: "coreNode",
@@ -300,13 +310,16 @@ export const nodeLibrary: NodeLibrary[] = [
dependsOn: 'parallel', dependsOn: 'parallel',
dependsOnValue: true dependsOnValue: true
}, },
flatten: { // 扁平化输出 flatten: { // Flatten output
type: 'switch', type: 'switch',
defaultValue: false defaultValue: false
}, },
output: { output: {
type: 'variableList', type: 'variableList',
filterChildNodes: true filterChildNodes: true
},
output_type: {
type: 'define',
} }
}, },
}, },
@@ -345,6 +358,9 @@ export const nodeLibrary: NodeLibrary[] = [
group_variables: { group_variables: {
type: 'groupVariableList', type: 'groupVariableList',
defaultValue: [], defaultValue: [],
},
group_type: {
type: 'define',
} }
} }
}, },
@@ -490,7 +506,10 @@ export const nodeLibrary: NodeLibrary[] = [
// }, // },
]; ];
// 节点注册库 /**
* Node registration library for X6 graph
* Maps node shapes to their React components
*/
export const nodeRegisterLibrary: ReactShapeConfig[] = [ export const nodeRegisterLibrary: ReactShapeConfig[] = [
{ {
shape: 'loop-node', shape: 'loop-node',
@@ -530,21 +549,39 @@ export const nodeRegisterLibrary: ReactShapeConfig[] = [
}, },
]; ];
/**
* Port configuration interface
*/
interface PortsConfig { interface PortsConfig {
/** Port group metadata */
groups?: GroupMetadata; groups?: GroupMetadata;
/** Port item metadata array */
items?: PortMetadata[]; items?: PortMetadata[];
} }
/**
* Node configuration interface
*/
interface NodeConfig { interface NodeConfig {
/** Node width in pixels */
width: number; width: number;
/** Node height in pixels */
height: number; height: number;
/** Node shape type */
shape: string; shape: string;
/** Port configuration */
ports?: PortsConfig; ports?: PortsConfig;
} }
/** Edge color for normal state */
export const edge_color = '#155EEF'; export const edge_color = '#155EEF';
/** Edge color for selected state */
export const edge_selected_color = '#4DA8FF' export const edge_selected_color = '#4DA8FF'
// 统一的端口 markup 配置
/**
* Unified port markup configuration
* Defines SVG elements for port rendering
*/
export const portMarkup = [ export const portMarkup = [
{ {
tagName: 'circle', tagName: 'circle',
@@ -556,7 +593,10 @@ export const portMarkup = [
}, },
]; ];
// 统一的端口属性配置 /**
* Unified port attributes configuration
* Defines visual styling for ports
*/
export const portAttrs = { export const portAttrs = {
body: { body: {
r: 6, r: 6,
@@ -576,20 +616,34 @@ export const portAttrs = {
} }
} }
// 统一的端口组配置 /**
* Unified port group configuration
* Defines port positions and attributes for different sides
*/
const defaultPortGroups = { const defaultPortGroups = {
// top: { position: 'top', markup: portMarkup, attrs: portAttrs }, // top: { position: 'top', markup: portMarkup, attrs: portAttrs },
right: { position: 'right', markup: portMarkup, attrs: portAttrs }, right: { position: 'right', markup: portMarkup, attrs: portAttrs },
// bottom: { position: 'bottom', markup: portMarkup, attrs: portAttrs }, // bottom: { position: 'bottom', markup: portMarkup, attrs: portAttrs },
left: { position: 'left', markup: portMarkup, attrs: portAttrs }, left: { position: 'left', markup: portMarkup, attrs: portAttrs },
} }
/**
* Default port items for standard nodes
*/
const defaultPortItems = [ const defaultPortItems = [
// { group: 'top' }, // { group: 'top' },
{ group: 'right' }, { group: 'right' },
// { group: 'bottom' }, // { group: 'bottom' },
{ group: 'left' } { group: 'left' }
]; ];
/**
* Port position arguments
*/
export const portArgs = { dy: 18 } export const portArgs = { dy: 18 }
/**
* Graph node library configuration
* Maps node types to their visual and structural properties
*/
export const graphNodeLibrary: Record<string, NodeConfig> = { export const graphNodeLibrary: Record<string, NodeConfig> = {
iteration: { iteration: {
width: 240, width: 240,
@@ -701,21 +755,33 @@ export const graphNodeLibrary: Record<string, NodeConfig> = {
} }
/**
* Output variable configuration interface
*/
export interface OutputVariable { export interface OutputVariable {
/** Default output variables */
default?: Array<{ default?: Array<{
name: string; name: string;
type: string; type: string;
}>; }>;
/** Dynamically defined variable keys */
define?: string[]; define?: string[];
/** System-level output variables */
sys?: Array<{ sys?: Array<{
name: string; name: string;
type: string; type: string;
}>; }>;
/** Error-related output variables */
error?: Array<{ error?: Array<{
name: string; name: string;
type: string; type: string;
}>; }>;
} }
/**
* Output variable definitions for each node type
* Specifies what variables each node produces
*/
export const outputVariable: { [key: string]: OutputVariable } = { export const outputVariable: { [key: string]: OutputVariable } = {
start: { start: {
sys: [ sys: [
@@ -806,6 +872,10 @@ export const outputVariable: { [key: string]: OutputVariable } = {
}, },
} }
/**
* Default edge attributes configuration
* Defines visual styling for edges/connections
*/
export const edgeAttrs = { export const edgeAttrs = {
attrs: { attrs: {
line: { line: {

View File

@@ -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 { useRef, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { App } from 'antd' import { App } from 'antd'
import { Graph, Node, MiniMap, Snapline, Clipboard, Keyboard, type Edge } from '@antv/x6'; import { Graph, Node, MiniMap, Snapline, Clipboard, Keyboard, type Edge } from '@antv/x6';
import { register } from '@antv/x6-react-shape'; 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 { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portArgs } from '../constant';
import type { WorkflowConfig, NodeProperties, ChatVariable } from '../types'; import type { WorkflowConfig, NodeProperties, ChatVariable } from '../types';
import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application' import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application'
import type { PortMetadata } from '@antv/x6/lib/model/port';
/**
* Props for useWorkflowGraph hook
*/
export interface UseWorkflowGraphProps { export interface UseWorkflowGraphProps {
/** Reference to the main graph container element */
containerRef: React.RefObject<HTMLDivElement>; containerRef: React.RefObject<HTMLDivElement>;
/** Reference to the minimap container element */
miniMapRef: React.RefObject<HTMLDivElement>; miniMapRef: React.RefObject<HTMLDivElement>;
} }
/**
* Return type for useWorkflowGraph hook
*/
export interface UseWorkflowGraphReturn { export interface UseWorkflowGraphReturn {
/** Current workflow configuration */
config: WorkflowConfig | null; config: WorkflowConfig | null;
/** Function to update workflow configuration */
setConfig: React.Dispatch<React.SetStateAction<WorkflowConfig | null>>; setConfig: React.Dispatch<React.SetStateAction<WorkflowConfig | null>>;
/** Reference to the X6 graph instance */
graphRef: React.MutableRefObject<Graph | undefined>; graphRef: React.MutableRefObject<Graph | undefined>;
/** Currently selected node */
selectedNode: Node | null; selectedNode: Node | null;
/** Function to update selected node */
setSelectedNode: React.Dispatch<React.SetStateAction<Node | null>>; setSelectedNode: React.Dispatch<React.SetStateAction<Node | null>>;
/** Current zoom level of the graph */
zoomLevel: number; zoomLevel: number;
/** Function to update zoom level */
setZoomLevel: React.Dispatch<React.SetStateAction<number>>; setZoomLevel: React.Dispatch<React.SetStateAction<number>>;
/** Whether hand/pan mode is enabled */
isHandMode: boolean; isHandMode: boolean;
/** Function to toggle hand mode */
setIsHandMode: React.Dispatch<React.SetStateAction<boolean>>; setIsHandMode: React.Dispatch<React.SetStateAction<boolean>>;
/** Handler for dropping nodes onto canvas */
onDrop: (event: React.DragEvent) => void; onDrop: (event: React.DragEvent) => void;
/** Handler for clicking blank canvas area */
blankClick: () => void; blankClick: () => void;
/** Handler for delete keyboard event */
deleteEvent: () => boolean | void; deleteEvent: () => boolean | void;
/** Handler for copy keyboard event */
copyEvent: () => boolean | void; copyEvent: () => boolean | void;
/** Handler for paste keyboard event */
parseEvent: () => boolean | void; parseEvent: () => boolean | void;
/** Function to save workflow configuration */
handleSave: (flag?: boolean) => Promise<unknown>; handleSave: (flag?: boolean) => Promise<unknown>;
/** Chat variables for workflow */
chatVariables: ChatVariable[]; chatVariables: ChatVariable[];
/** Function to update chat variables */
setChatVariables: React.Dispatch<React.SetStateAction<ChatVariable[]>>; setChatVariables: React.Dispatch<React.SetStateAction<ChatVariable[]>>;
} }
/**
* 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 = ({ export const useWorkflowGraph = ({
containerRef, containerRef,
miniMapRef, miniMapRef,
}: UseWorkflowGraphProps): UseWorkflowGraphReturn => { }: UseWorkflowGraphProps): UseWorkflowGraphReturn => {
// Hooks
const { id } = useParams(); const { id } = useParams();
const { message } = App.useApp(); const { message } = App.useApp();
const { t } = useTranslation() const { t } = useTranslation()
// Refs
const graphRef = useRef<Graph>(); const graphRef = useRef<Graph>();
// State
const [selectedNode, setSelectedNode] = useState<Node | null>(null); const [selectedNode, setSelectedNode] = useState<Node | null>(null);
const [zoomLevel, setZoomLevel] = useState(1); const [zoomLevel, setZoomLevel] = useState(1);
const [isHandMode, setIsHandMode] = useState(true); const [isHandMode, setIsHandMode] = useState(true);
@@ -52,6 +94,9 @@ export const useWorkflowGraph = ({
useEffect(() => { useEffect(() => {
getConfig() getConfig()
}, [id]) }, [id])
/**
* Fetch workflow configuration from API
*/
const getConfig = () => { const getConfig = () => {
if (!id) return if (!id) return
getWorkflowConfig(id) getWorkflowConfig(id)
@@ -73,6 +118,9 @@ export const useWorkflowGraph = ({
initWorkflow() initWorkflow()
}, [config, graphRef.current]) }, [config, graphRef.current])
/**
* Initialize workflow graph with nodes and edges from configuration
*/
const initWorkflow = () => { const initWorkflow = () => {
if (!config || !graphRef.current) return if (!config || !graphRef.current) return
const { nodes, edges } = config const { nodes, edges } = config
@@ -129,7 +177,7 @@ export const useWorkflowGraph = ({
...position, ...position,
} }
// 如果是if-else节点根据cases动态生成端口 // Generate ports dynamically for if-else node based on cases
if (type === 'if-else' && config.cases && Array.isArray(config.cases)) { if (type === 'if-else' && config.cases && Array.isArray(config.cases)) {
const caseCount = config.cases.length; const caseCount = config.cases.length;
const totalPorts = caseCount + 1; // IF/ELIF + ELSE 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' }} } { 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++) { for (let i = 1; i < caseCount; i++) {
portItems.push({ portItems.push({
group: 'right', group: 'right',
@@ -151,7 +199,7 @@ export const useWorkflowGraph = ({
}); });
} }
// 添加 ELSE 端口 // Add ELSE port
portItems.push({ portItems.push({
group: 'right', group: 'right',
id: `CASE${caseCount + 1}`, id: `CASE${caseCount + 1}`,
@@ -170,7 +218,7 @@ export const useWorkflowGraph = ({
nodeConfig.height = newHeight; 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)) { if (type === 'question-classifier' && config.categories && Array.isArray(config.categories)) {
const categoryCount = config.categories.length; const categoryCount = config.categories.length;
const baseHeight = 88; const baseHeight = 88;
@@ -180,7 +228,7 @@ export const useWorkflowGraph = ({
{ group: 'left' } { group: 'left' }
]; ];
// 添加分类端口 // Add category ports
config.categories.forEach((_category: any, index: number) => { config.categories.forEach((_category: any, index: number) => {
portItems.push({ portItems.push({
group: 'right', group: 'right',
@@ -201,7 +249,7 @@ export const useWorkflowGraph = ({
nodeConfig.height = newHeight; 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') { if (type === 'http-request' && (config as any).error_handle?.method === 'branch') {
nodeConfig.ports = { nodeConfig.ports = {
groups: { groups: {
@@ -219,14 +267,14 @@ export const useWorkflowGraph = ({
return nodeConfig return nodeConfig
}) })
// 分离父节点和子节点 // Separate parent nodes and child nodes
const parentNodes = nodeList.filter(node => !node.data.cycle) const parentNodes = nodeList.filter(node => !node.data.cycle)
const childNodes = nodeList.filter(node => node.data.cycle) const childNodes = nodeList.filter(node => node.data.cycle)
// 先添加父节点 // Add parent nodes first
graphRef.current?.addNodes(parentNodes) graphRef.current?.addNodes(parentNodes)
// 然后处理子节点使用addChild添加到对应的父节点 // Then process child nodes, use addChild to add to corresponding parent node
childNodes.forEach(childNode => { childNodes.forEach(childNode => {
const cycleId = childNode.data.cycle const cycleId = childNode.data.cycle
if (cycleId) { if (cycleId) {
@@ -240,7 +288,7 @@ export const useWorkflowGraph = ({
} }
}) })
// 调整父节点大小以适应子节点 // Adjust parent node size to fit child nodes
setTimeout(() => { setTimeout(() => {
const parentNodesWithChildren = parentNodes.filter(parentNode => { const parentNodesWithChildren = parentNodes.filter(parentNode => {
const parentId = parentNode.data.id const parentId = parentNode.data.id
@@ -274,7 +322,7 @@ export const useWorkflowGraph = ({
}, 100) }, 100)
} }
if (edges.length) { if (edges.length) {
// 去重处理:对于if-elsequestion-classifier节点,不同连接桩允许连接到相同节点 // Deduplication: For if-else and question-classifier nodes, different ports can connect to same node
const uniqueEdges = edges.filter((edge, index, arr) => { const uniqueEdges = edges.filter((edge, index, arr) => {
return arr.findIndex(e => { return arr.findIndex(e => {
const sourceCell = graphRef.current?.getCellById(e.source); const sourceCell = graphRef.current?.getCellById(e.source);
@@ -282,10 +330,10 @@ export const useWorkflowGraph = ({
const isMultiPortNode = sourceType === 'question-classifier' || sourceType === 'if-else'; const isMultiPortNode = sourceType === 'question-classifier' || sourceType === 'if-else';
if (isMultiPortNode) { if (isMultiPortNode) {
// 多端口节点需要同时比较sourcetargetlabel // Multi-port nodes need to compare source, target and label
return e.source === edge.source && e.target === edge.target && e.label === edge.label; return e.source === edge.source && e.target === edge.target && e.label === edge.label;
} else { } else {
// 其他节点只比较sourcetarget // Other nodes only compare source and target
return e.source === edge.source && e.target === edge.target; return e.source === edge.source && e.target === edge.target;
} }
}) === index; }) === index;
@@ -302,16 +350,16 @@ export const useWorkflowGraph = ({
let sourcePort = sourcePorts.find((port: any) => port.group === 'right')?.id || 'right'; 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) { if (sourceCell.getData()?.type === 'if-else' && label) {
// 查找匹配的端口ID // Find matching port ID
const matchingPort = sourcePorts.find((port: any) => port.id === label); const matchingPort = sourcePorts.find((port: any) => port.id === label);
if (matchingPort) { if (matchingPort) {
sourcePort = label; sourcePort = label;
} }
} }
// 如果是question-classifier节点且有label根据label匹配对应的端口 // If question-classifier node has label, match corresponding port by label
if (sourceCell.getData()?.type === 'question-classifier' && label) { if (sourceCell.getData()?.type === 'question-classifier' && label) {
const matchingPort = sourcePorts.find((port: any) => port.id === label); const matchingPort = sourcePorts.find((port: any) => port.id === label);
if (matchingPort) { 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) { if (sourceCell.getData()?.type === 'http-request' && label) {
const matchingPort = sourcePorts.find((port: any) => port.id === label); const matchingPort = sourcePorts.find((port: any) => port.id === label);
if (matchingPort) { if (matchingPort) {
@@ -348,7 +396,7 @@ export const useWorkflowGraph = ({
graphRef.current.addEdges(edgeList.filter(vo => vo !== null)) graphRef.current.addEdges(edgeList.filter(vo => vo !== null))
} }
// 初始化完成后,将节点展示在可视区域内 // Initialize after completion, display nodes in visible area
if (nodes.length > 0 || edges.length > 0) { if (nodes.length > 0 || edges.length > 0) {
setTimeout(() => { setTimeout(() => {
if (graphRef.current) { if (graphRef.current) {
@@ -357,7 +405,9 @@ export const useWorkflowGraph = ({
}, 200) }, 200)
} }
} }
// 使用插件 /**
* Setup X6 graph plugins (MiniMap, Snapline, Clipboard, Keyboard)
*/
const setupPlugins = () => { const setupPlugins = () => {
if (!graphRef.current || !miniMapRef.current) return; if (!graphRef.current || !miniMapRef.current) return;
// 添加小地图 // 添加小地图
@@ -395,9 +445,12 @@ export const useWorkflowGraph = ({
// ports[i].style.visibility = show ? 'visible' : 'hidden'; // ports[i].style.visibility = show ? 'visible' : 'hidden';
// } // }
// }; // };
// 节点选择事件 /**
* Handle node click event
* @param node - Clicked node
*/
const nodeClick = ({ node }: { node: 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') { if (node.getData()?.type === 'add-node' || node.getData().type === 'break' || node.getData().type === 'cycle-start') {
setSelectedNode(null) setSelectedNode(null)
return; return;
@@ -420,12 +473,17 @@ export const useWorkflowGraph = ({
}); });
setSelectedNode(node); setSelectedNode(node);
}; };
// 连线选择事件 /**
* Handle edge click event
* @param edge - Clicked edge
*/
const edgeClick = ({ edge }: { edge: Edge }) => { const edgeClick = ({ edge }: { edge: Edge }) => {
edge.setAttrByPath('line/stroke', edge_selected_color); edge.setAttrByPath('line/stroke', edge_selected_color);
clearNodeSelect(); clearNodeSelect();
}; };
// 清空选中节点 /**
* Clear all selected nodes
*/
const clearNodeSelect = () => { const clearNodeSelect = () => {
const nodes = graphRef.current?.getNodes(); const nodes = graphRef.current?.getNodes();
@@ -440,44 +498,54 @@ export const useWorkflowGraph = ({
}); });
setSelectedNode(null); setSelectedNode(null);
}; };
// 清空选中连线 /**
* Clear all selected edges
*/
const clearEdgeSelect = () => { const clearEdgeSelect = () => {
graphRef.current?.getEdges().forEach(e => { graphRef.current?.getEdges().forEach(e => {
e.setAttrByPath('line/stroke', edge_color); e.setAttrByPath('line/stroke', edge_color);
e.setAttrByPath('line/strokeWidth', 1); e.setAttrByPath('line/strokeWidth', 1);
}); });
}; };
// 画布点击事件,取消选择 /**
* Handle blank canvas click - deselect all
*/
const blankClick = () => { const blankClick = () => {
clearNodeSelect(); clearNodeSelect();
clearEdgeSelect(); clearEdgeSelect();
graphRef.current?.cleanSelection(); graphRef.current?.cleanSelection();
}; };
// 画布缩放事件 /**
* Handle canvas scale/zoom event
* @param sx - Scale factor on x-axis
*/
const scaleEvent = ({ sx }: { sx: number }) => { const scaleEvent = ({ sx }: { sx: number }) => {
setZoomLevel(sx); setZoomLevel(sx);
}; };
// 节点移动事件 /**
* Handle node moved event - restrict child nodes within parent bounds
* @param node - Moved node
*/
const nodeMoved = ({ node }: { node: Node }) => { const nodeMoved = ({ node }: { node: Node }) => {
const cycle = node.getData()?.cycle; const cycle = node.getData()?.cycle;
if (cycle) { if (cycle) {
const parentNode = graphRef.current!.getNodes().find(n => n.id === cycle); const parentNode = graphRef.current!.getNodes().find(n => n.id === cycle);
if (parentNode?.getData()?.isGroup) { if (parentNode?.getData()?.isGroup) {
// 获取父节点和子节点的边界框 // Get parent node and child node bounding boxes
const parentBBox = parentNode.getBBox(); const parentBBox = parentNode.getBBox();
const childBBox = node.getBBox(); const childBBox = node.getBBox();
// 计算父节点的内边距 // Calculate parent node padding
const padding = 24; const padding = 24;
const headerHeight = 50; const headerHeight = 50;
// 计算子节点允许的最小和最大位置 // Calculate minimum and maximum positions allowed for child node
const minX = parentBBox.x + padding; const minX = parentBBox.x + padding;
const minY = parentBBox.y + padding + headerHeight; const minY = parentBBox.y + padding + headerHeight;
const maxX = parentBBox.x + parentBBox.width - padding - childBBox.width; const maxX = parentBBox.x + parentBBox.width - padding - childBBox.width;
const maxY = parentBBox.y + parentBBox.height - padding - childBBox.height; const maxY = parentBBox.y + parentBBox.height - padding - childBBox.height;
// 限制子节点在父节点内移动 // Restrict child node movement within parent node
let newX = childBBox.x; let newX = childBBox.x;
let newY = childBBox.y; let newY = childBBox.y;
@@ -486,14 +554,17 @@ export const useWorkflowGraph = ({
if (newX > maxX) newX = maxX; if (newX > maxX) newX = maxX;
if (newY > maxY) newY = maxY; if (newY > maxY) newY = maxY;
// 如果子节点位置被限制,更新其位置 // If child node position is restricted, update its position
if (newX !== childBBox.x || newY !== childBBox.y) { if (newX !== childBBox.x || newY !== childBBox.y) {
node.setPosition(newX, newY); node.setPosition(newX, newY);
} }
} }
} }
}; };
// 复制快捷键事件 /**
* Handle copy keyboard shortcut (Ctrl+C / Cmd+C)
* @returns false to prevent default behavior
*/
const copyEvent = () => { const copyEvent = () => {
if (!graphRef.current) return false; if (!graphRef.current) return false;
const selectedNodes = graphRef.current.getNodes().filter(node => node.getData()?.isSelected); const selectedNodes = graphRef.current.getNodes().filter(node => node.getData()?.isSelected);
@@ -502,7 +573,10 @@ export const useWorkflowGraph = ({
} }
return false; return false;
}; };
// 粘贴快捷键事件 /**
* Handle paste keyboard shortcut (Ctrl+V / Cmd+V)
* @returns false to prevent default behavior
*/
const parseEvent = () => { const parseEvent = () => {
if (!graphRef.current?.isClipboardEmpty()) { if (!graphRef.current?.isClipboardEmpty()) {
graphRef.current?.paste({ offset: 32 }); graphRef.current?.paste({ offset: 32 });
@@ -510,7 +584,11 @@ export const useWorkflowGraph = ({
} }
return false; return false;
}; };
// 删除选中的节点和连线事件 /**
* Handle delete keyboard shortcut
* Removes selected nodes, edges, and handles parent-child relationships
* @returns false to prevent default behavior
*/
const deleteEvent = () => { const deleteEvent = () => {
if (!graphRef.current) return; if (!graphRef.current) return;
const nodes = graphRef.current?.getNodes(); const nodes = graphRef.current?.getNodes();
@@ -519,16 +597,16 @@ export const useWorkflowGraph = ({
const nodesToDelete: Node[] = []; const nodesToDelete: Node[] = [];
const parentNodesToUpdate: Node[] = []; const parentNodesToUpdate: Node[] = [];
// 首先收集所有选中的节点,但排除默认子节点 // First collect all selected nodes, but exclude default child nodes
nodes?.forEach(node => { nodes?.forEach(node => {
const data = node.getData(); const data = node.getData();
// 如果节点是默认子节点,不允许单独删除 // If node is default child node, do not allow individual deletion
if (data.isSelected && !data.isDefault) { if (data.isSelected && !data.isDefault) {
nodesToDelete.push(node); nodesToDelete.push(node);
} }
}); });
// 收集与选中节点相关的连线 // Collect edges related to selected nodes
edges?.forEach(edge => { edges?.forEach(edge => {
const attrs = edge.getAttrs() const attrs = edge.getAttrs()
if (attrs.line.stroke === edge_selected_color) { if (attrs.line.stroke === edge_selected_color) {
@@ -545,35 +623,35 @@ export const useWorkflowGraph = ({
} }
}) })
// 对于每个选中的节点 // For each selected node
if (nodesToDelete.length > 0) { if (nodesToDelete.length > 0) {
nodesToDelete.forEach(nodeToDelete => { nodesToDelete.forEach(nodeToDelete => {
// 检查是否为子节点 // Check if it's a child node
const nodeData = nodeToDelete.getData(); const nodeData = nodeToDelete.getData();
if (nodeData.cycle) { if (nodeData.cycle) {
// 找到对应的父节点 // Find corresponding parent node
const parentNode = nodes?.find(n => n.id === nodeData.cycle); const parentNode = nodes?.find(n => n.id === nodeData.cycle);
if (parentNode) { if (parentNode) {
// 使用removeChild方法删除子节点 // Use removeChild method to delete child node
parentNode.removeChild(nodeToDelete); parentNode.removeChild(nodeToDelete);
parentNodesToUpdate.push(parentNode); parentNodesToUpdate.push(parentNode);
} }
// 将子节点添加到删除列表 // Add child node to deletion list
cells.push(nodeToDelete); cells.push(nodeToDelete);
} }
// 检查是否为 LoopNodeIterationNode SubGraphNode // Check if it's LoopNode, IterationNode or SubGraphNode
else if (nodeToDelete.shape === 'loop-node' || nodeToDelete.shape === 'iteration-node' || nodeToDelete.shape === 'subgraph-node') { 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 => { nodes?.forEach(node => {
const data = node.getData(); const data = node.getData();
if (data.cycle === nodeToDelete.id || data.cycle === nodeToDelete.getData()?.id) { if (data.cycle === nodeToDelete.id || data.cycle === nodeToDelete.getData()?.id) {
cells.push(node); cells.push(node);
} }
}); });
// 添加父节点到删除列表 // Add parent node to deletion list
cells.push(nodeToDelete); cells.push(nodeToDelete);
} }
// 普通节点 // Normal node
else { else {
cells.push(nodeToDelete); cells.push(nodeToDelete);
} }
@@ -581,25 +659,29 @@ export const useWorkflowGraph = ({
blankClick(); blankClick();
} }
// 删除所有收集的节点和连线 // Delete all collected nodes and edges
if (cells.length > 0) { if (cells.length > 0) {
graphRef.current?.removeCells(cells); graphRef.current?.removeCells(cells);
} }
return false; return false;
}; };
// 调整画布大小 /**
* Handle window resize event
*/
const handleResize = () => { const handleResize = () => {
if (containerRef.current && graphRef.current) { if (containerRef.current && graphRef.current) {
graphRef.current.resize(containerRef.current.offsetWidth, containerRef.current.offsetHeight); graphRef.current.resize(containerRef.current.offsetWidth, containerRef.current.offsetHeight);
} }
}; };
// 初始化 /**
* Initialize X6 graph with configuration and event listeners
*/
const init = () => { const init = () => {
if (!containerRef.current || !miniMapRef.current) return; if (!containerRef.current || !miniMapRef.current) return;
// 注册React形状 // Register React shapes
nodeRegisterLibrary.forEach((item) => { nodeRegisterLibrary.forEach((item) => {
register(item); register(item);
}); });
@@ -616,8 +698,8 @@ export const useWorkflowGraph = ({
type: 'dot', type: 'dot',
size: 10, size: 10,
args: { args: {
color: '#939AB1', // 网点颜色 color: '#939AB1', // Grid dot color
thickness: 1, // 网点大小 thickness: 1, // Grid dot size
} }
}, },
panning: isHandMode, panning: isHandMode,
@@ -649,32 +731,32 @@ export const useWorkflowGraph = ({
validateConnection({ sourceCell, targetCell, targetMagnet }) { validateConnection({ sourceCell, targetCell, targetMagnet }) {
if (!targetMagnet) return false; if (!targetMagnet) return false;
// 节点不能与自己连线 // Node cannot connect to itself
if (sourceCell?.id === targetCell?.id) return false; if (sourceCell?.id === targetCell?.id) return false;
const sourceType = sourceCell?.getData()?.type; const sourceType = sourceCell?.getData()?.type;
const targetType = targetCell?.getData()?.type; const targetType = targetCell?.getData()?.type;
// 开始节点不能作为连线的终点 // Start node cannot be connection target
if (targetType === 'start') return false; if (targetType === 'start') return false;
// 结束节点不能作为连线的起点 // End node cannot be connection source
if (sourceType === 'end') return false; if (sourceType === 'end') return false;
// 获取源节点和目标节点的父节点ID // Get source node and target node parent IDs
const sourceParentId = sourceCell?.getData()?.cycle; const sourceParentId = sourceCell?.getData()?.cycle;
const targetParentId = targetCell?.getData()?.cycle; const targetParentId = targetCell?.getData()?.cycle;
// 验证父子节点关系: // Validate parent-child relationship:
// 1. 如果两个节点都有父节点ID必须相同才能连线 // 1. If both nodes have parent IDs, they must be same to connect
// 2. 如果两个都没有父节点ID可以正常连线 // 2. If both have no parent ID, can connect normally
// 3. 如果一个有父节点,一个没有,不能连线 // 3. If one has parent, one doesn't, cannot connect
console.log('sourceParentId', sourceParentId, targetParentId) console.log('sourceParentId', sourceParentId, targetParentId)
if (sourceParentId && targetParentId) { if (sourceParentId && targetParentId) {
// 同一父节点下的子节点可以互相连线 // Child nodes under same parent can connect to each other
return sourceParentId === targetParentId; return sourceParentId === targetParentId;
} else if (sourceParentId || targetParentId) { } else if (sourceParentId || targetParentId) {
// 一个有父节点,一个没有,不能连线 // One has parent, one doesn't, cannot connect
return false; return false;
} }
@@ -710,26 +792,26 @@ export const useWorkflowGraph = ({
}, },
}, },
}); });
// 使用插件 // Use plugins
setupPlugins(); setupPlugins();
// 监听连线mouseleave事件 // Listen to edge mouseleave event
graphRef.current.on('edge:mouseleave', ({ edge }: { edge: Edge }) => { graphRef.current.on('edge:mouseleave', ({ edge }: { edge: Edge }) => {
if (edge.getAttrByPath('line/stroke') !== edge_selected_color) { if (edge.getAttrByPath('line/stroke') !== edge_selected_color) {
edge.setAttrByPath('line/stroke', edge_color); edge.setAttrByPath('line/stroke', edge_color);
edge.setAttrByPath('line/strokeWidth', 1); edge.setAttrByPath('line/strokeWidth', 1);
} }
}); });
// 监听节点选择事件 // Listen to node selection event
graphRef.current.on('node:click', nodeClick); graphRef.current.on('node:click', nodeClick);
// 监听连线选择事件 // Listen to edge selection event
graphRef.current.on('edge:click', edgeClick); 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 }) => { graphRef.current.on('node:port:click', ({ e, node, port }: { e: MouseEvent, node: Node, port: string }) => {
e.stopPropagation(); e.stopPropagation();
const portElement = e.target as HTMLElement; const portElement = e.target as HTMLElement;
const rect = portElement.getBoundingClientRect(); const rect = portElement.getBoundingClientRect();
// 创建临时的popover触发元素 // Create temporary popover trigger element
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
tempDiv.style.position = 'fixed'; tempDiv.style.position = 'fixed';
tempDiv.style.left = rect.left + 'px'; tempDiv.style.left = rect.left + 'px';
@@ -739,23 +821,23 @@ export const useWorkflowGraph = ({
tempDiv.style.zIndex = '9999'; tempDiv.style.zIndex = '9999';
document.body.appendChild(tempDiv); document.body.appendChild(tempDiv);
// 触发自定义事件来显示节点选择popover // Trigger custom event to show node selection popover
const customEvent = new CustomEvent('port:click', { const customEvent = new CustomEvent('port:click', {
detail: { node, port, element: tempDiv, rect } detail: { node, port, element: tempDiv, rect }
}); });
window.dispatchEvent(customEvent); window.dispatchEvent(customEvent);
}); });
// 监听画布点击事件,取消选择 // Listen to canvas click event, cancel selection
graphRef.current.on('blank:click', blankClick); graphRef.current.on('blank:click', blankClick);
// 监听缩放事件 // Listen to zoom event
graphRef.current.on('scale', scaleEvent); graphRef.current.on('scale', scaleEvent);
// 监听节点移动事件 // Listen to node move event
graphRef.current.on('node:moved', nodeMoved); graphRef.current.on('node:moved', nodeMoved);
// 监听复制键盘事件 // Listen to copy keyboard event
graphRef.current.bindKey(['ctrl+c', 'cmd+c'], copyEvent); graphRef.current.bindKey(['ctrl+c', 'cmd+c'], copyEvent);
// 监听粘贴键盘事件 // Listen to paste keyboard event
graphRef.current.bindKey(['ctrl+v', 'cmd+v'], parseEvent); graphRef.current.bindKey(['ctrl+v', 'cmd+v'], parseEvent);
// 删除选中的节点和连线 // Delete selected nodes and edges
graphRef.current.bindKey(['ctrl+d', 'cmd+d', 'delete', 'backspace'], deleteEvent); 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) => { const onDrop = (event: React.DragEvent) => {
if (!graphRef.current) return; if (!graphRef.current) return;
event.preventDefault(); event.preventDefault();
@@ -780,13 +867,13 @@ export const useWorkflowGraph = ({
const point = graphRef.current.clientToLocal(event.clientX, event.clientY); const point = graphRef.current.clientToLocal(event.clientX, event.clientY);
// 获取节点库中的原始配置避免config数据串联 // Get original config from node library to avoid config data chaining
let nodeLibraryConfig = [...nodeLibrary] let nodeLibraryConfig = [...nodeLibrary]
.flatMap(category => category.nodes) .flatMap(category => category.nodes)
.find(n => n.type === dragData.type); .find(n => n.type === dragData.type);
nodeLibraryConfig = JSON.parse(JSON.stringify({ config: {}, ...nodeLibraryConfig })) as NodeProperties nodeLibraryConfig = JSON.parse(JSON.stringify({ config: {}, ...nodeLibraryConfig })) as NodeProperties
// 创建干净的节点数据,只保留必要的字段 // Create clean node data, only keep necessary fields
const cleanNodeData = { const cleanNodeData = {
id: `${dragData.type.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, id: `${dragData.type.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
name: t(`workflow.${dragData.type}`), name: t(`workflow.${dragData.type}`),
@@ -802,7 +889,7 @@ export const useWorkflowGraph = ({
data: { ...cleanNodeData, isGroup: true }, data: { ...cleanNodeData, isGroup: true },
}); });
} else if (dragData.type === 'if-else') { } else if (dragData.type === 'if-else') {
// 创建条件节点 // Create condition node
graphRef.current.addNode({ graphRef.current.addNode({
...graphNodeLibrary[dragData.type], ...graphNodeLibrary[dragData.type],
x: point.x - 100, x: point.x - 100,
@@ -811,7 +898,7 @@ export const useWorkflowGraph = ({
data: { ...cleanNodeData }, data: { ...cleanNodeData },
}); });
} else { } else {
// 普通节点创建,不支持拖拽到循环节点内 // Normal node creation, does not support dragging into loop node
graphRef.current.addNode({ graphRef.current.addNode({
...(graphNodeLibrary[dragData.type] || graphNodeLibrary.default), ...(graphNodeLibrary[dragData.type] || graphNodeLibrary.default),
x: point.x - 60, 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) => { const handleSave = (flag = true) => {
if (!graphRef.current || !config) return Promise.resolve() if (!graphRef.current || !config) return Promise.resolve()
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -869,6 +961,18 @@ export const useWorkflowGraph = ({
}) })
} }
itemConfig[key] = group_variables 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]) { } else if (data.type === 'http-request' && (key === 'headers' || key === 'params') && data.config[key] && 'defaultValue' in data.config[key]) {
const value = data.config[key].defaultValue const value = data.config[key].defaultValue
itemConfig[key] = {} itemConfig[key] = {}
@@ -897,7 +1001,7 @@ export const useWorkflowGraph = ({
id: data.id || node.id, id: data.id || node.id,
type: data.type, type: data.type,
name: data.name, name: data.name,
cycle: data.cycle, // 保存cycle参数 cycle: data.cycle, // Save cycle parameter
position: { position: {
x: position.x, x: position.x,
y: position.y, y: position.y,
@@ -910,13 +1014,13 @@ export const useWorkflowGraph = ({
const targetCell = graphRef.current?.getCellById(edge.getTargetCellId()); const targetCell = graphRef.current?.getCellById(edge.getTargetCellId());
const sourcePortId = edge.getSourcePortId(); 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 || if (!sourceCell?.getData()?.id || !targetCell?.getData()?.id ||
sourceCell?.getData()?.type === 'add-node' || targetCell?.getData()?.type === 'add-node') { sourceCell?.getData()?.type === 'add-node' || targetCell?.getData()?.type === 'add-node') {
return null; return null;
} }
// 如果是if-else节点的右侧端口连线,添加label // If if-else node right port connection, add label
if (sourceCell?.getData()?.type === 'if-else' && sourcePortId?.startsWith('CASE')) { if (sourceCell?.getData()?.type === 'if-else' && sourcePortId?.startsWith('CASE')) {
return { return {
source: sourceCell.getData().id, 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')) { if (sourceCell?.getData()?.type === 'question-classifier' && sourcePortId?.startsWith('CASE')) {
return { return {
source: sourceCell.getData().id, 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 (sourceCell?.getData()?.type === 'http-request') {
if (sourcePortId === 'ERROR') { if (sourcePortId === 'ERROR') {
return { return {
@@ -958,7 +1062,7 @@ export const useWorkflowGraph = ({
}) })
.filter(edge => edge !== null) .filter(edge => edge !== null)
.filter((edge, index, arr) => { .filter((edge, index, arr) => {
// 去重:对于if-elsequestion-classifier节点,不同连接桩允许连接到相同节点 // Deduplication: For if-else and question-classifier nodes, different ports can connect to same node
return arr.findIndex(e => { return arr.findIndex(e => {
if (!e || !edge) return false; if (!e || !edge) return false;
const sourceCell = graphRef.current?.getCellById(e.source); const sourceCell = graphRef.current?.getCellById(e.source);
@@ -966,10 +1070,10 @@ export const useWorkflowGraph = ({
const isMultiPortNode = sourceType === 'question-classifier' || sourceType === 'if-else'; const isMultiPortNode = sourceType === 'question-classifier' || sourceType === 'if-else';
if (isMultiPortNode) { if (isMultiPortNode) {
// 多端口节点需要同时比较sourcetargetlabel // Multi-port nodes need to compare source, target and label
return e.source === edge.source && e.target === edge.target && e.label === edge.label; return e.source === edge.source && e.target === edge.target && e.label === edge.label;
} else { } else {
// 其他节点只比较sourcetarget // Other nodes only compare source and target
return e.source === edge.source && e.target === edge.target; return e.source === edge.source && e.target === edge.target;
} }
}) === index; }) === index;