feat(web): Add Workflow

This commit is contained in:
zhaoying
2025-12-22 10:46:19 +08:00
parent e1bccff79b
commit 281aec23e3
65 changed files with 2843 additions and 31 deletions

View File

@@ -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;

View File

@@ -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;

View 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;