Feat: Add a child operator node by clicking the operator node anchor point #3221 (#8309)

### What problem does this PR solve?

Feat: Add a child operator node by clicking the operator node anchor
point #3221

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
balibabu
2025-06-17 11:57:07 +08:00
committed by GitHub
parent a9532cb9e7
commit 307d5299e7
14 changed files with 375 additions and 177 deletions

View File

@ -149,38 +149,40 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
</marker>
</defs>
</svg>
<ReactFlow
connectionMode={ConnectionMode.Loose}
nodes={nodes}
onNodesChange={onNodesChange}
edges={edges}
onEdgesChange={onEdgesChange}
fitView
onConnect={onConnect}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onDrop={onDrop}
onDragOver={onDragOver}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
onInit={setReactFlowInstance}
onSelectionChange={onSelectionChange}
nodeOrigin={[0.5, 0]}
isValidConnection={isValidConnection}
defaultEdgeOptions={{
type: 'buttonEdge',
markerEnd: 'logo',
style: {
strokeWidth: 2,
stroke: 'rgb(202 197 245)',
},
zIndex: 1001, // https://github.com/xyflow/xyflow/discussions/3498
}}
deleteKeyCode={['Delete', 'Backspace']}
onBeforeDelete={handleBeforeDelete}
>
<Background />
</ReactFlow>
<AgentInstanceContext.Provider value={{ addCanvasNode }}>
<ReactFlow
connectionMode={ConnectionMode.Loose}
nodes={nodes}
onNodesChange={onNodesChange}
edges={edges}
onEdgesChange={onEdgesChange}
fitView
onConnect={onConnect}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onDrop={onDrop}
onDragOver={onDragOver}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
onInit={setReactFlowInstance}
onSelectionChange={onSelectionChange}
nodeOrigin={[0.5, 0]}
isValidConnection={isValidConnection}
defaultEdgeOptions={{
type: 'buttonEdge',
markerEnd: 'logo',
style: {
strokeWidth: 2,
stroke: 'rgb(202 197 245)',
},
zIndex: 1001, // https://github.com/xyflow/xyflow/discussions/3498
}}
deleteKeyCode={['Delete', 'Backspace']}
onBeforeDelete={handleBeforeDelete}
>
<Background />
</ReactFlow>
</AgentInstanceContext.Provider>
{formDrawerVisible && (
<AgentInstanceContext.Provider value={{ addCanvasNode }}>
<FormSheet

View File

@ -36,6 +36,7 @@ function InnerAgentNode({
position={Position.Left}
isConnectable={isConnectable}
style={LeftHandleStyle}
nodeId={id}
></CommonHandle>
<CommonHandle
type="source"
@ -44,6 +45,7 @@ function InnerAgentNode({
className={styles.handle}
id="b"
style={RightHandleStyle}
nodeId={id}
></CommonHandle>
</>
)}

View File

@ -17,7 +17,7 @@ import styles from './index.less';
import { NodeWrapper } from './node-wrapper';
// TODO: do not allow other nodes to connect to this node
function InnerBeginNode({ data }: NodeProps<IBeginNode>) {
function InnerBeginNode({ data, id }: NodeProps<IBeginNode>) {
const { t } = useTranslation();
const query: BeginQuery[] = get(data, 'form.query', []);
@ -29,14 +29,15 @@ function InnerBeginNode({ data }: NodeProps<IBeginNode>) {
isConnectable
className={styles.handle}
style={RightHandleStyle}
nodeId={id}
></CommonHandle>
<Flex align="center" justify={'center'} gap={10}>
<section className="flex items-center justify-center gap-2">
<OperatorIcon name={data.label as Operator}></OperatorIcon>
<div className="truncate text-center font-semibold text-sm">
{t(`flow.begin`)}
</div>
</Flex>
</section>
<Flex gap={8} vertical className={styles.generateParameters}>
{query.map((x, idx) => {
const Icon = BeginQueryTypeIconMap[x.type as BeginQueryType];

View File

@ -24,6 +24,7 @@ export function InnerCategorizeNode({
position={Position.Left}
isConnectable
id={'a'}
nodeId={id}
></CommonHandle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
@ -45,6 +46,7 @@ export function InnerCategorizeNode({
position={Position.Right}
isConnectable
style={{ ...RightHandleStyle, top: position.top }}
nodeId={id}
></CommonHandle>
</div>
);

View File

@ -0,0 +1,111 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Operator } from '@/pages/agent/constant';
import { AgentInstanceContext, HandleContext } from '@/pages/agent/context';
import OperatorIcon from '@/pages/agent/operator-icon';
import { PropsWithChildren, useContext } from 'react';
type OperatorItemProps = { operators: Operator[] };
function OperatorItemList({ operators }: OperatorItemProps) {
const { addCanvasNode } = useContext(AgentInstanceContext);
const { nodeId, id, type, position } = useContext(HandleContext);
return (
<ul className="space-y-2">
{operators.map((x) => {
return (
<DropdownMenuItem
key={x}
className="hover:bg-background-card py-1 px-3 cursor-pointer rounded-sm flex gap-2 items-center justify-start"
onClick={addCanvasNode(x, {
id: nodeId,
sourceHandle: id,
position,
})}
>
<OperatorIcon name={x}></OperatorIcon>
{x}
</DropdownMenuItem>
);
})}
</ul>
);
}
function AccordionOperators() {
return (
<Accordion
type="multiple"
className="px-2 text-text-title"
defaultValue={['item-1', 'item-2', 'item-3', 'item-4', 'item-5']}
>
<AccordionItem value="item-1">
<AccordionTrigger className="text-xl">AI</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList
operators={[Operator.Agent, Operator.Retrieval]}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger className="text-xl">Dialogue </AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList operators={[Operator.Message]}></OperatorItemList>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger className="text-xl">Flow</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList
operators={[
Operator.Switch,
Operator.Iteration,
Operator.Categorize,
]}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-4">
<AccordionTrigger className="text-xl">
Data Manipulation
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList operators={[Operator.Code]}></OperatorItemList>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-5">
<AccordionTrigger className="text-xl">Tools</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList operators={[]}></OperatorItemList>
</AccordionContent>
</AccordionItem>
</Accordion>
);
}
export function NextStepDropdown({ children }: PropsWithChildren) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
onClick={(e) => e.stopPropagation()}
className="w-[300px] font-semibold"
>
<DropdownMenuLabel>Next Step</DropdownMenuLabel>
<AccordionOperators></AccordionOperators>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -1,17 +1,41 @@
import { cn } from '@/lib/utils';
import { Handle, HandleProps } from '@xyflow/react';
import { Plus } from 'lucide-react';
import { useMemo } from 'react';
import { HandleContext } from '../../context';
import { NextStepDropdown } from './dropdown/next-step-dropdown';
export function CommonHandle({
className,
nodeId,
...props
}: HandleProps & { nodeId: string }) {
const value = useMemo(
() => ({
nodeId,
id: props.id,
type: props.type,
position: props.position,
}),
[nodeId, props.id, props.position, props.type],
);
export function CommonHandle({ className, ...props }: HandleProps) {
return (
<Handle
{...props}
className={cn(
'inline-flex justify-center items-center !bg-background-checked !size-4 !rounded-sm !border-none ',
className,
)}
>
<Plus className="size-3 pointer-events-none" />
</Handle>
<HandleContext.Provider value={value}>
<NextStepDropdown>
<Handle
{...props}
className={cn(
'inline-flex justify-center items-center !bg-background-checked !size-4 !rounded-sm !border-none ',
className,
)}
onClick={(e) => {
e.stopPropagation();
}}
>
<Plus className="size-3 pointer-events-none" />
</Handle>
</NextStepDropdown>
</HandleContext.Provider>
);
}

View File

@ -1,11 +1,10 @@
import { useTheme } from '@/components/theme-provider';
import { IRagNode } from '@/interfaces/database/flow';
import { Handle, NodeProps, Position } from '@xyflow/react';
import classNames from 'classnames';
import { NodeProps, Position } from '@xyflow/react';
import { memo } from 'react';
import { CommonHandle } from './handle';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
function InnerRagNode({
@ -14,36 +13,27 @@ function InnerRagNode({
isConnectable = true,
selected,
}: NodeProps<IRagNode>) {
const { theme } = useTheme();
return (
<ToolBar selected={selected} id={id} label={data.label}>
<section
className={classNames(
styles.ragNode,
theme === 'dark' ? styles.dark : '',
{
[styles.selectedNode]: selected,
},
)}
>
<Handle
<NodeWrapper>
<CommonHandle
id="c"
type="source"
position={Position.Left}
isConnectable={isConnectable}
className={styles.handle}
style={LeftHandleStyle}
></Handle>
<Handle
nodeId={id}
></CommonHandle>
<CommonHandle
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
id="b"
style={RightHandleStyle}
></Handle>
nodeId={id}
></CommonHandle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
</section>
</NodeWrapper>
</ToolBar>
);
}

View File

@ -1,11 +1,11 @@
import { useTheme } from '@/components/theme-provider';
import { ILogicNode } from '@/interfaces/database/flow';
import { Handle, NodeProps, Position } from '@xyflow/react';
import classNames from 'classnames';
import { NodeProps, Position } from '@xyflow/react';
import { memo } from 'react';
import { CommonHandle } from './handle';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
export function InnerLogicNode({
id,
@ -13,35 +13,28 @@ export function InnerLogicNode({
isConnectable = true,
selected,
}: NodeProps<ILogicNode>) {
const { theme } = useTheme();
return (
<section
className={classNames(
styles.logicNode,
theme === 'dark' ? styles.dark : '',
{
[styles.selectedNode]: selected,
},
)}
>
<Handle
id="c"
type="source"
position={Position.Left}
isConnectable={isConnectable}
className={styles.handle}
style={LeftHandleStyle}
></Handle>
<Handle
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
style={RightHandleStyle}
id="b"
></Handle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
</section>
<ToolBar selected={selected} id={id} label={data.label}>
<NodeWrapper>
<CommonHandle
id="c"
type="source"
position={Position.Left}
isConnectable={isConnectable}
style={LeftHandleStyle}
nodeId={id}
></CommonHandle>
<CommonHandle
type="source"
position={Position.Right}
isConnectable={isConnectable}
style={RightHandleStyle}
id="b"
nodeId={id}
></CommonHandle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
</NodeWrapper>
</ToolBar>
);
}

View File

@ -27,6 +27,7 @@ function InnerMessageNode({
position={Position.Left}
isConnectable={isConnectable}
style={LeftHandleStyle}
nodeId={id}
></CommonHandle>
<CommonHandle
type="source"
@ -34,6 +35,7 @@ function InnerMessageNode({
isConnectable={isConnectable}
style={RightHandleStyle}
id="b"
nodeId={id}
></CommonHandle>
<NodeHeader
id={id}

View File

@ -42,6 +42,7 @@ function InnerRetrievalNode({
isConnectable={isConnectable}
className={styles.handle}
style={LeftHandleStyle}
nodeId={id}
></CommonHandle>
<CommonHandle
type="source"
@ -50,6 +51,7 @@ function InnerRetrievalNode({
className={styles.handle}
style={RightHandleStyle}
id="b"
nodeId={id}
></CommonHandle>
<NodeHeader
id={id}

View File

@ -66,6 +66,7 @@ function InnerSwitchNode({ id, data, selected }: NodeProps<ISwitchNode>) {
position={Position.Left}
isConnectable
id={'a'}
nodeId={id}
></CommonHandle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
<section className="gap-2.5 flex flex-col">
@ -94,6 +95,7 @@ function InnerSwitchNode({ id, data, selected }: NodeProps<ISwitchNode>) {
position={Position.Right}
isConnectable
style={{ ...RightHandleStyle, top: position.top }}
nodeId={id}
></CommonHandle>
</div>
);