feat(web): Add Workflow
This commit is contained in:
203
web/src/views/Workflow/components/CanvasToolbar.tsx
Normal file
203
web/src/views/Workflow/components/CanvasToolbar.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import type { FC } from 'react';
|
||||
import { Select, Button } from 'antd';
|
||||
import { Node } from '@antv/x6';
|
||||
import type { GraphRef } from '../types'
|
||||
|
||||
interface CanvasToolbarProps {
|
||||
miniMapRef: React.RefObject<HTMLDivElement>;
|
||||
graphRef: GraphRef;
|
||||
isHandMode: boolean;
|
||||
setIsHandMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
zoomLevel: number;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
}
|
||||
|
||||
const CanvasToolbar: FC<CanvasToolbarProps> = ({
|
||||
miniMapRef,
|
||||
graphRef,
|
||||
isHandMode,
|
||||
setIsHandMode,
|
||||
zoomLevel,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onUndo,
|
||||
onRedo,
|
||||
}) => {
|
||||
// 整理布局函数
|
||||
const handleLayout = () => {
|
||||
if (!graphRef.current) return;
|
||||
const nodes = graphRef.current.getNodes();
|
||||
const edges = graphRef.current.getEdges();
|
||||
|
||||
// 如果没有连线,使用垂直布局避免节点重叠
|
||||
if (edges.length === 0) {
|
||||
nodes.forEach((node, index) => {
|
||||
const nodeData = node.getData();
|
||||
const isSpecialNode = nodeData?.isGroup || nodeData?.type === 'condition';
|
||||
const nodeHeight = isSpecialNode ? 220 : 50;
|
||||
const xPosition = 100;
|
||||
const yPosition = index * (nodeHeight + 100) + 100;
|
||||
node.setPosition(xPosition, yPosition);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 简单的树布局算法
|
||||
const nodeMap = new Map<string, Node>();
|
||||
const children = new Map<string, string[]>();
|
||||
const roots: string[] = [];
|
||||
|
||||
// 初始化节点映射
|
||||
nodes.forEach(node => {
|
||||
nodeMap.set(node.id, node);
|
||||
children.set(node.id, []);
|
||||
});
|
||||
|
||||
// 构建父子关系
|
||||
edges.forEach(edge => {
|
||||
const sourceId = edge.getSourceCellId();
|
||||
const targetId = edge.getTargetCellId();
|
||||
if (sourceId && targetId) {
|
||||
children.get(sourceId)?.push(targetId);
|
||||
}
|
||||
});
|
||||
|
||||
// 找到根节点
|
||||
const hasParent = new Set<string>();
|
||||
edges.forEach(edge => {
|
||||
const targetId = edge.getTargetCellId();
|
||||
if (targetId) hasParent.add(targetId);
|
||||
});
|
||||
|
||||
nodes.forEach(node => {
|
||||
if (!hasParent.has(node.id)) {
|
||||
roots.push(node.id);
|
||||
}
|
||||
});
|
||||
|
||||
// 布局参数
|
||||
const levelWidths: number[] = [];
|
||||
const baseNodeSpacing = 120;
|
||||
let currentY = 100;
|
||||
|
||||
// 计算每层的最大宽度
|
||||
const calculateLevelWidths = (nodeId: string, level: number) => {
|
||||
const node = nodeMap.get(nodeId);
|
||||
if (!node) return;
|
||||
|
||||
const nodeData = node.getData();
|
||||
const isSpecialNode = nodeData?.isGroup || nodeData?.type === 'condition';
|
||||
const nodeWidth = isSpecialNode ? 400 : 160;
|
||||
const gap = isSpecialNode ? 150 : 100;
|
||||
|
||||
levelWidths[level] = Math.max(levelWidths[level] || 0, nodeWidth + gap);
|
||||
|
||||
const childIds = children.get(nodeId) || [];
|
||||
childIds.forEach((childId: string) => calculateLevelWidths(childId, level + 1));
|
||||
};
|
||||
|
||||
roots.forEach(rootId => calculateLevelWidths(rootId, 0));
|
||||
|
||||
// 递归布局函数
|
||||
const layoutNode = (nodeId: string, level: number, parentY: number): number => {
|
||||
const node = nodeMap.get(nodeId);
|
||||
if (!node) return parentY;
|
||||
|
||||
const nodeData = node.getData();
|
||||
const isSpecialNode = nodeData?.isGroup || nodeData?.type === 'condition';
|
||||
const nodeHeight = isSpecialNode ? 220 : 50;
|
||||
const verticalGap = isSpecialNode ? 80 : 40;
|
||||
const spacing = baseNodeSpacing + nodeHeight + verticalGap;
|
||||
|
||||
const xPosition = levelWidths.slice(0, level).reduce((sum, width) => sum + width, 100);
|
||||
|
||||
const childIds = children.get(nodeId) || [];
|
||||
|
||||
if (childIds.length === 0) {
|
||||
// 叶子节点
|
||||
node.setPosition(xPosition, currentY);
|
||||
currentY += spacing;
|
||||
return currentY - spacing;
|
||||
} else {
|
||||
// 非叶子节点,先布局子节点
|
||||
const childPositions: number[] = [];
|
||||
childIds.forEach((childId: string) => {
|
||||
const childY = layoutNode(childId, level + 1, currentY);
|
||||
childPositions.push(childY);
|
||||
});
|
||||
|
||||
// 父节点居中,确保有足够间隙
|
||||
const minY = Math.min(...childPositions);
|
||||
const maxY = Math.max(...childPositions);
|
||||
const centerY = (minY + maxY) / 2;
|
||||
node.setPosition(xPosition, centerY);
|
||||
return centerY;
|
||||
}
|
||||
};
|
||||
|
||||
// 布局所有根节点
|
||||
roots.forEach(rootId => {
|
||||
layoutNode(rootId, 0, currentY);
|
||||
currentY += 300; // 不同树之间的间距
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 小地图 */}
|
||||
<div ref={miniMapRef} className="rb:absolute rb:bottom-17 rb:left-5 rb:z-1000"></div>
|
||||
{/* 缩放控制按钮 */}
|
||||
<div className="rb:absolute rb:bottom-5 rb:left-5 rb:flex rb:flex-row rb:gap-2 rb:z-1000">
|
||||
<Button
|
||||
type={isHandMode ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
const newHandMode = !isHandMode;
|
||||
setIsHandMode(newHandMode);
|
||||
if (newHandMode) {
|
||||
graphRef.current?.enablePanning();
|
||||
} else {
|
||||
graphRef.current?.disablePanning();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isHandMode ? '✋' : '👆'}
|
||||
</Button>
|
||||
<Button onClick={() => graphRef.current?.zoom(0.1)}>+</Button>
|
||||
<Select
|
||||
value={Math.round(zoomLevel * 100)}
|
||||
onChange={(value: number | string) => {
|
||||
if (value === 'fit') {
|
||||
graphRef.current?.zoomToFit({ padding: 20 });
|
||||
} else {
|
||||
graphRef.current?.zoomTo((value as number) / 100);
|
||||
}
|
||||
}}
|
||||
labelRender={(props) => {
|
||||
console.log('props', props)
|
||||
return `${props.value}%`
|
||||
}}
|
||||
className="rb:w-20"
|
||||
options={[
|
||||
{ label: '25%', value: 25 },
|
||||
{ label: '50%', value: 50 },
|
||||
{ label: '75%', value: 75 },
|
||||
{ label: '100%', value: 100 },
|
||||
{ label: '125%', value: 125 },
|
||||
{ label: '150%', value: 150 },
|
||||
{ label: '200%', value: 200 },
|
||||
{ label: '自适应', value: 'fit' },
|
||||
]}
|
||||
/>
|
||||
<Button onClick={() => graphRef.current?.zoom(-0.1)}>-</Button>
|
||||
<Button disabled={!canUndo} onClick={onUndo}>撤销</Button>
|
||||
<Button disabled={!canRedo} onClick={onRedo}>重做</Button>
|
||||
<Button onClick={handleLayout}>整理</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CanvasToolbar;
|
||||
174
web/src/views/Workflow/components/Chat/Chat.tsx
Normal file
174
web/src/views/Workflow/components/Chat/Chat.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { forwardRef, useImperativeHandle, useState, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import clsx from 'clsx'
|
||||
import { Input, Form } from 'antd'
|
||||
import { Space, Button } from 'antd'
|
||||
|
||||
import ChatIcon from '@/assets/images/application/chat.png'
|
||||
import RbDrawer from '@/components/RbDrawer';
|
||||
import VariableConfigModal from './VariableConfigModal'
|
||||
import { draftRun } from '@/api/application';
|
||||
import Empty from '@/components/Empty'
|
||||
import ChatContent from '@/components/Chat/ChatContent'
|
||||
import type { ChatItem } from '@/components/Chat/types'
|
||||
import ChatSendIcon from '@/assets/images/application/chatSend.svg'
|
||||
import dayjs from 'dayjs'
|
||||
import type { ChatRef, VariableEditModalRef, StartVariableItem, GraphRef } from '../../types'
|
||||
import { type SSEMessage } from '@/utils/stream'
|
||||
|
||||
const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId, graphRef }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const [form] = Form.useForm<{ message: string }>()
|
||||
const variableConfigModalRef = useRef<VariableEditModalRef>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [chatList, setChatList] = useState<ChatItem[]>([])
|
||||
const [variables, setVariables] = useState<StartVariableItem[]>([])
|
||||
const [streamLoading, setStreamLoading] = useState(false)
|
||||
|
||||
const handleOpen = () => {
|
||||
setOpen(true)
|
||||
getVariables()
|
||||
}
|
||||
const getVariables = () => {
|
||||
const nodes = graphRef.current?.getNodes()
|
||||
const list = nodes?.map(node => node.getData()) || []
|
||||
const startNodes = list.filter(vo => vo.type === 'start')
|
||||
if (startNodes.length) {
|
||||
const curVariables = startNodes[0].config.variables?.defaultValue
|
||||
|
||||
const initialValue: Record<string, any> = {}
|
||||
|
||||
curVariables.forEach((vo: StartVariableItem) => {
|
||||
if (vo.default) {
|
||||
initialValue[vo.name] = vo.default
|
||||
}
|
||||
})
|
||||
setVariables(curVariables)
|
||||
form.setFieldsValue(initialValue)
|
||||
}
|
||||
}
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
setChatList([])
|
||||
}
|
||||
const handleEditVariables = () => {
|
||||
variableConfigModalRef.current?.handleOpen()
|
||||
}
|
||||
const handleSave = (values: StartVariableItem[]) => {
|
||||
setVariables([...values])
|
||||
}
|
||||
const handleClusterSend = () => {
|
||||
if (loading || !appId) return
|
||||
|
||||
setLoading(true)
|
||||
const message = form.getFieldValue('message')
|
||||
setChatList(prev => [...prev, {
|
||||
role: 'user',
|
||||
content: message,
|
||||
created_at: Date.now(),
|
||||
}])
|
||||
setChatList(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: message,
|
||||
created_at: Date.now(),
|
||||
}])
|
||||
|
||||
const handleStreamMessage = (data: SSEMessage[]) => {
|
||||
setStreamLoading(false)
|
||||
|
||||
data.map(item => {
|
||||
const { chunk } = item.data as { chunk: string; };
|
||||
|
||||
switch(item.event) {
|
||||
case 'message':
|
||||
setChatList(prev => {
|
||||
const lastChat = { ...prev[prev.length - 1] }
|
||||
lastChat.content = lastChat.content + chunk
|
||||
|
||||
return [
|
||||
...prev.slice(0, prev.length - 1),
|
||||
lastChat
|
||||
]
|
||||
})
|
||||
break
|
||||
case 'workflow_end':
|
||||
setStreamLoading(false);
|
||||
break;
|
||||
}
|
||||
})
|
||||
};
|
||||
const params: Record<string, any> = {}
|
||||
if (variables.length > 0) {
|
||||
variables.forEach(vo => {
|
||||
params[vo.name] = vo.value ?? vo.defaultValue
|
||||
})
|
||||
}
|
||||
form.setFieldValue('message', undefined)
|
||||
draftRun(appId, {
|
||||
message: message,
|
||||
variables: params,
|
||||
stream: true
|
||||
}, handleStreamMessage)
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
// 暴露给父组件的方法
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
return (
|
||||
<RbDrawer
|
||||
title={<div className="rb:flex rb:items-center rb:gap-2.5">
|
||||
{t('workflow.run')}
|
||||
{variables.length > 0 && <Space>
|
||||
<Button size="small" onClick={handleEditVariables}>变量</Button>
|
||||
</Space>}
|
||||
</div>}
|
||||
classNames={{
|
||||
body: 'rb:p-0!'
|
||||
}}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<ChatContent
|
||||
classNames={{
|
||||
'rb:mx-[16px] rb:pt-[24px] rb:h-[calc(100%-76px)]': true,
|
||||
|
||||
}}
|
||||
contentClassNames="rb:max-w-[400px]!'"
|
||||
empty={<Empty url={ChatIcon} title={t('application.chatEmpty')} isNeedSubTitle={false} size={[240, 200]} className="rb:h-full" />}
|
||||
data={chatList}
|
||||
streamLoading={streamLoading}
|
||||
labelPosition="bottom"
|
||||
labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}
|
||||
errorDesc={t('application.ReplyException')}
|
||||
/>
|
||||
<div className="rb:flex rb:items-center rb:gap-2.5 rb:p-4">
|
||||
<Form form={form} style={{width: 'calc(100% - 54px)'}}>
|
||||
<Form.Item name="message" className="rb:mb-0!">
|
||||
<Input
|
||||
className="rb:h-11 rb:shadow-[0px_2px_8px_0px_rgba(33,35,50,0.1)]"
|
||||
placeholder={t('application.chatPlaceholder')}
|
||||
onPressEnter={handleClusterSend}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<img src={ChatSendIcon} className={clsx("rb:w-11 rb:h-11 rb:cursor-pointer", {
|
||||
'rb:opacity-50': loading,
|
||||
})} onClick={handleClusterSend} />
|
||||
</div>
|
||||
|
||||
<VariableConfigModal
|
||||
ref={variableConfigModalRef}
|
||||
refresh={handleSave}
|
||||
variables={variables}
|
||||
/>
|
||||
</RbDrawer>
|
||||
)
|
||||
})
|
||||
|
||||
export default Chat
|
||||
@@ -0,0 +1,98 @@
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Form, Input, InputNumber, Checkbox } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { StartVariableItem, VariableEditModalRef } from '../../types'
|
||||
import RbModal from '@/components/RbModal'
|
||||
|
||||
interface VariableEditModalProps {
|
||||
refresh: (values: StartVariableItem[]) => void;
|
||||
variables: StartVariableItem[]
|
||||
}
|
||||
|
||||
const VariableConfigModal = forwardRef<VariableEditModalRef, VariableEditModalProps>(({
|
||||
refresh,
|
||||
variables
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [form] = Form.useForm<{variables: StartVariableItem[]}>();
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// 封装取消方法,添加关闭弹窗逻辑
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
setLoading(false)
|
||||
};
|
||||
|
||||
const handleOpen = () => {
|
||||
|
||||
setVisible(true);
|
||||
};
|
||||
// 封装保存方法,添加提交逻辑
|
||||
const handleSave = () => {
|
||||
form.validateFields().then((values) => {
|
||||
refresh([
|
||||
...(values?.variables ?? []),
|
||||
])
|
||||
handleClose()
|
||||
})
|
||||
}
|
||||
|
||||
// 暴露给父组件的方法
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={t('workflow.variableConfig')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t('common.save')}
|
||||
onOk={handleSave}
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
scrollToFirstError={{ behavior: 'instant', block: 'end', focus: true }}
|
||||
initialValues={{ variables: variables }}
|
||||
>
|
||||
<Form.List name="variables">
|
||||
{(fields) => (
|
||||
<>
|
||||
{fields.map(({ name }, index) => {
|
||||
const field = variables[index]
|
||||
return (
|
||||
<Form.Item
|
||||
key={name}
|
||||
name={[name, 'value']}
|
||||
label={field.type === 'boolean' ? undefined : `${field.name}·${field.description}`}
|
||||
rules={[
|
||||
{ required: field.required, message: field.type === 'boolean' ? t('common.pleaseSelect') : t('common.pleaseEnter') },
|
||||
]}
|
||||
>
|
||||
{
|
||||
field.type === 'string' && <Input placeholder={t('common.pleaseEnter')} />
|
||||
}
|
||||
{
|
||||
field.type === 'number' && <InputNumber placeholder={t('common.pleaseEnter')} style={{ width: '100%' }} />
|
||||
}
|
||||
{
|
||||
field.type === 'boolean' && <Checkbox>{`${field.name}·${field.description}`}</Checkbox>
|
||||
}
|
||||
</Form.Item>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</RbModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default VariableConfigModal;
|
||||
48
web/src/views/Workflow/components/NodeLibrary.tsx
Normal file
48
web/src/views/Workflow/components/NodeLibrary.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { type FC } from 'react';
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Card, Space } from 'antd'
|
||||
|
||||
import { nodeLibrary } from '../constant';
|
||||
|
||||
const NodeLibrary: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
console.log('nodeLibrary', nodeLibrary)
|
||||
|
||||
return (
|
||||
<div className="rb:w-80 rb:fixed rb:h-screen rb:left-0 rb:py-5 rb:px-5.5 rb:overflow-y-auto">
|
||||
<Space size={12} direction="vertical" className="rb:w-full">
|
||||
{nodeLibrary.map(category => (
|
||||
<Card
|
||||
key={category.category}
|
||||
type="inner"
|
||||
title={t(`workflow.${category.category}`)}
|
||||
classNames={{
|
||||
body: "rb:p-[10px]!",
|
||||
header: "rb:bg-[#F6F8FC]!"
|
||||
}}
|
||||
>
|
||||
<Space size={8} direction="vertical" className="rb:w-full">
|
||||
{category.nodes.map((node, nodeIndex) => (
|
||||
<div
|
||||
key={nodeIndex}
|
||||
className="rb:bg-white rb:rounded-lg rb:p-2 rb:border rb:border-[#DFE4ED] rb:cursor-pointer rb:flex rb:items-center rb:gap-2 rb:hover:border-[#155EEF] rb:hover:shadow-[0px_2px_4px_0px_rgba(33,35,50,0.15)]"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('application/reactflow', node.type);
|
||||
e.dataTransfer.setData('application/json', JSON.stringify(node));
|
||||
}}
|
||||
>
|
||||
<img src={node.icon} className="rb:w-5 rb:h-5" />
|
||||
<span className="rb:font-medium rb:text-[12px]">{t(`workflow.${node.type}`)}</span>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodeLibrary;
|
||||
19
web/src/views/Workflow/components/Nodes/AddNode.tsx
Normal file
19
web/src/views/Workflow/components/Nodes/AddNode.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import clsx from 'clsx';
|
||||
import type { ReactShapeConfig } from '@antv/x6-react-shape';
|
||||
|
||||
const AddNode: ReactShapeConfig['component'] = ({ node }) => {
|
||||
const data = node?.getData() || {}
|
||||
|
||||
return (
|
||||
<div className={clsx('rb:group rb:relative rb:h-10 rb:w-30 rb:border rb:rounded-xl rb:flex rb:items-center rb:justify-center rb:text-[12px] rb:p-1 rb:box-border', {
|
||||
'rb:border-orange-500 rb:border-[3px] rb:bg-white rb:text-gray-700': data.isSelected,
|
||||
'rb:border-[#d1d5db] rb:bg-white rb:text-[#374151]': !data.isSelected
|
||||
})}>
|
||||
<span className="rb:overflow-hidden rb:whitespace-nowrap rb:text-ellipsis">
|
||||
{data.icon} {data.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddNode;
|
||||
155
web/src/views/Workflow/components/Nodes/ConditionNode.tsx
Normal file
155
web/src/views/Workflow/components/Nodes/ConditionNode.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
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();
|
||||
// };
|
||||
|
||||
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
|
||||
})}>
|
||||
|
||||
<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>
|
||||
条件分支
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConditionNode;
|
||||
19
web/src/views/Workflow/components/Nodes/GroupStartNode.tsx
Normal file
19
web/src/views/Workflow/components/Nodes/GroupStartNode.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import clsx from 'clsx';
|
||||
import type { ReactShapeConfig } from '@antv/x6-react-shape';
|
||||
|
||||
const GroupStartNode: ReactShapeConfig['component'] = ({ node }) => {
|
||||
const data = node?.getData() || {}
|
||||
|
||||
return (
|
||||
<div className={clsx('rb:group rb:relative rb:h-10 rb:w-20 rb:border rb:rounded-xl rb:flex rb:items-center rb:justify-center rb:text-[12px] rb:p-1 rb:box-border', {
|
||||
'rb:border-orange-500 rb:border-[3px] rb:bg-white rb:text-gray-700': data.isSelected,
|
||||
'rb:border-[#d1d5db] rb:bg-white rb:text-[#374151]': !data.isSelected
|
||||
})}>
|
||||
<span className="rb:overflow-hidden rb:whitespace-nowrap rb:text-ellipsis">
|
||||
{data.icon} {data.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupStartNode;
|
||||
98
web/src/views/Workflow/components/Nodes/IterationNode.tsx
Normal file
98
web/src/views/Workflow/components/Nodes/IterationNode.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
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 (
|
||||
<div className={clsx('rb:group rb:border-2 rb:border-dashed rb:rounded-xl rb:relative rb:min-w-75 rb:min-h-50 rb:p-4', {
|
||||
'rb:border-orange-500 rb:border-[3px] rb:bg-white rb:text-gray-700': data?.isSelected,
|
||||
'rb:border-[#d1d5db] rb:bg-white rb:text-[#374151]': !data?.isSelected
|
||||
})}>
|
||||
{/* 标题区域 */}
|
||||
<div className="rb:absolute rb:-top-3 rb:left-4 rb:bg-[#10b981] rb:rounded-[20px] rb:p-[8px_16px] rb:flex rb:items-center rb:gap-2 rb:text-white rb:text-[14px] rb:font-bold rb:z-10">
|
||||
<div className="rb:w-5 rb:h-5 rb:bg-[#FFFFFF] rb:rounded-sm rb:flex rb:items-center rb:justify-center rb:text-[12px] rb:text-[#10b981]">
|
||||
🔁
|
||||
</div>
|
||||
迭代
|
||||
</div>
|
||||
<Dropdown
|
||||
menu={{items: [
|
||||
{
|
||||
key: '1',
|
||||
label: '删除',
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: '复制',
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: '删除',
|
||||
}
|
||||
]}}
|
||||
>
|
||||
<SmallDashOutlined
|
||||
className={clsx("rb:cursor-pointer rb:right-1 rb:top-1 rb:invisible rb:absolute rb:group-hover:visible", {
|
||||
'rb:visible': data.isSelected
|
||||
})}
|
||||
/>
|
||||
</Dropdown>
|
||||
|
||||
{/* 画布内容区域 */}
|
||||
<div className="rb:mt-6 rb:min-h-37.5 rb:w-full rb:bg-[radial-gradient(circle,#e5e7eb_1px,transparent_1px)] rb:bg-size-[12px_12px]"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IterationNode;
|
||||
98
web/src/views/Workflow/components/Nodes/LoopNode.tsx
Normal file
98
web/src/views/Workflow/components/Nodes/LoopNode.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
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 LoopNode: 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 (
|
||||
<div className={clsx('rb:group rb:border-2 rb:border-dashed rb:rounded-[12px] rb:relative rb:min-w-[300px] rb:min-h-[200px] rb:p-4', {
|
||||
'rb:border-orange-500 rb:border-[3px] rb:bg-white rb:text-gray-700': data?.isSelected,
|
||||
'rb:border-[#d1d5db] rb:bg-white rb:text-[#374151]': !data?.isSelected
|
||||
})}>
|
||||
{/* 标题区域 */}
|
||||
<div className="rb:absolute rb:-top-3 rb:left-4 rb:bg-[#10b981] rb:rounded-[20px] rb:p-[8px_16px] rb:flex rb:items-center rb:gap-2 rb:text-white rb:text-[14px] rb:font-bold rb:z-10">
|
||||
<div className="rb:w-5 rb:h-5 rb:bg-[#FFFFFF] rb:rounded-sm rb:flex rb:items-center rb:justify-center rb:text-[12px] rb:text-[#10b981]">
|
||||
♻️
|
||||
</div>
|
||||
循环
|
||||
</div>
|
||||
<Dropdown
|
||||
menu={{items: [
|
||||
{
|
||||
key: '1',
|
||||
label: '删除',
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: '复制',
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: '删除',
|
||||
}
|
||||
]}}
|
||||
>
|
||||
<SmallDashOutlined
|
||||
className={clsx("rb:cursor-pointer rb:right-1 rb:top-1 rb:invisible rb:absolute rb:group-hover:visible", {
|
||||
'rb:visible': data.isSelected
|
||||
})}
|
||||
/>
|
||||
</Dropdown>
|
||||
|
||||
{/* 画布内容区域 */}
|
||||
<div className="rb:mt-6 rb:min-h-[150px] rb:w-full rb:bg-[radial-gradient(circle,#e5e7eb_1px,transparent_1px)] rb:bg-size-[12px_12px]"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoopNode;
|
||||
31
web/src/views/Workflow/components/Nodes/NormalNode.tsx
Normal file
31
web/src/views/Workflow/components/Nodes/NormalNode.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ReactShapeConfig } from '@antv/x6-react-shape';
|
||||
|
||||
const NormalNode: ReactShapeConfig['component'] = ({ node }) => {
|
||||
const data = node?.getData() || {}
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className={clsx('rb:cursor-pointer rb:group rb:relative rb:h-16 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
|
||||
})}>
|
||||
<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={() => {}}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4 rb:mt-1.5">{t('workflow.clickToConfigure')}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NormalNode;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { type FC } from 'react';
|
||||
import { Input, Form, Space, Button, Row, Col, Select, type FormListOperation } from 'antd';
|
||||
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
interface TextareaProps {
|
||||
parentName?: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
onChange?: (value?: string) => void;
|
||||
}
|
||||
const roleOptions = [
|
||||
// { label: 'SYSTEM', value: 'SYSTEM' },
|
||||
{ label: 'USER', value: 'USER' },
|
||||
{ label: 'ASSISTANT', value: 'ASSISTANT' },
|
||||
]
|
||||
const MessageEditor: FC<TextareaProps> = ({
|
||||
parentName = 'messages',
|
||||
placeholder,
|
||||
}) => {
|
||||
const form = Form.useFormInstance();
|
||||
const values = form.getFieldsValue()
|
||||
|
||||
const handleAdd = (add: FormListOperation['add']) => {
|
||||
const list = values[parentName];
|
||||
const lastRole = list[list.length - 1].role
|
||||
|
||||
add({
|
||||
role: lastRole === 'USER' ? 'ASSISTANT' : 'USER',
|
||||
content: undefined
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form.List name={parentName}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map(({ key, name, ...restField }) => {
|
||||
const currentRole = values[parentName]?.[key].role || 'USER'
|
||||
|
||||
return (
|
||||
<Space key={key} direction="vertical" className="rb:w-full rb:border rb:border-[#DFE4ED] rb:rounded-md rb:px-2 rb:py-1.5">
|
||||
<Row>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'role']}
|
||||
noStyle
|
||||
>
|
||||
{currentRole === 'SYSTEM'
|
||||
? <Input disabled />
|
||||
:
|
||||
<Select
|
||||
options={roleOptions}
|
||||
disabled={currentRole === 'SYSTEM'}
|
||||
/>
|
||||
}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{currentRole !== 'SYSTEM' && <Col span={12}>
|
||||
<div className="rb:h-full rb:flex rb:justify-end rb:items-center">
|
||||
<MinusCircleOutlined onClick={() => remove(name)} />
|
||||
</div>
|
||||
</Col>}
|
||||
</Row>
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'content']}
|
||||
noStyle
|
||||
>
|
||||
<Input.TextArea placeholder={placeholder} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
)
|
||||
})}
|
||||
<Form.Item className="rb:mt-3!">
|
||||
<Button type="dashed" onClick={() => handleAdd(add)} block icon={<PlusOutlined />}>
|
||||
Add field
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageEditor;
|
||||
@@ -0,0 +1,180 @@
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Form, Input, Select, InputNumber, Checkbox, Tag } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { StartVariableItem, VariableEditModalRef } from '../../types'
|
||||
import RbModal from '@/components/RbModal'
|
||||
import SortableList from '@/components/SortableList'
|
||||
|
||||
const FormItem = Form.Item;
|
||||
|
||||
interface VariableEditModalProps {
|
||||
refresh: (values: StartVariableItem) => void;
|
||||
}
|
||||
|
||||
const types = [
|
||||
'string',
|
||||
'number',
|
||||
'boolean',
|
||||
// 'array',
|
||||
// 'object'
|
||||
]
|
||||
const variableType = {
|
||||
string: 'string',
|
||||
number: 'number',
|
||||
boolean: 'boolean',
|
||||
// array: 'array',
|
||||
// object: 'object',
|
||||
}
|
||||
|
||||
const VariableEditModal = forwardRef<VariableEditModalRef, VariableEditModalProps>(({
|
||||
refresh
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [form] = Form.useForm<StartVariableItem>();
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [editVo, setEditVo] = useState<StartVariableItem | null>(null)
|
||||
|
||||
const values = Form.useWatch([], form);
|
||||
|
||||
// 封装取消方法,添加关闭弹窗逻辑
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
setLoading(false)
|
||||
setEditVo(null)
|
||||
};
|
||||
|
||||
const handleOpen = (variable?: StartVariableItem) => {
|
||||
setVisible(true);
|
||||
if (variable) {
|
||||
setEditVo(variable || null)
|
||||
form.setFieldsValue(variable)
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
};
|
||||
// 封装保存方法,添加提交逻辑
|
||||
const handleSave = () => {
|
||||
form.validateFields().then((values) => {
|
||||
refresh({
|
||||
...(editVo || {}),
|
||||
...values,
|
||||
})
|
||||
handleClose()
|
||||
})
|
||||
}
|
||||
|
||||
// 暴露给父组件的方法
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
// 变量类型改变时,更新初始化其他字段值
|
||||
const handleChangeType = (value: StartVariableItem['type']) => {
|
||||
if (value) {
|
||||
form.setFieldsValue({
|
||||
type: value,
|
||||
name: undefined,
|
||||
description: undefined,
|
||||
max_length: undefined,
|
||||
default: undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={editVo ? t('workflow.config.start.editVariable') : t('workflow.config.start.addVariable')}
|
||||
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="type"
|
||||
label={t('workflow.config.start.variableType')}
|
||||
rules={[{ required: true, message: t('common.pleaseSelect') }]}
|
||||
>
|
||||
<Select
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={types.map(key => ({
|
||||
value: key,
|
||||
label: t(`workflow.config.start.${key}`),
|
||||
}))}
|
||||
onChange={handleChangeType}
|
||||
labelRender={(props) => <div className="rb:flex rb:justify-between rb:items-center">{props.label} <Tag color="blue">{variableType[props.value as keyof typeof variableType]}</Tag></div>}
|
||||
optionRender={(props) => <div className="rb:flex rb:justify-between rb:items-center">{props.label} <Tag color="blue">{variableType[props.value as keyof typeof variableType]}</Tag></div>}
|
||||
/>
|
||||
</FormItem>
|
||||
{/* 变量名称 */}
|
||||
<FormItem
|
||||
name="name"
|
||||
label={t('workflow.config.start.variableName')}
|
||||
rules={[
|
||||
{ required: true, message: t('common.pleaseEnter') },
|
||||
{ pattern: /^[a-zA-Z_][a-zA-Z0-9_]*$/, message: t('workflow.config.start.invalidVariableName') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('common.enter')} />
|
||||
</FormItem>
|
||||
|
||||
{/* 显示名称 */}
|
||||
<FormItem
|
||||
name="description"
|
||||
label={t('workflow.config.start.description')}
|
||||
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||
>
|
||||
<Input placeholder={t('common.enter')} />
|
||||
</FormItem>
|
||||
|
||||
{/* 最大长度 */}
|
||||
{['string'].includes(values?.type) && (
|
||||
<FormItem
|
||||
name="max_length"
|
||||
label={t('workflow.config.start.max_length')}
|
||||
>
|
||||
<InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} />
|
||||
</FormItem>
|
||||
)}
|
||||
{/* 默认值 */}
|
||||
{['string', 'number', 'boolean'].includes(values?.type) && (
|
||||
<FormItem
|
||||
name="default"
|
||||
label={t('workflow.config.start.default')}
|
||||
>
|
||||
{['string'].includes(values.type) && <Input placeholder={t('common.enter')} />}
|
||||
{['number'].includes(values.type) && <InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} />}
|
||||
{['boolean'].includes(values.type) && <Select placeholder={t('common.pleaseSelect')} options={[{ value: true, label: t('workflow.config.start.defaultChecked') }, { value: false, label: t('workflow.config.start.notDefaultChecked') }]} />}
|
||||
</FormItem>
|
||||
)}
|
||||
{/* 选项 */}
|
||||
{['array'].includes(values?.type) && (
|
||||
<FormItem
|
||||
name="options"
|
||||
label={t('workflow.config.start.options')}
|
||||
>
|
||||
<SortableList />
|
||||
</FormItem>
|
||||
)}
|
||||
{/* 是否必填 */}
|
||||
<FormItem
|
||||
name="required"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Checkbox>{t('workflow.config.start.required')}</Checkbox>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</RbModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default VariableEditModal;
|
||||
231
web/src/views/Workflow/components/Properties/index.tsx
Normal file
231
web/src/views/Workflow/components/Properties/index.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { type FC, useEffect, useState, useRef } 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 type { NodeConfig, NodeProperties, StartVariableItem, VariableEditModalRef } from '../../types'
|
||||
import Empty from '@/components/Empty';
|
||||
import emptyIcon from '@/assets/images/workflow/empty.png'
|
||||
import CustomSelect from "@/components/CustomSelect";
|
||||
import VariableEditModal from './VariableEditModal';
|
||||
import MessageEditor from './MessageEditor'
|
||||
|
||||
interface PropertiesProps {
|
||||
selectedNode?: Node | null;
|
||||
setSelectedNode: (node: Node | null) => void;
|
||||
graphRef: React.MutableRefObject<Graph | undefined>;
|
||||
blankClick: () => void;
|
||||
deleteEvent: () => void;
|
||||
copyEvent: () => void;
|
||||
parseEvent: () => void;
|
||||
}
|
||||
const Properties: FC<PropertiesProps> = ({
|
||||
selectedNode,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { modal } = App.useApp()
|
||||
const [form] = Form.useForm<NodeConfig>();
|
||||
const [configs, setConfigs] = useState<Record<string,NodeConfig>>({} as Record<string,NodeConfig>)
|
||||
const values = Form.useWatch([], form);
|
||||
const variableModalRef = useRef<VariableEditModalRef>(null)
|
||||
const [editIndex, setEditIndex] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedNode && form) {
|
||||
const { type = 'default', name = '', config } = selectedNode.getData() || {}
|
||||
const initialValue: Record<string, any> = {}
|
||||
Object.keys(config || {}).forEach(key => {
|
||||
if (config && config[key] && 'defaultValue' in config[key]) {
|
||||
initialValue[key] = config[key].defaultValue
|
||||
}
|
||||
})
|
||||
|
||||
form.setFieldsValue({
|
||||
type,
|
||||
id: selectedNode.id,
|
||||
name,
|
||||
...initialValue,
|
||||
})
|
||||
setConfigs(config || {})
|
||||
}
|
||||
}, [selectedNode, form])
|
||||
|
||||
const updateNodeLabel = (newLabel: string) => {
|
||||
if (selectedNode && form) {
|
||||
const nodeData = selectedNode.data as NodeProperties;
|
||||
selectedNode.setAttrByPath('text/text', `${nodeData.icon} ${newLabel}`);
|
||||
selectedNode.setData({ ...selectedNode.data, name: newLabel });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (values && selectedNode) {
|
||||
const { id, ...rest } = values
|
||||
|
||||
Object.keys(values).forEach(key => {
|
||||
if (selectedNode.data.config[key]) {
|
||||
selectedNode.data.config[key].defaultValue = values[key]
|
||||
}
|
||||
})
|
||||
|
||||
selectedNode?.setData({ ...selectedNode.data, ...rest })
|
||||
}
|
||||
}, [values, selectedNode])
|
||||
|
||||
const handleAddVariable = () => {
|
||||
variableModalRef.current?.handleOpen()
|
||||
}
|
||||
const handleEditVariable = (index: number, vo: StartVariableItem) => {
|
||||
variableModalRef.current?.handleOpen(vo)
|
||||
setEditIndex(index)
|
||||
}
|
||||
const handleRefreshVariable = (value: StartVariableItem) => {
|
||||
if (!selectedNode) return
|
||||
if (editIndex !== null) {
|
||||
const defaultValue = selectedNode.data.config.variables.defaultValue ?? []
|
||||
defaultValue[editIndex] = value
|
||||
selectedNode.data.config.variables.defaultValue = [...defaultValue]
|
||||
} else {
|
||||
const defaultValue = selectedNode.data.config.variables.defaultValue ?? []
|
||||
selectedNode.data.config.variables.defaultValue = [...defaultValue, value]
|
||||
}
|
||||
selectedNode?.setData({ ...selectedNode.data})
|
||||
|
||||
setConfigs({ ...selectedNode.data.config})
|
||||
}
|
||||
const handleDeleteVariable = (index: number, vo: StartVariableItem) => {
|
||||
if (!selectedNode) return
|
||||
|
||||
modal.confirm({
|
||||
title: t('common.confirmDeleteDesc', { name: vo.name }),
|
||||
okText: t('common.delete'),
|
||||
okType: 'danger',
|
||||
onOk: () => {
|
||||
const defaultValue = selectedNode.data.config.variables.defaultValue ?? []
|
||||
defaultValue.splice(index, 1)
|
||||
selectedNode.data.config.variables.defaultValue = [...defaultValue]
|
||||
|
||||
selectedNode?.setData({ ...selectedNode.data })
|
||||
|
||||
setConfigs({ ...selectedNode.data.config })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rb:w-75 rb:fixed rb:right-0 rb:top-16 rb:bottom-0 rb:p-3">
|
||||
<div className="rb:font-medium rb:leading-5 rb:mb-3">{t('workflow.nodeProperties')}</div>
|
||||
{!selectedNode
|
||||
? <Empty url={emptyIcon} size={140} className="rb:h-full rb:mx-15" title={t('workflow.empty')} />
|
||||
: <Form form={form} layout="vertical" className="rb:h-[calc(100%-20px)] rb:overflow-y-auto">
|
||||
<Form.Item name="name" label={t('workflow.nodeName')}>
|
||||
<Input
|
||||
placeholder={t('common.pleaseEnter')}
|
||||
onChange={(e) => {
|
||||
updateNodeLabel(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{configs && Object.keys(configs).length > 0 && Object.keys(configs).map((key) => {
|
||||
const config = configs[key] || {}
|
||||
|
||||
if (selectedNode.data.type === 'start' && key === 'variables' && config.type === 'define') {
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="rb:flex rb:items-center rb:justify-between rb:mb-2.75">
|
||||
<div className="rb:leading-5">
|
||||
{t(`workflow.config.${selectedNode.data.type}.${key}`)}
|
||||
</div>
|
||||
<Button style={{padding: '0 8px', height: '24px'}} onClick={handleAddVariable}>+{t('application.addVariables')}</Button>
|
||||
</div>
|
||||
|
||||
<Space size={4} direction="vertical" className="rb:w-full">
|
||||
{Array.isArray(config.defaultValue) && config.defaultValue?.map((vo, index) =>
|
||||
<div key={`${vo.name}}-${index}`} className="rb:p-[4px_8px] rb:text-[12px] rb:text-[#5B6167] rb:flex rb:items-center rb:justify-between rb:border rb:border-[#DFE4ED] rb:rounded-md rb:group rb:cursor-pointer">
|
||||
<span>{vo.name}·{vo.description}</span>
|
||||
|
||||
<div className="rb:group-hover:hidden rb:flex rb:items-center rb:gap-1">
|
||||
{vo.required && <span>{t('workflow.config.start.required')}</span>}
|
||||
{vo.type}
|
||||
</div>
|
||||
<Space className="rb:hidden! rb:group-hover:flex!">
|
||||
<div
|
||||
className="rb:w-4.5 rb:h-4.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/editBorder.svg')] rb:hover:bg-[url('@/assets/images/editBg.svg')]"
|
||||
onClick={() => handleEditVariable(index, vo)}
|
||||
></div>
|
||||
<div
|
||||
className="rb:w-4.5 rb:h-4.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/deleteBorder.svg')] rb:hover:bg-[url('@/assets/images/deleteBg.svg')]"
|
||||
onClick={() => handleDeleteVariable(index, vo)}
|
||||
></div>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
<Divider size="small" />
|
||||
{config.sys?.map((vo, index) =>
|
||||
<div key={index} className="rb:p-[4px_8px] rb:text-[12px] rb:text-[#5B6167] rb:flex rb:items-center rb:justify-between rb:border rb:border-[#DFE4ED] rb:rounded-md">
|
||||
<div>
|
||||
<span>sys.{vo.name}</span>
|
||||
</div>
|
||||
{vo.type}
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedNode.data.type === 'llm' && key === 'messages' && config.type === 'define') {
|
||||
return (
|
||||
<Form.Item key={key} name={key}>
|
||||
<MessageEditor />
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
|
||||
if (config.type === 'define') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={key}
|
||||
label={t(`workflow.config.${selectedNode.data.type}.${key}`)}
|
||||
>
|
||||
{config.type === 'input'
|
||||
? <Input placeholder={t('common.pleaseEnter')} />
|
||||
: config.type === 'textarea'
|
||||
? <Input.TextArea placeholder={t('common.pleaseEnter')} />
|
||||
: config.type === 'select'
|
||||
? <Select
|
||||
options={config.options}
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
/>
|
||||
: config.type === 'inputNumber'
|
||||
? <InputNumber />
|
||||
: config.type === 'slider'
|
||||
? <Slider min={config.min} max={config.max} step={config.step} />
|
||||
: config.type === 'customSelect'
|
||||
? <CustomSelect
|
||||
url={config.url as string}
|
||||
params={config.params}
|
||||
hasAll={false}
|
||||
valueKey={config.valueKey}
|
||||
labelKey={config.labelKey}
|
||||
/>
|
||||
: null
|
||||
}
|
||||
</Form.Item>
|
||||
)
|
||||
})}
|
||||
</Form>
|
||||
}
|
||||
|
||||
<VariableEditModal
|
||||
ref={variableModalRef}
|
||||
refresh={handleRefreshVariable}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default Properties;
|
||||
Reference in New Issue
Block a user