diff --git a/web/app/i18n.ts b/web/app/i18n.ts index f9da468af..a3b065d31 100644 --- a/web/app/i18n.ts +++ b/web/app/i18n.ts @@ -182,6 +182,16 @@ const en = { choice_agent_tip: 'Please choose an agent', no_context_tip: 'Please enter your question', Terminal: 'Terminal', + awel_flow: 'AWEL Flow', + save: 'Save', + add_node: 'Add Node', + no_node: 'No Node', + connect_warning: 'Nodes cannot be connected', + flow_modal_title: 'Save Flow', + flow_name: 'Flow Name', + flow_name_required: 'Please enter the flow name', + save_flow_success: 'Save flow success', + delete_flow_confirm: 'Are you sure you want to delete this flow?', } as const; export type I18nKeys = keyof typeof en; @@ -370,6 +380,16 @@ const zh: Resources['translation'] = { choice_agent_tip: '请选择代理', no_context_tip: '请输入你的问题', Terminal: '终端', + awel_flow: 'AWEL 工作流', + save: '保存', + add_node: '添加节点', + no_node: '没有可编排节点', + connect_warning: '节点无法连接', + flow_modal_title: '保存工作流', + flow_name: '工作流名称', + flow_name_required: '请输入工作流名称', + save_flow_success: '保存工作流成功', + delete_flow_confirm: '确定删除该工作流吗?', } as const; i18n.use(initReactI18next).init({ diff --git a/web/client/api/request.ts b/web/client/api/request.ts index 70567fd37..3e419d1f9 100644 --- a/web/client/api/request.ts +++ b/web/client/api/request.ts @@ -1,5 +1,5 @@ import { AxiosRequestConfig } from 'axios'; -import { GET, POST } from '.'; +import { DELETE, GET, POST, PUT } from '.'; import { DbListResponse, DbSupportTypeResponse, PostDbParams, ChatFeedBackSchema } from '@/types/db'; import { DialogueListResponse, IChatDialogueSchema, NewDialogueParam, SceneResponse, ChatHistoryResponse, FeedBack, IDB } from '@/types/chat'; import { IModelData, StartModelParams, BaseModelParams, SupportModel } from '@/types/model'; @@ -32,6 +32,7 @@ import { ISyncBatchResponse, } from '@/types/knowledge'; import { UpdatePromptParams, IPrompt, PromptParams } from '@/types/prompt'; +import { IFlow, IFlowNode, IFlowResponse, IFlowUpdateParam } from '@/types/flow'; /** App */ export const postScenes = () => { @@ -249,3 +250,28 @@ export const updatePrompt = (data: UpdatePromptParams) => { export const addPrompt = (data: UpdatePromptParams) => { return POST('/prompt/add', data); }; + +/** AWEL Flow */ +export const addFlow = (data: IFlowUpdateParam) => { + return POST('/api/v1/serve/awel/flows', data); +}; + +export const getFlows = () => { + return GET('/api/v1/serve/awel/flows'); +}; + +export const getFlowById = (id: string) => { + return GET(`/api/v1/serve/awel/flows/${id}`); +}; + +export const updateFlowById = (id: string, data: IFlowUpdateParam) => { + return PUT(`/api/v1/serve/awel/flows/${id}`, data); +}; + +export const deleteFlowById = (id: string) => { + return DELETE(`/api/v1/serve/awel/flows/${id}`); +}; + +export const getFlowNodes = () => { + return GET>(`/api/v1/serve/awel/nodes`); +}; diff --git a/web/components/flow/add-nodes.tsx b/web/components/flow/add-nodes.tsx new file mode 100644 index 000000000..ea3978d2b --- /dev/null +++ b/web/components/flow/add-nodes.tsx @@ -0,0 +1,120 @@ +import { apiInterceptors, getFlowNodes } from '@/client/api'; +import { IFlowNode } from '@/types/flow'; +import { PlusOutlined } from '@ant-design/icons'; +import { Avatar, Badge, Button, Collapse, CollapseProps, Divider, Empty, Input, List, Popover } from 'antd'; +import React, { DragEvent, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +const { Search } = Input; + +const AddNodes: React.FC = () => { + const { t } = useTranslation(); + const [nodes, setNodes] = useState>([]); + const [operators, setOperators] = useState>([]); + const [resources, setResources] = useState>([]); + + useEffect(() => { + getNodes(); + }, []); + + async function getNodes() { + const [_, data] = await apiInterceptors(getFlowNodes()); + if (data && data.length > 0) { + setNodes(data); + groupNodes(data); + } + } + + function groupNodes(data: IFlowNode[]) { + // show operator nodes first, then show resource nodes + setOperators((data || []).filter((node) => node.flow_type === 'operator')); + setResources((data || []).filter((node) => node.flow_type === 'resource')); + } + + const items: CollapseProps['items'] = useMemo( + () => [ + { + key: 'operator', + label: 'Operator', + children: renderNodes(operators), + extra: 0 ? '#52c41a' : '#7f9474' }} />, + }, + { + key: 'resource', + label: 'Resource', + children: renderNodes(resources), + extra: 0 ? '#52c41a' : '#7f9474' }} />, + }, + ], + [operators, resources], + ); + + function searchNode(val: string) { + if (!val) { + groupNodes(nodes); + } else { + const lowerSearchTerm = val.toLowerCase(); + const searchOperators = operators.filter((node) => node.label.toLowerCase().includes(lowerSearchTerm)); + const searchResources = resources.filter((node) => node.label.toLowerCase().includes(lowerSearchTerm)); + setOperators(searchOperators); + setResources(searchResources); + } + } + + function onDragStart(event: DragEvent, node: IFlowNode) { + event.dataTransfer.setData('application/reactflow', JSON.stringify(node)); + event.dataTransfer.effectAllowed = 'move'; + } + + function renderNodes(nodes: Array) { + if (nodes?.length > 0) { + return ( + ( + onDragStart(event, node)} + > + } + title={

{node.label}

} + description={

{node.description}

} + /> +
+ )} + /> + ); + } else { + return ; + } + } + + return ( + +

{t('add_node')}

+ + + + + } + > + +
+ ); +}; + +export default AddNodes; diff --git a/web/components/flow/button-edge.tsx b/web/components/flow/button-edge.tsx new file mode 100644 index 000000000..662cf5832 --- /dev/null +++ b/web/components/flow/button-edge.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { getBezierPath, EdgeProps, BaseEdge, useReactFlow } from 'reactflow'; + +const ButtonEdge: React.FC = ({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style = {}, data, markerEnd }) => { + const [edgePath, edgeCenterX, edgeCenterY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + const reactFlow = useReactFlow(); + + function onEdgeClick(event: React.MouseEvent, id: string) { + event.stopPropagation(); + reactFlow.setEdges(reactFlow.getEdges().filter((edge) => edge.id !== id)); + } + return ( + <> + + + + + + ); +}; + +export default ButtonEdge; diff --git a/web/components/flow/canvas-node.tsx b/web/components/flow/canvas-node.tsx new file mode 100644 index 000000000..1f0f40fd3 --- /dev/null +++ b/web/components/flow/canvas-node.tsx @@ -0,0 +1,122 @@ +import { IFlowNode, IFlowNodeParameter } from '@/types/flow'; +import Image from 'next/image'; +import NodeParamHandler from './node-param-handler'; +import classNames from 'classnames'; +import { useState } from 'react'; +import NodeHandler from './node-handler'; +import { Popover } from 'antd'; +import { CopyOutlined, DeleteOutlined } from '@ant-design/icons'; +import { useReactFlow } from 'reactflow'; + +type CanvasNodeProps = { + data: IFlowNode; +}; + +const ICON_PATH_PREFIX = '/icons/node/'; + +function TypeLabel({ label }: { label: string }) { + return
{label}
; +} + +const CanvasNode: React.FC = ({ data }) => { + const node = data; + const { inputs, outputs, flow_type: flowType } = node; + const parameters = orderParams(node.parameters || []); + const [isHovered, setIsHovered] = useState(false); + const reactFlow = useReactFlow(); + + function orderParams(params: Array) { + // we show resource params first, and build-int params last + const resourceParams = params.filter((param) => param.category === 'resource'); + const commonParams = params.filter((param) => param.category === 'common'); + return [...resourceParams, ...commonParams]; + } + + function onHover() { + setIsHovered(true); + } + + function onLeave() { + setIsHovered(false); + } + + function copyNode() {} + + function deleteNode() { + const nodes = reactFlow.getNodes(); + console.log('delete node', nodes); + reactFlow.setNodes((nodes) => { + const list = nodes.filter((item) => item.id !== node.id); + return list; + }); + reactFlow.setEdges((edges) => edges.filter((edge) => edge.source !== node.id && edge.target !== node.id)); + } + + function renderOutput(data: IFlowNode) { + if (flowType === 'operator' && outputs?.length > 0) { + return ( + <> + + {(outputs || []).map((output, index) => ( + + ))} + + ); + } else if (flowType === 'resource') { + // resource nodes show output default + return ( + <> + + + + ); + } + } + + return ( + + + + + } + > +
+ {/* icon and label */} +
+ +

{node.label}

+
+ {inputs && inputs.length > 0 && ( + <> + + {(inputs || []).map((input, index) => ( + + ))} + + )} + {parameters && parameters.length > 0 && ( + <> + + {(parameters || []).map((parameter, index) => ( + + ))} + + )} + {renderOutput(node)} +
+
+ ); +}; + +export default CanvasNode; diff --git a/web/components/flow/flow-card.tsx b/web/components/flow/flow-card.tsx new file mode 100644 index 000000000..c31428a71 --- /dev/null +++ b/web/components/flow/flow-card.tsx @@ -0,0 +1,57 @@ +import { apiInterceptors, deleteFlowById } from '@/client/api'; +import { IFlow } from '@/types/flow'; +import { DeleteFilled, WarningOutlined } from '@ant-design/icons'; +import { Modal } from 'antd'; +import Link from 'next/link'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +interface FlowCardProps { + flow: IFlow; + deleteCallback: (uid: string) => void; +} + +const FlowCard: React.FC = ({ flow, deleteCallback }) => { + const { t } = useTranslation(); + const [modal, contextHolder] = Modal.useModal(); + + async function deleteFlow() { + const [res] = await apiInterceptors(deleteFlowById(flow.uid)); + deleteCallback && deleteCallback(flow.uid); + } + + return ( + <> + +
+
+
+

{flow.name}

+
+
+ { + e.preventDefault(); + e.stopPropagation(); + modal.confirm({ + title: t('Tips'), + icon: , + content: t('delete_flow_confirm'), + okText: 'Yes', + okType: 'danger', + cancelText: 'No', + async onOk() { + deleteFlow(); + }, + }); + }} + /> +
+ + {contextHolder} + + ); +}; + +export default FlowCard; diff --git a/web/components/flow/node-handler.tsx b/web/components/flow/node-handler.tsx new file mode 100644 index 000000000..89a1ca220 --- /dev/null +++ b/web/components/flow/node-handler.tsx @@ -0,0 +1,67 @@ +import { Tooltip, Typography, message } from 'antd'; +import React from 'react'; +import { Connection, Handle, Position, useReactFlow } from 'reactflow'; +import RequiredIcon from './required-icon'; +import { InfoCircleOutlined } from '@ant-design/icons'; +import { IFlowNode, IFlowNodeInput, IFlowNodeOutput, IFlowNodeParameter } from '@/types/flow'; +import { useTranslation } from 'react-i18next'; + +interface NodeHandlerProps { + node: IFlowNode; + data: IFlowNodeInput | IFlowNodeParameter | IFlowNodeOutput; + type: 'source' | 'target'; + label: 'inputs' | 'outputs' | 'parameters'; + index: number; +} + +// render react flow handle item +const NodeHandler: React.FC = ({ node, data, type, label, index }) => { + const { t } = useTranslation(); + const reactflow = useReactFlow(); + + function isValidConnection(connection: Connection) { + const { sourceHandle, targetHandle, source, target } = connection; + const sourceNode = reactflow.getNode(source!); + const targetNode = reactflow.getNode(target!); + const { flow_type: sourceFlowType } = sourceNode?.data; + const { flow_type: targetFlowType } = targetNode?.data; + const sourceLabel = sourceHandle?.split('|')[1]; + const targetLabel = targetHandle?.split('|')[1]; + const sourceIndex = sourceHandle?.split('|')[2]; + const targetIndex = targetHandle?.split('|')[2]; + const targetTypeCls = targetNode?.data[targetLabel!][targetIndex!].type_cls; + if (sourceFlowType === targetFlowType && sourceFlowType === 'operator') { + // operator to operator, only type_cls matched can be connected + const sourceTypeCls = sourceNode?.data[sourceLabel!][sourceIndex!].type_cls; + return sourceTypeCls === targetTypeCls; + } else if (sourceFlowType === 'resource' && targetFlowType === 'operator') { + // resource to operator, check operator type_cls and resource parent_cls + const sourceParentCls = sourceNode?.data.parent_cls; + return sourceParentCls.includes(targetTypeCls); + } + message.warning(t('connect_warning')); + return false; + } + + return ( +
+ isValidConnection(connection)} + /> + + {data.label}: + {data.description && ( + + + + )} + +
+ ); +}; + +export default NodeHandler; diff --git a/web/components/flow/node-param-handler.tsx b/web/components/flow/node-param-handler.tsx new file mode 100644 index 000000000..2147d2606 --- /dev/null +++ b/web/components/flow/node-param-handler.tsx @@ -0,0 +1,74 @@ +import { IFlowNode, IFlowNodeParameter } from '@/types/flow'; +import { Checkbox, Input, InputNumber } from 'antd'; +import React from 'react'; +import RequiredIcon from './required-icon'; +import NodeHandler from './node-handler'; + +interface NodeParamHandlerProps { + node: IFlowNode; + data: IFlowNodeParameter; + label: 'inputs' | 'outputs' | 'parameters'; + index: number; // index of array +} + +// render node parameters item +const NodeParamHandler: React.FC = ({ node, data, label, index }) => { + function handleChange(value: any) { + data.value = value; + } + + if (data.category === 'resource') { + return ; + } else if (data.category === 'common') { + const defaultValue = data.default || data.value; + switch (data.type_name) { + case 'int': + return ( +
+

+ {data.label}: +

+ { + handleChange(e.target.value); + }} + /> +
+ ); + case 'str': + return ( +
+

+ {data.label}: +

+ { + handleChange(e.target.value); + }} + /> +
+ ); + case 'bool': + return ( +
+

+ {data.label}: + { + handleChange(e.target.value); + }} + /> +

+
+ ); + } + } +}; + +export default NodeParamHandler; diff --git a/web/components/flow/required-icon.tsx b/web/components/flow/required-icon.tsx new file mode 100644 index 000000000..3fa25ac34 --- /dev/null +++ b/web/components/flow/required-icon.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +const RequiredIcon: React.FC<{ optional?: boolean | undefined }> = ({ optional }) => { + if (optional) { + return null; + } + return  *; +}; + +export default RequiredIcon; diff --git a/web/components/layout/side-bar.tsx b/web/components/layout/side-bar.tsx index 5d14823d8..e9e26345a 100644 --- a/web/components/layout/side-bar.tsx +++ b/web/components/layout/side-bar.tsx @@ -16,6 +16,7 @@ import Icon, { MenuOutlined, SettingOutlined, BuildOutlined, + ForkOutlined, } from '@ant-design/icons'; import { Modal, message, Tooltip, Dropdown } from 'antd'; import { ItemType } from 'antd/es/menu/hooks/useItems'; @@ -42,7 +43,7 @@ type RouteItem = { }; function menuItemStyle(active?: boolean) { - return `flex items-center h-12 hover:bg-[#F1F5F9] dark:hover:bg-theme-dark text-base w-full transition-colors whitespace-nowrap px-4 ${ + return `flex items-center h-10 hover:bg-[#F1F5F9] dark:hover:bg-theme-dark text-base w-full transition-colors whitespace-nowrap px-4 ${ active ? 'bg-[#F1F5F9] dark:bg-theme-dark' : '' }`; } @@ -64,10 +65,10 @@ function SideBar() { const routes = useMemo(() => { const items: RouteItem[] = [ { - key: 'prompt', - name: t('Prompt'), - icon: , - path: '/prompt', + key: 'flow', + name: t('awel_flow'), + icon: , + path: '/flow', }, { key: 'database', @@ -93,6 +94,12 @@ function SideBar() { path: '/agent', icon: , }, + { + key: 'prompt', + name: t('Prompt'), + icon: , + path: '/prompt', + }, ]; return items; }, [i18n.language]); @@ -318,7 +325,7 @@ function SideBar() { {/* Settings */}
-
+
{routes.map((item) => ( <> diff --git a/web/package-lock.json b/web/package-lock.json index 6f7f7899e..4fcaf62ca 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -34,6 +34,7 @@ "react-i18next": "^13.2.0", "react-markdown": "^8.0.7", "react-syntax-highlighter": "^15.5.0", + "reactflow": "^11.10.2", "rehype-raw": "6.1.1", "remark-gfm": "^3.0.1", "sql-formatter": "^12.2.4", @@ -2427,6 +2428,102 @@ "react-dom": ">=16.9.0" } }, + "node_modules/@reactflow/background": { + "version": "11.3.7", + "resolved": "https://registry.npmmirror.com/@reactflow/background/-/background-11.3.7.tgz", + "integrity": "sha512-PhkvoFtO/NXJgFtBvfbPwdR/6/dl25egQlFhKWS3T4aYa7rh80dvf6dF3t6+JXJS4q5ToYJizD2/n8/qylo1yQ==", + "dependencies": { + "@reactflow/core": "11.10.2", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/controls": { + "version": "11.2.7", + "resolved": "https://registry.npmmirror.com/@reactflow/controls/-/controls-11.2.7.tgz", + "integrity": "sha512-mugzVALH/SuKlVKk+JCRm1OXQ+p8e9+k8PCTIaqL+nBl+lPF8KA4uMm8ApsOvhuSAb2A80ezewpyvYHr0qSYVA==", + "dependencies": { + "@reactflow/core": "11.10.2", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/core": { + "version": "11.10.2", + "resolved": "https://registry.npmmirror.com/@reactflow/core/-/core-11.10.2.tgz", + "integrity": "sha512-/cbTxtFpfkIGReSVkcnQhS4Jx4VFY2AhPlJ5n0sbPtnR7OWowF9zodh5Yyzr4j1NOUoBgJ9h+UqGEwwY2dbAlw==", + "dependencies": { + "@types/d3": "^7.4.0", + "@types/d3-drag": "^3.0.1", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/minimap": { + "version": "11.7.7", + "resolved": "https://registry.npmmirror.com/@reactflow/minimap/-/minimap-11.7.7.tgz", + "integrity": "sha512-Pwqw31tJ663cJur6ypqyJU33nPckvTepmz96erdQZoHsfOyLmFj4nXT7afC30DJ48lp0nfNsw+028mlf7f/h4g==", + "dependencies": { + "@reactflow/core": "11.10.2", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-resizer": { + "version": "2.2.7", + "resolved": "https://registry.npmmirror.com/@reactflow/node-resizer/-/node-resizer-2.2.7.tgz", + "integrity": "sha512-BMBstmWNiklHnnAjHu8irkiPQ8/k8nnjzqlTql4acbVhD6Tsdxx/t/saOkELmfQODqGZNiPw9+pHcAHgtE6oNQ==", + "dependencies": { + "@reactflow/core": "11.10.2", + "classcat": "^5.0.4", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-toolbar": { + "version": "1.3.7", + "resolved": "https://registry.npmmirror.com/@reactflow/node-toolbar/-/node-toolbar-1.3.7.tgz", + "integrity": "sha512-75moEQKg23YKA3A2DNSFhq719ZPmby5mpwOD+NO7ZffJ88oMS/2eY8l8qpA3hvb1PTBHDxyKazhJirW+f4t0Wg==", + "dependencies": { + "@reactflow/core": "11.10.2", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, "node_modules/@rushstack/eslint-patch": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.3.3.tgz", @@ -2446,11 +2543,227 @@ "resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.4.2.tgz", "integrity": "sha512-gbiHvCuBS9aXkE3OEDfS69bscNLTYtbbx2TQf6WyOu+4eCH1AH1gPSiDGF2UzwkRFAbqKNsC5F0mY0xcaEHCbg==" }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmmirror.com/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz", + "integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-force/-/d3-force-3.0.9.tgz", + "integrity": "sha512-IKtvyFdb4Q0LWna6ymywQsEYjK/94SGhPrMfEr1TIc5OBeziTi+1jcCvttts8e0UWZIxpasjnQk9MNk/3iS+kA==" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.6", + "resolved": "https://registry.npmmirror.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.6.tgz", + "integrity": "sha512-qlmD/8aMk5xGorUvTUWHCiumvgaUXYldYjNVOWtYoTYY/L+WwIEAmJxUmTgr9LoGNG0PPAOmqMDJVDPc7DOpPw==" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.0.2.tgz", + "integrity": "sha512-WAIEVlOCdd/NKRYTsqCpOMHQHemKBEINf8YXMYOtXH0GA7SY0dqMB78P3Uhgfy+4X+/Mlw2wDtlETkN6kQUCMA==" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.8.tgz", + "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.3.tgz", + "integrity": "sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.10", + "resolved": "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.10.tgz", + "integrity": "sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.6", + "resolved": "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.6.tgz", + "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.3.tgz", + "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==" + }, "node_modules/@types/d3-timer": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-2.0.1.tgz", - "integrity": "sha512-TF8aoF5cHcLO7W7403blM7L1T+6NF3XMyN3fxyUolq2uOcFeicG/khQg/dGxiCJWoAcmYulYN7LYSRKO54IXaA==", - "peer": true + "integrity": "sha512-TF8aoF5cHcLO7W7403blM7L1T+6NF3XMyN3fxyUolq2uOcFeicG/khQg/dGxiCJWoAcmYulYN7LYSRKO54IXaA==" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.8", + "resolved": "https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.8.tgz", + "integrity": "sha512-ew63aJfQ/ms7QQ4X7pk5NxQ9fZH/z+i24ZfJ6tJSfqxJMrYLiK01EAs2/Rtw/JreGUsS3pLPNV644qXFGnoZNQ==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmmirror.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } }, "node_modules/@types/debug": { "version": "4.1.8", @@ -2460,6 +2773,11 @@ "@types/ms": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.13", + "resolved": "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.13.tgz", + "integrity": "sha512-bmrNrgKMOhM3WsafmbGmC+6dsF2Z308vLFsQ3a/bT8X8Sv5clVYpPars/UPq+sAaJP+5OoLAYgwbkS5QEJdLUQ==" + }, "node_modules/@types/hast": { "version": "2.3.5", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.5.tgz", @@ -3420,6 +3738,11 @@ "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz", "integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A==" }, + "node_modules/classcat": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/classcat/-/classcat-5.0.4.tgz", + "integrity": "sha512-sbpkOw6z413p+HDGcBENe498WM9woqWHiJxCq7nvmxe9WmrUmqfAcxpIwAiMtM5Q3AhYkzXcNQHqsWq0mND51g==" + }, "node_modules/classnames": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", @@ -3639,6 +3962,18 @@ "node": ">=12" } }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-dsv": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", @@ -3756,6 +4091,14 @@ "node": ">=12" } }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -3772,11 +4115,44 @@ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==" }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, "node_modules/d3-voronoi": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz", "integrity": "sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==" }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/dagre": { "version": "0.8.5", "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", @@ -8565,6 +8941,23 @@ "react-dom": ">=16.6.0" } }, + "node_modules/reactflow": { + "version": "11.10.2", + "resolved": "https://registry.npmmirror.com/reactflow/-/reactflow-11.10.2.tgz", + "integrity": "sha512-tqQJfPEiIkXonT3piVYf+F9CvABI5e28t5I6rpaLTnO8YVCAOh1h0f+ziDKz0Bx9Y2B/mFgyz+H7LZeUp/+lhQ==", + "dependencies": { + "@reactflow/background": "11.3.7", + "@reactflow/controls": "11.2.7", + "@reactflow/core": "11.10.2", + "@reactflow/minimap": "11.7.7", + "@reactflow/node-resizer": "2.2.7", + "@reactflow/node-toolbar": "1.3.7" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -9322,6 +9715,11 @@ "resolved": "https://registry.npmjs.org/svg-path-parser/-/svg-path-parser-1.1.0.tgz", "integrity": "sha512-jGCUqcQyXpfe38R7RFfhrMyfXcBmpMNJI/B+4CE9/Unkh98UporAc461GTthv+TVDuZXsBx7/WiwJb1Oh4tt4A==" }, + "node_modules/tailwind-scrollbar-hide": { + "version": "1.1.7", + "resolved": "https://registry.npmmirror.com/tailwind-scrollbar-hide/-/tailwind-scrollbar-hide-1.1.7.tgz", + "integrity": "sha512-X324n9OtpTmOMqEgDUEA/RgLrNfBF/jwJdctaPZDzB3mppxJk7TLIDmOreEDm1Bq4R9LSPu4Epf8VSdovNU+iA==" + }, "node_modules/tailwindcss": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz", @@ -9868,6 +10266,14 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -10147,6 +10553,33 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zustand": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.0.tgz", + "integrity": "sha512-zlVFqS5TQ21nwijjhJlx4f9iGrXSL0o/+Dpy4txAP22miJ8Ti6c1Ol1RLNN98BMib83lmDH/2KmLwaNXpjrO1A==", + "dependencies": { + "use-sync-external-store": "1.2.0" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/web/package.json b/web/package.json index 48af5150e..afe9ef82d 100644 --- a/web/package.json +++ b/web/package.json @@ -37,6 +37,7 @@ "react-i18next": "^13.2.0", "react-markdown": "^8.0.7", "react-syntax-highlighter": "^15.5.0", + "reactflow": "^11.10.2", "rehype-raw": "6.1.1", "remark-gfm": "^3.0.1", "sql-formatter": "^12.2.4", diff --git a/web/pages/flow/canvas/index.tsx b/web/pages/flow/canvas/index.tsx new file mode 100644 index 000000000..d968c99c7 --- /dev/null +++ b/web/pages/flow/canvas/index.tsx @@ -0,0 +1,213 @@ +import { addFlow, apiInterceptors, getFlowById, updateFlowById } from '@/client/api'; +import MuiLoading from '@/components/common/loading'; +import AddNodes from '@/components/flow/add-nodes'; +import ButtonEdge from '@/components/flow/button-edge'; +import CanvasNode from '@/components/flow/canvas-node'; +import { IFlowData } from '@/types/flow'; +import { getUniqueNodeId, mapHumpToUnderline } from '@/utils/flow'; +import { SaveOutlined } from '@ant-design/icons'; +import { Divider, Input, Modal, message } from 'antd'; +import { useSearchParams } from 'next/navigation'; +import React, { DragEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import ReactFlow, { Background, Connection, Controls, ReactFlowProvider, addEdge, useEdgesState, useNodesState, useReactFlow, Node } from 'reactflow'; +import 'reactflow/dist/style.css'; + +interface Props { + // Define your component props here +} +const nodeTypes = { customNode: CanvasNode }; +const edgeTypes = { buttonedge: ButtonEdge }; + +const Canvas: React.FC = () => { + const { t } = useTranslation(); + const [messageApi, contextHolder] = message.useMessage(); + const searchParams = useSearchParams(); + const id = searchParams?.get('id') || ''; + const reactFlow = useReactFlow(); + + const [loading, setLoading] = useState(false); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const reactFlowWrapper = useRef(null); + const [isModalVisible, setIsModalVisible] = useState(false); + const [name, setName] = useState(''); + + async function getFlowData() { + setLoading(true); + const [_, data] = await apiInterceptors(getFlowById(id)); + if (data) { + const flowData = mapHumpToUnderline(data.flow_data); + setName(data.name); + setNodes(flowData.nodes.map((node) => ({ ...node, type: 'customNode' }))); + setEdges(flowData.edges.map((edge) => ({ ...edge, type: 'buttonedge' }))); + } + setLoading(false); + } + + useEffect(() => { + id && getFlowData(); + }, [id]); + + function onNodesClick(event: any, clickedNode: Node) { + reactFlow.setNodes((nds) => + nds.map((node) => { + if (node.id === clickedNode.id) { + node.data = { + ...node.data, + selected: true, + }; + } else { + node.data = { + ...node.data, + selected: false, + }; + } + return node; + }), + ); + } + + function onConnect(connection: Connection) { + const newEdge = { + ...connection, + type: 'buttonedge', + id: `${connection.source}|${connection.target}`, + }; + setEdges((eds) => addEdge(newEdge, eds)); + } + + const onDrop = useCallback( + (event: DragEvent) => { + event.preventDefault(); + const reactFlowBounds = reactFlowWrapper.current!.getBoundingClientRect(); + let nodeStr = event.dataTransfer.getData('application/reactflow'); + if (!nodeStr || typeof nodeStr === 'undefined') { + return; + } + const nodeData = JSON.parse(nodeStr); + const position = reactFlow.screenToFlowPosition({ + x: event.clientX - reactFlowBounds.left, + y: event.clientY - reactFlowBounds.top, + }); + const nodeId = getUniqueNodeId(nodeData, reactFlow.getNodes()); + const newNode = { + id: nodeId, + position, + type: 'customNode', + data: nodeData, + }; + setNodes((nds) => + nds.concat(newNode).map((node) => { + if (node.id === newNode.id) { + node.data = { + ...node.data, + selected: true, + }; + } else { + node.data = { + ...node.data, + selected: false, + }; + } + return node; + }), + ); + }, + [reactFlow], + ); + + const onDragOver = useCallback((event: DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + }, []); + + function clickSave() { + if (id) { + handleSaveFlow(); + } else { + setIsModalVisible(true); + } + } + + async function handleSaveFlow() { + const reactFlowObject = mapHumpToUnderline(reactFlow.toObject() as IFlowData); + if (id) { + const [, , res] = await apiInterceptors(updateFlowById(id, { name, uid: id, flow_data: reactFlowObject })); + if (res?.success) { + messageApi.success(t('save_flow_success')); + } else if (res?.err_msg) { + messageApi.error(res?.err_msg); + } + } else { + if (!name) { + return messageApi.warning(t('flow_name_required')); + } + const [_, res] = await apiInterceptors(addFlow({ name, flow_data: reactFlowObject })); + if (res?.uid) { + messageApi.success(t('save_flow_success')); + const history = window.history; + history.pushState(null, '', `/flow/canvas?id=${res.uid}`); + } + setIsModalVisible(false); + } + } + + return ( +
+ +
+
+ +
+
+ +
+ + + + + +
+ { + setIsModalVisible(false); + }} + > + <> +

{t('flow_name')}

+ { + setName(e.target.value); + }} + /> + +
+ {contextHolder} +
+ ); +}; + +export default function CanvasWrapper() { + return ( + + + + ); +} diff --git a/web/pages/flow/index.tsx b/web/pages/flow/index.tsx new file mode 100644 index 000000000..f105b1f7e --- /dev/null +++ b/web/pages/flow/index.tsx @@ -0,0 +1,49 @@ +import { apiInterceptors, getFlows } from '@/client/api'; +import MuiLoading from '@/components/common/loading'; +import FlowCard from '@/components/flow/flow-card'; +import { IFlow } from '@/types/flow'; +import { PlusOutlined } from '@ant-design/icons'; +import { Button, Empty } from 'antd'; +import Link from 'next/link'; +import React, { useEffect, useState } from 'react'; + +function Flow() { + const [loading, setLoading] = useState(false); + const [flowList, setFlowList] = useState>([]); + + async function getFlowList() { + setLoading(true); + const [_, data] = await apiInterceptors(getFlows()); + setLoading(false); + setFlowList(data?.items ?? []); + } + + useEffect(() => { + getFlowList(); + }, []); + + function updateFlowList(uid: string) { + setFlowList((flows) => flows.filter((flow) => flow.uid !== uid)); + } + + return ( +
+ +
+ + + +
+
+ {flowList.map((flow) => ( + + ))} + {flowList.length === 0 && } +
+
+ ); +} + +export default Flow; diff --git a/web/public/icons/node/default_node_icon.svg b/web/public/icons/node/default_node_icon.svg new file mode 100644 index 000000000..6b9d9f553 --- /dev/null +++ b/web/public/icons/node/default_node_icon.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/web/public/icons/node/http_trigger.svg b/web/public/icons/node/http_trigger.svg new file mode 100644 index 000000000..6b9d9f553 --- /dev/null +++ b/web/public/icons/node/http_trigger.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/web/public/icons/node/llm_operator.svg b/web/public/icons/node/llm_operator.svg new file mode 100644 index 000000000..6b9d9f553 --- /dev/null +++ b/web/public/icons/node/llm_operator.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/web/public/icons/node/openai_llm_client.svg b/web/public/icons/node/openai_llm_client.svg new file mode 100644 index 000000000..6b9d9f553 --- /dev/null +++ b/web/public/icons/node/openai_llm_client.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/web/styles/globals.css b/web/styles/globals.css index f1a687edc..47685babc 100644 --- a/web/styles/globals.css +++ b/web/styles/globals.css @@ -18,6 +18,9 @@ body { color: #f7f7f7; background-color: #151622; } +.dark-sub-bg { + background-color: rgb(35, 38, 44); +} .ant-btn-primary { background-color: var(--antd-primary-color); @@ -48,7 +51,10 @@ table tr td { white-space: nowrap; text-overflow: ellipsis; } - +.scrollbar-default::-webkit-scrollbar { + display: block; + width: 6px; +} /* 自定义滚动条样式 */ ::-webkit-scrollbar { display: none; @@ -70,7 +76,7 @@ table tr td { color: white; } -:where(.css-dev-only-do-not-override-18iikkb).ant-form-item .ant-form-item-label>label { +:where(.css-dev-only-do-not-override-18iikkb).ant-form-item .ant-form-item-label > label { height: 36px; } @@ -78,4 +84,4 @@ table tr td { to { transform: rotate(360deg); } -} \ No newline at end of file +} diff --git a/web/types/flow.ts b/web/types/flow.ts new file mode 100644 index 000000000..a40345850 --- /dev/null +++ b/web/types/flow.ts @@ -0,0 +1,116 @@ +import { Node } from 'reactflow'; + +export type IFlowUpdateParam = { + name: string; + uid?: string; + flow_data: IFlowData; +}; + +export type IFlow = { + uid: string; + name: string; + flow_data: IFlowData; +}; + +export type IFlowResponse = { + items: Array; + total_count: number; + total_pages: number; + page: number; + page_size: number; +}; + +export type IFlowNodeParameter = { + id: string; + type_name: string; + type_cls: string; + label: string; + name: string; + category: string; + optional: boolean; + default?: any; + placeholder?: any; + description: string; + options?: any; + value: any; +}; + +export type IFlowNodeInput = { + type_name: string; + type_cls: string; + label: string; + name: string; + description: string; + id: string; + optional?: boolean | undefined; + value: any; +}; + +export type IFlowNodeOutput = { + type_name: string; + type_cls: string; + label: string; + name: string; + description: string; + id: string; + optional?: boolean | undefined; +}; + +export type IFlowNode = Node & { + type_name: string; + type_cls: string; + type: string; + label: string; + name: string; + description: string; + category: string; + flow_type: 'resource' | 'operator'; + icon?: string; + documentation_url?: null; + id: string; + tags?: any; + parameters: Array; + inputs: Array; + outputs: Array; + version: string; +}; + +interface Position { + x: number; + y: number; + zoom: number; +} + +// flodata, the data of the flow +export type IFlowDataNode = { + width: number; + height: number; + id: string; + position: Position; + position_absolute?: Position; + positionAbsolute?: Position; + data: IFlowNode; +}; + +export type IFlowDataEdge = { + source: string; + target: string; + source_handle?: string; + sourceHandle?: string; + target_handle?: string; + targetHandle?: string; + id: string; + type: string; +}; + +export type IFlowDataViewport = { + x: number; + y: number; + zoom: number; +}; + +export type IFlowData = { + nodes: Array; + edges: Array; + viewport: IFlowDataViewport; +}; diff --git a/web/utils/flow.ts b/web/utils/flow.ts new file mode 100644 index 000000000..e336e8b14 --- /dev/null +++ b/web/utils/flow.ts @@ -0,0 +1,71 @@ +import { IFlowData, IFlowNode } from '@/types/flow'; +import { Node } from 'reactflow'; + +export const getUniqueNodeId = (nodeData: IFlowNode, nodes: Node[]) => { + let count = 0; + nodes.forEach((node) => { + if (node.data.name === nodeData.name) { + count++; + } + }); + return `${nodeData.id}_${count}`; +}; + +// 驼峰转下划线,接口协议字段命名规范 +export const mapHumpToUnderline = (flowData: IFlowData) => { + /** + * sourceHandle -> source_handle, + * targetHandle -> target_handle, + * positionAbsolute -> position_absolute + */ + const { nodes, edges, ...rest } = flowData; + const newNodes = nodes.map((node) => { + const { positionAbsolute, ...rest } = node; + return { + position_absolute: positionAbsolute, + ...rest, + }; + }); + const newEdges = edges.map((edge) => { + const { sourceHandle, targetHandle, ...rest } = edge; + return { + source_handle: sourceHandle, + target_handle: targetHandle, + ...rest, + }; + }); + return { + nodes: newNodes, + edges: newEdges, + ...rest, + }; +}; + +export const mapUnderlineToHump = (flowData: IFlowData) => { + /** + * source_handle -> sourceHandle, + * target_handle -> targetHandle, + * position_absolute -> positionAbsolute + */ + const { nodes, edges, ...rest } = flowData; + const newNodes = nodes.map((node) => { + const { position_absolute, ...rest } = node; + return { + positionAbsolute: position_absolute, + ...rest, + }; + }); + const newEdges = edges.map((edge) => { + const { source_handle, target_handle, ...rest } = edge; + return { + sourceHandle: source_handle, + targetHandle: target_handle, + ...rest, + }; + }); + return { + nodes: newNodes, + edges: newEdges, + ...rest, + }; +};