feat(web): add parameter-extractor、if-else、var-aggregator Node

This commit is contained in:
zhaoying
2025-12-30 13:59:36 +08:00
parent 1383f4abcf
commit 262952c022
18 changed files with 1042 additions and 346 deletions

View File

@@ -36,7 +36,7 @@ const CanvasToolbar: FC<CanvasToolbarProps> = ({
if (edges.length === 0) {
nodes.forEach((node, index) => {
const nodeData = node.getData();
const isSpecialNode = nodeData?.isGroup || nodeData?.type === 'condition';
const isSpecialNode = nodeData?.isGroup || nodeData?.type === 'if-else';
const nodeHeight = isSpecialNode ? 220 : 50;
const xPosition = 100;
const yPosition = index * (nodeHeight + 100) + 100;
@@ -89,7 +89,7 @@ const CanvasToolbar: FC<CanvasToolbarProps> = ({
if (!node) return;
const nodeData = node.getData();
const isSpecialNode = nodeData?.isGroup || nodeData?.type === 'condition';
const isSpecialNode = nodeData?.isGroup || nodeData?.type === 'if-else';
const nodeWidth = isSpecialNode ? 400 : 160;
const gap = isSpecialNode ? 150 : 100;
@@ -107,7 +107,7 @@ const CanvasToolbar: FC<CanvasToolbarProps> = ({
if (!node) return parentY;
const nodeData = node.getData();
const isSpecialNode = nodeData?.isGroup || nodeData?.type === 'condition';
const isSpecialNode = nodeData?.isGroup || nodeData?.type === 'if-else';
const nodeHeight = isSpecialNode ? 220 : 50;
const verticalGap = isSpecialNode ? 80 : 40;
const spacing = baseNodeSpacing + nodeHeight + verticalGap;

View File

@@ -20,7 +20,7 @@ interface LexicalEditorProps {
placeholder?: string;
value?: string;
onChange?: (value: string) => void;
suggestions: Suggestion[];
options: Suggestion[];
}
const theme = {
@@ -35,7 +35,7 @@ const Editor: FC<LexicalEditorProps> =({
placeholder = "请输入内容...",
value = "",
onChange,
suggestions,
options,
}) => {
const [_count, setCount] = useState(0);
const initialConfig = {
@@ -91,9 +91,9 @@ const Editor: FC<LexicalEditorProps> =({
/>
<HistoryPlugin />
<CommandPlugin />
<AutocompletePlugin suggestions={suggestions} />
<AutocompletePlugin options={options} />
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} />
<InitialValuePlugin value={value} suggestions={suggestions} />
<InitialValuePlugin value={value} options={options} />
</div>
</LexicalComposer>
);

View File

@@ -14,7 +14,7 @@ export interface Suggestion {
nodeData: NodeProperties
}
const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions }) => {
const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
const [editor] = useLexicalComposerContext();
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -73,8 +73,8 @@ const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions })
if (!showSuggestions) return null;
// Group suggestions by node id
const groupedSuggestions = suggestions.reduce((groups: Record<string, any[]>, suggestion) => {
// Group options by node id
const groupedSuggestions = options.reduce((groups: Record<string, any[]>, suggestion) => {
const { nodeData } = suggestion
const nodeId = nodeData.id as string;
if (!groups[nodeId]) {
@@ -84,6 +84,9 @@ const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions })
return groups;
}, {});
if (Object.entries(groupedSuggestions).length === 0) {
return null
}
return (
<div
style={{

View File

@@ -7,10 +7,10 @@ import { type Suggestion } from '../plugin/AutocompletePlugin'
interface InitialValuePluginProps {
value: string;
suggestions?: Suggestion[];
options?: Suggestion[];
}
const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, suggestions = [] }) => {
const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options = [] }) => {
const [editor] = useLexicalComposerContext();
const initializedRef = useRef(false);
@@ -29,7 +29,7 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, suggesti
if (match) {
const [_, nodeId, label] = match;
const suggestion = suggestions.find(s => {
const suggestion = options.find(s => {
if (nodeId === 'sys') {
return s.nodeData.type === 'start' && s.label === `sys.${label}`
}
@@ -51,7 +51,7 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, suggesti
initializedRef.current = true;
}
}, [suggestions]);
}, [options]);
return null;
};

View File

@@ -1,153 +1,32 @@
import React from 'react';
import { useTranslation } from 'react-i18next'
import clsx from 'clsx';
import { Button } from 'antd'
import type { ReactShapeConfig } from '@antv/x6-react-shape';
const ConditionNode: ReactShapeConfig['component'] = ({ node }) => {
const data = node?.getData() || {};
const addPort = (e: React.MouseEvent) => {
if (!node || !node.addPort) return;
e.stopPropagation();
const currentPorts = node.getPorts();
const totalPorts = currentPorts.length;
// 如果没有端口添加第一个端口和ELSE端口
if (totalPorts === 0) {
// 添加第一个ELIF端口
node.addPort({
id: 'elif_1',
group: 'right',
attrs: {
text: {
text: 'ELIF 1',
},
},
});
// 添加ELSE端口
node.addPort({
id: 'else',
group: 'right',
attrs: {
text: {
text: 'ELSE',
},
},
});
return;
}
// 如果只有一个端口确保它是ELSE然后在之前添加ELIF
if (totalPorts === 1) {
const existingPort = currentPorts[0];
// 如果现有端口不是ELSE先移除它
if (node.removePort && existingPort.id !== 'else') {
node.removePort(existingPort.id as string);
// 添加ELIF端口
node.addPort({
id: 'elif_1',
group: 'right',
attrs: {
text: {
text: 'ELIF 1',
},
},
});
}
// 添加或确保存在ELSE端口
if (existingPort.id !== 'else') {
node.addPort({
id: 'else',
group: 'right',
attrs: {
text: {
text: 'ELSE',
},
},
});
}
return;
}
// 获取最后一个端口确保它是ELSE
let lastPort = currentPorts[totalPorts - 1];
// 如果最后一个端口不是ELSE先移除它
if (node.removePort && lastPort.id !== 'else') {
node.removePort(lastPort.id as string);
// 添加ELSE端口作为最后一个
node.addPort({
id: 'else',
group: 'right',
attrs: {
text: {
text: 'ELSE',
},
},
});
// 更新currentPorts和totalPorts
const updatedPorts = node.getPorts();
const updatedTotal = updatedPorts.length;
lastPort = updatedPorts[updatedTotal - 1];
}
// 计算新的ELIF端口数量最后一个是ELSE不算在内
const elifCount = totalPorts - 1;
const newElifCount = elifCount + 1;
// 如果有removePort方法先移除最后一个端口(ELSE)添加新的ELIF端口再添加回ELSE端口
if (node.removePort) {
// 移除最后一个端口(ELSE)
node.removePort(lastPort.id as string);
// 添加新的ELIF端口在倒数第二个位置
node.addPort({
id: `elif_${newElifCount}`,
group: 'right',
attrs: {
text: {
text: `ELIF ${newElifCount}`,
},
},
});
// 添加回ELSE端口
node.addPort({
id: 'else',
group: 'right',
attrs: {
text: {
text: 'ELSE',
},
},
});
}
};
// const removeElif = (e: React.MouseEvent) => {
// e.stopPropagation();
// };
const { t } = useTranslation()
return (
<div className={clsx(`rb:border rb:rounded-[12px] rb:relative rb:min-w-[200px] rb:min-h-[120px] rb:p-2`, {
'rb:border-orange-500 rb:border-[3px] rb:bg-orange-50 rb:text-gray-700': data.isSelected,
'rb:border-[#d1d5db] rb:bg-[#FFFFFF] rb:text-[#374151]': !data.isSelected
<div className={clsx('rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-60 rb:p-2.5 rb:border rb:rounded-xl rb:bg-white rb:hover:shadow-[0px_2px_6px_0px_rgba(33,35,50,0.12)]', {
'rb:border-[#155EEF]': data.isSelected,
'rb:border-[#DFE4ED]': !data.isSelected
})}>
<Button onClick={addPort}>+ ELIF</Button>
{/* 标题区域 */}
<div className="rb:absolute rb:-top-3 rb:left-2 rb:bg-blue-500 rb:rounded-2xl rb:px-3 rb:py-1 rb:flex rb:items-center rb:gap-1.5 rb:text-white rb:text-xs rb:font-bold rb:z-10">
<div className="rb:w-4 rb:h-4 rb:bg-white rb:rounded rb:flex rb:items-center rb:justify-center rb:text-blue-500 rb:text-[10px]">
🔀
<div className="rb:flex rb:items-center rb:justify-between">
<div className="rb:flex rb:items-center rb:gap-2 rb:flex-1">
<img src={data.icon} className="rb:w-5 rb:h-5" />
<div className="rb:wrap-break-word rb:line-clamp-1">{data.name ?? t(`workflow.${data.type}`)}</div>
</div>
<div
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/deleteBorder.svg')] rb:hover:bg-[url('@/assets/images/deleteBg.svg')]"
onClick={(e) => {
e.stopPropagation()
node.remove()
}}
></div>
</div>
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4 rb:mt-7">{t('workflow.clickToConfigure')}</div>
</div>
);
};

View File

@@ -0,0 +1,309 @@
import { type FC } from 'react'
import clsx from 'clsx'
import { useTranslation } from 'react-i18next';
import { Form, Button, Select, Space, Row, Col, Divider } from 'antd'
import { DeleteOutlined } from '@ant-design/icons';
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin'
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;
options: Suggestion[];
name: string;
selectedNode?: any;
graphRef?: any;
}
const operatorList = [
"empty",
"not_empty",
"contains",
"not_contains",
"startwith",
"endwith",
"eq",
"ne",
"lt",
"le",
"gt",
"ge"
]
const CaseList: FC<CaseListProps> = ({
value = [],
options,
name,
onChange,
selectedNode,
graphRef
}) => {
const { t } = useTranslation();
const updateNodePorts = (caseCount: number, removedCaseIndex?: number) => {
if (!selectedNode || !graphRef?.current) return;
// 保存现有连线信息(包括左侧端口连线)
const existingEdges = graphRef.current.getEdges().filter((edge: any) =>
edge.getSourceCellId() === selectedNode.id || edge.getTargetCellId() === selectedNode.id
);
const edgeConnections = existingEdges.map((edge: any) => ({
edge,
sourcePortId: edge.getSourcePortId(),
targetCellId: edge.getTargetCellId(),
targetPortId: edge.getTargetPortId(),
sourceCellId: edge.getSourceCellId(),
isIncoming: edge.getTargetCellId() === selectedNode.id
}));
// 移除所有现有的右侧端口
const existingPorts = selectedNode.getPorts();
existingPorts.forEach((port: any) => {
if (port.group === 'right') {
selectedNode.removePort(port.id);
}
});
// 计算新的节点高度基础高度88px + 每个额外port增加30px
const baseHeight = 88;
const totalPorts = caseCount + 1; // IF/ELIF + ELSE
const newHeight = baseHeight + (totalPorts - 2) * 30;
selectedNode.prop('size', { width: 240, height: newHeight })
// 添加 IF 端口
selectedNode.addPort({
id: 'CASE1',
group: 'right',
args: { dy: 24 },
attrs: { text: { text: 'IF', fontSize: 12, fill: '#5B6167' }}
});
// 添加 ELIF 端口
for (let i = 1; i < caseCount; i++) {
selectedNode.addPort({
id: `CASE${i + 1}`,
group: 'right',
attrs: { text: { text: 'ELIF', fontSize: 12, fill: '#5B6167' }}
});
}
// 添加 ELSE 端口
selectedNode.addPort({
id: `CASE${caseCount + 1}`,
group: 'right',
attrs: { text: { text: 'ELSE', fontSize: 12, fill: '#5B6167' }}
});
// 恢复仍然存在的端口连线
setTimeout(() => {
edgeConnections.forEach(({ edge, sourcePortId, targetCellId, targetPortId, sourceCellId, isIncoming }: any) => {
// 如果是进入连线(左侧端口),直接恢复
if (isIncoming) {
const sourceCell = graphRef.current?.getCellById(sourceCellId);
if (sourceCell) {
graphRef.current?.addEdge({
source: { cell: sourceCellId, port: sourcePortId },
target: { cell: selectedNode.id, port: targetPortId },
attrs: {
line: {
stroke: '#155EEF',
strokeWidth: 1,
targetMarker: {
name: 'block',
size: 8,
},
},
},
});
}
graphRef.current?.removeCell(edge);
return;
}
// 处理右侧端口连线
const originalCaseNumber = parseInt(sourcePortId.match(/CASE(\d+)/)?.[1] || '0');
// 如果是被删除的端口,不重新创建连线
if (removedCaseIndex !== undefined && originalCaseNumber === removedCaseIndex + 1) {
graphRef.current?.removeCell(edge);
return;
}
let newPortId = sourcePortId;
// 如果是原来的ELSE端口重新映射到新的ELSE端口
const maxOriginalCaseNumber = Math.max(...edgeConnections
.filter(({ isIncoming }: any) => !isIncoming)
.map(({ sourcePortId }: any) => {
const match = sourcePortId.match(/CASE(\d+)/);
return match ? parseInt(match[1]) : 0;
}));
if (originalCaseNumber === maxOriginalCaseNumber) {
newPortId = `CASE${caseCount + 1}`; // 新的ELSE端口
} else if (removedCaseIndex !== undefined && originalCaseNumber > removedCaseIndex + 1) {
// 如果是被删除端口之后的端口,编号向前移动
newPortId = `CASE${originalCaseNumber - 1}`;
}
const newPorts = selectedNode.getPorts();
const matchingPort = newPorts.find((port: any) => port.id === newPortId);
if (matchingPort) {
const targetCell = graphRef.current?.getCellById(targetCellId);
if (targetCell) {
graphRef.current?.addEdge({
source: { cell: selectedNode.id, port: newPortId },
target: { cell: targetCellId, port: targetPortId },
attrs: {
line: {
stroke: '#155EEF',
strokeWidth: 1,
targetMarker: {
name: 'block',
size: 8,
},
},
},
});
}
}
graphRef.current?.removeCell(edge);
});
}, 50);
};
const handleChangeLogicalOperator = (index: number) => {
const newValue = [...value]
newValue[index] = {
...newValue[index],
logical_operator: newValue[index].logical_operator === 'and' ? 'or' : 'and'
}
onChange && onChange(newValue)
}
const handleAddCase = (addCaseFunc: Function) => {
addCaseFunc({ logical_operator: 'and', expressions: [] });
setTimeout(() => {
updateNodePorts((value?.length || 0) + 1);
}, 100);
};
const handleRemoveCase = (removeCaseFunc: Function, fieldName: number, caseIndex: number) => {
removeCaseFunc(fieldName);
setTimeout(() => {
updateNodePorts((value?.length || 1) - 1, caseIndex);
}, 100);
};
return (
<>
<Form.List name={name}>
{(caseFields, { add: addCase, remove: removeCase }) => (
<>
{caseFields.map((caseField, caseIndex) => (
<div key={caseField.key}>
<Form.List name={[caseField.name, 'expressions']}>
{(conditionFields, { add: addCondition, remove: removeCondition }) => {
return (
<div className={clsx("rb:relative rb:mb-4 rb:border rb:border-gray-200 rb:rounded rb:p-3 rb:pl-5")}>
<div className="rb:flex rb:items-center rb:justify-between rb:mb-3">
<span className="rb:font-medium">
{caseIndex === 0 ? 'IF' : 'ELIF'}<br/>
{caseFields.length > 1 && <span className="rb:text-[10px] rb:text-[#5B6167]">{`CASE ${caseIndex + 1}`}</span>}
</span>
<Space>
<Button
type="dashed"
onClick={() => addCondition()}
size="small"
>
+
</Button>
{caseFields.length > 1 && <DeleteOutlined
className="rb:text-[12px]"
onClick={() => handleRemoveCase(removeCase, caseField.name, caseIndex)}
/>}
</Space>
</div>
{conditionFields?.length > 1 && <>
<div className="rb:absolute rb:w-3 rb:left-2 rb:top-15 rb:bottom-6 rb:z-10 rb:border rb:border-[#DFE4ED] rb:rounded-l-md rb:border-r-0"></div>
<div className="rb:absolute rb:z-10 rb:left-0 rb:top-[50%] rb:transform-[translateY(-50%)]]">
<Form.Item name={[caseField.name, 'logical_operator']} noStyle >
<Button size="small" className="rb:cursor-pointer" onClick={() => handleChangeLogicalOperator(caseIndex)}>{value?.[caseIndex].logical_operator}</Button>
</Form.Item>
</div>
</>
}
{conditionFields.map((conditionField, conditionIndex) => (
<div key={conditionField.key} className={clsx({
"rb:mb-3": conditionIndex !== conditionFields.length - 1
})}>
<div className="rb:border rb:border-[#DFE4ED] rb:rounded-md rb:px-2 rb:py-1.5 rb:bg-white">
<Row gutter={12} className="rb:mb-1">
<Col span={14}>
<Form.Item name={[conditionField.name, 'left']} noStyle>
<VariableSelect
placeholder={t('common.pleaseSelect')}
options={options}
size="small"
allowClear={false}
popupMatchSelectWidth={false}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name={[conditionField.name, 'operator']} noStyle>
<Select
placeholder="包含"
options={operatorList.map(key => ({
value: key,
label: t(`workflow.config.if-else.${key}`)
}))}
size="small"
popupMatchSelectWidth={false}
/>
</Form.Item>
</Col>
<Col span={2}>
<DeleteOutlined
className="rb:text-[12px]"
onClick={() => removeCondition(conditionField.name)}
/>
</Col>
</Row>
<Form.Item name={[conditionField.name, 'right']} noStyle>
<Editor options={options} />
</Form.Item>
</div>
</div>
))}
</div>
)
}}
</Form.List>
</div>
))}
<Button
type="dashed"
block
onClick={() => handleAddCase(addCase)}
>
+ ELIF
</Button>
</>
)}
</Form.List>
<Divider />
<div className="rb:font-medium">ELSE</div>
<div className="rb:text-[12px] rb:text-[#5B6167] ">{t('workflow.config.if-else.else_desc')}</div>
</>
)
}
export default CaseList

View File

@@ -0,0 +1,101 @@
import { type FC } from 'react'
import { useTranslation } from 'react-i18next';
import { Form, Input, Button, Row, Col } from 'antd'
import { MinusCircleOutlined } from '@ant-design/icons';
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin'
import VariableSelect from '../VariableSelect'
interface GroupVariableListProps {
value?: Array<{ key: string; value: string[]; }>;
name: string;
options: Suggestion[];
isCanAdd: boolean
}
const GroupVariableList: FC<GroupVariableListProps> = ({
name,
options = [],
isCanAdd = false
}) => {
const { t } = useTranslation();
if (!isCanAdd) {
return (
<div className="rb:mb-4">
<Row gutter={12} className="rb:mb-2!">
<Col span={12}>
<Form.Item
name={[name,0, 'key']}
noStyle
>
{t('workflow.config.var-aggregator.variable')}
</Form.Item>
</Col>
</Row>
<Form.Item
name={[name, 0, 'value']}
noStyle
rules={[{ required: true, message: 'Missing last name' }]}
>
<VariableSelect
placeholder={t('common.pleaseSelect')}
options={options}
mode="multiple"
/>
</Form.Item>
</div>
)
}
return (
<Form.List name={name}>
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => {
return (
<div key={key} className="rb:mb-4">
<Row gutter={12} className="rb:mb-2!">
<Col span={12}>
<Form.Item
{...restField}
name={isCanAdd ? [name, 'key'] : undefined}
rules={[
{ pattern: /^[a-zA-Z_][a-zA-Z0-9_]*$/, message: t('workflow.config.var-aggregator.invalidVariableName') },
]}
noStyle
>
{isCanAdd ? <Input placeholder={t('common.pleaseEnter')} /> : t('workflow.config.var-aggregator.variable')}
</Form.Item>
</Col>
{isCanAdd && <Col span={12} className="rb:flex! rb:items-center rb:justify-end">
<MinusCircleOutlined onClick={() => remove(name)} />
</Col>}
</Row>
<Form.Item
{...restField}
name={[name, 'value']}
noStyle
rules={[{ required: true, message: 'Missing last name' }]}
>
<VariableSelect
placeholder={t('common.pleaseSelect')}
options={options}
mode="multiple"
/>
</Form.Item>
</div>
)
})}
{isCanAdd && <Form.Item noStyle>
<Button type="dashed" onClick={() => add({ key: `Group${fields.length + 1}` })} block>
+ {t('workflow.config.var-aggregator.addGroup')}
</Button>
</Form.Item>}
</>
)}
</Form.List>
)
}
export default GroupVariableList

View File

@@ -1,20 +1,19 @@
import { type FC, useMemo } from 'react';
import { type FC } from 'react';
import { useTranslation } from 'react-i18next'
import { Input, Form, Space, Button, Row, Col, Select, type FormListOperation } from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { Graph, Node } from '@antv/x6';
import Editor from '../Editor'
import type { Suggestion } from '../Editor/plugin/AutocompletePlugin'
interface TextareaProps {
options: Suggestion[];
title?: string
isArray?: boolean;
parentName?: string;
label?: string;
placeholder?: string;
value?: string;
onChange?: (value?: string) => void;
selectedNode?: Node | null;
graphRef?: React.MutableRefObject<Graph | undefined>;
}
const roleOptions = [
// { label: 'SYSTEM', value: 'SYSTEM' },
@@ -22,92 +21,15 @@ const roleOptions = [
{ label: 'ASSISTANT', value: 'ASSISTANT' },
]
const MessageEditor: FC<TextareaProps> = ({
title,
isArray = true,
parentName = 'messages',
placeholder,
selectedNode,
graphRef,
options,
}) => {
const { t } = useTranslation()
const form = Form.useFormInstance();
const values = form.getFieldsValue()
const suggestions = useMemo(() => {
if (!selectedNode || !graphRef?.current) return [];
const suggestions: Suggestion[] = [];
const graph = graphRef.current;
const edges = graph.getEdges();
const nodes = graph.getNodes();
// Find all connected previous nodes (recursive)
const getAllPreviousNodes = (nodeId: string, visited = new Set<string>()): string[] => {
if (visited.has(nodeId)) return [];
visited.add(nodeId);
const directPrevious = edges
.filter(edge => edge.getTargetCellId() === nodeId)
.map(edge => edge.getSourceCellId());
const allPrevious = [...directPrevious];
directPrevious.forEach(prevNodeId => {
allPrevious.push(...getAllPreviousNodes(prevNodeId, visited));
});
return allPrevious;
};
const allPreviousNodeIds = getAllPreviousNodes(selectedNode.id);
console.log('allPreviousNodeIds', allPreviousNodeIds)
allPreviousNodeIds.forEach(nodeId => {
const node = nodes.find(n => n.id === nodeId);
if (!node) return;
const nodeData = node.getData();
switch(nodeData.type) {
case 'start':
const list = [
...(nodeData.config?.variables?.defaultValue ?? []),
...(nodeData.config?.variables?.value ?? [])
]
list.forEach((variable: any) => {
suggestions.push({
key: `${nodeId}_${variable.name}`,
label: variable.name,
type: 'variable',
dataType: variable.type,
value: `${nodeId}.${variable.name}`,
nodeData: nodeData,
});
});
nodeData.config?.variables?.sys.forEach((variable: any) => {
suggestions.push({
key: `${nodeId}_${variable.name}`,
label: `sys.${variable.name}`,
type: 'variable',
dataType: variable.type,
value: `sys.${variable.name}`,
nodeData: nodeData,
});
});
break
case 'llm':
suggestions.push({
key: `${nodeId}_output`,
label: 'output',
type: 'variable',
dataType: 'String',
value: `${nodeId}.output`,
nodeData: nodeData,
});
break
}
});
return suggestions;
}, [selectedNode, graphRef]);
const handleAdd = (add: FormListOperation['add']) => {
const list = values[parentName];
@@ -158,7 +80,7 @@ const MessageEditor: FC<TextareaProps> = ({
name={[name, 'content']}
noStyle
>
<Editor placeholder={placeholder} suggestions={suggestions} />
<Editor placeholder={placeholder} options={options} />
</Form.Item>
</Space>
)
@@ -175,14 +97,14 @@ const MessageEditor: FC<TextareaProps> = ({
<Space size={12} direction="vertical" className="rb:w-full rb:border rb:border-[#DFE4ED] rb:rounded-md rb:px-2 rb:py-1.5 rb:bg-white">
<Row>
<Col span={12}>
{t('workflow.answerDesc')}
{title ?? t('workflow.answerDesc')}
</Col>
</Row>
<Form.Item
name={parentName}
noStyle
>
<Editor placeholder={placeholder} suggestions={suggestions} />
<Editor placeholder={placeholder} options={options} />
</Form.Item>
</Space>
}

View File

@@ -0,0 +1,121 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Input, Select, Checkbox } from 'antd';
import { useTranslation } from 'react-i18next';
import type { ParamItem, ParamEditModalRef } from './types'
import RbModal from '@/components/RbModal'
const FormItem = Form.Item;
interface ParamEditModalProps {
refresh: (values: ParamItem, editIndex?: number) => void;
}
const types = [
'string',
'number',
'boolean',
'array[string]',
'array[number]',
'array[boolean]',
'array[object]'
]
const ParamEditModal = forwardRef<ParamEditModalRef, ParamEditModalProps>(({
refresh
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<ParamItem>();
const [loading, setLoading] = useState(false)
const [editIndex, setEditIndex] = useState<number | undefined>(undefined)
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
setLoading(false)
setEditIndex(undefined)
};
const handleOpen = (variable?: ParamItem, 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,
handleClose
}));
return (
<RbModal
title={editIndex !== undefined ? t('workflow.config.parameter-extractor.editParam') : t('workflow.config.parameter-extractor.addParam')}
open={visible}
onCancel={handleClose}
okText={t('common.save')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
scrollToFirstError={{ behavior: 'instant', block: 'end', focus: true }}
>
<FormItem
name="name"
label={t('workflow.config.parameter-extractor.name')}
rules={[
{ required: true, message: t('common.pleaseEnter') },
{ pattern: /^[a-zA-Z_][a-zA-Z0-9_]*$/, message: t('workflow.config.parameter-extractor.invalidParamName') },
]}
>
<Input placeholder={t('common.enter')} />
</FormItem>
<FormItem
name="type"
label={t('workflow.config.parameter-extractor.type')}
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<Select
placeholder={t('common.pleaseSelect')}
options={types.map(key => ({
value: key,
label: t(`workflow.config.parameter-extractor.${key}`),
}))}
/>
</FormItem>
<FormItem
name="desc"
label={t('workflow.config.parameter-extractor.desc')}
>
<Input.TextArea placeholder={t('common.enter')} />
</FormItem>
<FormItem
name="required"
valuePropName="checked"
>
<Checkbox>{t('workflow.config.parameter-extractor.required')}</Checkbox>
</FormItem>
</Form>
</RbModal>
);
});
export default ParamEditModal;

View File

@@ -0,0 +1,94 @@
import { type FC, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Space, List } from 'antd'
import Empty from '@/components/Empty'
import type { ParamItem, ParamEditModalRef } from './types'
import ParamEditModal from './ParamEditModal'
interface ParamsListProps {
label: string;
value?: ParamItem[];
onChange?: (value: ParamItem[]) => void
}
const ParamsList: FC<ParamsListProps> = ({
label,
value = [],
onChange
}) => {
const { t } = useTranslation()
const paramEditModalRef = useRef<ParamEditModalRef>(null)
const handleAdd = () => {
paramEditModalRef.current?.handleOpen()
}
const handleEdit = (index: number) => {
paramEditModalRef.current?.handleOpen(value[index], index)
}
const handleDelete = (index: number) => {
const list = [...value]
list.splice(index, 1)
onChange && onChange(list)
}
const handleSave = (vo: ParamItem, index?: number) => {
if (index !== undefined) {
const list = [...value]
list[index] = vo
onChange && onChange(list)
} else {
onChange && onChange([...value, vo])
}
}
return (
<div>
<div className="rb:flex rb:justify-between rb:items-center">
<div>{label}</div>
<Space>
<Button style={{ padding: '0 8px', height: '24px' }} onClick={handleAdd}>+</Button>
</Space>
</div>
{value?.length === 0
? <Empty size={88} />
:
<List
grid={{ gutter: 12, column: 1 }}
dataSource={value}
renderItem={(item, index) => (
<List.Item>
<div key={index} className="rb:group rb:relative rb:p-[12px_16px] rb:bg-[#FBFDFF] rb:cursor-pointer rb:border rb:border-[#DFE4ED] rb:rounded-lg">
<div className="rb:flex rb:items-center rb:justify-between">
<div className="rb:leading-4">
<span className="rb:font-medium">{item.name}</span>
<span className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular"> ({t(`workflow.config.parameter-extractor.${item.type}`)})</span>
</div>
<span className="rb:block rb:group-hover:hidden rb:text-[12px] rb:text-[#5B6167] rb:font-regular">{item.required ? t('workflow.config.parameter-extractor.required') : ''}</span>
</div>
<div className="rb:mt-1 rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:wrap-break-word rb:line-clamp-1">{item.desc}</div>
<Space size={12} className="rb:hidden! rb:group-hover:flex! rb:absolute rb:right-4 rb:top-[50%] rb:transform-[translateY(-50%)] rb:bg-white">
<div
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/editBorder.svg')] rb:hover:bg-[url('@/assets/images/editBg.svg')]"
onClick={() => handleEdit(index)}
></div>
<div
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/deleteBorder.svg')] rb:hover:bg-[url('@/assets/images/deleteBg.svg')]"
onClick={() => handleDelete(index)}
></div>
</Space>
</div>
</List.Item>
)}
/>
}
<ParamEditModal
ref={paramEditModalRef}
refresh={handleSave}
/>
</div>
)
}
export default ParamsList

View File

@@ -0,0 +1,10 @@
export interface ParamItem {
name: string;
type: string;
desc: string;
required: boolean
}
export interface ParamEditModalRef {
handleOpen: (vo?: ParamItem, index?: number) => void;
}

View File

@@ -1,4 +1,5 @@
import { type FC } from 'react'
import clsx from 'clsx';
import { Select, type SelectProps } from 'antd'
import type { Suggestion } from '../Editor/plugin/AutocompletePlugin'
type LabelRender = SelectProps['labelRender'];
@@ -7,13 +8,17 @@ interface VariableSelectProps extends SelectProps {
options: Suggestion[];
value?: string;
onChange?: (value: string) => void;
allowClear?: boolean;
}
const VariableSelect: FC<VariableSelectProps> = ({
placeholder,
options,
value,
allowClear = true,
onChange,
size,
...resetPorps
}) => {
const handleChange = (value: string) => {
@@ -26,7 +31,10 @@ const VariableSelect: FC<VariableSelectProps> = ({
if (filterOption) {
return (
<span
className="rb:border rb:border-[#DFE4ED] rb:rounded-md rb:bg-white rb:leading-5.5! rb:text-[12px] rb:inline-flex rb:items-center rb:px-1.5 rb:cursor-pointer"
className={clsx("rb:w-full rb:wrap-break-word rb:line-clamp-1 rb:border rb:border-[#DFE4ED] rb:rounded-md rb:bg-white rb:text-[12px] rb:inline-flex rb:items-center rb:px-1.5 rb:cursor-pointer", {
'rb:leading-5.5!': size !== 'small',
'rb:leading-4!': size === 'small'
})}
contentEditable={false}
>
<img
@@ -59,6 +67,8 @@ const VariableSelect: FC<VariableSelectProps> = ({
return (
<Select
{...resetPorps}
size={size}
placeholder={placeholder}
value={value}
style={{ width: '100%' }}
@@ -66,6 +76,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
labelRender={labelRender}
onChange={handleChange}
showSearch
allowClear={allowClear}
filterOption={(input, option) => {
if (option?.options) {
return option.label?.toLowerCase().includes(input.toLowerCase()) ||

View File

@@ -1,7 +1,7 @@
import { type FC, useEffect, useState, useRef, useMemo } from "react";
import { useTranslation } from 'react-i18next'
import { Graph, Node } from '@antv/x6';
import { Form, Input, Button, Select, InputNumber, Slider, Space, Divider, App } from 'antd'
import { Form, Input, Button, Select, InputNumber, Slider, Space, Divider, App, Switch } from 'antd'
import type { NodeConfig, NodeProperties, StartVariableItem, VariableEditModalRef } from '../../types'
import Empty from '@/components/Empty';
@@ -12,6 +12,9 @@ import MessageEditor from './MessageEditor'
import Knowledge from './Knowledge/Knowledge';
import type { Suggestion } from '../Editor/plugin/AutocompletePlugin'
import VariableSelect from './VariableSelect';
import ParamsList from './ParamsList';
import GroupVariableList from './GroupVariableList'
import CaseList from './CaseList'
interface PropertiesProps {
selectedNode?: Node | null;
@@ -70,12 +73,24 @@ const Properties: FC<PropertiesProps> = ({
useEffect(() => {
if (values && selectedNode) {
const { id, knowledge_retrieval, ...rest } = values
const { knowledge_bases, ...restKnowledgeConfig } = knowledge_retrieval || {}
const { id, knowledge_retrieval, group, group_names, ...rest } = values
const { knowledge_bases = [], ...restKnowledgeConfig } = (knowledge_retrieval as any) || {}
let groupNames: Record<string, string[]> | string[] = {}
if (group && group_names?.length) {
group_names.forEach(vo => {
(groupNames as Record<string, string[]>)[vo.key] = vo.value
})
} else if (!group) {
groupNames = group_names?.[0]?.value || []
}
let allRest = {
...rest,
...restKnowledgeConfig,
knowledge_bases: knowledge_bases?.map(vo => ({
}
if (knowledge_bases?.length) {
allRest.knowledge_bases = knowledge_bases?.map((vo: any) => ({
id: vo.id,
...vo.config
}))
@@ -134,7 +149,6 @@ const Properties: FC<PropertiesProps> = ({
})
}
const variableList = useMemo(() => {
if (!selectedNode || !graphRef?.current) return [];
@@ -142,6 +156,7 @@ const Properties: FC<PropertiesProps> = ({
const graph = graphRef.current;
const edges = graph.getEdges();
const nodes = graph.getNodes();
const addedKeys = new Set<string>();
// Find all connected previous nodes (recursive)
const getAllPreviousNodes = (nodeId: string, visited = new Set<string>()): string[] => {
@@ -175,35 +190,47 @@ const Properties: FC<PropertiesProps> = ({
...(nodeData.config?.variables?.value ?? [])
]
list.forEach((variable: any) => {
variableList.push({
key: `${nodeId}_${variable.name}`,
label: variable.name,
type: 'variable',
dataType: variable.type,
value: `{{${nodeId}.${variable.name}}}`,
nodeData: nodeData,
});
const key = `${nodeId}_${variable.name}`;
if (!addedKeys.has(key)) {
addedKeys.add(key);
variableList.push({
key,
label: variable.name,
type: 'variable',
dataType: variable.type,
value: `{{${nodeId}.${variable.name}}}`,
nodeData: nodeData,
});
}
});
nodeData.config?.variables?.sys.forEach((variable: any) => {
variableList.push({
key: `${nodeId}_${variable.name}`,
label: `sys.${variable.name}`,
type: 'variable',
dataType: variable.type,
value: `{{sys.${variable.name}}}`,
nodeData: nodeData,
});
const key = `${nodeId}_sys_${variable.name}`;
if (!addedKeys.has(key)) {
addedKeys.add(key);
variableList.push({
key,
label: `sys.${variable.name}`,
type: 'variable',
dataType: variable.type,
value: `sys.${variable.name}`,
nodeData: nodeData,
});
}
});
break
case 'llm':
variableList.push({
key: `${nodeId}_output`,
label: 'output',
type: 'variable',
dataType: 'String',
value: `${nodeId}.output`,
nodeData: nodeData,
});
const llmKey = `${nodeId}_output`;
if (!addedKeys.has(llmKey)) {
addedKeys.add(llmKey);
variableList.push({
key: llmKey,
label: 'output',
type: 'variable',
dataType: 'String',
value: `${nodeId}.output`,
nodeData: nodeData,
});
}
break
}
});
@@ -279,14 +306,14 @@ const Properties: FC<PropertiesProps> = ({
if (selectedNode.data?.type === 'llm' && key === 'messages' && config.type === 'define') {
return (
<Form.Item key={key} name={key}>
<MessageEditor selectedNode={selectedNode} graphRef={graphRef} />
<MessageEditor options={variableList} />
</Form.Item>
)
}
if (selectedNode.data?.type === 'end' && key === 'output') {
return (
<Form.Item key={key} name={key}>
<MessageEditor isArray={false} parentName={key} selectedNode={selectedNode} graphRef={graphRef} />
<MessageEditor isArray={false} parentName={key} options={variableList} />
</Form.Item>
)
}
@@ -306,11 +333,61 @@ const Properties: FC<PropertiesProps> = ({
)
}
if (config.type === 'messageEditor') {
return (
<Form.Item key={key} name={key}>
<MessageEditor
title={t(`workflow.config.${selectedNode.data.type}.${key}`)}
isArray={!!config.isArray}
parentName={key}
options={variableList}
/>
</Form.Item>
)
}
if (config.type === 'paramList') {
return (
<Form.Item key={key} name={key}>
<ParamsList
label={t(`workflow.config.${selectedNode.data.type}.${key}`)}
/>
</Form.Item>
)
}
if (config.type === 'groupVariableList') {
return (
<Form.Item key={key} name={key}>
<GroupVariableList
name={key}
options={variableList}
isCanAdd={!!values?.group}
/>
</Form.Item>
)
}
if (config.type === 'caseList') {
console.log('key', key)
return (
<Form.Item key={key} name={key}>
<CaseList
name={key}
options={variableList}
selectedNode={selectedNode}
graphRef={graphRef}
/>
</Form.Item>
)
}
return (
<Form.Item
key={key}
name={key}
label={t(`workflow.config.${selectedNode.data.type}.${key}`)}
layout={config.type === 'switch' ? 'horizontal' : 'vertical'}
>
{config.type === 'input'
? <Input placeholder={t('common.pleaseEnter')} />
@@ -339,6 +416,8 @@ const Properties: FC<PropertiesProps> = ({
placeholder={t('common.pleaseSelect')}
options={variableList}
/>
: config.type === 'switch'
? <Switch />
: null
}
</Form.Item>