diff --git a/web/src/assets/images/workflow/break.png b/web/src/assets/images/workflow/break.png new file mode 100644 index 00000000..473ab068 Binary files /dev/null and b/web/src/assets/images/workflow/break.png differ diff --git a/web/src/assets/images/workflow/question-classifier.png b/web/src/assets/images/workflow/question-classifier.png new file mode 100644 index 00000000..754a0a62 Binary files /dev/null and b/web/src/assets/images/workflow/question-classifier.png differ diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index bf0d89c0..80b69879 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1560,6 +1560,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re 'question-classifier': 'Question Classifier', iteration: 'Iteration', loop: 'Loop', + 'cycle-start': '', + break: 'Break Loop', parallel: 'Parallel Execution', 'var-aggregator': 'Variable Aggregator', externalInteraction: 'External Interaction', @@ -1585,9 +1587,11 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re clickToConfigure: 'Click to configure node parameters', nodeProperties: 'Node Properties', - empty: "Emmm... The box is empty, nothing here~", + empty: "Emmm…The box is empty, there's nothing here~", nodeName: 'Node Name', - + addvariable: 'Chat Variables', + addChatVariable: 'Add Chat Variable', + editChatVariable: 'Edit Chat Variable', config: { llm: { @@ -1609,7 +1613,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re editVariable: 'Edit Variable', variableType: 'Variable Type', variableName: 'Variable Name', - invalidVariableName: 'Variable name must start with a letter and contain only letters, numbers, and underscores', + invalidVariableName: 'Variable name can only start with English letters and contain English letters, numbers, and underscores', description: 'Display Name', default: 'Default Value', required: 'Required', @@ -1636,10 +1640,11 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re editParam: 'Edit Extract Parameter', name: 'Name', - invalidParamName: 'Parameter name must start with a letter and contain only letters, numbers, and underscores', + invalidParamName: 'Extract parameter name can only start with English letters and contain English letters, numbers, and underscores', type: 'Type', desc: 'Description', required: 'Required', + default: 'Default Value', 'string': 'String', 'number': 'Number', @@ -1651,7 +1656,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re }, 'var-aggregator': { group: 'Aggregation Group', - invalidVariableName: 'Variable name must start with a letter and contain only letters, numbers, and underscores', + invalidVariableName: 'Variable name can only start with English letters and contain English letters, numbers, and underscores', addGroup: 'Add Group', variable: 'Variable Assignment' }, @@ -1670,17 +1675,49 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re "ge": '>=', else_desc: 'Used to define the logic that should be executed when the if condition is not met.' }, + 'http-request': { + auth: 'Authentication', + authType: 'Auth Type', + apiKey: 'API Key', + basic: 'Basic', + bearer: 'Bearer', + custom: 'Custom', + header: 'Header', + api_key: 'API Key', + timeouts: 'Timeout Settings', + "connect_timeout": 'Connection Timeout (seconds)', + "read_timeout": 'Read Timeout (seconds)', + "write_timeout": 'Write Timeout (seconds)', + retry: 'Retry on Failure', + error_handle: 'Error Handling', + verify_ssl: 'Verify SSL Certificate', + none: 'None', + default: 'Default Value', + branch: 'Error Branch', + status_code: 'Status Code', + max_attempts: 'Max Retry Attempts', + retry_interval: 'Retry Interval', + }, + 'jinja-render': { + template: 'Code', + mapping: 'Input Variables' + }, 'question-classifier': { model_id: 'Model', input_variable: 'Input Variable', categories: 'Categories', user_supplement_prompt: 'Instruction', - class_name: 'Category', - addClassName: 'Add Category' + class_name: 'Classification', + addClassName: 'Add Classification' + }, + loop: { + cycle_vars: 'Loop Variables', + condition: 'Loop Termination Condition', }, name: 'Key', type: 'Type', value: 'Value', + addCase: 'Add Condition', }, clear: 'Clear', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 6784ed97..f081b42d 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1662,7 +1662,7 @@ export const zh = { iteration: '迭代 (Iteration)', loop: '循环 (Loop)', 'cycle-start': '', - 'cycle-end': '退出循环', + break: '退出循环', parallel: '并行执行', 'var-aggregator': '变量聚合器', externalInteraction: '外部交互', @@ -1690,7 +1690,9 @@ export const zh = { nodeProperties: '节点属性', empty: "Emmm…盒子是空的,这里什么都没有~", nodeName: '节点名称', - + addvariable: '会话变量', + addChatVariable: '添加会话变量', + editChatVariable: '编辑会话变量', config: { llm: { @@ -1743,6 +1745,7 @@ export const zh = { type: '类型', desc: '描述', required: '必填', + default: '默认值', 'string': 'String', 'number': 'Number', diff --git a/web/src/views/ApplicationConfig/components/ConfigHeader.tsx b/web/src/views/ApplicationConfig/components/ConfigHeader.tsx index ec899a32..37a56c80 100644 --- a/web/src/views/ApplicationConfig/components/ConfigHeader.tsx +++ b/web/src/views/ApplicationConfig/components/ConfigHeader.tsx @@ -101,6 +101,9 @@ const ConfigHeader: FC = ({ const clear = () => { workflowRef?.current?.graphRef?.current?.clearCells() } + const addvariable = () => { + workflowRef?.current?.addVariable() + } return ( <>
@@ -132,6 +135,7 @@ const ConfigHeader: FC = ({ {application?.type === 'workflow' ?
+ {/* */} diff --git a/web/src/views/ApplicationConfig/types.ts b/web/src/views/ApplicationConfig/types.ts index abe7a008..3a1c262c 100644 --- a/web/src/views/ApplicationConfig/types.ts +++ b/web/src/views/ApplicationConfig/types.ts @@ -121,7 +121,8 @@ export interface ClusterRef { export interface WorkflowRef { handleSave: (flag?: boolean) => Promise; handleRun: () => void; - graphRef: GraphRef + graphRef: GraphRef; + addVariable: () => void; } export interface ApplicationModalRef { handleOpen: (application?: Config) => void; diff --git a/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx b/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx new file mode 100644 index 00000000..571f1e4e --- /dev/null +++ b/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx @@ -0,0 +1,144 @@ +import { forwardRef, useImperativeHandle, useState } from 'react'; +import { Form, Input, Select, Checkbox, InputNumber } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import type { ChatVariableModalRef } from './types' +import type { ChatVariable } from '../../types'; +import RbModal from '@/components/RbModal' + +const FormItem = Form.Item; + +interface ChatVariableModalProps { + refresh: (value: ChatVariable, editIndex?: number) => void; +} + +const types = [ + 'string', + 'number', + 'boolean', +] + +const ChatVariableModal = forwardRef(({ + refresh +}, ref) => { + const { t } = useTranslation(); + const [visible, setVisible] = useState(false); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false) + const [editIndex, setEditIndex] = useState(undefined) + const typeValue = Form.useWatch('type', form); + + // 封装取消方法,添加关闭弹窗逻辑 + const handleClose = () => { + setVisible(false); + form.resetFields(); + setLoading(false) + setEditIndex(undefined) + }; + + const handleOpen = (variable?: ChatVariable, index?: number) => { + setVisible(true); + if (variable) { + form.setFieldsValue(variable) + setEditIndex(index) + } else { + form.resetFields(); + setEditIndex(undefined) + } + }; + // 封装保存方法,添加提交逻辑 + const handleSave = () => { + form.validateFields().then((values) => { + refresh({ ...values }, editIndex) + handleClose() + }) + } + + // 暴露给父组件的方法 + useImperativeHandle(ref, () => ({ + handleOpen + })); + + return ( + +
+ + + + + + ); + } + return ; + }} + + + + + + + + + {t('workflow.config.parameter-extractor.required')} + +
+
+ ); +}); + +export default ChatVariableModal; \ No newline at end of file diff --git a/web/src/views/Workflow/components/AddChatVariable/index.tsx b/web/src/views/Workflow/components/AddChatVariable/index.tsx new file mode 100644 index 00000000..f765b5eb --- /dev/null +++ b/web/src/views/Workflow/components/AddChatVariable/index.tsx @@ -0,0 +1,113 @@ +import React, { useState, useImperativeHandle, forwardRef, useRef } from 'react'; +import { Button, Input, Space, Typography, Tooltip, message, List } from 'antd'; +import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import type { ChatVariable, AddChatVariableRef } from '../../types'; +import type { ChatVariableModalRef } from './types' +import RbDrawer from '@/components/RbDrawer'; +import Empty from '@/components/Empty'; +import ChatVariableModal from './ChatVariableModal'; + +interface AddChatVariableProps { + variables?: ChatVariable[]; + onChange?: (variables: ChatVariable[]) => void; + disabled?: boolean; + maxVariables?: number; +} +const AddChatVariable = forwardRef(({ + variables = [], + onChange, +}, ref) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const chatVariableRef = useRef(null); + + const handleAddVariable = () => { + chatVariableRef.current?.handleOpen() + }; + + const handleEdit = (index: number) => { + chatVariableRef.current?.handleOpen(variables[index], index) + } + const handleDelete = (index: number) => { + const list = [...variables] + list.splice(index, 1) + onChange && onChange(list) + } + + const handleOpen = () => { + setOpen(true) + } + const handleSave = (value: ChatVariable, index?: number) => { + const list = [...variables] + if (index && index > -1) { + list[index] = value + } else { + list.push(value) + } + onChange && onChange(list) + } + // 暴露给父组件的方法 + useImperativeHandle(ref, () => ({ + handleOpen, + })); + + return ( + setOpen(false)} + > +
+ + + {variables.length === 0 + ? + : + ( + +
+
+
+ {item.name} + ({t(`workflow.config.parameter-extractor.${item.type}`)}) +
+ {item.required ? t('workflow.config.parameter-extractor.required') : ''} + +
+
{item.description}
+ +
handleEdit(index)} + >
+
handleDelete(index)} + >
+
+
+
+ )} + /> + } +
+ + +
+ ); +}); + +export default AddChatVariable; \ No newline at end of file diff --git a/web/src/views/Workflow/components/AddChatVariable/types.ts b/web/src/views/Workflow/components/AddChatVariable/types.ts new file mode 100644 index 00000000..ab00ae69 --- /dev/null +++ b/web/src/views/Workflow/components/AddChatVariable/types.ts @@ -0,0 +1,24 @@ +import type { ChatVariable } from '../../types' + +export interface AddChatVariableProps { + variables?: ChatVariable[]; + onChange?: (variables: ChatVariable[]) => void; + disabled?: boolean; + maxVariables?: number; +} + +export interface VariableFormData { + name: string; + type: ChatVariable['type']; + description?: string; + required?: boolean; + defaultValue?: any; +} + +export interface ChatVariableModalRef { + handleOpen: (value?: ChatVariable, index?: number) => void; +} + +export interface ChatVariableModalRef { + handleOpen: (vo?: ChatVariable, index?: number) => void; +} \ No newline at end of file diff --git a/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx b/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx index bf810bec..13d12ee1 100644 --- a/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx +++ b/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx @@ -44,14 +44,14 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({ > {data.isContext ? ( 📄 - ) : ( + ) : data.group !== 'CONVERSATION' ? ( - )} - {!data.isContext && ( + ) : null} + {!data.isContext && data.group !== 'CONVERSATION' && ( <> {data.nodeData?.name} / diff --git a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx index a35096a4..5c5d3956 100644 --- a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx @@ -11,6 +11,7 @@ export interface Suggestion { type: string; dataType: string; value: string; + group?: string nodeData: NodeProperties; isContext?: boolean; // 标记是否为context变量 disabled?: boolean; // 标记是否禁用 diff --git a/web/src/views/Workflow/components/NodeLibrary.tsx b/web/src/views/Workflow/components/NodeLibrary.tsx index a43c65eb..ecc4d5f4 100644 --- a/web/src/views/Workflow/components/NodeLibrary.tsx +++ b/web/src/views/Workflow/components/NodeLibrary.tsx @@ -23,7 +23,9 @@ const NodeLibrary: FC = () => { }} > - {category.nodes.map((node, nodeIndex) => ( + {category.nodes + .filter(node => node.type !== 'cycle-start' && node.type !== 'break') + .map((node, nodeIndex) => (
{ - const data = node?.getData() || {} +const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => { + const data = node?.getData() || {}; + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + const handleNodeSelect = (selectedNodeType: any) => { + const parentBBox = node.getBBox(); + const cycleId = data.cycle; + + const newNode = graph.addNode({ + ...(graphNodeLibrary[selectedNodeType.type] || graphNodeLibrary.default), + x: parentBBox.x, + y: parentBBox.y, + data: { + id: `${selectedNodeType.type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + type: selectedNodeType.type, + icon: selectedNodeType.icon, + name: t(`workflow.${selectedNodeType.type}`), + cycle: cycleId, + parentId: data.parentId, + config: selectedNodeType.config || {} + }, + }); + + // 将新节点添加为父节点的子节点 + if (cycleId) { + const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId); + if (parentNode) { + parentNode.addChild(newNode); + } + } + + const incomingEdges = graph.getIncomingEdges(node); + const outgoingEdges = graph.getOutgoingEdges(node); + + incomingEdges?.forEach(edge => { + graph.addEdge({ + source: { cell: edge.getSourceCellId(), port: edge.getSourcePortId() }, + target: { cell: newNode.id, port: newNode.getPorts().find((port: any) => port.group === 'left')?.id || 'left' }, + attrs: edge.getAttrs() + }); + }); + + outgoingEdges?.forEach(edge => { + const targetCell = graph.getCellById(edge.getTargetCellId()) as any; + const targetPortId = targetCell?.getPorts?.()?.find((port: any) => port.group === 'left')?.id || edge.getTargetPortId(); + graph.addEdge({ + source: { cell: newNode.id, port: newNode.getPorts().find((port: any) => port.group === 'right')?.id || 'right' }, + target: { cell: edge.getTargetCellId(), port: targetPortId }, + attrs: edge.getAttrs() + }); + }); + + // 删除所有add-node类型的节点 + graph.getNodes().forEach((n: any) => { + if (n.getData()?.type === 'add-node' && n.getData()?.cycle === cycleId) { + n.remove(); + } + }); + + // 自动调整循环节点大小 + const loopNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId); + if (loopNode) { + const adjustLoopSize = () => { + const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); + if (childNodes.length > 0) { + const bounds = childNodes.reduce((acc, child) => { + const bbox = child.getBBox(); + return { + minX: Math.min(acc.minX, bbox.x), + minY: Math.min(acc.minY, bbox.y), + maxX: Math.max(acc.maxX, bbox.x + bbox.width), + maxY: Math.max(acc.maxY, bbox.y + bbox.height) + }; + }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }); + + const padding = 20; + const newWidth = Math.max(240, bounds.maxX - bounds.minX + padding * 2); + const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2); + + loopNode.prop('size', { width: newWidth, height: newHeight }); + } + }; + + adjustLoopSize(); + + // 监听子节点移动事件 + const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); + childNodes.forEach((childNode: any) => { + childNode.on('change:position', adjustLoopSize); + }); + } + setOpen(false); + }; + + const content = ( +
+ {nodeLibrary.map((category, categoryIndex) => { + const filteredNodes = category.nodes.filter(nodeType => + nodeType.type !== 'start' && nodeType.type !== 'end' && nodeType.type !== 'loop' && nodeType.type !== 'cycle-start' + ); + + if (filteredNodes.length === 0) return null; + + return ( +
+ {categoryIndex > 0 &&
} +
+ {t(`workflow.${category.category}`)} +
+ {filteredNodes.map((nodeType) => ( +
handleNodeSelect(nodeType)} + onMouseEnter={(e) => { + e.currentTarget.style.background = '#f0f8ff'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = 'white'; + }} + > + + {t(`workflow.${nodeType.type}`)} +
+ ))} +
+ ); + })} +
+ ); return ( -
- - {data.icon} {data.label} - -
+ +
+ + {data.icon} {data.label} + +
+
); }; diff --git a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx index de6fcb1c..48b926dc 100644 --- a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx +++ b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx @@ -7,7 +7,7 @@ const ConditionNode: ReactShapeConfig['component'] = ({ node }) => { const { t } = useTranslation() return ( -
diff --git a/web/src/views/Workflow/components/Nodes/GroupStartNode.tsx b/web/src/views/Workflow/components/Nodes/GroupStartNode.tsx index cd8a9c50..7fce262e 100644 --- a/web/src/views/Workflow/components/Nodes/GroupStartNode.tsx +++ b/web/src/views/Workflow/components/Nodes/GroupStartNode.tsx @@ -1,17 +1,11 @@ import clsx from 'clsx'; import type { ReactShapeConfig } from '@antv/x6-react-shape'; +import startIcon from '@/assets/images/workflow/start.png'; -const GroupStartNode: ReactShapeConfig['component'] = ({ node }) => { - const data = node?.getData() || {} - +const GroupStartNode: ReactShapeConfig['component'] = () => { return ( -
- - {data.icon} {data.label} - +
+
); }; diff --git a/web/src/views/Workflow/components/Nodes/IterationNode.tsx b/web/src/views/Workflow/components/Nodes/IterationNode.tsx deleted file mode 100644 index a6c55138..00000000 --- a/web/src/views/Workflow/components/Nodes/IterationNode.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useEffect } from 'react'; -import clsx from 'clsx'; -import { Dropdown } from 'antd'; -import { SmallDashOutlined } from '@ant-design/icons'; -import type { ReactShapeConfig } from '@antv/x6-react-shape'; -import { graphNodeLibrary } from '../../constant'; - -interface NodeData { - isSelected?: boolean; - type?: string; - label?: string; - icon?: string; - parentId?: string; - isGroup?: boolean; -} - -const IterationNode: ReactShapeConfig['component'] = ({ node, graph }) => { - const data = node.getData() as NodeData; - - useEffect(() => { - initNodes() - }, []) - - const initNodes = () => { - // 添加默认子节点 - const parentBBox = node.getBBox(); - const centerX = parentBBox.x + 24; // 默认节点宽度的一半 - const centerY = parentBBox.y + 50; // 默认节点高度的一半 - - const childNode1 = graph.addNode({ - ...graphNodeLibrary.groupStart, - x: centerX, - y: centerY, - data: { - type: 'default', - label: '开始', - // icon: '📌', - parentId: node.id, - isDefault: true // 标记为默认节点,不可删除 - }, - }); - const childNode2 = graph.addNode({ - ...graphNodeLibrary.addStart, - x: centerX + 150, - y: centerY, - data: { - type: 'default', - label: '添加节点', - icon: '+', - parentId: node.id, - }, - }); - node.addChild(childNode1) - node.addChild(childNode2) - } - - return ( -
- {/* 标题区域 */} -
-
- 🔁 -
- 迭代 -
- - - - - {/* 画布内容区域 */} -
-
- ); -}; - -export default IterationNode; diff --git a/web/src/views/Workflow/components/Nodes/LoopNode.tsx b/web/src/views/Workflow/components/Nodes/LoopNode.tsx index da73e4ce..b0b8d4ce 100644 --- a/web/src/views/Workflow/components/Nodes/LoopNode.tsx +++ b/web/src/views/Workflow/components/Nodes/LoopNode.tsx @@ -1,19 +1,10 @@ import { useEffect } from 'react'; import { useTranslation } from 'react-i18next' import clsx from 'clsx'; -import { Dropdown } from 'antd'; -import { SmallDashOutlined } from '@ant-design/icons'; import type { ReactShapeConfig } from '@antv/x6-react-shape'; import { graphNodeLibrary } from '../../constant'; -interface NodeData { - isSelected?: boolean; - type?: string; - label?: string; - icon?: string; - parentId?: string; - isGroup?: boolean; -} +import { edge_color } from '../../hooks/useWorkflowGraph' const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => { const data = node.getData() || {}; @@ -21,63 +12,145 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => { useEffect(() => { initNodes() + // 检查是否需要添加add-node + checkAndAddAddNode() }, []) + const checkAndAddAddNode = () => { + if (!graph) return; + + const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === data.id); + const cycleStartNodes = childNodes.filter((n: any) => n.getData()?.type === 'cycle-start'); + + // 如果只有一个cycle-start节点且没有其他类型的子节点,则添加add-node + if (cycleStartNodes.length === 1 && childNodes.length === 1) { + const cycleStartNode = cycleStartNodes[0]; + const cycleStartBBox = cycleStartNode.getBBox(); + + const addNode = graph.addNode({ + ...graphNodeLibrary.addStart, + x: cycleStartBBox.x + 64, + y: cycleStartBBox.y, + data: { + type: 'add-node', + label: '添加节点', + icon: '+', + parentId: node.id, + cycle: data.id, + }, + }); + + node.addChild(addNode); + + // 连接cycle-start和add-node + const sourcePorts = cycleStartNode.getPorts(); + const targetPorts = addNode.getPorts(); + const sourcePort = sourcePorts.find((port: any) => port.group === 'right')?.id || 'right'; + const targetPort = targetPorts.find((port: any) => port.group === 'left')?.id || 'left'; + + graph.addEdge({ + source: { cell: cycleStartNode.id, port: sourcePort }, + target: { cell: addNode.id, port: targetPort }, + attrs: { + line: { + stroke: edge_color, + strokeWidth: 1, + targetMarker: { + name: 'block', + size: 8, + }, + }, + }, + }); + } + } + const initNodes = () => { + // 检查是否存在cycle为当前节点ID的子节点,若存在则不调用initNodes,避免重复创建 + const existingCycleNodes = graph.getNodes().filter((n: any) => + n.getData()?.cycle === data.id + ); + if (existingCycleNodes.length > 0) return; // 添加默认子节点 const parentBBox = node.getBBox(); const centerX = parentBBox.x + 24; // 默认节点宽度的一半 const centerY = parentBBox.y + 50; // 默认节点高度的一半 - const childNode1 = graph.addNode({ - ...graphNodeLibrary.groupStart, + const cycleStartNode = graph.addNode({ + ...graphNodeLibrary.cycleStart, x: centerX, y: centerY, data: { - type: 'default', - label: '开始', - // icon: '📌', + type: 'cycle-start', parentId: node.id, - isDefault: true // 标记为默认节点,不可删除 + isDefault: true, // 标记为默认节点,不可删除 + cycle: data.id, }, }); - const childNode2 = graph.addNode({ + const addNode = graph.addNode({ ...graphNodeLibrary.addStart, - x: centerX + 150, + x: centerX + 64, y: centerY, data: { - type: 'default', + type: 'add-node', label: '添加节点', icon: '+', parentId: node.id, + cycle: data.id, }, }); - node.addChild(childNode1) - node.addChild(childNode2) + node.addChild(cycleStartNode) + node.addChild(addNode) + const sourcePorts = cycleStartNode.getPorts() + const targetPorts = addNode.getPorts() + let sourcePort = sourcePorts.find((port: any) => port.group === 'right')?.id || 'right'; + + const edgeConfig = { + source: { + cell: cycleStartNode.id, + port: sourcePort + }, + target: { + cell: addNode.id, + port: targetPorts.find((port: any) => port.group === 'left')?.id || 'left' + }, + attrs: { + line: { + stroke: edge_color, + strokeWidth: 1, + targetMarker: { + name: 'block', + size: 8, + }, + }, + }, + } + + graph.addEdge(edgeConfig) } - return ( -
-
-
- -
{data.name ?? t(`workflow.${data.type}`)}
-
- -
{ - e.stopPropagation() - node.remove() - }} - >
+ return ( +
+
+
+ +
{data.name ?? t(`workflow.${data.type}`)}
-
+ +
{ + e.stopPropagation() + node.remove() + }} + >
- ); +
+
+ ); }; export default LoopNode; diff --git a/web/src/views/Workflow/components/Nodes/NormalNode.tsx b/web/src/views/Workflow/components/Nodes/NormalNode.tsx index 17c55494..b910ee68 100644 --- a/web/src/views/Workflow/components/Nodes/NormalNode.tsx +++ b/web/src/views/Workflow/components/Nodes/NormalNode.tsx @@ -7,7 +7,7 @@ const NormalNode: ReactShapeConfig['component'] = ({ node }) => { const { t } = useTranslation() return ( -
diff --git a/web/src/views/Workflow/components/PortClickHandler.tsx b/web/src/views/Workflow/components/PortClickHandler.tsx new file mode 100644 index 00000000..d26a40a1 --- /dev/null +++ b/web/src/views/Workflow/components/PortClickHandler.tsx @@ -0,0 +1,220 @@ +import { useEffect, useState } from 'react'; +import { Popover } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { nodeLibrary, graphNodeLibrary } from '../constant'; + +interface PortClickHandlerProps { + graph: any; +} + +const PortClickHandler: React.FC = ({ graph }) => { + const { t } = useTranslation(); + const [popoverVisible, setPopoverVisible] = useState(false); + const [popoverPosition, setPopoverPosition] = useState({ x: 0, y: 0 }); + const [sourceNode, setSourceNode] = useState(null); + const [sourcePort, setSourcePort] = useState(''); + const [tempElement, setTempElement] = useState(null); + + useEffect(() => { + const handlePortClick = (event: CustomEvent) => { + const { node, port, element, rect } = event.detail; + setSourceNode(node); + setSourcePort(port); + setTempElement(element); + setPopoverPosition({ x: rect.left, y: rect.top }); + setPopoverVisible(true); + }; + + window.addEventListener('port:click', handlePortClick as EventListener); + + return () => { + window.removeEventListener('port:click', handlePortClick as EventListener); + }; + }, []); + + const handleNodeSelect = (selectedNodeType: any) => { + if (!sourceNode || !graph) return; + + const sourceNodeData = sourceNode.getData(); + + // 计算新节点位置(在源节点右侧) + const sourceBBox = sourceNode.getBBox(); + const newX = sourceBBox.x + sourceBBox.width + 50; + const newY = sourceBBox.y; + + // 创建新节点 + const newNode = graph.addNode({ + ...(graphNodeLibrary[selectedNodeType.type] || graphNodeLibrary.default), + x: newX, + y: newY, + data: { + id: `${selectedNodeType.type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + type: selectedNodeType.type, + icon: selectedNodeType.icon, + name: t(`workflow.${selectedNodeType.type}`), + cycle: sourceNodeData.cycle, // 继承源节点的cycle + config: selectedNodeType.config || {} + }, + }); + + // 将新节点添加为父节点的子节点 + if (sourceNodeData.cycle) { + const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle); + if (parentNode) { + parentNode.addChild(newNode); + } + } + + // 创建连线 + setTimeout(() => { + const targetPorts = newNode.getPorts(); + const targetPort = targetPorts.find((port: any) => port.group === 'left')?.id || 'left'; + + graph.addEdge({ + source: { cell: sourceNode.id, port: sourcePort }, + target: { cell: newNode.id, port: targetPort }, + attrs: { + line: { + stroke: '#155EEF', + strokeWidth: 1, + targetMarker: { + name: 'block', + size: 8, + }, + }, + }, + }); + + // 循环节点内子节点通过连接桩添加时,调整循环节点大小 + const cycleId = sourceNodeData.cycle; + if (cycleId) { + const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId); + + if (parentNode) { + const adjustLoopSize = () => { + const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); + if (childNodes.length > 0) { + const bounds = childNodes.reduce((acc: any, child: any) => { + const bbox = child.getBBox(); + return { + minX: Math.min(acc.minX, bbox.x), + minY: Math.min(acc.minY, bbox.y), + maxX: Math.max(acc.maxX, bbox.x + bbox.width), + maxY: Math.max(acc.maxY, bbox.y + bbox.height) + }; + }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }); + + const padding = 20; + const newWidth = Math.max(240, bounds.maxX - bounds.minX + padding * 2); + const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2); + + parentNode.prop('size', { width: newWidth, height: newHeight }); + } + }; + + adjustLoopSize(); + + // 监听子节点移动事件 + const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); + childNodes.forEach((childNode: any) => { + childNode.on('change:position', adjustLoopSize); + }); + } + } + }, 50); + + // 清理临时元素 + if (tempElement) { + document.body.removeChild(tempElement); + setTempElement(null); + } + + setPopoverVisible(false); + }; + + const handlePopoverClose = () => { + setPopoverVisible(false); + if (tempElement) { + document.body.removeChild(tempElement); + setTempElement(null); + } + }; + + const content = ( +
+ {nodeLibrary.map((category, categoryIndex) => { + const sourceNodeData = sourceNode?.getData(); + const isChildOfLoop = sourceNodeData?.cycle && graph?.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle && n.getData()?.type === 'loop'); + + let filteredNodes; + if (isChildOfLoop) { + // Use same filtering as AddNode for child nodes of loop + filteredNodes = category.nodes.filter(nodeType => + nodeType.type !== 'start' && nodeType.type !== 'end' && nodeType.type !== 'loop' && nodeType.type !== 'cycle-start' + ); + + } else { + // Original filtering for non-loop child nodes + filteredNodes = category.nodes.filter(nodeType => + nodeType.type !== 'start' && nodeType.type !== 'end' && nodeType.type !== 'cycle-start' && nodeType.type !== 'break' + ); + } + + if (filteredNodes.length === 0) return null; + + return ( +
+ {categoryIndex > 0 &&
} +
+ {t(`workflow.${category.category}`)} +
+ {filteredNodes.map((nodeType) => ( +
handleNodeSelect(nodeType)} + onMouseEnter={(e) => { + e.currentTarget.style.background = '#f0f8ff'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = 'white'; + }} + > + + {t(`workflow.${nodeType.type}`)} +
+ ))} +
+ ); + })} +
+ ); + + if (!tempElement) return null; + + return ( + { + if (!visible) handlePopoverClose(); + }} + placement="right" + overlayStyle={{ + position: 'fixed', + left: popoverPosition.x + 10, + top: popoverPosition.y - 10, + }} + > +
+ + ); +}; + +export default PortClickHandler; \ No newline at end of file diff --git a/web/src/views/Workflow/components/Properties/CaseList/index.tsx b/web/src/views/Workflow/components/Properties/CaseList/index.tsx index 9e273115..e0006c89 100644 --- a/web/src/views/Workflow/components/Properties/CaseList/index.tsx +++ b/web/src/views/Workflow/components/Properties/CaseList/index.tsx @@ -9,8 +9,8 @@ import VariableSelect from '../VariableSelect' import Editor from '../../Editor' interface CaseListProps { - value?: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; operator: string; right: string; }[] }>; - onChange?: (value: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; operator: string; right: string; }[] }>) => void; + value?: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; comparison_operator: string; right: string; }[] }>; + onChange?: (value: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; comparison_operator: string; right: string; }[] }>) => void; options: Suggestion[]; name: string; selectedNode?: any; @@ -221,7 +221,7 @@ const CaseList: FC = ({ onClick={() => addCondition()} size="small" > - + 添加条件 + + {t('workflow.config.addCase')} {caseFields.length > 1 && = ({ />}
- {conditionFields?.length > 1 && <> + {conditionFields?.length > 1 && + <>
@@ -238,50 +239,56 @@ const CaseList: FC = ({
} - {conditionFields.map((conditionField, conditionIndex) => ( -
-
- - - - { + const currentOperator = value?.[caseIndex]?.expressions?.[conditionIndex]?.comparison_operator; + const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty'; + + return ( +
+
+ + + + + + + + + ({ - value: key, - label: t(`workflow.config.if-else.${key}`) - }))} - size="small" - popupMatchSelectWidth={false} - /> - - - - removeCondition(conditionField.name)} - /> - - - - - - + )} +
-
- ))} + ) + })}
) }} diff --git a/web/src/views/Workflow/components/Properties/CategoryList/index.tsx b/web/src/views/Workflow/components/Properties/CategoryList/index.tsx index db1cd658..18cf434b 100644 --- a/web/src/views/Workflow/components/Properties/CategoryList/index.tsx +++ b/web/src/views/Workflow/components/Properties/CategoryList/index.tsx @@ -24,7 +24,7 @@ const CategoryList: FC = ({ parentName }) => { return (
-
{t('workflow.config.question-classifier.class_name')} {index + 1}
+
{t('workflow.config.question-classifier.class_name')} {index + 1}
{contentLength} + +
+ )} + +
+ + + + + + + + + + + + + + + { + // 重置 value 字段 + form.setFieldValue([parentName, index, 'value'], undefined); + }} + /> + + + + remove(name)} + /> + + + + + {currentInputType === 'variable' ? ( + + ) : ( + + )} + +
+ ) + })} + + + + )} + +
+ ) +} + +export default CycleVarsList \ No newline at end of file diff --git a/web/src/views/Workflow/components/Properties/VariableSelect.tsx b/web/src/views/Workflow/components/Properties/VariableSelect.tsx index 165706f9..64d1090a 100644 --- a/web/src/views/Workflow/components/Properties/VariableSelect.tsx +++ b/web/src/views/Workflow/components/Properties/VariableSelect.tsx @@ -26,7 +26,7 @@ const VariableSelect: FC = ({ } const labelRender: LabelRender = (props) => { const { value } = props - const filterOption = options.find(vo => vo.value === value) + const filterOption = options.find(vo => `{{${vo.value}}}` === value) if (filterOption) { return ( @@ -62,8 +62,10 @@ const VariableSelect: FC = ({ const groupedOptions = Object.entries(groupedSuggestions).map(([nodeId, suggestions]) => ({ label: suggestions[0].nodeData.name, - options: suggestions.map(s => ({ label: s.label, value: s.value })) + options: suggestions.map(s => ({ label: s.label, value: `{{${s.value}}}` })) })); + + console.log('groupedOptions', groupedOptions) return (