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 ( +
+
{position.text}
+ +
+ ); + })} +
+
+ ); +} 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 ( + <> + + + +
+ + + + + +
+ +