mirror of
https://github.com/csunny/DB-GPT.git
synced 2025-09-09 12:59:43 +00:00
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:
@@ -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 }}
|
||||
|
@@ -61,6 +61,7 @@ function GPTCard({
|
||||
return icon;
|
||||
}, [icon]);
|
||||
|
||||
// TODO: 算子资源标签
|
||||
const tagNode = useMemo(() => {
|
||||
if (!tags || !tags.length) return null;
|
||||
return (
|
||||
|
@@ -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';
|
||||
|
||||
|
234
web/components/flow/add-nodes-sider.tsx
Normal file
234
web/components/flow/add-nodes-sider.tsx
Normal 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;
|
@@ -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>
|
||||
);
|
||||
};
|
||||
|
104
web/components/flow/canvas-modal/export-flow-modal.tsx
Normal file
104
web/components/flow/canvas-modal/export-flow-modal.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
};
|
113
web/components/flow/canvas-modal/import-flow-modal.tsx
Normal file
113
web/components/flow/canvas-modal/import-flow-modal.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
};
|
3
web/components/flow/canvas-modal/index.ts
Normal file
3
web/components/flow/canvas-modal/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './save-flow-modal';
|
||||
export * from './export-flow-modal';
|
||||
export * from './import-flow-modal';
|
203
web/components/flow/canvas-modal/save-flow-modal.tsx
Normal file
203
web/components/flow/canvas-modal/save-flow-modal.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
};
|
@@ -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>
|
||||
|
@@ -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" />
|
||||
|
@@ -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;
|
||||
|
16
web/components/flow/node-renderer/cascader.tsx
Normal file
16
web/components/flow/node-renderer/cascader.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
};
|
15
web/components/flow/node-renderer/checkbox.tsx
Normal file
15
web/components/flow/node-renderer/checkbox.tsx
Normal 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>
|
||||
)
|
||||
);
|
||||
};
|
62
web/components/flow/node-renderer/code-editor.tsx
Normal file
62
web/components/flow/node-renderer/code-editor.tsx
Normal 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>
|
||||
);
|
||||
};
|
9
web/components/flow/node-renderer/date-picker.tsx
Normal file
9
web/components/flow/node-renderer/date-picker.tsx
Normal 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" />;
|
||||
};
|
14
web/components/flow/node-renderer/index.ts
Normal file
14
web/components/flow/node-renderer/index.ts
Normal 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';
|
22
web/components/flow/node-renderer/input.tsx
Normal file
22
web/components/flow/node-renderer/input.tsx
Normal 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 />;
|
||||
};
|
11
web/components/flow/node-renderer/password.tsx
Normal file
11
web/components/flow/node-renderer/password.tsx
Normal 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" />;
|
||||
};
|
13
web/components/flow/node-renderer/radio.tsx
Normal file
13
web/components/flow/node-renderer/radio.tsx
Normal 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>
|
||||
);
|
||||
};
|
9
web/components/flow/node-renderer/select.tsx
Normal file
9
web/components/flow/node-renderer/select.tsx
Normal 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} />;
|
||||
};
|
16
web/components/flow/node-renderer/slider.tsx
Normal file
16
web/components/flow/node-renderer/slider.tsx
Normal 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} />)}
|
||||
</>
|
||||
)
|
||||
};
|
13
web/components/flow/node-renderer/textarea.tsx
Normal file
13
web/components/flow/node-renderer/textarea.tsx
Normal 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} />
|
||||
);
|
||||
};
|
23
web/components/flow/node-renderer/time-picker.tsx
Normal file
23
web/components/flow/node-renderer/time-picker.tsx
Normal 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" />;
|
||||
};
|
17
web/components/flow/node-renderer/tree-select.tsx
Normal file
17
web/components/flow/node-renderer/tree-select.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
90
web/components/flow/node-renderer/upload.tsx
Normal file
90
web/components/flow/node-renderer/upload.tsx
Normal 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>
|
||||
);
|
||||
};
|
9
web/components/flow/node-renderer/variables.tsx
Normal file
9
web/components/flow/node-renderer/variables.tsx
Normal 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 />;
|
||||
};
|
@@ -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')} />;
|
||||
}
|
||||
};
|
||||
|
||||
|
Reference in New Issue
Block a user