feat(web): AWEL flow 2.0 frontend codes (#1898)

Co-authored-by: Fangyin Cheng <staneyffer@gmail.com>
Co-authored-by: 谨欣 <echo.cmy@antgroup.com>
Co-authored-by: 严志勇 <yanzhiyong@tiansuixiansheng.com>
Co-authored-by: yanzhiyong <932374019@qq.com>
This commit is contained in:
Dreammy23
2024-08-28 12:39:13 +08:00
committed by GitHub
parent 9502251c08
commit 131bc7b89b
60 changed files with 2334 additions and 2243 deletions

View File

@@ -184,7 +184,7 @@ const Completion = ({ messages, onSubmit }: Props) => {
question={showMessages?.filter((e) => e?.role === 'human' && e?.order === content.order)[0]?.context}
knowledge_space={spaceNameOriginal || dbParam || ''}
/>
<Tooltip title={t('copy')}>
<Tooltip title={t('Copy_Btn')}>
<Button
onClick={() => onCopyContext(content?.context)}
slots={{ root: IconButton }}

View File

@@ -61,6 +61,7 @@ function GPTCard({
return icon;
}, [icon]);
// TODO: 算子资源标签
const tagNode = useMemo(() => {
if (!tags || !tags.length) return null;
return (

View File

@@ -3,7 +3,7 @@ import { Button, Form, Input, InputNumber, Modal, Select, Spin, Tooltip, message
import { useEffect, useMemo, useState } from 'react';
import { addOmcDB, apiInterceptors, getSupportDBList, postDbAdd, postDbEdit, postDbTestConnect } from '@/client/api';
import { DBOption, DBType, DbListResponse, PostDbParams } from '@/types/db';
import { isFileDb } from '@/pages/database';
import { isFileDb } from '@/pages/construct/database';
import { useTranslation } from 'react-i18next';
import { useDebounceFn } from 'ahooks';

View File

@@ -0,0 +1,234 @@
import { ChatContext } from '@/app/chat-context';
import { apiInterceptors, getFlowNodes } from '@/client/api';
import { CaretLeftOutlined, CaretRightOutlined } from '@ant-design/icons';
import type { CollapseProps } from 'antd';
import { Badge, Collapse, Input, Layout, Space } from 'antd';
import React, { useContext, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import StaticNodes from './static-nodes';
import { IFlowNode } from '@/types/flow';
import { FLOW_NODES_KEY } from '@/utils';
const { Search } = Input;
const { Sider } = Layout;
type GroupType = {
category: string;
categoryLabel: string;
nodes: IFlowNode[];
};
const zeroWidthTriggerDefaultStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 16,
height: 48,
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
border: '1px solid #d6d8da',
borderRadius: 8,
right: -8,
};
const AddNodesSider: React.FC = () => {
const { t } = useTranslation();
const { mode } = useContext(ChatContext);
const [collapsed, setCollapsed] = useState<boolean>(false);
const [searchValue, setSearchValue] = useState<string>('');
const [operators, setOperators] = useState<Array<IFlowNode>>([]);
const [resources, setResources] = useState<Array<IFlowNode>>([]);
const [operatorsGroup, setOperatorsGroup] = useState<GroupType[]>([]);
const [resourcesGroup, setResourcesGroup] = useState<GroupType[]>([]);
useEffect(() => {
getNodes();
}, []);
async function getNodes() {
const [_, data] = await apiInterceptors(getFlowNodes());
if (data && data.length > 0) {
localStorage.setItem(FLOW_NODES_KEY, JSON.stringify(data));
const operatorNodes = data.filter(
(node) => node.flow_type === 'operator'
);
const resourceNodes = data.filter(
(node) => node.flow_type === 'resource'
);
setOperators(operatorNodes);
setResources(resourceNodes);
setOperatorsGroup(groupNodes(operatorNodes));
setResourcesGroup(groupNodes(resourceNodes));
}
}
const triggerStyle: React.CSSProperties = useMemo(() => {
if (collapsed) {
return {
...zeroWidthTriggerDefaultStyle,
right: -16,
borderRadius: '0px 8px 8px 0',
borderLeft: '1px solid #d5e5f6',
};
}
return {
...zeroWidthTriggerDefaultStyle,
borderLeft: '1px solid #d6d8da',
};
}, [collapsed]);
function groupNodes(data: IFlowNode[]) {
const groups: GroupType[] = [];
const categoryMap: Record<
string,
{ category: string; categoryLabel: string; nodes: IFlowNode[] }
> = {};
data.forEach((item) => {
const { category, category_label } = item;
if (!categoryMap[category]) {
categoryMap[category] = {
category,
categoryLabel: category_label,
nodes: [],
};
groups.push(categoryMap[category]);
}
categoryMap[category].nodes.push(item);
});
return groups;
}
const operatorItems: CollapseProps['items'] = useMemo(() => {
if (!searchValue) {
return operatorsGroup.map(({ category, categoryLabel, nodes }) => ({
key: category,
label: categoryLabel,
children: <StaticNodes nodes={nodes} />,
extra: (
<Badge
showZero
count={nodes.length || 0}
style={{
backgroundColor: nodes.length > 0 ? '#52c41a' : '#7f9474',
}}
/>
),
}));
} else {
const searchedNodes = operators.filter((node) =>
node.label.toLowerCase().includes(searchValue.toLowerCase())
);
return groupNodes(searchedNodes).map(
({ category, categoryLabel, nodes }) => ({
key: category,
label: categoryLabel,
children: <StaticNodes nodes={nodes} />,
extra: (
<Badge
showZero
count={nodes.length || 0}
style={{
backgroundColor: nodes.length > 0 ? '#52c41a' : '#7f9474',
}}
/>
),
})
);
}
}, [operatorsGroup, searchValue]);
const resourceItems: CollapseProps['items'] = useMemo(() => {
if (!searchValue) {
return resourcesGroup.map(({ category, categoryLabel, nodes }) => ({
key: category,
label: categoryLabel,
children: <StaticNodes nodes={nodes} />,
extra: (
<Badge
showZero
count={nodes.length || 0}
style={{
backgroundColor: nodes.length > 0 ? '#52c41a' : '#7f9474',
}}
/>
),
}));
} else {
const searchedNodes = resources.filter((node) =>
node.label.toLowerCase().includes(searchValue.toLowerCase())
);
return groupNodes(searchedNodes).map(
({ category, categoryLabel, nodes }) => ({
key: category,
label: categoryLabel,
children: <StaticNodes nodes={nodes} />,
extra: (
<Badge
showZero
count={nodes.length || 0}
style={{
backgroundColor: nodes.length > 0 ? '#52c41a' : '#7f9474',
}}
/>
),
})
);
}
}, [resourcesGroup, searchValue]);
function searchNode(val: string) {
setSearchValue(val);
}
return (
<Sider
className='flex justify-center items-start nodrag bg-[#ffffff80] border-r border-[#d5e5f6] dark:bg-[#ffffff29] dark:border-[#ffffff66]'
theme={mode}
width={280}
collapsible={true}
collapsed={collapsed}
collapsedWidth={0}
trigger={
collapsed ? (
<CaretRightOutlined className='text-base' />
) : (
<CaretLeftOutlined className='text-base' />
)
}
zeroWidthTriggerStyle={triggerStyle}
onCollapse={(collapsed) => setCollapsed(collapsed)}
>
<Space
direction='vertical'
className='w-[280px] pt-4 px-4 overflow-hidden overflow-y-auto scrollbar-default'
>
<p className='w-full text-base font-semibold text-[#1c2533] dark:text-[rgba(255,255,255,0.85)] line-clamp-1'>
{t('add_node')}
</p>
<Search placeholder='Search node' onSearch={searchNode} allowClear />
<h2 className='font-semibold'>{t('operators')}</h2>
<Collapse
size='small'
bordered={false}
className='max-h-[calc((100vh-156px)/2)] overflow-hidden overflow-y-auto scrollbar-default'
defaultActiveKey={['']}
items={operatorItems}
/>
<h2 className='font-semibold'>{t('resource')}</h2>
<Collapse
size='small'
bordered={false}
className='max-h-[calc((100vh-156px)/2)] overflow-hidden overflow-y-auto scrollbar-default'
defaultActiveKey={['']}
items={resourceItems}
/>
</Space>
</Sider>
);
};
export default AddNodesSider;

View File

@@ -100,6 +100,7 @@ const AddNodes: React.FC = () => {
<div className="w-[320px] overflow-hidden overflow-y-auto scrollbar-default">
<p className="my-2 font-bold">{t('add_node')}</p>
<Search placeholder="Search node" onSearch={searchNode} />
<h2 className="my-2 ml-2 font-semibold">{t('operators')}</h2>
<Collapse
className="max-h-[300px] overflow-hidden overflow-y-auto scrollbar-default"
@@ -107,6 +108,7 @@ const AddNodes: React.FC = () => {
defaultActiveKey={['']}
items={operatorItems}
/>
<h2 className="my-2 ml-2 font-semibold">{t('resource')}</h2>
<Collapse
className="max-h-[300px] overflow-hidden overflow-y-auto scrollbar-default"
@@ -122,7 +124,7 @@ const AddNodes: React.FC = () => {
className="flex items-center justify-center rounded-full left-4 top-4"
style={{ zIndex: 1050 }}
icon={<PlusOutlined />}
></Button>
/>
</Popover>
);
};

View File

@@ -0,0 +1,104 @@
import { Modal, Form, Input, Button, Space, Radio, message } from 'antd';
import { IFlowData, IFlowUpdateParam } from '@/types/flow';
import { apiInterceptors, exportFlow } from '@/client/api';
import { ReactFlowInstance } from 'reactflow';
import { useTranslation } from 'react-i18next';
type Props = {
reactFlow: ReactFlowInstance<any, any>;
flowInfo?: IFlowUpdateParam;
isExportFlowModalOpen: boolean;
setIsExportFlowModalOpen: (value: boolean) => void;
};
export const ExportFlowModal: React.FC<Props> = ({
reactFlow,
flowInfo,
isExportFlowModalOpen,
setIsExportFlowModalOpen,
}) => {
const { t } = useTranslation();
const [form] = Form.useForm();
const [messageApi, contextHolder] = message.useMessage();
const onFlowExport = async (values: any) => {
if (values.format === 'json') {
const flowData = reactFlow.toObject() as IFlowData;
const blob = new Blob([JSON.stringify(flowData)], {
type: 'text/plain;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = values.file_name || 'flow.json';
a.click();
}else{
const linkUrl = `${process.env.API_BASE_URL}/api/v2/serve/awel/flow/export/${values.uid}?export_type=${values.export_type}&format=${values.format}`
window.open(linkUrl)
}
messageApi.success(t('Export_Flow_Success'));
setIsExportFlowModalOpen(false);
};
return (
<>
<Modal
centered
title={t('Export_Flow')}
open={isExportFlowModalOpen}
onCancel={() => setIsExportFlowModalOpen(false)}
cancelButtonProps={{ className: 'hidden' }}
okButtonProps={{ className: 'hidden' }}
>
<Form
form={form}
className='mt-6'
labelCol={{ span: 6 }}
wrapperCol={{ span: 16 }}
onFinish={onFlowExport}
initialValues={{
export_type: 'json',
format: 'file',
uid: flowInfo?.uid,
}}
>
<Form.Item label={t('Export_File_Type')} name='export_type'>
<Radio.Group>
<Radio value='json'>JSON</Radio>
<Radio value='dbgpts'>DBGPTS</Radio>
</Radio.Group>
</Form.Item>
<Form.Item label={t('Export_File_Format')} name='format'>
<Radio.Group>
<Radio value='file'>File</Radio>
<Radio value='json'>JSON</Radio>
</Radio.Group>
</Form.Item>
<Form.Item hidden name='uid'>
<Input />
</Form.Item>
<Form.Item wrapperCol={{ offset: 14, span: 8 }}>
<Space>
<Button
htmlType='button'
onClick={() => setIsExportFlowModalOpen(false)}
>
{t('cancel')}
</Button>
<Button type='primary' htmlType='submit'>
{t('verify')}
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{contextHolder}
</>
);
};

View File

@@ -0,0 +1,113 @@
import {
Modal,
Form,
Button,
message,
Upload,
UploadFile,
UploadProps,
GetProp,
Radio,
Space,
} from 'antd';
import { apiInterceptors, importFlow } from '@/client/api';
import { Node, Edge } from 'reactflow';
import { UploadOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
type Props = {
isImportModalOpen: boolean;
setNodes: React.Dispatch<
React.SetStateAction<Node<any, string | undefined>[]>
>;
setEdges: React.Dispatch<React.SetStateAction<Edge<any>[]>>;
setIsImportFlowModalOpen: (value: boolean) => void;
};
type FileType = Parameters<GetProp<UploadProps, 'beforeUpload'>>[0];
export const ImportFlowModal: React.FC<Props> = ({
setNodes,
setEdges,
isImportModalOpen,
setIsImportFlowModalOpen,
}) => {
const { t } = useTranslation();
const [form] = Form.useForm();
const [messageApi, contextHolder] = message.useMessage();
const [fileList, setFileList] = useState<UploadFile[]>([]);
const onFlowImport = async (values: any) => {
values.file = values.file?.[0];
const formData: any = new FormData();
fileList.forEach((file) => {
formData.append('file', file as FileType);
});
const [, , res] = await apiInterceptors(importFlow(formData));
if (res?.success) {
messageApi.success(t('Export_Flow_Success'));
} else if (res?.err_msg) {
messageApi.error(res?.err_msg);
}
setIsImportFlowModalOpen(false);
};
return (
<>
<Modal
centered
title={t('Import_Flow')}
open={isImportModalOpen}
onCancel={() => setIsImportFlowModalOpen(false)}
cancelButtonProps={{ className: 'hidden' }}
okButtonProps={{ className: 'hidden' }}
>
<Form
form={form}
className='mt-6'
labelCol={{ span: 6 }}
wrapperCol={{ span: 16 }}
onFinish={onFlowImport}
initialValues={{
save_flow: false,
}}
>
<Form.Item
name='file'
label={t('Select_File')}
valuePropName='fileList'
getValueFromEvent={(e) => (Array.isArray(e) ? e : e && e.fileList)}
rules={[{ required: true, message: 'Please upload a file' }]}
>
<Upload accept='.json,.zip' beforeUpload={() => false} maxCount={1}>
<Button icon={<UploadOutlined />}> {t('Upload')}</Button>
</Upload>
</Form.Item>
<Form.Item name='save_flow' label={t('Save_After_Import')}>
<Radio.Group>
<Radio value={true}>{t('Yes')}</Radio>
<Radio value={false}>{t('No')}</Radio>
</Radio.Group>
</Form.Item>
<Form.Item wrapperCol={{ offset: 14, span: 8 }}>
<Space>
<Button onClick={() => setIsImportFlowModalOpen(false)}>
{t('cancel')}
</Button>
<Button type='primary' htmlType='submit'>
{t('verify')}
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{contextHolder}
</>
);
};

View File

@@ -0,0 +1,3 @@
export * from './save-flow-modal';
export * from './export-flow-modal';
export * from './import-flow-modal';

View File

@@ -0,0 +1,203 @@
import { useState } from 'react';
import { Modal, Form, Input, Button, Space, message, Checkbox } from 'antd';
import { IFlowData, IFlowUpdateParam } from '@/types/flow';
import { apiInterceptors, addFlow, updateFlowById } from '@/client/api';
import { mapHumpToUnderline } from '@/utils/flow';
import { useTranslation } from 'react-i18next';
import { ReactFlowInstance } from 'reactflow';
import { useSearchParams } from 'next/navigation';
const { TextArea } = Input;
type Props = {
reactFlow: ReactFlowInstance<any, any>;
flowInfo?: IFlowUpdateParam;
isSaveFlowModalOpen: boolean;
setIsSaveFlowModalOpen: (value: boolean) => void;
};
export const SaveFlowModal: React.FC<Props> = ({
reactFlow,
isSaveFlowModalOpen,
flowInfo,
setIsSaveFlowModalOpen,
}) => {
const [deploy, setDeploy] = useState(true);
const { t } = useTranslation();
const searchParams = useSearchParams();
const id = searchParams?.get('id') || '';
const [form] = Form.useForm<IFlowUpdateParam>();
const [messageApi, contextHolder] = message.useMessage();
function onLabelChange(e: React.ChangeEvent<HTMLInputElement>) {
const label = e.target.value;
// replace spaces with underscores, convert uppercase letters to lowercase, remove characters other than digits, letters, _, and -.
let result = label
.replace(/\s+/g, '_')
.replace(/[^a-z0-9_-]/g, '')
.toLowerCase();
result = result;
form.setFieldsValue({ name: result });
}
async function onSaveFlow() {
const {
name,
label,
description = '',
editable = false,
state = 'deployed',
} = form.getFieldsValue();
console.log(form.getFieldsValue());
const reactFlowObject = mapHumpToUnderline(
reactFlow.toObject() as IFlowData
);
if (id) {
const [, , res] = await apiInterceptors(
updateFlowById(id, {
name,
label,
description,
editable,
uid: id,
flow_data: reactFlowObject,
state,
})
);
if (res?.success) {
messageApi.success(t('save_flow_success'));
} else if (res?.err_msg) {
messageApi.error(res?.err_msg);
}
} else {
const [_, res] = await apiInterceptors(
addFlow({
name,
label,
description,
editable,
flow_data: reactFlowObject,
state,
})
);
if (res?.uid) {
messageApi.success(t('save_flow_success'));
const history = window.history;
history.pushState(null, '', `/flow/canvas?id=${res.uid}`);
}
}
setIsSaveFlowModalOpen(false);
}
return (
<>
<Modal
centered
title={t('flow_modal_title')}
open={isSaveFlowModalOpen}
onCancel={() => {
setIsSaveFlowModalOpen(false);
}}
cancelButtonProps={{ className: 'hidden' }}
okButtonProps={{ className: 'hidden' }}
>
<Form
name='flow_form'
form={form}
labelCol={{ span: 6 }}
wrapperCol={{ span: 16 }}
className='mt-6 max-w-2xl'
initialValues={{ remember: true }}
onFinish={onSaveFlow}
autoComplete='off'
>
<Form.Item
label='Title'
name='label'
initialValue={flowInfo?.label}
rules={[{ required: true, message: 'Please input flow title!' }]}
>
<Input onChange={onLabelChange} />
</Form.Item>
<Form.Item
label='Name'
name='name'
initialValue={flowInfo?.name}
rules={[
{ required: true, message: 'Please input flow name!' },
() => ({
validator(_, value) {
const regex = /^[a-zA-Z0-9_\-]+$/;
if (!regex.test(value)) {
return Promise.reject(
'Can only contain numbers, letters, underscores, and dashes'
);
}
return Promise.resolve();
},
}),
]}
>
<Input />
</Form.Item>
<Form.Item
label='Description'
initialValue={flowInfo?.description}
name='description'
>
<TextArea rows={3} />
</Form.Item>
<Form.Item
label='Editable'
name='editable'
initialValue={flowInfo?.editable}
valuePropName='checked'
>
<Checkbox />
</Form.Item>
<Form.Item hidden name='state'>
<Input />
</Form.Item>
<Form.Item label='Deploy'>
<Checkbox
defaultChecked={
flowInfo?.state === 'deployed' || flowInfo?.state === 'running'
}
checked={deploy}
onChange={(e) => {
const val = e.target.checked;
form.setFieldValue('state', val ? 'deployed' : 'developing');
setDeploy(val);
}}
/>
</Form.Item>
<Form.Item wrapperCol={{ offset: 14, span: 8 }}>
<Space>
<Button
htmlType='button'
onClick={() => {
setIsSaveFlowModalOpen(false);
}}
>
{t('cancel')}
</Button>
<Button type='primary' htmlType='submit'>
{t('verify')}
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{contextHolder}
</>
);
};

View File

@@ -4,28 +4,33 @@ import NodeParamHandler from './node-param-handler';
import classNames from 'classnames';
import { useState } from 'react';
import NodeHandler from './node-handler';
import { Popover, Tooltip } from 'antd';
import { CopyOutlined, DeleteOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { Form, Popover, Tooltip } from 'antd';
import {
CopyOutlined,
DeleteOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import { useReactFlow } from 'reactflow';
import IconWrapper from '../common/icon-wrapper';
import { getUniqueNodeId } from '@/utils/flow';
import { getUniqueNodeId, removeIndexFromNodeId } from '@/utils/flow';
import { cloneDeep } from 'lodash';
import { apiInterceptors, refreshFlowNodeById } from '@/client/api';
type CanvasNodeProps = {
data: IFlowNode;
};
const ICON_PATH_PREFIX = '/icons/node/';
function TypeLabel({ label }: { label: string }) {
return <div className="w-full h-8 bg-stone-100 dark:bg-zinc-700 px-2 flex items-center justify-center">{label}</div>;
return <div className='w-full h-8 align-middle font-semibold'>{label}</div>;
}
const forceTypeList = ['file', 'multiple_files', 'time','images','csv_file'];
const CanvasNode: React.FC<CanvasNodeProps> = ({ data }) => {
const node = data;
const { inputs, outputs, parameters, flow_type: flowType } = node;
const [isHovered, setIsHovered] = useState(false);
const reactFlow = useReactFlow();
const [form] = Form.useForm();
function onHover() {
setIsHovered(true);
@@ -68,81 +73,211 @@ const CanvasNode: React.FC<CanvasNodeProps> = ({ data }) => {
e.preventDefault();
e.stopPropagation();
reactFlow.setNodes((nodes) => nodes.filter((item) => item.id !== node.id));
reactFlow.setEdges((edges) => edges.filter((edge) => edge.source !== node.id && edge.target !== node.id));
reactFlow.setEdges((edges) =>
edges.filter((edge) => edge.source !== node.id && edge.target !== node.id)
);
}
function updateCurrentNodeValue(changedKey: string, changedVal: any) {
parameters.forEach((item) => {
if (item.name === changedKey) {
item.value = changedVal;
}
});
}
async function updateDependsNodeValue(changedKey: string, changedVal: any) {
const dependParamNodes = parameters.filter(({ ui }) =>
ui?.refresh_depends?.includes(changedKey)
);
if (dependParamNodes?.length === 0) return;
dependParamNodes.forEach(async (item) => {
const params = {
id: removeIndexFromNodeId(data?.id),
type_name: data.type_name,
type_cls: data.type_cls,
flow_type: 'operator' as const,
refresh: [
{
name: item.name,
depends: [
{
name: changedKey,
value: changedVal,
has_value: true,
},
],
},
],
};
const [_, res] = await apiInterceptors(refreshFlowNodeById(params));
// update value of the node
if (res) {
reactFlow.setNodes((nodes) =>
nodes.map((n) => {
return n.id === node.id
? {
...n,
data: {
...n.data,
parameters: res.parameters,
},
}
: n;
})
);
}
});
}
function onParameterValuesChange(changedValues: any, allValues: any) {
const [changedKey, changedVal] = Object.entries(changedValues)[0];
if (!allValues?.force && forceTypeList.includes(changedKey)) {
return;
}
updateCurrentNodeValue(changedKey, changedVal);
if (changedVal) {
updateDependsNodeValue(changedKey, changedVal);
}
}
function renderOutput(data: IFlowNode) {
if (flowType === 'operator' && outputs?.length > 0) {
return (
<>
<TypeLabel label="Outputs" />
<div className='bg-zinc-100 dark:bg-zinc-700 rounded p-2'>
<TypeLabel label='Outputs' />
{(outputs || []).map((output, index) => (
<NodeHandler key={`${data.id}_input_${index}`} node={data} data={output} type="source" label="outputs" index={index} />
<NodeHandler
key={`${data.id}_input_${index}`}
node={data}
data={output}
type='source'
label='outputs'
index={index}
/>
))}
</>
</div>
);
} else if (flowType === 'resource') {
// resource nodes show output default
return (
<>
<TypeLabel label="Outputs" />
<NodeHandler key={`${data.id}_input_0`} node={data} data={data} type="source" label="outputs" index={0} />
</>
<div className='bg-zinc-100 dark:bg-zinc-700 rounded p-2'>
<TypeLabel label='Outputs' />
<NodeHandler
key={`${data.id}_input_0`}
node={data}
data={data}
type='source'
label='outputs'
index={0}
/>
</div>
);
}
}
return (
<Popover
placement="rightTop"
placement='rightTop'
trigger={['hover']}
content={
<>
<IconWrapper className="hover:text-blue-500">
<CopyOutlined className="h-full text-lg cursor-pointer" onClick={copyNode} />
<IconWrapper className='hover:text-blue-500'>
<CopyOutlined
className='h-full text-lg cursor-pointer'
onClick={copyNode}
/>
</IconWrapper>
<IconWrapper className="mt-2 hover:text-red-500">
<DeleteOutlined className="h-full text-lg cursor-pointer" onClick={deleteNode} />
<IconWrapper className='mt-2 hover:text-red-500'>
<DeleteOutlined
className='h-full text-lg cursor-pointer'
onClick={deleteNode}
/>
</IconWrapper>
<IconWrapper className="mt-2">
<Tooltip title={<><p className="font-bold">{node.label}</p><p>{node.description}</p></>} placement="right">
<InfoCircleOutlined className="h-full text-lg cursor-pointer" />
<IconWrapper className='mt-2'>
<Tooltip
title={
<>
<p className='font-bold'>{node.label}</p>
<p>{node.description}</p>
</>
}
placement='right'
>
<InfoCircleOutlined className='h-full text-lg cursor-pointer' />
</Tooltip>
</IconWrapper>
</>
}
>
<div
className={classNames('w-72 h-auto rounded-xl shadow-md p-0 border bg-white dark:bg-zinc-800 cursor-grab', {
'border-blue-500': node.selected || isHovered,
'border-stone-400 dark:border-white': !node.selected && !isHovered,
'border-dashed': flowType !== 'operator',
'border-red-600': node.invalid,
})}
className={classNames(
'w-80 h-auto rounded-xl shadow-md px-2 py-4 border bg-white dark:bg-zinc-800 cursor-grab flex flex-col space-y-2 text-sm',
{
'border-blue-500': node.selected || isHovered,
'border-stone-400 dark:border-white': !node.selected && !isHovered,
'border-dashed': flowType !== 'operator',
'border-red-600': node.invalid,
}
)}
onMouseEnter={onHover}
onMouseLeave={onLeave}
>
{/* icon and label */}
<div className="flex flex-row items-center p-2">
<Image src={'/icons/node/vis.png'} width={24} height={24} alt="" />
<p className="ml-2 text-lg font-bold text-ellipsis overflow-hidden whitespace-nowrap">{node.label}</p>
<div className='flex flex-row items-center'>
<Image src={'/icons/node/vis.png'} width={24} height={24} alt='' />
<p className='ml-2 text-lg font-bold text-ellipsis overflow-hidden whitespace-nowrap'>
{node.label}
</p>
</div>
{inputs && inputs.length > 0 && (
<>
<TypeLabel label="Inputs" />
{(inputs || []).map((input, index) => (
<NodeHandler key={`${node.id}_input_${index}`} node={node} data={input} type="target" label="inputs" index={index} />
))}
</>
{inputs?.length > 0 && (
<div className='bg-zinc-100 dark:bg-zinc-700 rounded p-2'>
<TypeLabel label='Inputs' />
<div className='flex flex-col space-y-2'>
{inputs?.map((item, index) => (
<NodeHandler
key={`${node.id}_input_${index}`}
node={node}
data={item}
type='target'
label='inputs'
index={index}
/>
))}
</div>
</div>
)}
{parameters && parameters.length > 0 && (
<>
<TypeLabel label="Parameters" />
{(parameters || []).map((parameter, index) => (
<NodeParamHandler key={`${node.id}_param_${index}`} node={node} data={parameter} label="parameters" index={index} />
))}
</>
{parameters?.length > 0 && (
<div className='bg-zinc-100 dark:bg-zinc-700 rounded p-2'>
<TypeLabel label='Parameters' />
<Form
form={form}
layout='vertical'
onValuesChange={onParameterValuesChange}
className='flex flex-col space-y-3 text-neutral-500'
>
{parameters?.map((item, index) => (
<NodeParamHandler
key={`${node.id}_param_${index}`}
formValuesChange={onParameterValuesChange}
node={node}
paramData={item}
label='parameters'
index={index}
/>
))}
</Form>
</div>
)}
{renderOutput(node)}
</div>
</Popover>

View File

@@ -94,15 +94,15 @@ const NodeHandler: React.FC<NodeHandlerProps> = ({ node, data, type, label, inde
})}
>
<Handle
className="w-2 h-2"
className={classNames('w-2 h-2', type === 'source' ? '-mr-4' : '-ml-4')}
type={type}
position={type === 'source' ? Position.Right : Position.Left}
id={`${node.id}|${label}|${index}`}
isValidConnection={(connection) => isValidConnection(connection)}
/>
<Typography
className={classNames('p-2', {
'pr-4': label === 'outputs',
className={classNames('bg-white dark:bg-[#232734] w-full px-2 py-1 rounded text-neutral-500', {
'text-right': label === 'outputs',
})}
>
<Popconfirm
@@ -117,9 +117,10 @@ const NodeHandler: React.FC<NodeHandlerProps> = ({ node, data, type, label, inde
</div>
}
>
{['inputs', 'parameters'].includes(label) && <PlusOutlined className="mr-2 cursor-pointer" onClick={showRelatedNodes} />}
{['inputs', 'parameters'].includes(label) && <PlusOutlined className="cursor-pointer" onClick={showRelatedNodes} />}
</Popconfirm>
{data.type_name}:{label !== 'outputs' && <RequiredIcon optional={data.optional} />}
{label !== 'outputs' && <RequiredIcon optional={data.optional} />}
{data.type_name}
{data.description && (
<Tooltip title={data.description}>
<InfoCircleOutlined className="ml-2 cursor-pointer" />

View File

@@ -1,102 +1,153 @@
import { IFlowNode, IFlowNodeParameter } from '@/types/flow';
import { Checkbox, Input, InputNumber, Select, Tooltip } from 'antd';
import { Checkbox, Form, Input, InputNumber, Select } from 'antd';
import React from 'react';
import RequiredIcon from './required-icon';
import NodeHandler from './node-handler';
import { InfoCircleOutlined } from '@ant-design/icons';
import {
renderSelect,
renderCheckbox,
renderRadio,
renderCascader,
renderDatePicker,
renderInput,
renderSlider,
renderTreeSelect,
renderTimePicker,
renderTextArea,
renderUpload,
renderCodeEditor,
renderPassword,
renderVariables,
} from './node-renderer';
interface NodeParamHandlerProps {
formValuesChange:any;
node: IFlowNode;
data: IFlowNodeParameter;
paramData: IFlowNodeParameter;
label: 'inputs' | 'outputs' | 'parameters';
index: number; // index of array
}
// render node parameters item
const NodeParamHandler: React.FC<NodeParamHandlerProps> = ({ node, data, label, index }) => {
function handleChange(value: any) {
data.value = value;
}
const NodeParamHandler: React.FC<NodeParamHandlerProps> = ({ formValuesChange,node, paramData, label, index }) => {
// render node parameters based on AWEL1.0
function renderNodeWithoutUiParam(data: IFlowNodeParameter) {
let defaultValue = data.value ?? data.default;
if (data.category === 'resource') {
return <NodeHandler node={node} data={data} type="target" label={label} index={index} />;
} else if (data.category === 'common') {
let defaultValue = data.value !== null && data.value !== undefined ? data.value : data.default;
switch (data.type_name) {
case 'int':
case 'float':
return (
<div className="p-2 text-sm">
<p>
{data.label}:<RequiredIcon optional={data.optional} />
{data.description && (
<Tooltip title={data.description}>
<InfoCircleOutlined className="ml-2 cursor-pointer" />
</Tooltip>
)}
</p>
<InputNumber
className="w-full"
defaultValue={defaultValue}
onChange={(value: number | null) => {
handleChange(value);
}}
/>
</div>
<Form.Item
className="mb-2 text-sm"
name={data.name}
initialValue={defaultValue}
rules={[{ required: !data.optional }]}
label={<span className="text-neutral-500">{data.label}</span>}
tooltip={data.description ? { title: data.description, icon: <InfoCircleOutlined /> } : ''}
>
<InputNumber className="w-full nodrag" />
</Form.Item>
);
case 'str':
return (
<div className="p-2 text-sm">
<p>
{data.label}:<RequiredIcon optional={data.optional} />
{data.description && (
<Tooltip title={data.description}>
<InfoCircleOutlined className="ml-2 cursor-pointer" />
</Tooltip>
)}
</p>
<Form.Item
className="mb-2 text-sm"
name={data.name}
initialValue={defaultValue}
rules={[{ required: !data.optional }]}
label={<span className="text-neutral-500">{data.label}</span>}
tooltip={data.description ? { title: data.description, icon: <InfoCircleOutlined /> } : ''}
>
{data.options?.length > 0 ? (
<Select
className="w-full nodrag"
defaultValue={defaultValue}
options={data.options.map((item: any) => ({ label: item.label, value: item.value }))}
onChange={handleChange}
/>
<Select className="w-full nodrag" options={data.options.map((item: any) => ({ label: item.label, value: item.value }))} />
) : (
<Input
className="w-full"
defaultValue={defaultValue}
onChange={(e) => {
handleChange(e.target.value);
}}
/>
<Input className="w-full" />
)}
</div>
</Form.Item>
);
case 'bool':
defaultValue = defaultValue === 'False' ? false : defaultValue;
defaultValue = defaultValue === 'True' ? true : defaultValue;
return (
<div className="p-2 text-sm">
<p>
{data.label}:<RequiredIcon optional={data.optional} />
{data.description && (
<Tooltip title={data.description}>
<InfoCircleOutlined className="ml-2 cursor-pointer" />
</Tooltip>
)}
<Checkbox
className="ml-2"
defaultChecked={defaultValue}
onChange={(e) => {
handleChange(e.target.checked);
}}
/>
</p>
</div>
<Form.Item
className="mb-2 text-sm"
name={data.name}
initialValue={defaultValue}
rules={[{ required: !data.optional }]}
label={<span className="text-neutral-500">{data.label}</span>}
tooltip={data.description ? { title: data.description, icon: <InfoCircleOutlined /> } : ''}
>
<Checkbox className="ml-2" />
</Form.Item>
);
}
}
function renderComponentByType(type: string, data: IFlowNodeParameter,formValuesChange:any) {
switch (type) {
case 'select':
return renderSelect(data);
case 'cascader':
return renderCascader(data);
case 'checkbox':
return renderCheckbox(data);
case 'radio':
return renderRadio(data);
case 'input':
return renderInput(data);
case 'text_area':
return renderTextArea(data);
case 'slider':
return renderSlider(data);
case 'date_picker':
return renderDatePicker( data );
case 'time_picker':
return renderTimePicker({ data,formValuesChange });
case 'tree_select':
return renderTreeSelect(data);
case 'password':
return renderPassword(data);
case 'upload':
return renderUpload({ data,formValuesChange });
case 'variables':
return renderVariables(data);
case 'code_editor':
return renderCodeEditor(data);
default:
return null;
}
}
// render node parameters based on AWEL2.0
function renderNodeWithUiParam(data: IFlowNodeParameter,formValuesChange:any) {
const { refresh_depends, ui_type } = data.ui;
let defaultValue = data.value ?? data.default;
if (ui_type === 'slider' && data.is_list) {
defaultValue = [0,1]
}
return (
<Form.Item
className="mb-2"
initialValue={defaultValue}
name={data.name}
rules={[{ required: !data.optional }]}
label={<span className="text-neutral-500">{data.label}</span>}
{...(refresh_depends && { dependencies: refresh_depends })}
{...(data.description && { tooltip: { title: data.description, icon: <InfoCircleOutlined /> } })}
>
{renderComponentByType(ui_type, data,formValuesChange)}
</Form.Item>
);
}
if (paramData.category === 'resource') {
return <NodeHandler node={node} data={paramData} type="target" label={label} index={index} />;
} else if (paramData.category === 'common') {
return paramData?.ui ? renderNodeWithUiParam(paramData,formValuesChange) : renderNodeWithoutUiParam(paramData);
}
};
export default NodeParamHandler;

View File

@@ -0,0 +1,16 @@
import { IFlowNodeParameter } from '@/types/flow';
import { convertKeysToCamelCase } from '@/utils/flow';
import { Cascader } from 'antd';
export const renderCascader = (data: IFlowNodeParameter) => {
const attr = convertKeysToCamelCase(data.ui?.attr || {});
return (
<Cascader
{...attr}
options={data.options}
placeholder="please select"
className="w-full nodrag"
/>
);
};

View File

@@ -0,0 +1,15 @@
import { IFlowNodeParameter } from '@/types/flow';
import { convertKeysToCamelCase } from '@/utils/flow';
import { Checkbox } from 'antd';
export const renderCheckbox = (data: IFlowNodeParameter) => {
const attr = convertKeysToCamelCase(data.ui?.attr || {});
return (
data.options?.length > 0 && (
<div className="bg-white p-2 rounded">
<Checkbox.Group {...attr} options={data.options} />
</div>
)
);
};

View File

@@ -0,0 +1,62 @@
import React, { useState, useMemo } from 'react';
import { Button, Form, Modal } from 'antd';
import Editor from '@monaco-editor/react';
import { IFlowNodeParameter } from '@/types/flow';
import { convertKeysToCamelCase } from '@/utils/flow';
import { useTranslation } from 'react-i18next';
type Props = {
data: IFlowNodeParameter;
defaultValue?: any;
};
export const renderCodeEditor = (data: IFlowNodeParameter) => {
const { t } = useTranslation();
const attr = convertKeysToCamelCase(data.ui?.attr || {});
const [isModalOpen, setIsModalOpen] = useState(false);
const showModal = () => {
setIsModalOpen(true);
};
const onOk = () => {
setIsModalOpen(false);
};
const onCancel = () => {
setIsModalOpen(false);
};
const modalWidth = useMemo(() => {
if (data?.ui?.editor?.width) {
return data?.ui?.editor?.width + 100;
}
return '80%';
}, [data?.ui?.editor?.width]);
return (
<div className="p-2 text-sm">
<Button type="default" onClick={showModal}>
{t('Open_Code_Editor')}
</Button>
<Modal title={t('Code_Editor')} width={modalWidth} open={isModalOpen} onOk={onOk} onCancel={onCancel}>
<Form.Item name={data?.name}>
<Editor
{...attr}
width={data?.ui?.editor?.width || '100%'}
height={data?.ui?.editor?.height || 200}
defaultLanguage={data?.ui?.language}
theme="vs-dark"
options={{
minimap: {
enabled: false,
},
wordWrap: 'on',
}}
/>
</Form.Item>
</Modal>
</div>
);
};

View File

@@ -0,0 +1,9 @@
import { IFlowNodeParameter } from '@/types/flow';
import { convertKeysToCamelCase } from '@/utils/flow';
import { DatePicker } from 'antd';
export const renderDatePicker = (data: IFlowNodeParameter) => {
const attr = convertKeysToCamelCase(data.ui?.attr || {});
return <DatePicker {...attr} className="w-full" placeholder="please select a date" />;
};

View File

@@ -0,0 +1,14 @@
export * from './select';
export * from './cascader';
export * from './date-picker';
export * from './input';
export * from './checkbox';
export * from './radio';
export * from './textarea';
export * from './slider';
export * from './time-picker';
export * from './tree-select';
export * from './code-editor';
export * from './upload';
export * from './password';
export * from './variables';

View File

@@ -0,0 +1,22 @@
import { IFlowNodeParameter } from '@/types/flow';
import { convertKeysToCamelCase } from '@/utils/flow';
import { Input } from 'antd';
import * as Icons from '@ant-design/icons';
const getIconComponent = (iconString: string) => {
const match = iconString.match(/^icon:(\w+)$/);
if (match) {
const iconName = match[1] as keyof typeof Icons;
const IconComponent = Icons[iconName];
// @ts-ignore
return IconComponent ? <IconComponent /> : null;
}
return null;
};
export const renderInput = (data: IFlowNodeParameter) => {
const attr = convertKeysToCamelCase(data.ui?.attr || {});
attr.prefix = getIconComponent(data.ui?.attr?.prefix || '');
return <Input {...attr} className="w-full" placeholder="please input" allowClear />;
};

View File

@@ -0,0 +1,11 @@
import { IFlowNodeParameter } from '@/types/flow';
import { Input } from 'antd';
import { convertKeysToCamelCase } from '@/utils/flow';
const { Password } = Input;
export const renderPassword = (data: IFlowNodeParameter) => {
const attr = convertKeysToCamelCase(data.ui?.attr || {});
return <Password {...attr} placeholder="input password" />;
};

View File

@@ -0,0 +1,13 @@
import { IFlowNodeParameter } from '@/types/flow';
import { convertKeysToCamelCase } from '@/utils/flow';
import { Radio } from 'antd';
export const renderRadio = (data: IFlowNodeParameter) => {
const attr = convertKeysToCamelCase(data.ui?.attr || {});
return (
<div className="bg-white p-2 rounded">
<Radio.Group {...attr} options={data.options} />
</div>
);
};

View File

@@ -0,0 +1,9 @@
import { IFlowNodeParameter } from '@/types/flow';
import { Select } from 'antd';
import { convertKeysToCamelCase } from '@/utils/flow';
export const renderSelect = (data: IFlowNodeParameter) => {
const attr = convertKeysToCamelCase(data?.ui?.attr || {});
return <Select {...attr} className="w-full nodrag" placeholder="please select" options={data.options} />;
};

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { IFlowNodeParameter } from '@/types/flow';
import { convertKeysToCamelCase } from '@/utils/flow';
import { Slider } from 'antd';
export const renderSlider = (data: IFlowNodeParameter) => {
const attr = convertKeysToCamelCase(data.ui?.attr || {});
return (
<>
{data.is_list?
(<Slider range className="mt-8 nodrag" {...attr} />)
:(<Slider className="mt-8 nodrag" {...attr} />)}
</>
)
};

View File

@@ -0,0 +1,13 @@
import { IFlowNodeParameter } from '@/types/flow';
import { Input } from 'antd';
import { convertKeysToCamelCase } from '@/utils/flow';
import classNames from 'classnames';
const { TextArea } = Input;
export const renderTextArea = (data: IFlowNodeParameter) => {
const attr = convertKeysToCamelCase(data.ui?.attr || {});
return (
<TextArea className="nowheel mb-3" {...attr} />
);
};

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { TimePicker } from 'antd';
import { IFlowNodeParameter } from '@/types/flow';
import { convertKeysToCamelCase } from '@/utils/flow';
import type { TimePickerProps } from 'antd';
type Props = {
formValuesChange:any,
data: IFlowNodeParameter;
};
export const renderTimePicker = (params: Props) => {
const { data ,formValuesChange} = params;
const attr = convertKeysToCamelCase(data.ui?.attr || {});
const onChangeTime: TimePickerProps['onChange'] = (time, timeString) => {
formValuesChange({
time:timeString
},{force:true})
};
return <TimePicker {...attr} onChange={onChangeTime} className="w-full" placeholder="please select a moment" />;
};

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { TreeSelect } from 'antd';
import { IFlowNodeParameter } from '@/types/flow';
import { convertKeysToCamelCase } from '@/utils/flow';
export const renderTreeSelect = (data: IFlowNodeParameter) => {
const attr = convertKeysToCamelCase(data.ui?.attr || {});
return (
<TreeSelect
{...attr}
className="w-full nodrag"
treeDefaultExpandAll
treeData={data.options}
/>
);
};

View File

@@ -0,0 +1,90 @@
import React, { useState, useRef } from 'react';
import { UploadOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { Button, Upload, message,Form } from 'antd';
import { convertKeysToCamelCase } from '@/utils/flow';
import { IFlowNodeParameter } from '@/types/flow';
import { useTranslation } from 'react-i18next';
type Props = {
formValuesChange:any,
data: IFlowNodeParameter;
onChange?: (value: any) => void;
};
export const renderUpload = (params: Props) => {
const { t } = useTranslation();
const urlList = useRef<string[]>([]);
const { data ,formValuesChange} = params;
const form = Form.useFormInstance()
const attr = convertKeysToCamelCase(data.ui?.attr || {});
const [uploading, setUploading] = useState(false);
const [uploadType, setUploadType] = useState('');
const getUploadSuccessUrl = (url: string) => {
if (urlList.current.length === data.ui.attr.max_count) {
urlList.current.pop();
}
urlList.current.push(url);
if (data.ui.attr.max_count === 1) {
formValuesChange({file:urlList.current.toString()},{force:true})
}else{
formValuesChange({multiple_files:JSON.stringify(urlList.current)},{force:true})
}
};
const handleFileRemove = (file: any) => {
const index = urlList.current.indexOf(file.response.data[0].uri);
if (index !== -1) {
urlList.current.splice(index, 1);
}
if (data.ui.attr.max_count === 1) {
formValuesChange({file:urlList.current.toString()},{force:true})
}else{
formValuesChange({multiple_files:JSON.stringify(urlList.current)},{force:true})
}
};
const props: UploadProps = {
name: 'files',
action: process.env.API_BASE_URL + data.ui.action,
headers: {
authorization: 'authorization-text',
},
onChange(info) {
setUploading(true);
if (info.file.status !== 'uploading') {
}
if (info.file.status === 'done') {
setUploading(false);
message.success(`${info.file.response.data[0].file_name} ${t('Upload_Data_Successfully')}`);
getUploadSuccessUrl(info.file.response.data[0].uri);
} else if (info.file.status === 'error') {
setUploading(false);
message.error(`${info.file.response.data[0].file_name} ${t('Upload_Data_Failed')}`);
}
},
};
if (!uploadType && data.ui?.file_types && Array.isArray(data.ui?.file_types)) {
setUploadType(data.ui?.file_types.toString());
}
return (
<div className="p-2 text-sm text-center">
{data.is_list ? (
<Upload onRemove={handleFileRemove} {...props} {...attr} multiple={true} accept={uploadType}>
<Button loading={uploading} icon={<UploadOutlined />}>
{t('Upload_Data')}
</Button>
</Upload>
) : (
<Upload onRemove={handleFileRemove} {...props} {...attr} multiple={false} accept={uploadType}>
<Button loading={uploading} icon={<UploadOutlined />}>
{t('Upload_Data')}
</Button>
</Upload>
)}
</div>
);
};

View File

@@ -0,0 +1,9 @@
import { IFlowNodeParameter } from '@/types/flow';
import { convertKeysToCamelCase } from '@/utils/flow';
import { Input } from 'antd';
export const renderVariables = (data: IFlowNodeParameter) => {
const attr = convertKeysToCamelCase(data.ui?.attr || {});
return <Input {...attr} className="w-full" placeholder="please input" allowClear />;
};

View File

@@ -14,27 +14,28 @@ const StaticNodes: React.FC<{ nodes: IFlowNode[] }> = ({ nodes }) => {
if (nodes?.length > 0) {
return (
<List
className="overflow-hidden overflow-y-auto w-full"
itemLayout="horizontal"
className='overflow-hidden overflow-y-auto w-full'
size='small'
itemLayout='horizontal'
dataSource={nodes}
renderItem={(node) => (
<List.Item
className="cursor-move hover:bg-[#F1F5F9] dark:hover:bg-theme-dark p-0 py-2"
className='cursor-move hover:bg-[#F1F5F9] dark:hover:bg-theme-dark p-0 py-2'
draggable
onDragStart={(event) => onDragStart(event, node)}
>
<List.Item.Meta
className="flex items-center justify-center"
className='flex items-center justify-center'
avatar={<Avatar src={'/icons/node/vis.png'} size={'large'} />}
title={<p className="line-clamp-1 font-medium">{node.label}</p>}
description={<p className="line-clamp-2">{node.description}</p>}
title={<p className='line-clamp-1 font-medium'>{node.label}</p>}
description={<p className='line-clamp-2'>{node.description}</p>}
/>
</List.Item>
)}
/>
);
} else {
return <Empty className="px-2" description={t('no_node')} />;
return <Empty className='px-2' description={t('no_node')} />;
}
};