diff --git a/web/src/hooks/logic-hooks/navigate-hooks.ts b/web/src/hooks/logic-hooks/navigate-hooks.ts
index c09a01ab1..ef6f1559e 100644
--- a/web/src/hooks/logic-hooks/navigate-hooks.ts
+++ b/web/src/hooks/logic-hooks/navigate-hooks.ts
@@ -42,9 +42,12 @@ export const useNavigatePage = () => {
navigate(Routes.Agents);
}, [navigate]);
- const navigateToAgent = useCallback(() => {
- navigate(Routes.Agent);
- }, [navigate]);
+ const navigateToAgent = useCallback(
+ (id: string) => () => {
+ navigate(`${Routes.Agent}/${id}`);
+ },
+ [navigate],
+ );
const navigateToAgentTemplates = useCallback(() => {
navigate(Routes.AgentTemplates);
diff --git a/web/src/pages/agent/canvas/context-menu/index.less b/web/src/pages/agent/canvas/context-menu/index.less
new file mode 100644
index 000000000..5594aa912
--- /dev/null
+++ b/web/src/pages/agent/canvas/context-menu/index.less
@@ -0,0 +1,18 @@
+.contextMenu {
+ background: rgba(255, 255, 255, 0.1);
+ border-style: solid;
+ box-shadow: 10px 19px 20px rgba(0, 0, 0, 10%);
+ position: absolute;
+ z-index: 10;
+ button {
+ border: none;
+ display: block;
+ padding: 0.5em;
+ text-align: left;
+ width: 100%;
+ }
+
+ button:hover {
+ background: rgba(255, 255, 255, 0.1);
+ }
+}
diff --git a/web/src/pages/agent/canvas/context-menu/index.tsx b/web/src/pages/agent/canvas/context-menu/index.tsx
new file mode 100644
index 000000000..6cb306af9
--- /dev/null
+++ b/web/src/pages/agent/canvas/context-menu/index.tsx
@@ -0,0 +1,107 @@
+import { NodeMouseHandler, useReactFlow } from '@xyflow/react';
+import { useCallback, useRef, useState } from 'react';
+
+import styles from './index.less';
+
+export interface INodeContextMenu {
+ id: string;
+ top: number;
+ left: number;
+ right?: number;
+ bottom?: number;
+ [key: string]: unknown;
+}
+
+export function NodeContextMenu({
+ id,
+ top,
+ left,
+ right,
+ bottom,
+ ...props
+}: INodeContextMenu) {
+ const { getNode, setNodes, addNodes, setEdges } = useReactFlow();
+
+ const duplicateNode = useCallback(() => {
+ const node = getNode(id);
+ const position = {
+ x: node?.position?.x || 0 + 50,
+ y: node?.position?.y || 0 + 50,
+ };
+
+ addNodes({
+ ...(node || {}),
+ data: node?.data,
+ selected: false,
+ dragging: false,
+ id: `${node?.id}-copy`,
+ position,
+ });
+ }, [id, getNode, addNodes]);
+
+ const deleteNode = useCallback(() => {
+ setNodes((nodes) => nodes.filter((node) => node.id !== id));
+ setEdges((edges) => edges.filter((edge) => edge.source !== id));
+ }, [id, setNodes, setEdges]);
+
+ return (
+
+
+ node: {id}
+
+
+
+
+ );
+}
+
+/* @deprecated
+ */
+export const useHandleNodeContextMenu = (sideWidth: number) => {
+ const [menu, setMenu] = useState({} as INodeContextMenu);
+ const ref = useRef(null);
+
+ const onNodeContextMenu: NodeMouseHandler = useCallback(
+ (event, node) => {
+ // Prevent native context menu from showing
+ event.preventDefault();
+
+ // Calculate position of the context menu. We want to make sure it
+ // doesn't get positioned off-screen.
+ const pane = ref.current?.getBoundingClientRect();
+ // setMenu({
+ // id: node.id,
+ // top: event.clientY < pane.height - 200 ? event.clientY : 0,
+ // left: event.clientX < pane.width - 200 ? event.clientX : 0,
+ // right: event.clientX >= pane.width - 200 ? pane.width - event.clientX : 0,
+ // bottom:
+ // event.clientY >= pane.height - 200 ? pane.height - event.clientY : 0,
+ // });
+
+ setMenu({
+ id: node.id,
+ top: event.clientY - 144,
+ left: event.clientX - sideWidth,
+ // top: event.clientY < pane.height - 200 ? event.clientY - 72 : 0,
+ // left: event.clientX < pane.width - 200 ? event.clientX : 0,
+ });
+ },
+ [sideWidth],
+ );
+
+ // Close the context menu if it's open whenever the window is clicked.
+ const onPaneClick = useCallback(
+ () => setMenu({} as INodeContextMenu),
+ [setMenu],
+ );
+
+ return { onNodeContextMenu, menu, onPaneClick, ref };
+};
diff --git a/web/src/pages/agent/canvas/edge/index.less b/web/src/pages/agent/canvas/edge/index.less
new file mode 100644
index 000000000..281b67251
--- /dev/null
+++ b/web/src/pages/agent/canvas/edge/index.less
@@ -0,0 +1,31 @@
+.edgeButton {
+ width: 14px;
+ height: 14px;
+ background: #eee;
+ border: 1px solid #fff;
+ padding: 0;
+ cursor: pointer;
+ border-radius: 50%;
+ font-size: 10px;
+ line-height: 1;
+}
+
+.edgeButton:hover {
+ box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.08);
+}
+
+.edgeButtonDark {
+ width: 14px;
+ height: 14px;
+ background: #0e0c0c;
+ border: 1px solid #fff;
+ padding: 0;
+ cursor: pointer;
+ border-radius: 50%;
+ font-size: 10px;
+ line-height: 1;
+}
+
+.edgeButtonDark:hover {
+ box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.08);
+}
diff --git a/web/src/pages/agent/canvas/edge/index.tsx b/web/src/pages/agent/canvas/edge/index.tsx
new file mode 100644
index 000000000..52f939b8d
--- /dev/null
+++ b/web/src/pages/agent/canvas/edge/index.tsx
@@ -0,0 +1,108 @@
+import {
+ BaseEdge,
+ EdgeLabelRenderer,
+ EdgeProps,
+ getBezierPath,
+} from '@xyflow/react';
+import useGraphStore from '../../store';
+
+import { useTheme } from '@/components/theme-provider';
+import { useFetchFlow } from '@/hooks/flow-hooks';
+import { useMemo } from 'react';
+import styles from './index.less';
+
+export function ButtonEdge({
+ id,
+ sourceX,
+ sourceY,
+ targetX,
+ targetY,
+ sourcePosition,
+ targetPosition,
+ source,
+ target,
+ style = {},
+ markerEnd,
+ selected,
+}: EdgeProps) {
+ const deleteEdgeById = useGraphStore((state) => state.deleteEdgeById);
+ const [edgePath, labelX, labelY] = getBezierPath({
+ sourceX,
+ sourceY,
+ sourcePosition,
+ targetX,
+ targetY,
+ targetPosition,
+ });
+ const { theme } = useTheme();
+ const selectedStyle = useMemo(() => {
+ return selected ? { strokeWidth: 2, stroke: '#1677ff' } : {};
+ }, [selected]);
+
+ const onEdgeClick = () => {
+ deleteEdgeById(id);
+ };
+
+ // highlight the nodes that the workflow passes through
+ const { data: flowDetail } = useFetchFlow();
+
+ const graphPath = useMemo(() => {
+ // TODO: this will be called multiple times
+ const path = flowDetail?.dsl?.path ?? [];
+ // The second to last
+ const previousGraphPath: string[] = path.at(-2) ?? [];
+ let graphPath: string[] = path.at(-1) ?? [];
+ // The last of the second to last article
+ const previousLatestElement = previousGraphPath.at(-1);
+ if (previousGraphPath.length > 0 && previousLatestElement) {
+ graphPath = [previousLatestElement, ...graphPath];
+ }
+ return graphPath;
+ }, [flowDetail.dsl?.path]);
+
+ const highlightStyle = useMemo(() => {
+ const idx = graphPath.findIndex((x) => x === source);
+ if (idx !== -1) {
+ // The set of elements following source
+ const slicedGraphPath = graphPath.slice(idx + 1);
+ if (slicedGraphPath.some((x) => x === target)) {
+ return { strokeWidth: 2, stroke: 'red' };
+ }
+ }
+ return {};
+ }, [source, target, graphPath]);
+
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/web/src/pages/agent/canvas/index.less b/web/src/pages/agent/canvas/index.less
new file mode 100644
index 000000000..d824d88f1
--- /dev/null
+++ b/web/src/pages/agent/canvas/index.less
@@ -0,0 +1,10 @@
+.canvasWrapper {
+ position: relative;
+ height: 100%;
+ :global(.react-flow__node-group) {
+ .commonNode();
+ padding: 0;
+ border: 0;
+ background-color: transparent;
+ }
+}
diff --git a/web/src/pages/agent/canvas/index.tsx b/web/src/pages/agent/canvas/index.tsx
new file mode 100644
index 000000000..80129c28c
--- /dev/null
+++ b/web/src/pages/agent/canvas/index.tsx
@@ -0,0 +1,237 @@
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+import {
+ Background,
+ ConnectionMode,
+ ControlButton,
+ Controls,
+ NodeTypes,
+ ReactFlow,
+} from '@xyflow/react';
+import '@xyflow/react/dist/style.css';
+import { Book, FolderInput, FolderOutput } from 'lucide-react';
+// import ChatDrawer from '../chat/drawer';
+// import FormDrawer from '../flow-drawer';
+import {
+ useHandleDrop,
+ useSelectCanvasData,
+ useValidateConnection,
+ useWatchNodeFormDataChange,
+} from '../hooks';
+import { useBeforeDelete } from '../hooks/use-before-delete';
+import { useHandleExportOrImportJsonFile } from '../hooks/use-export-json';
+import { useOpenDocument } from '../hooks/use-open-document';
+import { useShowDrawer } from '../hooks/use-show-drawer';
+// import JsonUploadModal from '../json-upload-modal';
+// import RunDrawer from '../run-drawer';
+import { ButtonEdge } from './edge';
+import styles from './index.less';
+import { RagNode } from './node';
+import { BeginNode } from './node/begin-node';
+import { CategorizeNode } from './node/categorize-node';
+import { EmailNode } from './node/email-node';
+import { GenerateNode } from './node/generate-node';
+import { InvokeNode } from './node/invoke-node';
+import { IterationNode, IterationStartNode } from './node/iteration-node';
+import { KeywordNode } from './node/keyword-node';
+import { LogicNode } from './node/logic-node';
+import { MessageNode } from './node/message-node';
+import NoteNode from './node/note-node';
+import { RelevantNode } from './node/relevant-node';
+import { RetrievalNode } from './node/retrieval-node';
+import { RewriteNode } from './node/rewrite-node';
+import { SwitchNode } from './node/switch-node';
+import { TemplateNode } from './node/template-node';
+
+const nodeTypes: NodeTypes = {
+ ragNode: RagNode,
+ categorizeNode: CategorizeNode,
+ beginNode: BeginNode,
+ relevantNode: RelevantNode,
+ logicNode: LogicNode,
+ noteNode: NoteNode,
+ switchNode: SwitchNode,
+ generateNode: GenerateNode,
+ retrievalNode: RetrievalNode,
+ messageNode: MessageNode,
+ rewriteNode: RewriteNode,
+ keywordNode: KeywordNode,
+ invokeNode: InvokeNode,
+ templateNode: TemplateNode,
+ emailNode: EmailNode,
+ group: IterationNode,
+ iterationStartNode: IterationStartNode,
+};
+
+const edgeTypes = {
+ buttonEdge: ButtonEdge,
+};
+
+interface IProps {
+ drawerVisible: boolean;
+ hideDrawer(): void;
+}
+
+function FlowCanvas({ drawerVisible, hideDrawer }: IProps) {
+ const {
+ nodes,
+ edges,
+ onConnect,
+ onEdgesChange,
+ onNodesChange,
+ onSelectionChange,
+ } = useSelectCanvasData();
+ const isValidConnection = useValidateConnection();
+
+ const { onDrop, onDragOver, setReactFlowInstance } = useHandleDrop();
+
+ const {
+ handleExportJson,
+ handleImportJson,
+ fileUploadVisible,
+ onFileUploadOk,
+ hideFileUploadModal,
+ } = useHandleExportOrImportJsonFile();
+
+ const openDocument = useOpenDocument();
+
+ const {
+ onNodeClick,
+ onPaneClick,
+ clickedNode,
+ formDrawerVisible,
+ hideFormDrawer,
+ singleDebugDrawerVisible,
+ hideSingleDebugDrawer,
+ showSingleDebugDrawer,
+ chatVisible,
+ runVisible,
+ hideRunOrChatDrawer,
+ showChatModal,
+ } = useShowDrawer({
+ drawerVisible,
+ hideDrawer,
+ });
+
+ const { handleBeforeDelete } = useBeforeDelete();
+
+ useWatchNodeFormDataChange();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Import
+
+
+
+
+
+
+
+ Export
+
+
+
+
+
+
+
+ Document
+
+
+
+
+ {/* {formDrawerVisible && (
+
+ )} */}
+ {/* {chatVisible && (
+
+ )}
+
+ {runVisible && (
+
+ )}
+ {fileUploadVisible && (
+
+ )} */}
+
+ );
+}
+
+export default FlowCanvas;
diff --git a/web/src/pages/agent/canvas/node/begin-node.tsx b/web/src/pages/agent/canvas/node/begin-node.tsx
new file mode 100644
index 000000000..83e36652b
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/begin-node.tsx
@@ -0,0 +1,72 @@
+import { useTheme } from '@/components/theme-provider';
+import { IBeginNode } from '@/interfaces/database/flow';
+import { Handle, NodeProps, Position } from '@xyflow/react';
+import { Flex } from 'antd';
+import classNames from 'classnames';
+import get from 'lodash/get';
+import { useTranslation } from 'react-i18next';
+import {
+ BeginQueryType,
+ BeginQueryTypeIconMap,
+ Operator,
+ operatorMap,
+} from '../../constant';
+import { BeginQuery } from '../../interface';
+import OperatorIcon from '../../operator-icon';
+import { RightHandleStyle } from './handle-icon';
+import styles from './index.less';
+
+// TODO: do not allow other nodes to connect to this node
+export function BeginNode({ selected, data }: NodeProps) {
+ const { t } = useTranslation();
+ const query: BeginQuery[] = get(data, 'form.query', []);
+ const { theme } = useTheme();
+ return (
+
+
+
+
+
+
+ {t(`flow.begin`)}
+
+
+
+ {query.map((x, idx) => {
+ const Icon = BeginQueryTypeIconMap[x.type as BeginQueryType];
+ return (
+
+
+
+ {x.name}
+ {x.optional ? 'Yes' : 'No'}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/web/src/pages/agent/canvas/node/card.tsx b/web/src/pages/agent/canvas/node/card.tsx
new file mode 100644
index 000000000..042ca45e0
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/card.tsx
@@ -0,0 +1,57 @@
+import { Button } from '@/components/ui/button';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+
+export function CardWithForm() {
+ return (
+
+
+ Create project
+ Deploy your new project in one-click.
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/pages/agent/canvas/node/categorize-handle.tsx b/web/src/pages/agent/canvas/node/categorize-handle.tsx
new file mode 100644
index 000000000..ce1fc3624
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/categorize-handle.tsx
@@ -0,0 +1,40 @@
+import { Handle, Position } from '@xyflow/react';
+
+import React from 'react';
+import styles from './index.less';
+
+const DEFAULT_HANDLE_STYLE = {
+ width: 6,
+ height: 6,
+ bottom: -5,
+ fontSize: 8,
+};
+
+interface IProps extends React.PropsWithChildren {
+ top: number;
+ right: number;
+ id: string;
+ idx?: number;
+}
+
+const CategorizeHandle = ({ top, right, id, children }: IProps) => {
+ return (
+
+ {children || id}
+
+ );
+};
+
+export default CategorizeHandle;
diff --git a/web/src/pages/agent/canvas/node/categorize-node.tsx b/web/src/pages/agent/canvas/node/categorize-node.tsx
new file mode 100644
index 000000000..18c3cdff0
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/categorize-node.tsx
@@ -0,0 +1,68 @@
+import LLMLabel from '@/components/llm-select/llm-label';
+import { useTheme } from '@/components/theme-provider';
+import { ICategorizeNode } from '@/interfaces/database/flow';
+import { Handle, NodeProps, Position } from '@xyflow/react';
+import { Flex } from 'antd';
+import classNames from 'classnames';
+import { get } from 'lodash';
+import { RightHandleStyle } from './handle-icon';
+import { useBuildCategorizeHandlePositions } from './hooks';
+import styles from './index.less';
+import NodeHeader from './node-header';
+
+export function CategorizeNode({
+ id,
+ data,
+ selected,
+}: NodeProps) {
+ const { positions } = useBuildCategorizeHandlePositions({ data, id });
+ const { theme } = useTheme();
+ return (
+
+
+
+
+
+
+
+
+
+ {positions.map((position, idx) => {
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/web/src/pages/agent/canvas/node/dropdown.tsx b/web/src/pages/agent/canvas/node/dropdown.tsx
new file mode 100644
index 000000000..dd5263abc
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/dropdown.tsx
@@ -0,0 +1,58 @@
+import OperateDropdown from '@/components/operate-dropdown';
+import { CopyOutlined } from '@ant-design/icons';
+import { Flex, MenuProps } from 'antd';
+import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Operator } from '../../constant';
+import { useDuplicateNode } from '../../hooks';
+import useGraphStore from '../../store';
+
+interface IProps {
+ id: string;
+ iconFontColor?: string;
+ label: string;
+}
+
+const NodeDropdown = ({ id, iconFontColor, label }: IProps) => {
+ const { t } = useTranslation();
+ const deleteNodeById = useGraphStore((store) => store.deleteNodeById);
+ const deleteIterationNodeById = useGraphStore(
+ (store) => store.deleteIterationNodeById,
+ );
+
+ const deleteNode = useCallback(() => {
+ if (label === Operator.Iteration) {
+ deleteIterationNodeById(id);
+ } else {
+ deleteNodeById(id);
+ }
+ }, [label, deleteIterationNodeById, id, deleteNodeById]);
+
+ const duplicateNode = useDuplicateNode();
+
+ const items: MenuProps['items'] = [
+ {
+ key: '2',
+ onClick: () => duplicateNode(id, label),
+ label: (
+
+ {t('common.copy')}
+
+
+ ),
+ },
+ ];
+
+ return (
+
+ );
+};
+
+export default NodeDropdown;
diff --git a/web/src/pages/agent/canvas/node/email-node.tsx b/web/src/pages/agent/canvas/node/email-node.tsx
new file mode 100644
index 000000000..ae4af848c
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/email-node.tsx
@@ -0,0 +1,78 @@
+import { IEmailNode } from '@/interfaces/database/flow';
+import { Handle, NodeProps, Position } from '@xyflow/react';
+import { Flex } from 'antd';
+import classNames from 'classnames';
+import { useState } from 'react';
+import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
+import styles from './index.less';
+import NodeHeader from './node-header';
+
+export function EmailNode({
+ id,
+ data,
+ isConnectable = true,
+ selected,
+}: NodeProps) {
+ const [showDetails, setShowDetails] = useState(false);
+
+ return (
+
+
+
+
+
+
+ setShowDetails(!showDetails)}
+ >
+
+ SMTP:
+ {data.form?.smtp_server}
+
+
+ Port:
+ {data.form?.smtp_port}
+
+
+ From:
+ {data.form?.email}
+
+
{showDetails ? '▼' : '▶'}
+
+
+ {showDetails && (
+
+
Expected Input JSON:
+
+ {`{
+ "to_email": "...",
+ "cc_email": "...",
+ "subject": "...",
+ "content": "..."
+}`}
+
+
+ )}
+
+
+ );
+}
diff --git a/web/src/pages/agent/canvas/node/generate-node.tsx b/web/src/pages/agent/canvas/node/generate-node.tsx
new file mode 100644
index 000000000..255eccd99
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/generate-node.tsx
@@ -0,0 +1,57 @@
+import LLMLabel from '@/components/llm-select/llm-label';
+import { useTheme } from '@/components/theme-provider';
+import { IGenerateNode } from '@/interfaces/database/flow';
+import { Handle, NodeProps, Position } from '@xyflow/react';
+import classNames from 'classnames';
+import { get } from 'lodash';
+import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
+import styles from './index.less';
+import NodeHeader from './node-header';
+
+export function GenerateNode({
+ id,
+ data,
+ isConnectable = true,
+ selected,
+}: NodeProps) {
+ const { theme } = useTheme();
+ return (
+
+ );
+}
diff --git a/web/src/pages/agent/canvas/node/handle-icon.tsx b/web/src/pages/agent/canvas/node/handle-icon.tsx
new file mode 100644
index 000000000..36c7f3634
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/handle-icon.tsx
@@ -0,0 +1,20 @@
+import { PlusOutlined } from '@ant-design/icons';
+import { CSSProperties } from 'react';
+
+export const HandleIcon = () => {
+ return (
+
+ );
+};
+
+export const RightHandleStyle: CSSProperties = {
+ right: 0,
+};
+
+export const LeftHandleStyle: CSSProperties = {
+ left: 0,
+};
+
+export default HandleIcon;
diff --git a/web/src/pages/agent/canvas/node/hooks.ts b/web/src/pages/agent/canvas/node/hooks.ts
new file mode 100644
index 000000000..fbea8f166
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/hooks.ts
@@ -0,0 +1,104 @@
+import { useUpdateNodeInternals } from '@xyflow/react';
+import get from 'lodash/get';
+import { useEffect, useMemo } from 'react';
+import { SwitchElseTo } from '../../constant';
+
+import {
+ ICategorizeItemResult,
+ ISwitchCondition,
+ RAGFlowNodeType,
+} from '@/interfaces/database/flow';
+import { generateSwitchHandleText } from '../../utils';
+
+export const useBuildCategorizeHandlePositions = ({
+ data,
+ id,
+}: {
+ id: string;
+ data: RAGFlowNodeType['data'];
+}) => {
+ const updateNodeInternals = useUpdateNodeInternals();
+
+ const categoryData: ICategorizeItemResult = useMemo(() => {
+ return get(data, `form.category_description`, {});
+ }, [data]);
+
+ const positions = useMemo(() => {
+ const list: Array<{
+ text: string;
+ top: number;
+ idx: number;
+ }> = [];
+
+ Object.keys(categoryData)
+ .sort((a, b) => categoryData[a].index - categoryData[b].index)
+ .forEach((x, idx) => {
+ list.push({
+ text: x,
+ idx,
+ top: idx === 0 ? 98 + 20 : list[idx - 1].top + 8 + 26,
+ });
+ });
+
+ return list;
+ }, [categoryData]);
+
+ useEffect(() => {
+ updateNodeInternals(id);
+ }, [id, updateNodeInternals, categoryData]);
+
+ return { positions };
+};
+
+export const useBuildSwitchHandlePositions = ({
+ data,
+ id,
+}: {
+ id: string;
+ data: RAGFlowNodeType['data'];
+}) => {
+ const updateNodeInternals = useUpdateNodeInternals();
+
+ const conditions: ISwitchCondition[] = useMemo(() => {
+ return get(data, 'form.conditions', []);
+ }, [data]);
+
+ const positions = useMemo(() => {
+ const list: Array<{
+ text: string;
+ top: number;
+ idx: number;
+ condition?: ISwitchCondition;
+ }> = [];
+
+ [...conditions, ''].forEach((x, idx) => {
+ let top = idx === 0 ? 58 + 20 : list[idx - 1].top + 32; // case number (Case 1) height + flex gap
+ if (idx - 1 >= 0) {
+ const previousItems = conditions[idx - 1]?.items ?? [];
+ if (previousItems.length > 0) {
+ top += 12; // ConditionBlock padding
+ top += previousItems.length * 22; // condition variable height
+ top += (previousItems.length - 1) * 25; // operator height
+ }
+ }
+
+ list.push({
+ text:
+ idx < conditions.length
+ ? generateSwitchHandleText(idx)
+ : SwitchElseTo,
+ idx,
+ top,
+ condition: typeof x === 'string' ? undefined : x,
+ });
+ });
+
+ return list;
+ }, [conditions]);
+
+ useEffect(() => {
+ updateNodeInternals(id);
+ }, [id, updateNodeInternals, conditions]);
+
+ return { positions };
+};
diff --git a/web/src/pages/agent/canvas/node/index.less b/web/src/pages/agent/canvas/node/index.less
new file mode 100644
index 000000000..14d7e6077
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/index.less
@@ -0,0 +1,285 @@
+.dark {
+ background: rgb(63, 63, 63) !important;
+}
+.ragNode {
+ .commonNode();
+ .nodeName {
+ font-size: 10px;
+ color: black;
+ }
+ label {
+ display: block;
+ color: #777;
+ font-size: 12px;
+ }
+ .description {
+ font-size: 10px;
+ }
+
+ .categorizeAnchorPointText {
+ position: absolute;
+ top: -4px;
+ left: 8px;
+ white-space: nowrap;
+ }
+}
+
+@lightBackgroundColor: rgba(150, 150, 150, 0.1);
+@darkBackgroundColor: rgba(150, 150, 150, 0.2);
+
+.selectedNode {
+ border: 1.5px solid rgb(59, 118, 244);
+}
+
+.selectedIterationNode {
+ border-bottom: 1.5px solid rgb(59, 118, 244);
+ border-left: 1.5px solid rgb(59, 118, 244);
+ border-right: 1.5px solid rgb(59, 118, 244);
+}
+
+.iterationHeader {
+ .commonNodeShadow();
+}
+
+.selectedHeader {
+ border-top: 1.9px solid rgb(59, 118, 244);
+ border-left: 1.9px solid rgb(59, 118, 244);
+ border-right: 1.9px solid rgb(59, 118, 244);
+}
+
+.handle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 12px;
+ height: 12px;
+ background: rgb(59, 88, 253);
+ border: 1px solid white;
+ z-index: 1;
+ background-image: url('@/assets/svg/plus.svg');
+ background-size: cover;
+ background-position: center;
+}
+
+.jsonView {
+ word-wrap: break-word;
+ overflow: auto;
+ max-width: 300px;
+ max-height: 500px;
+}
+
+.logicNode {
+ .commonNode();
+
+ .nodeName {
+ font-size: 10px;
+ color: black;
+ }
+ label {
+ display: block;
+ color: #777;
+ font-size: 12px;
+ }
+
+ .description {
+ font-size: 10px;
+ }
+
+ .categorizeAnchorPointText {
+ position: absolute;
+ top: -4px;
+ left: 8px;
+ white-space: nowrap;
+ }
+ .relevantSourceLabel {
+ font-size: 10px;
+ }
+}
+
+.noteNode {
+ .commonNode();
+ min-width: 140px;
+ width: auto;
+ height: 100%;
+ padding: 8px;
+ border-radius: 10px;
+ min-height: 128px;
+ .noteTitle {
+ background-color: #edfcff;
+ font-size: 12px;
+ padding: 6px 6px 4px;
+ border-top-left-radius: 10px;
+ border-top-right-radius: 10px;
+ }
+ .noteTitleDark {
+ background-color: #edfcff;
+ font-size: 12px;
+ padding: 6px 6px 4px;
+ border-top-left-radius: 10px;
+ border-top-right-radius: 10px;
+ }
+ .noteForm {
+ margin-top: 4px;
+ height: calc(100% - 50px);
+ }
+ .noteName {
+ padding: 0px 4px;
+ }
+ .noteTextarea {
+ resize: none;
+ border: 0;
+ border-radius: 0;
+ height: 100%;
+ &:focus {
+ border: none;
+ box-shadow: none;
+ }
+ }
+}
+
+.iterationNode {
+ .commonNodeShadow();
+ border-bottom-left-radius: 10px;
+ border-bottom-right-radius: 10px;
+}
+
+.nodeText {
+ padding-inline: 0.4em;
+ padding-block: 0.2em 0.1em;
+ background: @lightBackgroundColor;
+ border-radius: 3px;
+ min-height: 22px;
+ .textEllipsis();
+}
+
+.nodeHeader {
+ padding-bottom: 12px;
+}
+
+.zeroDivider {
+ margin: 0 !important;
+}
+
+.conditionBlock {
+ border-radius: 4px;
+ padding: 6px;
+ background: @lightBackgroundColor;
+}
+
+.conditionLine {
+ border-radius: 4px;
+ padding: 0 4px;
+ background: @darkBackgroundColor;
+ .textEllipsis();
+}
+
+.conditionKey {
+ flex: 1;
+}
+
+.conditionOperator {
+ padding: 0 2px;
+ text-align: center;
+}
+
+.relevantLabel {
+ text-align: right;
+}
+
+.knowledgeNodeName {
+ .textEllipsis();
+}
+
+.messageNodeContainer {
+ overflow-y: auto;
+ max-height: 300px;
+}
+
+.generateParameters {
+ padding-top: 8px;
+ label {
+ flex: 2;
+ .textEllipsis();
+ }
+ .parameterValue {
+ flex: 3;
+ .conditionLine;
+ }
+}
+
+.emailNodeContainer {
+ padding: 8px;
+ font-size: 12px;
+
+ .emailConfig {
+ background: rgba(0, 0, 0, 0.02);
+ border-radius: 4px;
+ padding: 8px;
+ position: relative;
+ cursor: pointer;
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.04);
+ }
+
+ .configItem {
+ display: flex;
+ align-items: center;
+ margin-bottom: 4px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ .configLabel {
+ color: #666;
+ width: 45px;
+ flex-shrink: 0;
+ }
+
+ .configValue {
+ color: #333;
+ word-break: break-all;
+ }
+ }
+
+ .expandIcon {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: #666;
+ font-size: 12px;
+ }
+ }
+
+ .jsonExample {
+ background: #f5f5f5;
+ border-radius: 4px;
+ padding: 8px;
+ margin-top: 4px;
+ animation: slideDown 0.2s ease-out;
+
+ .jsonTitle {
+ color: #666;
+ margin-bottom: 4px;
+ }
+
+ .jsonContent {
+ margin: 0;
+ color: #333;
+ font-family: monospace;
+ }
+ }
+}
+
+@keyframes slideDown {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
diff --git a/web/src/pages/agent/canvas/node/index.tsx b/web/src/pages/agent/canvas/node/index.tsx
new file mode 100644
index 000000000..32191f5cc
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/index.tsx
@@ -0,0 +1,45 @@
+import { useTheme } from '@/components/theme-provider';
+import { IRagNode } from '@/interfaces/database/flow';
+import { Handle, NodeProps, Position } from '@xyflow/react';
+import classNames from 'classnames';
+import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
+import styles from './index.less';
+import NodeHeader from './node-header';
+
+export function RagNode({
+ id,
+ data,
+ isConnectable = true,
+ selected,
+}: NodeProps) {
+ const { theme } = useTheme();
+ return (
+
+ );
+}
diff --git a/web/src/pages/agent/canvas/node/invoke-node.tsx b/web/src/pages/agent/canvas/node/invoke-node.tsx
new file mode 100644
index 000000000..42d109f3d
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/invoke-node.tsx
@@ -0,0 +1,59 @@
+import { useTheme } from '@/components/theme-provider';
+import { IInvokeNode } from '@/interfaces/database/flow';
+import { Handle, NodeProps, Position } from '@xyflow/react';
+import { Flex } from 'antd';
+import classNames from 'classnames';
+import { get } from 'lodash';
+import { useTranslation } from 'react-i18next';
+import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
+import styles from './index.less';
+import NodeHeader from './node-header';
+
+export function InvokeNode({
+ id,
+ data,
+ isConnectable = true,
+ selected,
+}: NodeProps) {
+ const { t } = useTranslation();
+ const { theme } = useTheme();
+ const url = get(data, 'form.url');
+ return (
+
+
+
+
+
+ {t('flow.url')}
+ {url}
+
+
+ );
+}
diff --git a/web/src/pages/agent/canvas/node/iteration-node.tsx b/web/src/pages/agent/canvas/node/iteration-node.tsx
new file mode 100644
index 000000000..c15b4fc6c
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/iteration-node.tsx
@@ -0,0 +1,127 @@
+import { useTheme } from '@/components/theme-provider';
+import {
+ IIterationNode,
+ IIterationStartNode,
+} from '@/interfaces/database/flow';
+import { cn } from '@/lib/utils';
+import { Handle, NodeProps, NodeResizeControl, Position } from '@xyflow/react';
+import { ListRestart } from 'lucide-react';
+import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
+import styles from './index.less';
+import NodeHeader from './node-header';
+
+function ResizeIcon() {
+ return (
+
+ );
+}
+
+const controlStyle = {
+ background: 'transparent',
+ border: 'none',
+ cursor: 'nwse-resize',
+};
+
+export function IterationNode({
+ id,
+ data,
+ isConnectable = true,
+ selected,
+}: NodeProps) {
+ const { theme } = useTheme();
+
+ return (
+
+ );
+}
+
+export function IterationStartNode({
+ isConnectable = true,
+ selected,
+}: NodeProps) {
+ const { theme } = useTheme();
+
+ return (
+
+ );
+}
diff --git a/web/src/pages/agent/canvas/node/keyword-node.tsx b/web/src/pages/agent/canvas/node/keyword-node.tsx
new file mode 100644
index 000000000..f607d4317
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/keyword-node.tsx
@@ -0,0 +1,57 @@
+import LLMLabel from '@/components/llm-select/llm-label';
+import { useTheme } from '@/components/theme-provider';
+import { IKeywordNode } from '@/interfaces/database/flow';
+import { Handle, NodeProps, Position } from '@xyflow/react';
+import classNames from 'classnames';
+import { get } from 'lodash';
+import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
+import styles from './index.less';
+import NodeHeader from './node-header';
+
+export function KeywordNode({
+ id,
+ data,
+ isConnectable = true,
+ selected,
+}: NodeProps) {
+ const { theme } = useTheme();
+ return (
+
+ );
+}
diff --git a/web/src/pages/agent/canvas/node/logic-node.tsx b/web/src/pages/agent/canvas/node/logic-node.tsx
new file mode 100644
index 000000000..28215617b
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/logic-node.tsx
@@ -0,0 +1,45 @@
+import { useTheme } from '@/components/theme-provider';
+import { ILogicNode } from '@/interfaces/database/flow';
+import { Handle, NodeProps, Position } from '@xyflow/react';
+import classNames from 'classnames';
+import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
+import styles from './index.less';
+import NodeHeader from './node-header';
+
+export function LogicNode({
+ id,
+ data,
+ isConnectable = true,
+ selected,
+}: NodeProps) {
+ const { theme } = useTheme();
+ return (
+
+ );
+}
diff --git a/web/src/pages/agent/canvas/node/message-node.tsx b/web/src/pages/agent/canvas/node/message-node.tsx
new file mode 100644
index 000000000..5b3a1736e
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/message-node.tsx
@@ -0,0 +1,65 @@
+import { useTheme } from '@/components/theme-provider';
+import { IMessageNode } from '@/interfaces/database/flow';
+import { Handle, NodeProps, Position } from '@xyflow/react';
+import { Flex } from 'antd';
+import classNames from 'classnames';
+import { get } from 'lodash';
+import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
+import styles from './index.less';
+import NodeHeader from './node-header';
+
+export function MessageNode({
+ id,
+ data,
+ isConnectable = true,
+ selected,
+}: NodeProps) {
+ const messages: string[] = get(data, 'form.messages', []);
+ const { theme } = useTheme();
+ return (
+
+
+
+ 0,
+ })}
+ >
+
+
+ {messages.map((message, idx) => {
+ return (
+
+ {message}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/web/src/pages/agent/canvas/node/node-header.tsx b/web/src/pages/agent/canvas/node/node-header.tsx
new file mode 100644
index 000000000..99a37dc1e
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/node-header.tsx
@@ -0,0 +1,73 @@
+import { useTranslate } from '@/hooks/common-hooks';
+import { Flex } from 'antd';
+import { Play } from 'lucide-react';
+import { Operator, operatorMap } from '../../constant';
+import OperatorIcon from '../../operator-icon';
+import { needsSingleStepDebugging } from '../../utils';
+import NodeDropdown from './dropdown';
+import { NextNodePopover } from './popover';
+
+import { RunTooltip } from '../../flow-tooltip';
+interface IProps {
+ id: string;
+ label: string;
+ name: string;
+ gap?: number;
+ className?: string;
+ wrapperClassName?: string;
+}
+
+const ExcludedRunStateOperators = [Operator.Answer];
+
+export function RunStatus({ id, name, label }: IProps) {
+ const { t } = useTranslate('flow');
+ return (
+
+ {needsSingleStepDebugging(label) && (
+
+
+ // data-play is used to trigger single step debugging
+ )}
+
+
+ {t('operationResults')}
+
+
+
+ );
+}
+
+const NodeHeader = ({
+ label,
+ id,
+ name,
+ gap = 4,
+ className,
+ wrapperClassName,
+}: IProps) => {
+ return (
+
+ {!ExcludedRunStateOperators.includes(label as Operator) && (
+
+ )}
+
+
+
+ {name}
+
+
+
+
+ );
+};
+
+export default NodeHeader;
diff --git a/web/src/pages/agent/canvas/node/note-node.tsx b/web/src/pages/agent/canvas/node/note-node.tsx
new file mode 100644
index 000000000..1917a8150
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/note-node.tsx
@@ -0,0 +1,92 @@
+import { NodeProps, NodeResizeControl } from '@xyflow/react';
+import { Flex, Form, Input } from 'antd';
+import classNames from 'classnames';
+import NodeDropdown from './dropdown';
+
+import SvgIcon from '@/components/svg-icon';
+import { useTheme } from '@/components/theme-provider';
+import { INoteNode } from '@/interfaces/database/flow';
+import { memo, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ useHandleFormValuesChange,
+ useHandleNodeNameChange,
+} from '../../hooks';
+import styles from './index.less';
+
+const { TextArea } = Input;
+
+const controlStyle = {
+ background: 'transparent',
+ border: 'none',
+};
+
+function NoteNode({ data, id }: NodeProps) {
+ const { t } = useTranslation();
+ const [form] = Form.useForm();
+ const { theme } = useTheme();
+
+ const { name, handleNameBlur, handleNameChange } = useHandleNodeNameChange({
+ id,
+ data,
+ });
+ const { handleValuesChange } = useHandleFormValuesChange(id);
+
+ useEffect(() => {
+ form.setFieldsValue(data?.form);
+ }, [form, data?.form]);
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
+
+export default memo(NoteNode);
diff --git a/web/src/pages/agent/canvas/node/popover.tsx b/web/src/pages/agent/canvas/node/popover.tsx
new file mode 100644
index 000000000..342ce40eb
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/popover.tsx
@@ -0,0 +1,121 @@
+import { useFetchFlow } from '@/hooks/flow-hooks';
+import get from 'lodash/get';
+import React, { MouseEventHandler, useCallback, useMemo } from 'react';
+import JsonView from 'react18-json-view';
+import 'react18-json-view/src/style.css';
+import { useReplaceIdWithText } from '../../hooks';
+
+import { useTheme } from '@/components/theme-provider';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import { useTranslate } from '@/hooks/common-hooks';
+import { useGetComponentLabelByValue } from '../../hooks/use-get-begin-query';
+
+interface IProps extends React.PropsWithChildren {
+ nodeId: string;
+ name?: string;
+}
+
+export function NextNodePopover({ children, nodeId, name }: IProps) {
+ const { t } = useTranslate('flow');
+
+ const { data } = useFetchFlow();
+ const { theme } = useTheme();
+ const component = useMemo(() => {
+ return get(data, ['dsl', 'components', nodeId], {});
+ }, [nodeId, data]);
+
+ const inputs: Array<{ component_id: string; content: string }> = get(
+ component,
+ ['obj', 'inputs'],
+ [],
+ );
+ const output = get(component, ['obj', 'output'], {});
+ const { replacedOutput } = useReplaceIdWithText(output);
+ const stopPropagation: MouseEventHandler = useCallback((e) => {
+ e.stopPropagation();
+ }, []);
+
+ const getLabel = useGetComponentLabelByValue(nodeId);
+
+ return (
+
+
+ {children}
+
+
+
+ {name} {t('operationResults')}
+
+
+
+
{t('input')}
+
+
+
+
+ {t('componentId')}
+ {t('content')}
+
+
+
+ {inputs.map((x, idx) => (
+
+ {getLabel(x.component_id)}
+ {x.content}
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/pages/agent/canvas/node/relevant-node.tsx b/web/src/pages/agent/canvas/node/relevant-node.tsx
new file mode 100644
index 000000000..acc098d69
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/relevant-node.tsx
@@ -0,0 +1,70 @@
+import { Handle, NodeProps, Position } from '@xyflow/react';
+import { Flex } from 'antd';
+import classNames from 'classnames';
+import { RightHandleStyle } from './handle-icon';
+
+import { useTheme } from '@/components/theme-provider';
+import { IRelevantNode } from '@/interfaces/database/flow';
+import { get } from 'lodash';
+import { useReplaceIdWithName } from '../../hooks';
+import styles from './index.less';
+import NodeHeader from './node-header';
+
+export function RelevantNode({ id, data, selected }: NodeProps) {
+ const yes = get(data, 'form.yes');
+ const no = get(data, 'form.no');
+ const replaceIdWithName = useReplaceIdWithName();
+ const { theme } = useTheme();
+ return (
+
+
+
+
+
+
+
+
+ Yes
+ {replaceIdWithName(yes)}
+
+
+ No
+ {replaceIdWithName(no)}
+
+
+
+ );
+}
diff --git a/web/src/pages/agent/canvas/node/retrieval-node.tsx b/web/src/pages/agent/canvas/node/retrieval-node.tsx
new file mode 100644
index 000000000..0fd2760ed
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/retrieval-node.tsx
@@ -0,0 +1,88 @@
+import { useTheme } from '@/components/theme-provider';
+import { useFetchKnowledgeList } from '@/hooks/knowledge-hooks';
+import { IRetrievalNode } from '@/interfaces/database/flow';
+import { UserOutlined } from '@ant-design/icons';
+import { Handle, NodeProps, Position } from '@xyflow/react';
+import { Avatar, Flex } from 'antd';
+import classNames from 'classnames';
+import { get } from 'lodash';
+import { useMemo } from 'react';
+import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
+import styles from './index.less';
+import NodeHeader from './node-header';
+
+export function RetrievalNode({
+ id,
+ data,
+ isConnectable = true,
+ selected,
+}: NodeProps) {
+ const knowledgeBaseIds: string[] = get(data, 'form.kb_ids', []);
+ const { theme } = useTheme();
+ const { list: knowledgeList } = useFetchKnowledgeList(true);
+ const knowledgeBases = useMemo(() => {
+ return knowledgeBaseIds.map((x) => {
+ const item = knowledgeList.find((y) => x === y.id);
+ return {
+ name: item?.name,
+ avatar: item?.avatar,
+ id: x,
+ };
+ });
+ }, [knowledgeList, knowledgeBaseIds]);
+
+ return (
+
+
+
+ 0,
+ })}
+ >
+
+ {knowledgeBases.map((knowledge) => {
+ return (
+
+
+ }
+ src={knowledge.avatar}
+ />
+
+ {knowledge.name}
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/web/src/pages/agent/canvas/node/rewrite-node.tsx b/web/src/pages/agent/canvas/node/rewrite-node.tsx
new file mode 100644
index 000000000..093b2c80e
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/rewrite-node.tsx
@@ -0,0 +1,57 @@
+import LLMLabel from '@/components/llm-select/llm-label';
+import { useTheme } from '@/components/theme-provider';
+import { IRewriteNode } from '@/interfaces/database/flow';
+import { Handle, NodeProps, Position } from '@xyflow/react';
+import classNames from 'classnames';
+import { get } from 'lodash';
+import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
+import styles from './index.less';
+import NodeHeader from './node-header';
+
+export function RewriteNode({
+ id,
+ data,
+ isConnectable = true,
+ selected,
+}: NodeProps) {
+ const { theme } = useTheme();
+ return (
+
+ );
+}
diff --git a/web/src/pages/agent/canvas/node/switch-node.tsx b/web/src/pages/agent/canvas/node/switch-node.tsx
new file mode 100644
index 000000000..860a0ba96
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/switch-node.tsx
@@ -0,0 +1,114 @@
+import { useTheme } from '@/components/theme-provider';
+import { ISwitchCondition, ISwitchNode } from '@/interfaces/database/flow';
+import { Handle, NodeProps, Position } from '@xyflow/react';
+import { Divider, Flex } from 'antd';
+import classNames from 'classnames';
+import { useGetComponentLabelByValue } from '../../hooks/use-get-begin-query';
+import { RightHandleStyle } from './handle-icon';
+import { useBuildSwitchHandlePositions } from './hooks';
+import styles from './index.less';
+import NodeHeader from './node-header';
+
+const getConditionKey = (idx: number, length: number) => {
+ if (idx === 0 && length !== 1) {
+ return 'If';
+ } else if (idx === length - 1) {
+ return 'Else';
+ }
+
+ return 'ElseIf';
+};
+
+const ConditionBlock = ({
+ condition,
+ nodeId,
+}: {
+ condition: ISwitchCondition;
+ nodeId: string;
+}) => {
+ const items = condition?.items ?? [];
+ const getLabel = useGetComponentLabelByValue(nodeId);
+ return (
+
+ {items.map((x, idx) => (
+
+
+
+ {getLabel(x?.cpn_id)}
+
+ {x?.operator}
+
+ {x?.value}
+
+
+ {idx + 1 < items.length && (
+
+ {condition?.logical_operator}
+
+ )}
+
+ ))}
+
+ );
+};
+
+export function SwitchNode({ id, data, selected }: NodeProps) {
+ const { positions } = useBuildSwitchHandlePositions({ data, id });
+ const { theme } = useTheme();
+ return (
+
+
+
+
+ {positions.map((position, idx) => {
+ return (
+
+
+
+ {idx < positions.length - 1 && position.text}
+ {getConditionKey(idx, positions.length)}
+
+ {position.condition && (
+
+ )}
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/web/src/pages/agent/canvas/node/template-node.tsx b/web/src/pages/agent/canvas/node/template-node.tsx
new file mode 100644
index 000000000..971fbab38
--- /dev/null
+++ b/web/src/pages/agent/canvas/node/template-node.tsx
@@ -0,0 +1,75 @@
+import { useTheme } from '@/components/theme-provider';
+import { Handle, NodeProps, Position } from '@xyflow/react';
+import { Flex } from 'antd';
+import classNames from 'classnames';
+import { get } from 'lodash';
+import { useGetComponentLabelByValue } from '../../hooks/use-get-begin-query';
+import { IGenerateParameter } from '../../interface';
+import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
+import NodeHeader from './node-header';
+
+import { ITemplateNode } from '@/interfaces/database/flow';
+import styles from './index.less';
+
+export function TemplateNode({
+ id,
+ data,
+ isConnectable = true,
+ selected,
+}: NodeProps) {
+ const parameters: IGenerateParameter[] = get(data, 'form.parameters', []);
+ const getLabel = useGetComponentLabelByValue(id);
+ const { theme } = useTheme();
+ return (
+
+
+
+
+
+
+
+ {parameters.map((x) => (
+
+
+
+ {getLabel(x.component_id)}
+
+
+ ))}
+
+
+ );
+}
diff --git a/web/src/pages/agent/flow-tooltip.tsx b/web/src/pages/agent/flow-tooltip.tsx
new file mode 100644
index 000000000..9386dd06b
--- /dev/null
+++ b/web/src/pages/agent/flow-tooltip.tsx
@@ -0,0 +1,19 @@
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+import { PropsWithChildren } from 'react';
+import { useTranslation } from 'react-i18next';
+
+export const RunTooltip = ({ children }: PropsWithChildren) => {
+ const { t } = useTranslation();
+ return (
+
+ {children}
+
+ {t('flow.testRun')}
+
+
+ );
+};
diff --git a/web/src/pages/agent/hooks.tsx b/web/src/pages/agent/hooks.tsx
new file mode 100644
index 000000000..0430170a0
--- /dev/null
+++ b/web/src/pages/agent/hooks.tsx
@@ -0,0 +1,567 @@
+import {
+ Connection,
+ Edge,
+ Node,
+ Position,
+ ReactFlowInstance,
+} from '@xyflow/react';
+import React, {
+ ChangeEvent,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react';
+// import { shallow } from 'zustand/shallow';
+import { settledModelVariableMap } from '@/constants/knowledge';
+import { useFetchModelId } from '@/hooks/logic-hooks';
+import {
+ ICategorizeForm,
+ IRelevantForm,
+ ISwitchForm,
+ RAGFlowNodeType,
+} from '@/interfaces/database/flow';
+import { message } from 'antd';
+import { humanId } from 'human-id';
+import { get, lowerFirst } from 'lodash';
+import trim from 'lodash/trim';
+import { useTranslation } from 'react-i18next';
+import { v4 as uuid } from 'uuid';
+import {
+ NodeMap,
+ Operator,
+ RestrictedUpstreamMap,
+ SwitchElseTo,
+ initialAkShareValues,
+ initialArXivValues,
+ initialBaiduFanyiValues,
+ initialBaiduValues,
+ initialBeginValues,
+ initialBingValues,
+ initialCategorizeValues,
+ initialConcentratorValues,
+ initialCrawlerValues,
+ initialDeepLValues,
+ initialDuckValues,
+ initialEmailValues,
+ initialExeSqlValues,
+ initialGenerateValues,
+ initialGithubValues,
+ initialGoogleScholarValues,
+ initialGoogleValues,
+ initialInvokeValues,
+ initialIterationValues,
+ initialJin10Values,
+ initialKeywordExtractValues,
+ initialMessageValues,
+ initialNoteValues,
+ initialPubMedValues,
+ initialQWeatherValues,
+ initialRelevantValues,
+ initialRetrievalValues,
+ initialRewriteQuestionValues,
+ initialSwitchValues,
+ initialTemplateValues,
+ initialTuShareValues,
+ initialWenCaiValues,
+ initialWikipediaValues,
+ initialYahooFinanceValues,
+} from './constant';
+import useGraphStore, { RFState } from './store';
+import {
+ generateNodeNamesWithIncreasingIndex,
+ generateSwitchHandleText,
+ getNodeDragHandle,
+ getRelativePositionToIterationNode,
+ replaceIdWithText,
+} from './utils';
+
+const selector = (state: RFState) => ({
+ nodes: state.nodes,
+ edges: state.edges,
+ onNodesChange: state.onNodesChange,
+ onEdgesChange: state.onEdgesChange,
+ onConnect: state.onConnect,
+ setNodes: state.setNodes,
+ onSelectionChange: state.onSelectionChange,
+});
+
+export const useSelectCanvasData = () => {
+ // return useStore(useShallow(selector)); // throw error
+ // return useStore(selector, shallow);
+ return useGraphStore(selector);
+};
+
+export const useInitializeOperatorParams = () => {
+ const llmId = useFetchModelId();
+
+ const initialFormValuesMap = useMemo(() => {
+ return {
+ [Operator.Begin]: initialBeginValues,
+ [Operator.Retrieval]: initialRetrievalValues,
+ [Operator.Generate]: { ...initialGenerateValues, llm_id: llmId },
+ [Operator.Answer]: {},
+ [Operator.Categorize]: { ...initialCategorizeValues, llm_id: llmId },
+ [Operator.Relevant]: { ...initialRelevantValues, llm_id: llmId },
+ [Operator.RewriteQuestion]: {
+ ...initialRewriteQuestionValues,
+ llm_id: llmId,
+ },
+ [Operator.Message]: initialMessageValues,
+ [Operator.KeywordExtract]: {
+ ...initialKeywordExtractValues,
+ llm_id: llmId,
+ },
+ [Operator.DuckDuckGo]: initialDuckValues,
+ [Operator.Baidu]: initialBaiduValues,
+ [Operator.Wikipedia]: initialWikipediaValues,
+ [Operator.PubMed]: initialPubMedValues,
+ [Operator.ArXiv]: initialArXivValues,
+ [Operator.Google]: initialGoogleValues,
+ [Operator.Bing]: initialBingValues,
+ [Operator.GoogleScholar]: initialGoogleScholarValues,
+ [Operator.DeepL]: initialDeepLValues,
+ [Operator.GitHub]: initialGithubValues,
+ [Operator.BaiduFanyi]: initialBaiduFanyiValues,
+ [Operator.QWeather]: initialQWeatherValues,
+ [Operator.ExeSQL]: { ...initialExeSqlValues, llm_id: llmId },
+ [Operator.Switch]: initialSwitchValues,
+ [Operator.WenCai]: initialWenCaiValues,
+ [Operator.AkShare]: initialAkShareValues,
+ [Operator.YahooFinance]: initialYahooFinanceValues,
+ [Operator.Jin10]: initialJin10Values,
+ [Operator.Concentrator]: initialConcentratorValues,
+ [Operator.TuShare]: initialTuShareValues,
+ [Operator.Note]: initialNoteValues,
+ [Operator.Crawler]: initialCrawlerValues,
+ [Operator.Invoke]: initialInvokeValues,
+ [Operator.Template]: initialTemplateValues,
+ [Operator.Email]: initialEmailValues,
+ [Operator.Iteration]: initialIterationValues,
+ [Operator.IterationStart]: initialIterationValues,
+ };
+ }, [llmId]);
+
+ const initializeOperatorParams = useCallback(
+ (operatorName: Operator) => {
+ return initialFormValuesMap[operatorName];
+ },
+ [initialFormValuesMap],
+ );
+
+ return initializeOperatorParams;
+};
+
+export const useHandleDrag = () => {
+ const handleDragStart = useCallback(
+ (operatorId: string) => (ev: React.DragEvent) => {
+ ev.dataTransfer.setData('application/@xyflow/react', operatorId);
+ ev.dataTransfer.effectAllowed = 'move';
+ },
+ [],
+ );
+
+ return { handleDragStart };
+};
+
+export const useGetNodeName = () => {
+ const { t } = useTranslation();
+
+ return (type: string) => {
+ const name = t(`flow.${lowerFirst(type)}`);
+ return name;
+ };
+};
+
+export const useHandleDrop = () => {
+ const addNode = useGraphStore((state) => state.addNode);
+ const nodes = useGraphStore((state) => state.nodes);
+ const [reactFlowInstance, setReactFlowInstance] =
+ useState>();
+ const initializeOperatorParams = useInitializeOperatorParams();
+ const getNodeName = useGetNodeName();
+
+ const onDragOver = useCallback((event: React.DragEvent) => {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'move';
+ }, []);
+
+ const onDrop = useCallback(
+ (event: React.DragEvent) => {
+ event.preventDefault();
+
+ const type = event.dataTransfer.getData('application/@xyflow/react');
+
+ // check if the dropped element is valid
+ if (typeof type === 'undefined' || !type) {
+ return;
+ }
+
+ // reactFlowInstance.project was renamed to reactFlowInstance.screenToFlowPosition
+ // and you don't need to subtract the reactFlowBounds.left/top anymore
+ // details: https://@xyflow/react.dev/whats-new/2023-11-10
+ const position = reactFlowInstance?.screenToFlowPosition({
+ x: event.clientX,
+ y: event.clientY,
+ });
+ const newNode: Node = {
+ id: `${type}:${humanId()}`,
+ type: NodeMap[type as Operator] || 'ragNode',
+ position: position || {
+ x: 0,
+ y: 0,
+ },
+ data: {
+ label: `${type}`,
+ name: generateNodeNamesWithIncreasingIndex(getNodeName(type), nodes),
+ form: initializeOperatorParams(type as Operator),
+ },
+ sourcePosition: Position.Right,
+ targetPosition: Position.Left,
+ dragHandle: getNodeDragHandle(type),
+ };
+
+ if (type === Operator.Iteration) {
+ newNode.width = 500;
+ newNode.height = 250;
+ const iterationStartNode: Node = {
+ id: `${Operator.IterationStart}:${humanId()}`,
+ type: 'iterationStartNode',
+ position: { x: 50, y: 100 },
+ // draggable: false,
+ data: {
+ label: Operator.IterationStart,
+ name: Operator.IterationStart,
+ form: {},
+ },
+ parentId: newNode.id,
+ extent: 'parent',
+ };
+ addNode(newNode);
+ addNode(iterationStartNode);
+ } else {
+ const subNodeOfIteration = getRelativePositionToIterationNode(
+ nodes,
+ position,
+ );
+ if (subNodeOfIteration) {
+ newNode.parentId = subNodeOfIteration.parentId;
+ newNode.position = subNodeOfIteration.position;
+ newNode.extent = 'parent';
+ }
+ addNode(newNode);
+ }
+ },
+ [reactFlowInstance, getNodeName, nodes, initializeOperatorParams, addNode],
+ );
+
+ return { onDrop, onDragOver, setReactFlowInstance };
+};
+
+export const useHandleFormValuesChange = (id?: string) => {
+ const updateNodeForm = useGraphStore((state) => state.updateNodeForm);
+ const handleValuesChange = useCallback(
+ (changedValues: any, values: any) => {
+ let nextValues: any = values;
+ // Fixed the issue that the related form value does not change after selecting the freedom field of the model
+ if (
+ Object.keys(changedValues).length === 1 &&
+ 'parameter' in changedValues &&
+ changedValues['parameter'] in settledModelVariableMap
+ ) {
+ nextValues = {
+ ...values,
+ ...settledModelVariableMap[
+ changedValues['parameter'] as keyof typeof settledModelVariableMap
+ ],
+ };
+ }
+ if (id) {
+ updateNodeForm(id, nextValues);
+ }
+ },
+ [updateNodeForm, id],
+ );
+
+ return { handleValuesChange };
+};
+
+export const useValidateConnection = () => {
+ const { edges, getOperatorTypeFromId, getParentIdById } = useGraphStore(
+ (state) => state,
+ );
+
+ const isSameNodeChild = useCallback(
+ (connection: Connection | Edge) => {
+ const sourceParentId = getParentIdById(connection.source);
+ const targetParentId = getParentIdById(connection.target);
+ if (sourceParentId || targetParentId) {
+ return sourceParentId === targetParentId;
+ }
+ return true;
+ },
+ [getParentIdById],
+ );
+
+ // restricted lines cannot be connected successfully.
+ const isValidConnection = useCallback(
+ (connection: Connection | Edge) => {
+ // node cannot connect to itself
+ const isSelfConnected = connection.target === connection.source;
+
+ // limit the connection between two nodes to only one connection line in one direction
+ const hasLine = edges.some(
+ (x) => x.source === connection.source && x.target === connection.target,
+ );
+
+ const ret =
+ !isSelfConnected &&
+ !hasLine &&
+ RestrictedUpstreamMap[
+ getOperatorTypeFromId(connection.source) as Operator
+ ]?.every((x) => x !== getOperatorTypeFromId(connection.target)) &&
+ isSameNodeChild(connection);
+ return ret;
+ },
+ [edges, getOperatorTypeFromId, isSameNodeChild],
+ );
+
+ return isValidConnection;
+};
+
+export const useHandleNodeNameChange = ({
+ id,
+ data,
+}: {
+ id?: string;
+ data: any;
+}) => {
+ const [name, setName] = useState('');
+ const { updateNodeName, nodes } = useGraphStore((state) => state);
+ const previousName = data?.name;
+
+ const handleNameBlur = useCallback(() => {
+ const existsSameName = nodes.some((x) => x.data.name === name);
+ if (trim(name) === '' || existsSameName) {
+ if (existsSameName && previousName !== name) {
+ message.error('The name cannot be repeated');
+ }
+ setName(previousName);
+ return;
+ }
+
+ if (id) {
+ updateNodeName(id, name);
+ }
+ }, [name, id, updateNodeName, previousName, nodes]);
+
+ const handleNameChange = useCallback((e: ChangeEvent) => {
+ setName(e.target.value);
+ }, []);
+
+ useEffect(() => {
+ setName(previousName);
+ }, [previousName]);
+
+ return { name, handleNameBlur, handleNameChange };
+};
+
+export const useReplaceIdWithName = () => {
+ const getNode = useGraphStore((state) => state.getNode);
+
+ const replaceIdWithName = useCallback(
+ (id?: string) => {
+ return getNode(id)?.data.name;
+ },
+ [getNode],
+ );
+
+ return replaceIdWithName;
+};
+
+export const useReplaceIdWithText = (output: unknown) => {
+ const getNameById = useReplaceIdWithName();
+
+ return {
+ replacedOutput: replaceIdWithText(output, getNameById),
+ getNameById,
+ };
+};
+
+/**
+ * monitor changes in the data.form field of the categorize and relevant operators
+ * and then synchronize them to the edge
+ */
+export const useWatchNodeFormDataChange = () => {
+ const { getNode, nodes, setEdgesByNodeId } = useGraphStore((state) => state);
+
+ const buildCategorizeEdgesByFormData = useCallback(
+ (nodeId: string, form: ICategorizeForm) => {
+ // add
+ // delete
+ // edit
+ const categoryDescription = form.category_description;
+ const downstreamEdges = Object.keys(categoryDescription).reduce(
+ (pre, sourceHandle) => {
+ const target = categoryDescription[sourceHandle]?.to;
+ if (target) {
+ pre.push({
+ id: uuid(),
+ source: nodeId,
+ target,
+ sourceHandle,
+ });
+ }
+
+ return pre;
+ },
+ [],
+ );
+
+ setEdgesByNodeId(nodeId, downstreamEdges);
+ },
+ [setEdgesByNodeId],
+ );
+
+ const buildRelevantEdgesByFormData = useCallback(
+ (nodeId: string, form: IRelevantForm) => {
+ const downstreamEdges = ['yes', 'no'].reduce((pre, cur) => {
+ const target = form[cur as keyof IRelevantForm] as string;
+ if (target) {
+ pre.push({ id: uuid(), source: nodeId, target, sourceHandle: cur });
+ }
+
+ return pre;
+ }, []);
+
+ setEdgesByNodeId(nodeId, downstreamEdges);
+ },
+ [setEdgesByNodeId],
+ );
+
+ const buildSwitchEdgesByFormData = useCallback(
+ (nodeId: string, form: ISwitchForm) => {
+ // add
+ // delete
+ // edit
+ const conditions = form.conditions;
+ const downstreamEdges = conditions.reduce((pre, _, idx) => {
+ const target = conditions[idx]?.to;
+ if (target) {
+ pre.push({
+ id: uuid(),
+ source: nodeId,
+ target,
+ sourceHandle: generateSwitchHandleText(idx),
+ });
+ }
+
+ return pre;
+ }, []);
+
+ // Splice the else condition of the conditional judgment to the edge list
+ const elseTo = form[SwitchElseTo];
+ if (elseTo) {
+ downstreamEdges.push({
+ id: uuid(),
+ source: nodeId,
+ target: elseTo,
+ sourceHandle: SwitchElseTo,
+ });
+ }
+
+ setEdgesByNodeId(nodeId, downstreamEdges);
+ },
+ [setEdgesByNodeId],
+ );
+
+ useEffect(() => {
+ nodes.forEach((node) => {
+ const currentNode = getNode(node.id);
+ const form = currentNode?.data.form ?? {};
+ const operatorType = currentNode?.data.label;
+ switch (operatorType) {
+ case Operator.Relevant:
+ buildRelevantEdgesByFormData(node.id, form as IRelevantForm);
+ break;
+ case Operator.Categorize:
+ buildCategorizeEdgesByFormData(node.id, form as ICategorizeForm);
+ break;
+ case Operator.Switch:
+ buildSwitchEdgesByFormData(node.id, form as ISwitchForm);
+ break;
+ default:
+ break;
+ }
+ });
+ }, [
+ nodes,
+ buildCategorizeEdgesByFormData,
+ getNode,
+ buildRelevantEdgesByFormData,
+ buildSwitchEdgesByFormData,
+ ]);
+};
+
+export const useDuplicateNode = () => {
+ const duplicateNodeById = useGraphStore((store) => store.duplicateNode);
+ const getNodeName = useGetNodeName();
+
+ const duplicateNode = useCallback(
+ (id: string, label: string) => {
+ duplicateNodeById(id, getNodeName(label));
+ },
+ [duplicateNodeById, getNodeName],
+ );
+
+ return duplicateNode;
+};
+
+export const useCopyPaste = () => {
+ const nodes = useGraphStore((state) => state.nodes);
+ const duplicateNode = useDuplicateNode();
+
+ const onCopyCapture = useCallback(
+ (event: ClipboardEvent) => {
+ if (get(event, 'srcElement.tagName') !== 'BODY') return;
+
+ event.preventDefault();
+ const nodesStr = JSON.stringify(
+ nodes.filter((n) => n.selected && n.data.label !== Operator.Begin),
+ );
+
+ event.clipboardData?.setData('agent:nodes', nodesStr);
+ },
+ [nodes],
+ );
+
+ const onPasteCapture = useCallback(
+ (event: ClipboardEvent) => {
+ const nodes = JSON.parse(
+ event.clipboardData?.getData('agent:nodes') || '[]',
+ ) as RAGFlowNodeType[] | undefined;
+
+ if (Array.isArray(nodes) && nodes.length) {
+ event.preventDefault();
+ nodes.forEach((n) => {
+ duplicateNode(n.id, n.data.label);
+ });
+ }
+ },
+ [duplicateNode],
+ );
+
+ useEffect(() => {
+ window.addEventListener('copy', onCopyCapture);
+ return () => {
+ window.removeEventListener('copy', onCopyCapture);
+ };
+ }, [onCopyCapture]);
+
+ useEffect(() => {
+ window.addEventListener('paste', onPasteCapture);
+ return () => {
+ window.removeEventListener('paste', onPasteCapture);
+ };
+ }, [onPasteCapture]);
+};
diff --git a/web/src/pages/agent/hooks/use-before-delete.tsx b/web/src/pages/agent/hooks/use-before-delete.tsx
new file mode 100644
index 000000000..14512ae96
--- /dev/null
+++ b/web/src/pages/agent/hooks/use-before-delete.tsx
@@ -0,0 +1,57 @@
+import { RAGFlowNodeType } from '@/interfaces/database/flow';
+import { OnBeforeDelete } from '@xyflow/react';
+import { Operator } from '../constant';
+import useGraphStore from '../store';
+
+const UndeletableNodes = [Operator.Begin, Operator.IterationStart];
+
+export function useBeforeDelete() {
+ const getOperatorTypeFromId = useGraphStore(
+ (state) => state.getOperatorTypeFromId,
+ );
+ const handleBeforeDelete: OnBeforeDelete = async ({
+ nodes, // Nodes to be deleted
+ edges, // Edges to be deleted
+ }) => {
+ const toBeDeletedNodes = nodes.filter((node) => {
+ const operatorType = node.data?.label as Operator;
+ if (operatorType === Operator.Begin) {
+ return false;
+ }
+
+ if (
+ operatorType === Operator.IterationStart &&
+ !nodes.some((x) => x.id === node.parentId)
+ ) {
+ return false;
+ }
+
+ return true;
+ });
+
+ const toBeDeletedEdges = edges.filter((edge) => {
+ const sourceType = getOperatorTypeFromId(edge.source) as Operator;
+ const downStreamNodes = nodes.filter((x) => x.id === edge.target);
+
+ // This edge does not need to be deleted, the range of edges that do not need to be deleted is smaller, so consider the case where it does not need to be deleted
+ if (
+ UndeletableNodes.includes(sourceType) && // Upstream node is Begin or IterationStart
+ downStreamNodes.length === 0 // Downstream node does not exist in the nodes to be deleted
+ ) {
+ if (!nodes.some((x) => x.id === edge.source)) {
+ return true; // Can be deleted
+ }
+ return false; // Cannot be deleted
+ }
+
+ return true;
+ });
+
+ return {
+ nodes: toBeDeletedNodes,
+ edges: toBeDeletedEdges,
+ };
+ };
+
+ return { handleBeforeDelete };
+}
diff --git a/web/src/pages/agent/hooks/use-build-dsl.ts b/web/src/pages/agent/hooks/use-build-dsl.ts
new file mode 100644
index 000000000..17a0681ed
--- /dev/null
+++ b/web/src/pages/agent/hooks/use-build-dsl.ts
@@ -0,0 +1,29 @@
+import { useFetchFlow } from '@/hooks/flow-hooks';
+import { RAGFlowNodeType } from '@/interfaces/database/flow';
+import { useCallback } from 'react';
+import useGraphStore from '../store';
+import { buildDslComponentsByGraph } from '../utils';
+
+export const useBuildDslData = () => {
+ const { data } = useFetchFlow();
+ const { nodes, edges } = useGraphStore((state) => state);
+
+ const buildDslData = useCallback(
+ (currentNodes?: RAGFlowNodeType[]) => {
+ const dslComponents = buildDslComponentsByGraph(
+ currentNodes ?? nodes,
+ edges,
+ data.dsl.components,
+ );
+
+ return {
+ ...data.dsl,
+ graph: { nodes: currentNodes ?? nodes, edges },
+ components: dslComponents,
+ };
+ },
+ [data.dsl, edges, nodes],
+ );
+
+ return { buildDslData };
+};
diff --git a/web/src/pages/agent/hooks/use-export-json.ts b/web/src/pages/agent/hooks/use-export-json.ts
new file mode 100644
index 000000000..5b8618de8
--- /dev/null
+++ b/web/src/pages/agent/hooks/use-export-json.ts
@@ -0,0 +1,62 @@
+import { FileMimeType } from '@/constants/common';
+import { useSetModalState } from '@/hooks/common-hooks';
+import { useFetchFlow } from '@/hooks/flow-hooks';
+import { IGraph } from '@/interfaces/database/flow';
+import { downloadJsonFile } from '@/utils/file-util';
+import { message, UploadFile } from 'antd';
+import isEmpty from 'lodash/isEmpty';
+import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useBuildDslData } from './use-build-dsl';
+import { useSetGraphInfo } from './use-set-graph';
+
+export const useHandleExportOrImportJsonFile = () => {
+ const { buildDslData } = useBuildDslData();
+ const {
+ visible: fileUploadVisible,
+ hideModal: hideFileUploadModal,
+ showModal: showFileUploadModal,
+ } = useSetModalState();
+ const setGraphInfo = useSetGraphInfo();
+ const { data } = useFetchFlow();
+ const { t } = useTranslation();
+
+ const onFileUploadOk = useCallback(
+ async (fileList: UploadFile[]) => {
+ if (fileList.length > 0) {
+ const file: File = fileList[0] as unknown as File;
+ if (file.type !== FileMimeType.Json) {
+ message.error(t('flow.jsonUploadTypeErrorMessage'));
+ return;
+ }
+
+ const graphStr = await file.text();
+ const errorMessage = t('flow.jsonUploadContentErrorMessage');
+ try {
+ const graph = JSON.parse(graphStr);
+ if (graphStr && !isEmpty(graph) && Array.isArray(graph?.nodes)) {
+ setGraphInfo(graph ?? ({} as IGraph));
+ hideFileUploadModal();
+ } else {
+ message.error(errorMessage);
+ }
+ } catch (error) {
+ message.error(errorMessage);
+ }
+ }
+ },
+ [hideFileUploadModal, setGraphInfo, t],
+ );
+
+ const handleExportJson = useCallback(() => {
+ downloadJsonFile(buildDslData().graph, `${data.title}.json`);
+ }, [buildDslData, data.title]);
+
+ return {
+ fileUploadVisible,
+ handleExportJson,
+ handleImportJson: showFileUploadModal,
+ hideFileUploadModal,
+ onFileUploadOk,
+ };
+};
diff --git a/web/src/pages/agent/hooks/use-fetch-data.ts b/web/src/pages/agent/hooks/use-fetch-data.ts
new file mode 100644
index 000000000..245ea6abf
--- /dev/null
+++ b/web/src/pages/agent/hooks/use-fetch-data.ts
@@ -0,0 +1,19 @@
+import { useFetchFlow } from '@/hooks/flow-hooks';
+import { IGraph } from '@/interfaces/database/flow';
+import { useEffect } from 'react';
+import { useSetGraphInfo } from './use-set-graph';
+
+export const useFetchDataOnMount = () => {
+ const { loading, data, refetch } = useFetchFlow();
+ const setGraphInfo = useSetGraphInfo();
+
+ useEffect(() => {
+ setGraphInfo(data?.dsl?.graph ?? ({} as IGraph));
+ }, [setGraphInfo, data]);
+
+ useEffect(() => {
+ refetch();
+ }, [refetch]);
+
+ return { loading, flowDetail: data };
+};
diff --git a/web/src/pages/agent/hooks/use-get-begin-query.tsx b/web/src/pages/agent/hooks/use-get-begin-query.tsx
new file mode 100644
index 000000000..557bbfce0
--- /dev/null
+++ b/web/src/pages/agent/hooks/use-get-begin-query.tsx
@@ -0,0 +1,113 @@
+import { RAGFlowNodeType } from '@/interfaces/database/flow';
+import { DefaultOptionType } from 'antd/es/select';
+import get from 'lodash/get';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { BeginId, Operator } from '../constant';
+import { BeginQuery } from '../interface';
+import useGraphStore from '../store';
+
+export const useGetBeginNodeDataQuery = () => {
+ const getNode = useGraphStore((state) => state.getNode);
+
+ const getBeginNodeDataQuery = useCallback(() => {
+ return get(getNode(BeginId), 'data.form.query', []);
+ }, [getNode]);
+
+ return getBeginNodeDataQuery;
+};
+
+export const useGetBeginNodeDataQueryIsSafe = () => {
+ const [isBeginNodeDataQuerySafe, setIsBeginNodeDataQuerySafe] =
+ useState(false);
+ const getBeginNodeDataQuery = useGetBeginNodeDataQuery();
+ const nodes = useGraphStore((state) => state.nodes);
+
+ useEffect(() => {
+ const query: BeginQuery[] = getBeginNodeDataQuery();
+ const isSafe = !query.some((q) => !q.optional && q.type === 'file');
+ setIsBeginNodeDataQuerySafe(isSafe);
+ }, [getBeginNodeDataQuery, nodes]);
+
+ return isBeginNodeDataQuerySafe;
+};
+
+// exclude nodes with branches
+const ExcludedNodes = [
+ Operator.Categorize,
+ Operator.Relevant,
+ Operator.Begin,
+ Operator.Note,
+];
+
+export const useBuildComponentIdSelectOptions = (
+ nodeId?: string,
+ parentId?: string,
+) => {
+ const nodes = useGraphStore((state) => state.nodes);
+ const getBeginNodeDataQuery = useGetBeginNodeDataQuery();
+ const query: BeginQuery[] = getBeginNodeDataQuery();
+
+ // Limit the nodes inside iteration to only reference peer nodes with the same parentId and other external nodes other than their parent nodes
+ const filterChildNodesToSameParentOrExternal = useCallback(
+ (node: RAGFlowNodeType) => {
+ // Node inside iteration
+ if (parentId) {
+ return (
+ (node.parentId === parentId || node.parentId === undefined) &&
+ node.id !== parentId
+ );
+ }
+
+ return node.parentId === undefined; // The outermost node
+ },
+ [parentId],
+ );
+
+ const componentIdOptions = useMemo(() => {
+ return nodes
+ .filter(
+ (x) =>
+ x.id !== nodeId &&
+ !ExcludedNodes.some((y) => y === x.data.label) &&
+ filterChildNodesToSameParentOrExternal(x),
+ )
+ .map((x) => ({ label: x.data.name, value: x.id }));
+ }, [nodes, nodeId, filterChildNodesToSameParentOrExternal]);
+
+ const groupedOptions = [
+ {
+ label: Component Output,
+ title: 'Component Output',
+ options: componentIdOptions,
+ },
+ {
+ label: Begin Input,
+ title: 'Begin Input',
+ options: query.map((x) => ({
+ label: x.name,
+ value: `begin@${x.key}`,
+ })),
+ },
+ ];
+
+ return groupedOptions;
+};
+
+export const useGetComponentLabelByValue = (nodeId: string) => {
+ const options = useBuildComponentIdSelectOptions(nodeId);
+ const flattenOptions = useMemo(
+ () =>
+ options.reduce((pre, cur) => {
+ return [...pre, ...cur.options];
+ }, []),
+ [options],
+ );
+
+ const getLabel = useCallback(
+ (val?: string) => {
+ return flattenOptions.find((x) => x.value === val)?.label;
+ },
+ [flattenOptions],
+ );
+ return getLabel;
+};
diff --git a/web/src/pages/agent/hooks/use-iteration.ts b/web/src/pages/agent/hooks/use-iteration.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/web/src/pages/agent/hooks/use-open-document.ts b/web/src/pages/agent/hooks/use-open-document.ts
new file mode 100644
index 000000000..384529c15
--- /dev/null
+++ b/web/src/pages/agent/hooks/use-open-document.ts
@@ -0,0 +1,12 @@
+import { useCallback } from 'react';
+
+export function useOpenDocument() {
+ const openDocument = useCallback(() => {
+ window.open(
+ 'https://ragflow.io/docs/dev/category/agent-components',
+ '_blank',
+ );
+ }, []);
+
+ return openDocument;
+}
diff --git a/web/src/pages/agent/hooks/use-save-graph.ts b/web/src/pages/agent/hooks/use-save-graph.ts
new file mode 100644
index 000000000..9567b56f2
--- /dev/null
+++ b/web/src/pages/agent/hooks/use-save-graph.ts
@@ -0,0 +1,85 @@
+import { useFetchFlow, useResetFlow, useSetFlow } from '@/hooks/flow-hooks';
+import { RAGFlowNodeType } from '@/interfaces/database/flow';
+import { useDebounceEffect } from 'ahooks';
+import dayjs from 'dayjs';
+import { useCallback, useEffect, useState } from 'react';
+import { useParams } from 'umi';
+import useGraphStore from '../store';
+import { useBuildDslData } from './use-build-dsl';
+
+export const useSaveGraph = () => {
+ const { data } = useFetchFlow();
+ const { setFlow, loading } = useSetFlow();
+ const { id } = useParams();
+ const { buildDslData } = useBuildDslData();
+
+ const saveGraph = useCallback(
+ async (currentNodes?: RAGFlowNodeType[]) => {
+ return setFlow({
+ id,
+ title: data.title,
+ dsl: buildDslData(currentNodes),
+ });
+ },
+ [setFlow, id, data.title, buildDslData],
+ );
+
+ return { saveGraph, loading };
+};
+
+export const useSaveGraphBeforeOpeningDebugDrawer = (show: () => void) => {
+ const { saveGraph, loading } = useSaveGraph();
+ const { resetFlow } = useResetFlow();
+
+ const handleRun = useCallback(
+ async (nextNodes?: RAGFlowNodeType[]) => {
+ const saveRet = await saveGraph(nextNodes);
+ if (saveRet?.code === 0) {
+ // Call the reset api before opening the run drawer each time
+ const resetRet = await resetFlow();
+ // After resetting, all previous messages will be cleared.
+ if (resetRet?.code === 0) {
+ show();
+ }
+ }
+ },
+ [saveGraph, resetFlow, show],
+ );
+
+ return { handleRun, loading };
+};
+
+export const useWatchAgentChange = (chatDrawerVisible: boolean) => {
+ const [time, setTime] = useState();
+ const nodes = useGraphStore((state) => state.nodes);
+ const edges = useGraphStore((state) => state.edges);
+ const { saveGraph } = useSaveGraph();
+ const { data: flowDetail } = useFetchFlow();
+
+ const setSaveTime = useCallback((updateTime: number) => {
+ setTime(dayjs(updateTime).format('YYYY-MM-DD HH:mm:ss'));
+ }, []);
+
+ useEffect(() => {
+ setSaveTime(flowDetail?.update_time);
+ }, [flowDetail, setSaveTime]);
+
+ const saveAgent = useCallback(async () => {
+ if (!chatDrawerVisible) {
+ const ret = await saveGraph();
+ setSaveTime(ret.data.update_time);
+ }
+ }, [chatDrawerVisible, saveGraph, setSaveTime]);
+
+ useDebounceEffect(
+ () => {
+ // saveAgent();
+ },
+ [nodes, edges],
+ {
+ wait: 1000 * 20,
+ },
+ );
+
+ return time;
+};
diff --git a/web/src/pages/agent/hooks/use-set-graph.ts b/web/src/pages/agent/hooks/use-set-graph.ts
new file mode 100644
index 000000000..6dd68a330
--- /dev/null
+++ b/web/src/pages/agent/hooks/use-set-graph.ts
@@ -0,0 +1,17 @@
+import { IGraph } from '@/interfaces/database/flow';
+import { useCallback } from 'react';
+import useGraphStore from '../store';
+
+export const useSetGraphInfo = () => {
+ const { setEdges, setNodes } = useGraphStore((state) => state);
+ const setGraphInfo = useCallback(
+ ({ nodes = [], edges = [] }: IGraph) => {
+ if (nodes.length || edges.length) {
+ setNodes(nodes);
+ setEdges(edges);
+ }
+ },
+ [setEdges, setNodes],
+ );
+ return setGraphInfo;
+};
diff --git a/web/src/pages/agent/hooks/use-show-drawer.tsx b/web/src/pages/agent/hooks/use-show-drawer.tsx
new file mode 100644
index 000000000..efc4cf32a
--- /dev/null
+++ b/web/src/pages/agent/hooks/use-show-drawer.tsx
@@ -0,0 +1,153 @@
+import { useSetModalState } from '@/hooks/common-hooks';
+import { Node, NodeMouseHandler } from '@xyflow/react';
+import get from 'lodash/get';
+import { useCallback, useEffect } from 'react';
+import { Operator } from '../constant';
+import { BeginQuery } from '../interface';
+import useGraphStore from '../store';
+import { useGetBeginNodeDataQuery } from './use-get-begin-query';
+import { useSaveGraph } from './use-save-graph';
+
+export const useShowFormDrawer = () => {
+ const {
+ clickedNodeId: clickNodeId,
+ setClickedNodeId,
+ getNode,
+ } = useGraphStore((state) => state);
+ const {
+ visible: formDrawerVisible,
+ hideModal: hideFormDrawer,
+ showModal: showFormDrawer,
+ } = useSetModalState();
+
+ const handleShow = useCallback(
+ (node: Node) => {
+ setClickedNodeId(node.id);
+ showFormDrawer();
+ },
+ [showFormDrawer, setClickedNodeId],
+ );
+
+ return {
+ formDrawerVisible,
+ hideFormDrawer,
+ showFormDrawer: handleShow,
+ clickedNode: getNode(clickNodeId),
+ };
+};
+
+export const useShowSingleDebugDrawer = () => {
+ const { visible, showModal, hideModal } = useSetModalState();
+ const { saveGraph } = useSaveGraph();
+
+ const showSingleDebugDrawer = useCallback(async () => {
+ const saveRet = await saveGraph();
+ if (saveRet?.code === 0) {
+ showModal();
+ }
+ }, [saveGraph, showModal]);
+
+ return {
+ singleDebugDrawerVisible: visible,
+ hideSingleDebugDrawer: hideModal,
+ showSingleDebugDrawer,
+ };
+};
+
+const ExcludedNodes = [Operator.IterationStart, Operator.Note];
+
+export function useShowDrawer({
+ drawerVisible,
+ hideDrawer,
+}: {
+ drawerVisible: boolean;
+ hideDrawer(): void;
+}) {
+ const {
+ visible: runVisible,
+ showModal: showRunModal,
+ hideModal: hideRunModal,
+ } = useSetModalState();
+ const {
+ visible: chatVisible,
+ showModal: showChatModal,
+ hideModal: hideChatModal,
+ } = useSetModalState();
+ const {
+ singleDebugDrawerVisible,
+ showSingleDebugDrawer,
+ hideSingleDebugDrawer,
+ } = useShowSingleDebugDrawer();
+ const { formDrawerVisible, hideFormDrawer, showFormDrawer, clickedNode } =
+ useShowFormDrawer();
+ const getBeginNodeDataQuery = useGetBeginNodeDataQuery();
+
+ useEffect(() => {
+ if (drawerVisible) {
+ const query: BeginQuery[] = getBeginNodeDataQuery();
+ if (query.length > 0) {
+ showRunModal();
+ hideChatModal();
+ } else {
+ showChatModal();
+ hideRunModal();
+ }
+ }
+ }, [
+ hideChatModal,
+ hideRunModal,
+ showChatModal,
+ showRunModal,
+ drawerVisible,
+ getBeginNodeDataQuery,
+ ]);
+
+ const hideRunOrChatDrawer = useCallback(() => {
+ hideChatModal();
+ hideRunModal();
+ hideDrawer();
+ }, [hideChatModal, hideDrawer, hideRunModal]);
+
+ const onPaneClick = useCallback(() => {
+ hideFormDrawer();
+ }, [hideFormDrawer]);
+
+ const onNodeClick: NodeMouseHandler = useCallback(
+ (e, node) => {
+ if (!ExcludedNodes.some((x) => x === node.data.label)) {
+ hideSingleDebugDrawer();
+ hideRunOrChatDrawer();
+ showFormDrawer(node);
+ }
+ // handle single debug icon click
+ if (
+ get(e.target, 'dataset.play') === 'true' ||
+ get(e.target, 'parentNode.dataset.play') === 'true'
+ ) {
+ showSingleDebugDrawer();
+ }
+ },
+ [
+ hideRunOrChatDrawer,
+ hideSingleDebugDrawer,
+ showFormDrawer,
+ showSingleDebugDrawer,
+ ],
+ );
+
+ return {
+ chatVisible,
+ runVisible,
+ onPaneClick,
+ singleDebugDrawerVisible,
+ showSingleDebugDrawer,
+ hideSingleDebugDrawer,
+ formDrawerVisible,
+ showFormDrawer,
+ clickedNode,
+ onNodeClick,
+ hideFormDrawer,
+ hideRunOrChatDrawer,
+ showChatModal,
+ };
+}
diff --git a/web/src/pages/agent/index.tsx b/web/src/pages/agent/index.tsx
index 82e811b1a..906427979 100644
--- a/web/src/pages/agent/index.tsx
+++ b/web/src/pages/agent/index.tsx
@@ -1,12 +1,22 @@
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
+import { useSetModalState } from '@/hooks/common-hooks';
import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks';
import { Trash2 } from 'lucide-react';
import { AgentSidebar } from './agent-sidebar';
+import FlowCanvas from './canvas';
+import { useFetchDataOnMount } from './hooks/use-fetch-data';
export default function Agent() {
const { navigateToAgentList } = useNavigatePage();
+ const {
+ visible: chatDrawerVisible,
+ hideModal: hideChatDrawer,
+ showModal: showChatDrawer,
+ } = useSetModalState();
+
+ useFetchDataOnMount();
return (
@@ -33,7 +43,15 @@ export default function Agent() {
diff --git a/web/src/pages/agent/interface.ts b/web/src/pages/agent/interface.ts
new file mode 100644
index 000000000..ff70f1e69
--- /dev/null
+++ b/web/src/pages/agent/interface.ts
@@ -0,0 +1,30 @@
+import { RAGFlowNodeType } from '@/interfaces/database/flow';
+import { FormInstance } from 'antd';
+
+export interface IOperatorForm {
+ onValuesChange?(changedValues: any, values: any): void;
+ form?: FormInstance;
+ node?: RAGFlowNodeType;
+ nodeId?: string;
+}
+
+export interface IGenerateParameter {
+ id?: string;
+ key: string;
+ component_id?: string;
+}
+
+export interface IInvokeVariable extends IGenerateParameter {
+ value?: string;
+}
+
+export type IPosition = { top: number; right: number; idx: number };
+
+export interface BeginQuery {
+ key: string;
+ type: string;
+ value: string;
+ optional: boolean;
+ name: string;
+ options: (number | string | boolean)[];
+}
diff --git a/web/src/pages/agent/operator-icon/index.tsx b/web/src/pages/agent/operator-icon.tsx
similarity index 88%
rename from web/src/pages/agent/operator-icon/index.tsx
rename to web/src/pages/agent/operator-icon.tsx
index 1f6ec2c7f..ac78f212b 100644
--- a/web/src/pages/agent/operator-icon/index.tsx
+++ b/web/src/pages/agent/operator-icon.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { Operator, operatorIconMap } from '../constant';
+import { Operator, operatorIconMap } from './constant';
interface IProps {
name: Operator;
diff --git a/web/src/pages/agent/store.ts b/web/src/pages/agent/store.ts
new file mode 100644
index 000000000..c6e8c69b6
--- /dev/null
+++ b/web/src/pages/agent/store.ts
@@ -0,0 +1,455 @@
+import { RAGFlowNodeType } from '@/interfaces/database/flow';
+import type {} from '@redux-devtools/extension';
+import {
+ Connection,
+ Edge,
+ EdgeChange,
+ OnConnect,
+ OnEdgesChange,
+ OnNodesChange,
+ OnSelectionChangeFunc,
+ OnSelectionChangeParams,
+ addEdge,
+ applyEdgeChanges,
+ applyNodeChanges,
+} from '@xyflow/react';
+import { omit } from 'lodash';
+import differenceWith from 'lodash/differenceWith';
+import intersectionWith from 'lodash/intersectionWith';
+import lodashSet from 'lodash/set';
+import { create } from 'zustand';
+import { devtools } from 'zustand/middleware';
+import { immer } from 'zustand/middleware/immer';
+import { Operator, SwitchElseTo } from './constant';
+import {
+ duplicateNodeForm,
+ generateDuplicateNode,
+ generateNodeNamesWithIncreasingIndex,
+ getOperatorIndex,
+ isEdgeEqual,
+} from './utils';
+
+export type RFState = {
+ nodes: RAGFlowNodeType[];
+ edges: Edge[];
+ selectedNodeIds: string[];
+ selectedEdgeIds: string[];
+ clickedNodeId: string; // currently selected node
+ onNodesChange: OnNodesChange;
+ onEdgesChange: OnEdgesChange;
+ onConnect: OnConnect;
+ setNodes: (nodes: RAGFlowNodeType[]) => void;
+ setEdges: (edges: Edge[]) => void;
+ setEdgesByNodeId: (nodeId: string, edges: Edge[]) => void;
+ updateNodeForm: (
+ nodeId: string,
+ values: any,
+ path?: (string | number)[],
+ ) => RAGFlowNodeType[];
+ onSelectionChange: OnSelectionChangeFunc;
+ addNode: (nodes: RAGFlowNodeType) => void;
+ getNode: (id?: string | null) => RAGFlowNodeType | undefined;
+ addEdge: (connection: Connection) => void;
+ getEdge: (id: string) => Edge | undefined;
+ updateFormDataOnConnect: (connection: Connection) => void;
+ updateSwitchFormData: (
+ source: string,
+ sourceHandle?: string | null,
+ target?: string | null,
+ ) => void;
+ deletePreviousEdgeOfClassificationNode: (connection: Connection) => void;
+ duplicateNode: (id: string, name: string) => void;
+ duplicateIterationNode: (id: string, name: string) => void;
+ deleteEdge: () => void;
+ deleteEdgeById: (id: string) => void;
+ deleteNodeById: (id: string) => void;
+ deleteIterationNodeById: (id: string) => void;
+ deleteEdgeBySourceAndSourceHandle: (connection: Partial) => void;
+ findNodeByName: (operatorName: Operator) => RAGFlowNodeType | undefined;
+ updateMutableNodeFormItem: (id: string, field: string, value: any) => void;
+ getOperatorTypeFromId: (id?: string | null) => string | undefined;
+ getParentIdById: (id?: string | null) => string | undefined;
+ updateNodeName: (id: string, name: string) => void;
+ generateNodeName: (name: string) => string;
+ setClickedNodeId: (id?: string) => void;
+};
+
+// this is our useStore hook that we can use in our components to get parts of the store and call actions
+const useGraphStore = create()(
+ devtools(
+ immer((set, get) => ({
+ nodes: [] as RAGFlowNodeType[],
+ edges: [] as Edge[],
+ selectedNodeIds: [] as string[],
+ selectedEdgeIds: [] as string[],
+ clickedNodeId: '',
+ onNodesChange: (changes) => {
+ set({
+ nodes: applyNodeChanges(changes, get().nodes),
+ });
+ },
+ onEdgesChange: (changes: EdgeChange[]) => {
+ set({
+ edges: applyEdgeChanges(changes, get().edges),
+ });
+ },
+ onConnect: (connection: Connection) => {
+ const {
+ deletePreviousEdgeOfClassificationNode,
+ updateFormDataOnConnect,
+ } = get();
+ set({
+ edges: addEdge(connection, get().edges),
+ });
+ deletePreviousEdgeOfClassificationNode(connection);
+ updateFormDataOnConnect(connection);
+ },
+ onSelectionChange: ({ nodes, edges }: OnSelectionChangeParams) => {
+ set({
+ selectedEdgeIds: edges.map((x) => x.id),
+ selectedNodeIds: nodes.map((x) => x.id),
+ });
+ },
+ setNodes: (nodes: RAGFlowNodeType[]) => {
+ set({ nodes });
+ },
+ setEdges: (edges: Edge[]) => {
+ set({ edges });
+ },
+ setEdgesByNodeId: (nodeId: string, currentDownstreamEdges: Edge[]) => {
+ const { edges, setEdges } = get();
+ // the previous downstream edge of this node
+ const previousDownstreamEdges = edges.filter(
+ (x) => x.source === nodeId,
+ );
+ const isDifferent =
+ previousDownstreamEdges.length !== currentDownstreamEdges.length ||
+ !previousDownstreamEdges.every((x) =>
+ currentDownstreamEdges.some(
+ (y) =>
+ y.source === x.source &&
+ y.target === x.target &&
+ y.sourceHandle === x.sourceHandle,
+ ),
+ ) ||
+ !currentDownstreamEdges.every((x) =>
+ previousDownstreamEdges.some(
+ (y) =>
+ y.source === x.source &&
+ y.target === x.target &&
+ y.sourceHandle === x.sourceHandle,
+ ),
+ );
+
+ const intersectionDownstreamEdges = intersectionWith(
+ previousDownstreamEdges,
+ currentDownstreamEdges,
+ isEdgeEqual,
+ );
+ if (isDifferent) {
+ // other operator's edges
+ const irrelevantEdges = edges.filter((x) => x.source !== nodeId);
+ // the added downstream edges
+ const selfAddedDownstreamEdges = differenceWith(
+ currentDownstreamEdges,
+ intersectionDownstreamEdges,
+ isEdgeEqual,
+ );
+ setEdges([
+ ...irrelevantEdges,
+ ...intersectionDownstreamEdges,
+ ...selfAddedDownstreamEdges,
+ ]);
+ }
+ },
+ addNode: (node: RAGFlowNodeType) => {
+ set({ nodes: get().nodes.concat(node) });
+ },
+ getNode: (id?: string | null) => {
+ return get().nodes.find((x) => x.id === id);
+ },
+ getOperatorTypeFromId: (id?: string | null) => {
+ return get().getNode(id)?.data?.label;
+ },
+ getParentIdById: (id?: string | null) => {
+ return get().getNode(id)?.parentId;
+ },
+ addEdge: (connection: Connection) => {
+ set({
+ edges: addEdge(connection, get().edges),
+ });
+ get().deletePreviousEdgeOfClassificationNode(connection);
+ // TODO: This may not be reasonable. You need to choose between listening to changes in the form.
+ get().updateFormDataOnConnect(connection);
+ },
+ getEdge: (id: string) => {
+ return get().edges.find((x) => x.id === id);
+ },
+ updateFormDataOnConnect: (connection: Connection) => {
+ const { getOperatorTypeFromId, updateNodeForm, updateSwitchFormData } =
+ get();
+ const { source, target, sourceHandle } = connection;
+ const operatorType = getOperatorTypeFromId(source);
+ if (source) {
+ switch (operatorType) {
+ case Operator.Relevant:
+ updateNodeForm(source, { [sourceHandle as string]: target });
+ break;
+ case Operator.Categorize:
+ if (sourceHandle)
+ updateNodeForm(source, target, [
+ 'category_description',
+ sourceHandle,
+ 'to',
+ ]);
+ break;
+ case Operator.Switch: {
+ updateSwitchFormData(source, sourceHandle, target);
+ break;
+ }
+ default:
+ break;
+ }
+ }
+ },
+ deletePreviousEdgeOfClassificationNode: (connection: Connection) => {
+ // Delete the edge on the classification node or relevant node anchor when the anchor is connected to other nodes
+ const { edges, getOperatorTypeFromId, deleteEdgeById } = get();
+ // the node containing the anchor
+ const anchoredNodes = [
+ Operator.Categorize,
+ Operator.Relevant,
+ Operator.Switch,
+ ];
+ if (
+ anchoredNodes.some(
+ (x) => x === getOperatorTypeFromId(connection.source),
+ )
+ ) {
+ const previousEdge = edges.find(
+ (x) =>
+ x.source === connection.source &&
+ x.sourceHandle === connection.sourceHandle &&
+ x.target !== connection.target,
+ );
+ if (previousEdge) {
+ deleteEdgeById(previousEdge.id);
+ }
+ }
+ },
+ duplicateNode: (id: string, name: string) => {
+ const { getNode, addNode, generateNodeName, duplicateIterationNode } =
+ get();
+ const node = getNode(id);
+
+ if (node?.data.label === Operator.Iteration) {
+ duplicateIterationNode(id, name);
+ return;
+ }
+
+ addNode({
+ ...(node || {}),
+ data: {
+ ...duplicateNodeForm(node?.data),
+ name: generateNodeName(name),
+ },
+ ...generateDuplicateNode(node?.position, node?.data?.label),
+ });
+ },
+ duplicateIterationNode: (id: string, name: string) => {
+ const { getNode, generateNodeName, nodes } = get();
+ const node = getNode(id);
+
+ const iterationNode: RAGFlowNodeType = {
+ ...(node || {}),
+ data: {
+ ...(node?.data || { label: Operator.Iteration, form: {} }),
+ name: generateNodeName(name),
+ },
+ ...generateDuplicateNode(node?.position, node?.data?.label),
+ };
+
+ const children = nodes
+ .filter((x) => x.parentId === node?.id)
+ .map((x) => ({
+ ...(x || {}),
+ data: {
+ ...duplicateNodeForm(x?.data),
+ name: generateNodeName(x.data.name),
+ },
+ ...omit(generateDuplicateNode(x?.position, x?.data?.label), [
+ 'position',
+ ]),
+ parentId: iterationNode.id,
+ }));
+
+ set({ nodes: nodes.concat(iterationNode, ...children) });
+ },
+ deleteEdge: () => {
+ const { edges, selectedEdgeIds } = get();
+ set({
+ edges: edges.filter((edge) =>
+ selectedEdgeIds.every((x) => x !== edge.id),
+ ),
+ });
+ },
+ deleteEdgeById: (id: string) => {
+ const {
+ edges,
+ updateNodeForm,
+ getOperatorTypeFromId,
+ updateSwitchFormData,
+ } = get();
+ const currentEdge = edges.find((x) => x.id === id);
+
+ if (currentEdge) {
+ const { source, sourceHandle } = currentEdge;
+ const operatorType = getOperatorTypeFromId(source);
+ // After deleting the edge, set the corresponding field in the node's form field to undefined
+ switch (operatorType) {
+ case Operator.Relevant:
+ updateNodeForm(source, {
+ [sourceHandle as string]: undefined,
+ });
+ break;
+ case Operator.Categorize:
+ if (sourceHandle)
+ updateNodeForm(source, undefined, [
+ 'category_description',
+ sourceHandle,
+ 'to',
+ ]);
+ break;
+ case Operator.Switch: {
+ updateSwitchFormData(source, sourceHandle, undefined);
+ break;
+ }
+ default:
+ break;
+ }
+ }
+ set({
+ edges: edges.filter((edge) => edge.id !== id),
+ });
+ },
+ deleteEdgeBySourceAndSourceHandle: ({
+ source,
+ sourceHandle,
+ }: Partial) => {
+ const { edges } = get();
+ const nextEdges = edges.filter(
+ (edge) =>
+ edge.source !== source || edge.sourceHandle !== sourceHandle,
+ );
+ set({
+ edges: nextEdges,
+ });
+ },
+ deleteNodeById: (id: string) => {
+ const { nodes, edges } = get();
+ set({
+ nodes: nodes.filter((node) => node.id !== id),
+ edges: edges
+ .filter((edge) => edge.source !== id)
+ .filter((edge) => edge.target !== id),
+ });
+ },
+ deleteIterationNodeById: (id: string) => {
+ const { nodes, edges } = get();
+ const children = nodes.filter((node) => node.parentId === id);
+ set({
+ nodes: nodes.filter((node) => node.id !== id && node.parentId !== id),
+ edges: edges.filter(
+ (edge) =>
+ edge.source !== id &&
+ edge.target !== id &&
+ !children.some(
+ (child) => edge.source === child.id && edge.target === child.id,
+ ),
+ ),
+ });
+ },
+ findNodeByName: (name: Operator) => {
+ return get().nodes.find((x) => x.data.label === name);
+ },
+ updateNodeForm: (
+ nodeId: string,
+ values: any,
+ path: (string | number)[] = [],
+ ) => {
+ const nextNodes = get().nodes.map((node) => {
+ if (node.id === nodeId) {
+ let nextForm: Record = { ...node.data.form };
+ if (path.length === 0) {
+ nextForm = Object.assign(nextForm, values);
+ } else {
+ lodashSet(nextForm, path, values);
+ }
+ return {
+ ...node,
+ data: {
+ ...node.data,
+ form: nextForm,
+ },
+ } as any;
+ }
+
+ return node;
+ });
+ set({
+ nodes: nextNodes,
+ });
+
+ return nextNodes;
+ },
+ updateSwitchFormData: (source, sourceHandle, target) => {
+ const { updateNodeForm } = get();
+ if (sourceHandle) {
+ if (sourceHandle === SwitchElseTo) {
+ updateNodeForm(source, target, [SwitchElseTo]);
+ } else {
+ const operatorIndex = getOperatorIndex(sourceHandle);
+ if (operatorIndex) {
+ updateNodeForm(source, target, [
+ 'conditions',
+ Number(operatorIndex) - 1, // The index is the conditions form index
+ 'to',
+ ]);
+ }
+ }
+ }
+ },
+ updateMutableNodeFormItem: (id: string, field: string, value: any) => {
+ const { nodes } = get();
+ const idx = nodes.findIndex((x) => x.id === id);
+ if (idx) {
+ lodashSet(nodes, [idx, 'data', 'form', field], value);
+ }
+ },
+ updateNodeName: (id, name) => {
+ if (id) {
+ set({
+ nodes: get().nodes.map((node) => {
+ if (node.id === id) {
+ node.data.name = name;
+ }
+
+ return node;
+ }),
+ });
+ }
+ },
+ setClickedNodeId: (id?: string) => {
+ set({ clickedNodeId: id });
+ },
+ generateNodeName: (name: string) => {
+ const { nodes } = get();
+
+ return generateNodeNamesWithIncreasingIndex(name, nodes);
+ },
+ })),
+ { name: 'graph' },
+ ),
+);
+
+export default useGraphStore;
diff --git a/web/src/pages/agent/utils.test.ts b/web/src/pages/agent/utils.test.ts
new file mode 100644
index 000000000..dbb89ce7e
--- /dev/null
+++ b/web/src/pages/agent/utils.test.ts
@@ -0,0 +1,106 @@
+import fs from 'fs';
+import path from 'path';
+import customer_service from '../../../../graph/test/dsl_examples/customer_service.json';
+import headhunter_zh from '../../../../graph/test/dsl_examples/headhunter_zh.json';
+import interpreter from '../../../../graph/test/dsl_examples/interpreter.json';
+import retrievalRelevantRewriteAndGenerate from '../../../../graph/test/dsl_examples/retrieval_relevant_rewrite_and_generate.json';
+import { dsl } from './mock';
+import { buildNodesAndEdgesFromDSLComponents } from './utils';
+
+test('buildNodesAndEdgesFromDSLComponents', () => {
+ const { edges, nodes } = buildNodesAndEdgesFromDSLComponents(dsl.components);
+
+ expect(nodes.length).toEqual(4);
+ expect(edges.length).toEqual(4);
+
+ expect(edges).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ source: 'begin',
+ target: 'Answer:China',
+ }),
+ expect.objectContaining({
+ source: 'Answer:China',
+ target: 'Retrieval:China',
+ }),
+ expect.objectContaining({
+ source: 'Retrieval:China',
+ target: 'Generate:China',
+ }),
+ expect.objectContaining({
+ source: 'Generate:China',
+ target: 'Answer:China',
+ }),
+ ]),
+ );
+});
+
+test('build nodes and edges from headhunter_zh dsl', () => {
+ const { edges, nodes } = buildNodesAndEdgesFromDSLComponents(
+ headhunter_zh.components,
+ );
+ console.info('node length', nodes.length);
+ console.info('edge length', edges.length);
+ try {
+ fs.writeFileSync(
+ path.join(__dirname, 'headhunter_zh.json'),
+ JSON.stringify({ edges, nodes }, null, 4),
+ );
+ console.log('JSON data is saved.');
+ } catch (error) {
+ console.warn(error);
+ }
+ expect(nodes.length).toEqual(12);
+});
+
+test('build nodes and edges from customer_service dsl', () => {
+ const { edges, nodes } = buildNodesAndEdgesFromDSLComponents(
+ customer_service.components,
+ );
+ console.info('node length', nodes.length);
+ console.info('edge length', edges.length);
+ try {
+ fs.writeFileSync(
+ path.join(__dirname, 'customer_service.json'),
+ JSON.stringify({ edges, nodes }, null, 4),
+ );
+ console.log('JSON data is saved.');
+ } catch (error) {
+ console.warn(error);
+ }
+ expect(nodes.length).toEqual(12);
+});
+
+test('build nodes and edges from interpreter dsl', () => {
+ const { edges, nodes } = buildNodesAndEdgesFromDSLComponents(
+ interpreter.components,
+ );
+ console.info('node length', nodes.length);
+ console.info('edge length', edges.length);
+ try {
+ fs.writeFileSync(
+ path.join(__dirname, 'interpreter.json'),
+ JSON.stringify({ edges, nodes }, null, 4),
+ );
+ console.log('JSON data is saved.');
+ } catch (error) {
+ console.warn(error);
+ }
+ expect(nodes.length).toEqual(12);
+});
+
+test('build nodes and edges from chat bot dsl', () => {
+ const { edges, nodes } = buildNodesAndEdgesFromDSLComponents(
+ retrievalRelevantRewriteAndGenerate.components,
+ );
+ try {
+ fs.writeFileSync(
+ path.join(__dirname, 'retrieval_relevant_rewrite_and_generate.json'),
+ JSON.stringify({ edges, nodes }, null, 4),
+ );
+ console.log('JSON data is saved.');
+ } catch (error) {
+ console.warn(error);
+ }
+ expect(nodes.length).toEqual(12);
+});
diff --git a/web/src/pages/agent/utils.ts b/web/src/pages/agent/utils.ts
new file mode 100644
index 000000000..dcd0867dd
--- /dev/null
+++ b/web/src/pages/agent/utils.ts
@@ -0,0 +1,418 @@
+import {
+ DSLComponents,
+ ICategorizeItem,
+ ICategorizeItemResult,
+ RAGFlowNodeType,
+} from '@/interfaces/database/flow';
+import { removeUselessFieldsFromValues } from '@/utils/form';
+import { Edge, Node, Position, XYPosition } from '@xyflow/react';
+import { FormInstance, FormListFieldData } from 'antd';
+import { humanId } from 'human-id';
+import { curry, get, intersectionWith, isEqual, sample } from 'lodash';
+import pipe from 'lodash/fp/pipe';
+import isObject from 'lodash/isObject';
+import { v4 as uuidv4 } from 'uuid';
+import {
+ CategorizeAnchorPointPositions,
+ NoDebugOperatorsList,
+ NodeMap,
+ Operator,
+} from './constant';
+import { IPosition } from './interface';
+
+const buildEdges = (
+ operatorIds: string[],
+ currentId: string,
+ allEdges: Edge[],
+ isUpstream = false,
+ componentName: string,
+ nodeParams: Record,
+) => {
+ operatorIds.forEach((cur) => {
+ const source = isUpstream ? cur : currentId;
+ const target = isUpstream ? currentId : cur;
+ if (!allEdges.some((e) => e.source === source && e.target === target)) {
+ const edge: Edge = {
+ id: uuidv4(),
+ label: '',
+ // type: 'step',
+ source: source,
+ target: target,
+ // markerEnd: {
+ // type: MarkerType.ArrowClosed,
+ // color: 'rgb(157 149 225)',
+ // width: 20,
+ // height: 20,
+ // },
+ };
+ if (componentName === Operator.Categorize && !isUpstream) {
+ const categoryDescription =
+ nodeParams.category_description as ICategorizeItemResult;
+
+ const name = Object.keys(categoryDescription).find(
+ (x) => categoryDescription[x].to === target,
+ );
+
+ if (name) {
+ edge.sourceHandle = name;
+ }
+ }
+ allEdges.push(edge);
+ }
+ });
+};
+
+export const buildNodesAndEdgesFromDSLComponents = (data: DSLComponents) => {
+ const nodes: Node[] = [];
+ let edges: Edge[] = [];
+
+ Object.entries(data).forEach(([key, value]) => {
+ const downstream = [...value.downstream];
+ const upstream = [...value.upstream];
+ const { component_name: componentName, params } = value.obj;
+ nodes.push({
+ id: key,
+ type: NodeMap[value.obj.component_name as Operator] || 'ragNode',
+ position: { x: 0, y: 0 },
+ data: {
+ label: componentName,
+ name: humanId(),
+ form: params,
+ },
+ sourcePosition: Position.Left,
+ targetPosition: Position.Right,
+ });
+
+ buildEdges(upstream, key, edges, true, componentName, params);
+ buildEdges(downstream, key, edges, false, componentName, params);
+ });
+
+ return { nodes, edges };
+};
+
+const buildComponentDownstreamOrUpstream = (
+ edges: Edge[],
+ nodeId: string,
+ isBuildDownstream = true,
+) => {
+ return edges
+ .filter((y) => y[isBuildDownstream ? 'source' : 'target'] === nodeId)
+ .map((y) => y[isBuildDownstream ? 'target' : 'source']);
+};
+
+const removeUselessDataInTheOperator = curry(
+ (operatorName: string, params: Record) => {
+ if (
+ operatorName === Operator.Generate ||
+ operatorName === Operator.Categorize
+ ) {
+ return removeUselessFieldsFromValues(params, '');
+ }
+ return params;
+ },
+);
+// initialize data for operators without parameters
+// const initializeOperatorParams = curry((operatorName: string, values: any) => {
+// if (isEmpty(values)) {
+// return initialFormValuesMap[operatorName as Operator];
+// }
+// return values;
+// });
+
+const buildOperatorParams = (operatorName: string) =>
+ pipe(
+ removeUselessDataInTheOperator(operatorName),
+ // initializeOperatorParams(operatorName), // Final processing, for guarantee
+ );
+
+// construct a dsl based on the node information of the graph
+export const buildDslComponentsByGraph = (
+ nodes: RAGFlowNodeType[],
+ edges: Edge[],
+ oldDslComponents: DSLComponents,
+): DSLComponents => {
+ const components: DSLComponents = {};
+
+ nodes
+ ?.filter((x) => x.data.label !== Operator.Note)
+ .forEach((x) => {
+ const id = x.id;
+ const operatorName = x.data.label;
+ components[id] = {
+ obj: {
+ ...(oldDslComponents[id]?.obj ?? {}),
+ component_name: operatorName,
+ params:
+ buildOperatorParams(operatorName)(
+ x.data.form as Record,
+ ) ?? {},
+ },
+ downstream: buildComponentDownstreamOrUpstream(edges, id, true),
+ upstream: buildComponentDownstreamOrUpstream(edges, id, false),
+ parent_id: x?.parentId,
+ };
+ });
+
+ return components;
+};
+
+export const receiveMessageError = (res: any) =>
+ res && (res?.response.status !== 200 || res?.data?.code !== 0);
+
+// Replace the id in the object with text
+export const replaceIdWithText = (
+ obj: Record | unknown[] | unknown,
+ getNameById: (id?: string) => string | undefined,
+) => {
+ if (isObject(obj)) {
+ const ret: Record | unknown[] = Array.isArray(obj)
+ ? []
+ : {};
+ Object.keys(obj).forEach((key) => {
+ const val = (obj as Record)[key];
+ const text = typeof val === 'string' ? getNameById(val) : undefined;
+ (ret as Record)[key] = text
+ ? text
+ : replaceIdWithText(val, getNameById);
+ });
+
+ return ret;
+ }
+
+ return obj;
+};
+
+export const isEdgeEqual = (previous: Edge, current: Edge) =>
+ previous.source === current.source &&
+ previous.target === current.target &&
+ previous.sourceHandle === current.sourceHandle;
+
+export const buildNewPositionMap = (
+ currentKeys: string[],
+ previousPositionMap: Record,
+) => {
+ // index in use
+ const indexesInUse = Object.values(previousPositionMap).map((x) => x.idx);
+ const previousKeys = Object.keys(previousPositionMap);
+ const intersectionKeys = intersectionWith(
+ previousKeys,
+ currentKeys,
+ (categoryDataKey: string, positionMapKey: string) =>
+ categoryDataKey === positionMapKey,
+ );
+ // difference set
+ const currentDifferenceKeys = currentKeys.filter(
+ (x) => !intersectionKeys.some((y: string) => y === x),
+ );
+ const newPositionMap = currentDifferenceKeys.reduce<
+ Record
+ >((pre, cur) => {
+ // take a coordinate
+ const effectiveIdxes = CategorizeAnchorPointPositions.map(
+ (x, idx) => idx,
+ ).filter((x) => !indexesInUse.some((y) => y === x));
+ const idx = sample(effectiveIdxes);
+ if (idx !== undefined) {
+ indexesInUse.push(idx);
+ pre[cur] = { ...CategorizeAnchorPointPositions[idx], idx };
+ }
+
+ return pre;
+ }, {});
+
+ return { intersectionKeys, newPositionMap };
+};
+
+export const isKeysEqual = (currentKeys: string[], previousKeys: string[]) => {
+ return isEqual(currentKeys.sort(), previousKeys.sort());
+};
+
+export const getOperatorIndex = (handleTitle: string) => {
+ return handleTitle.split(' ').at(-1);
+};
+
+// Get the value of other forms except itself
+export const getOtherFieldValues = (
+ form: FormInstance,
+ formListName: string = 'items',
+ field: FormListFieldData,
+ latestField: string,
+) =>
+ (form.getFieldValue([formListName]) ?? [])
+ .map((x: any) => {
+ return get(x, latestField);
+ })
+ .filter(
+ (x: string) =>
+ x !== form.getFieldValue([formListName, field.name, latestField]),
+ );
+
+export const generateSwitchHandleText = (idx: number) => {
+ return `Case ${idx + 1}`;
+};
+
+export const getNodeDragHandle = (nodeType?: string) => {
+ return nodeType === Operator.Note ? '.note-drag-handle' : undefined;
+};
+
+const splitName = (name: string) => {
+ const names = name.split('_');
+ const type = names.at(0);
+ const index = Number(names.at(-1));
+
+ return { type, index };
+};
+
+export const generateNodeNamesWithIncreasingIndex = (
+ name: string,
+ nodes: RAGFlowNodeType[],
+) => {
+ const templateNameList = nodes
+ .filter((x) => {
+ const temporaryName = x.data.name;
+
+ const { type, index } = splitName(temporaryName);
+
+ return (
+ temporaryName.match(/_/g)?.length === 1 &&
+ type === name &&
+ !isNaN(index)
+ );
+ })
+ .map((x) => {
+ const temporaryName = x.data.name;
+ const { index } = splitName(temporaryName);
+
+ return {
+ idx: index,
+ name: temporaryName,
+ };
+ })
+ .sort((a, b) => a.idx - b.idx);
+
+ let index: number = 0;
+ for (let i = 0; i < templateNameList.length; i++) {
+ const idx = templateNameList[i]?.idx;
+ const nextIdx = templateNameList[i + 1]?.idx;
+ if (idx + 1 !== nextIdx) {
+ index = idx + 1;
+ break;
+ }
+ }
+
+ return `${name}_${index}`;
+};
+
+export const duplicateNodeForm = (nodeData?: RAGFlowNodeType['data']) => {
+ const form: Record = { ...(nodeData?.form ?? {}) };
+
+ // Delete the downstream node corresponding to the to field of the Categorize operator
+ if (nodeData?.label === Operator.Categorize) {
+ form.category_description = Object.keys(form.category_description).reduce<
+ Record>
+ >((pre, cur) => {
+ pre[cur] = {
+ ...form.category_description[cur],
+ to: undefined,
+ };
+ return pre;
+ }, {});
+ }
+
+ // Delete the downstream nodes corresponding to the yes and no fields of the Relevant operator
+ if (nodeData?.label === Operator.Relevant) {
+ form.yes = undefined;
+ form.no = undefined;
+ }
+
+ return {
+ ...(nodeData ?? { label: '' }),
+ form,
+ };
+};
+
+export const getDrawerWidth = () => {
+ return window.innerWidth > 1278 ? '40%' : 470;
+};
+
+export const needsSingleStepDebugging = (label: string) => {
+ return !NoDebugOperatorsList.some((x) => (label as Operator) === x);
+};
+
+// Get the coordinates of the node relative to the Iteration node
+export function getRelativePositionToIterationNode(
+ nodes: RAGFlowNodeType[],
+ position?: XYPosition, // relative position
+) {
+ if (!position) {
+ return;
+ }
+
+ const iterationNodes = nodes.filter(
+ (node) => node.data.label === Operator.Iteration,
+ );
+
+ for (const iterationNode of iterationNodes) {
+ const {
+ position: { x, y },
+ width,
+ height,
+ } = iterationNode;
+ const halfWidth = (width || 0) / 2;
+ if (
+ position.x >= x - halfWidth &&
+ position.x <= x + halfWidth &&
+ position.y >= y &&
+ position.y <= y + (height || 0)
+ ) {
+ return {
+ parentId: iterationNode.id,
+ position: { x: position.x - x + halfWidth, y: position.y - y },
+ };
+ }
+ }
+}
+
+export const generateDuplicateNode = (
+ position?: XYPosition,
+ label?: string,
+) => {
+ const nextPosition = {
+ x: (position?.x || 0) + 50,
+ y: (position?.y || 0) + 50,
+ };
+
+ return {
+ selected: false,
+ dragging: false,
+ id: `${label}:${humanId()}`,
+ position: nextPosition,
+ dragHandle: getNodeDragHandle(label),
+ };
+};
+
+/**
+ * convert the following object into a list
+ *
+ * {
+ "product_related": {
+ "description": "The question is about product usage, appearance and how it works.",
+ "examples": "Why it always beaming?\nHow to install it onto the wall?\nIt leaks, what to do?",
+ "to": "generate:0"
+ }
+ }
+*/
+export const buildCategorizeListFromObject = (
+ categorizeItem: ICategorizeItemResult,
+) => {
+ // Categorize's to field has two data sources, with edges as the data source.
+ // Changes in the edge or to field need to be synchronized to the form field.
+ return Object.keys(categorizeItem)
+ .reduce>((pre, cur) => {
+ // synchronize edge data to the to field
+
+ pre.push({ name: cur, ...categorizeItem[cur] });
+ return pre;
+ }, [])
+ .sort((a, b) => a.index - b.index);
+};
diff --git a/web/src/pages/agents/agent-card.tsx b/web/src/pages/agents/agent-card.tsx
index 7524f96c6..dd72295ac 100644
--- a/web/src/pages/agents/agent-card.tsx
+++ b/web/src/pages/agents/agent-card.tsx
@@ -39,7 +39,11 @@ export function AgentCard({ data }: IProps) {
-