Feat: Modify the style of the canvas operator node #3221 (#8261)

### What problem does this PR solve?

Feat: Modify the style of the canvas operator node #3221

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
balibabu
2025-06-16 09:29:08 +08:00
committed by GitHub
parent f7074037ef
commit 0bde5397d0
12 changed files with 270 additions and 291 deletions

View File

@ -9,7 +9,7 @@ export const BaseNode = forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
'relative rounded-md border bg-card text-card-foreground', 'relative rounded-md bg-card text-card-foreground',
className, className,
selected ? 'border-muted-foreground shadow-lg' : '', selected ? 'border-muted-foreground shadow-lg' : '',
'hover:ring-1', 'hover:ring-1',

View File

@ -1,13 +1,14 @@
import { useTheme } from '@/components/theme-provider';
import { IAgentNode } from '@/interfaces/database/flow'; import { IAgentNode } from '@/interfaces/database/flow';
import { Handle, NodeProps, Position } from '@xyflow/react'; import { Handle, NodeProps, Position } from '@xyflow/react';
import classNames from 'classnames';
import { memo, useMemo } from 'react'; import { memo, useMemo } from 'react';
import { Operator } from '../../constant'; import { Operator } from '../../constant';
import useGraphStore from '../../store'; import useGraphStore from '../../store';
import { CommonHandle } from './handle';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon'; import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import styles from './index.less'; import styles from './index.less';
import NodeHeader, { ToolBar } from './node-header'; import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
function InnerAgentNode({ function InnerAgentNode({
id, id,
@ -15,7 +16,6 @@ function InnerAgentNode({
isConnectable = true, isConnectable = true,
selected, selected,
}: NodeProps<IAgentNode>) { }: NodeProps<IAgentNode>) {
const { theme } = useTheme();
const getNode = useGraphStore((state) => state.getNode); const getNode = useGraphStore((state) => state.getNode);
const edges = useGraphStore((state) => state.edges); const edges = useGraphStore((state) => state.edges);
@ -26,34 +26,25 @@ function InnerAgentNode({
}, [edges, getNode, id]); }, [edges, getNode, id]);
return ( return (
<ToolBar selected={selected}> <ToolBar selected={selected} id={id} label={data.label}>
<section <NodeWrapper>
className={classNames(
styles.ragNode,
theme === 'dark' ? styles.dark : '',
{
[styles.selectedNode]: selected,
},
)}
>
{isNotParentAgent && ( {isNotParentAgent && (
<> <>
<Handle <CommonHandle
id="c" id="c"
type="source" type="source"
position={Position.Left} position={Position.Left}
isConnectable={isConnectable} isConnectable={isConnectable}
className={styles.handle}
style={LeftHandleStyle} style={LeftHandleStyle}
></Handle> ></CommonHandle>
<Handle <CommonHandle
type="source" type="source"
position={Position.Right} position={Position.Right}
isConnectable={isConnectable} isConnectable={isConnectable}
className={styles.handle} className={styles.handle}
id="b" id="b"
style={RightHandleStyle} style={RightHandleStyle}
></Handle> ></CommonHandle>
</> </>
)} )}
<Handle <Handle
@ -70,7 +61,7 @@ function InnerAgentNode({
style={{ left: 180 }} style={{ left: 180 }}
></Handle> ></Handle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader> <NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
</section> </NodeWrapper>
</ToolBar> </ToolBar>
); );
} }

View File

@ -1,8 +1,6 @@
import { useTheme } from '@/components/theme-provider';
import { IBeginNode } from '@/interfaces/database/flow'; import { IBeginNode } from '@/interfaces/database/flow';
import { Handle, NodeProps, Position } from '@xyflow/react'; import { NodeProps, Position } from '@xyflow/react';
import { Flex } from 'antd'; import { Flex } from 'antd';
import classNames from 'classnames';
import get from 'lodash/get'; import get from 'lodash/get';
import { memo } from 'react'; import { memo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -10,42 +8,31 @@ import {
BeginQueryType, BeginQueryType,
BeginQueryTypeIconMap, BeginQueryTypeIconMap,
Operator, Operator,
operatorMap,
} from '../../constant'; } from '../../constant';
import { BeginQuery } from '../../interface'; import { BeginQuery } from '../../interface';
import OperatorIcon from '../../operator-icon'; import OperatorIcon from '../../operator-icon';
import { CommonHandle } from './handle';
import { RightHandleStyle } from './handle-icon'; import { RightHandleStyle } from './handle-icon';
import styles from './index.less'; import styles from './index.less';
import { NodeWrapper } from './node-wrapper';
// TODO: do not allow other nodes to connect to this node // TODO: do not allow other nodes to connect to this node
function InnerBeginNode({ selected, data }: NodeProps<IBeginNode>) { function InnerBeginNode({ data }: NodeProps<IBeginNode>) {
const { t } = useTranslation(); const { t } = useTranslation();
const query: BeginQuery[] = get(data, 'form.query', []); const query: BeginQuery[] = get(data, 'form.query', []);
const { theme } = useTheme();
return ( return (
<section <NodeWrapper>
className={classNames( <CommonHandle
styles.ragNode,
theme === 'dark' ? styles.dark : '',
{
[styles.selectedNode]: selected,
},
)}
>
<Handle
type="source" type="source"
position={Position.Right} position={Position.Right}
isConnectable isConnectable
className={styles.handle} className={styles.handle}
style={RightHandleStyle} style={RightHandleStyle}
></Handle> ></CommonHandle>
<Flex align="center" justify={'center'} gap={10}> <Flex align="center" justify={'center'} gap={10}>
<OperatorIcon <OperatorIcon name={data.label as Operator}></OperatorIcon>
name={data.label as Operator}
fontSize={24}
color={operatorMap[data.label as Operator].color}
></OperatorIcon>
<div className="truncate text-center font-semibold text-sm"> <div className="truncate text-center font-semibold text-sm">
{t(`flow.begin`)} {t(`flow.begin`)}
</div> </div>
@ -68,7 +55,7 @@ function InnerBeginNode({ selected, data }: NodeProps<IBeginNode>) {
); );
})} })}
</Flex> </Flex>
</section> </NodeWrapper>
); );
} }

View File

@ -0,0 +1,17 @@
import { cn } from '@/lib/utils';
import { Handle, HandleProps } from '@xyflow/react';
import { Plus } from 'lucide-react';
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>
);
}

View File

@ -6,6 +6,7 @@ import { memo } from 'react';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon'; import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import styles from './index.less'; import styles from './index.less';
import NodeHeader from './node-header'; import NodeHeader from './node-header';
import { ToolBar } from './toolbar';
function InnerRagNode({ function InnerRagNode({
id, id,
@ -15,6 +16,7 @@ function InnerRagNode({
}: NodeProps<IRagNode>) { }: NodeProps<IRagNode>) {
const { theme } = useTheme(); const { theme } = useTheme();
return ( return (
<ToolBar selected={selected} id={id} label={data.label}>
<section <section
className={classNames( className={classNames(
styles.ragNode, styles.ragNode,
@ -42,6 +44,7 @@ function InnerRagNode({
></Handle> ></Handle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader> <NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
</section> </section>
</ToolBar>
); );
} }

View File

@ -1,13 +1,15 @@
import { useTheme } from '@/components/theme-provider';
import { IMessageNode } from '@/interfaces/database/flow'; import { IMessageNode } from '@/interfaces/database/flow';
import { Handle, NodeProps, Position } from '@xyflow/react'; import { NodeProps, Position } from '@xyflow/react';
import { Flex } from 'antd'; import { Flex } from 'antd';
import classNames from 'classnames'; import classNames from 'classnames';
import { get } from 'lodash'; import { get } from 'lodash';
import { memo } from 'react'; import { memo } from 'react';
import { CommonHandle } from './handle';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon'; import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import styles from './index.less'; import styles from './index.less';
import NodeHeader from './node-header'; import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
function InnerMessageNode({ function InnerMessageNode({
id, id,
@ -16,33 +18,23 @@ function InnerMessageNode({
selected, selected,
}: NodeProps<IMessageNode>) { }: NodeProps<IMessageNode>) {
const messages: string[] = get(data, 'form.messages', []); const messages: string[] = get(data, 'form.messages', []);
const { theme } = useTheme();
return ( return (
<section <ToolBar selected={selected} id={id} label={data.label}>
className={classNames( <NodeWrapper>
styles.logicNode, <CommonHandle
theme === 'dark' ? styles.dark : '',
{
[styles.selectedNode]: selected,
},
)}
>
<Handle
id="c" id="c"
type="source" type="source"
position={Position.Left} position={Position.Left}
isConnectable={isConnectable} isConnectable={isConnectable}
className={styles.handle}
style={LeftHandleStyle} style={LeftHandleStyle}
></Handle> ></CommonHandle>
<Handle <CommonHandle
type="source" type="source"
position={Position.Right} position={Position.Right}
isConnectable={isConnectable} isConnectable={isConnectable}
className={styles.handle}
style={RightHandleStyle} style={RightHandleStyle}
id="b" id="b"
></Handle> ></CommonHandle>
<NodeHeader <NodeHeader
id={id} id={id}
name={data.name} name={data.name}
@ -61,7 +53,8 @@ function InnerMessageNode({
); );
})} })}
</Flex> </Flex>
</section> </NodeWrapper>
</ToolBar>
); );
} }

View File

@ -1,20 +1,7 @@
import { useTranslate } from '@/hooks/common-hooks'; import { cn } from '@/lib/utils';
import { Flex } from 'antd'; import { memo } from 'react';
import { Copy, Play, Trash2 } from 'lucide-react'; import { Operator } from '../../constant';
import { Operator, operatorMap } from '../../constant';
import OperatorIcon from '../../operator-icon'; import OperatorIcon from '../../operator-icon';
import { needsSingleStepDebugging } from '../../utils';
import NodeDropdown from './dropdown';
import { NextNodePopover } from './popover';
import {
TooltipContent,
TooltipNode,
TooltipTrigger,
} from '@/components/xyflow/tooltip-node';
import { Position } from '@xyflow/react';
import { PropsWithChildren, memo } from 'react';
import { RunTooltip } from '../../flow-tooltip';
interface IProps { interface IProps {
id: string; id: string;
label: string; label: string;
@ -24,55 +11,20 @@ interface IProps {
wrapperClassName?: string; wrapperClassName?: string;
} }
const ExcludedRunStateOperators = [Operator.Answer];
export function RunStatus({ id, name, label }: IProps) {
const { t } = useTranslate('flow');
return (
<section className="flex justify-end items-center pb-1 gap-2 text-blue-600">
{needsSingleStepDebugging(label) && (
<RunTooltip>
<Play className="size-3 cursor-pointer" data-play />
</RunTooltip> // data-play is used to trigger single step debugging
)}
<NextNodePopover nodeId={id} name={name}>
<span className="cursor-pointer text-[10px]">
{t('operationResults')}
</span>
</NextNodePopover>
</section>
);
}
const InnerNodeHeader = ({ const InnerNodeHeader = ({
label, label,
id,
name, name,
gap = 4,
className, className,
wrapperClassName, wrapperClassName,
}: IProps) => { }: IProps) => {
return ( return (
<section className={wrapperClassName}> <section className={cn(wrapperClassName, 'pb-4')}>
{!ExcludedRunStateOperators.includes(label as Operator) && ( <div className={cn(className, 'flex gap-2.5')}>
<RunStatus id={id} name={name} label={label}></RunStatus> <OperatorIcon name={label as Operator}></OperatorIcon>
)}
<Flex
flex={1}
align="center"
justify={'space-between'}
gap={gap}
className={className}
>
<OperatorIcon
name={label as Operator}
color={operatorMap[label as Operator]?.color}
></OperatorIcon>
<span className="truncate text-center font-semibold text-sm"> <span className="truncate text-center font-semibold text-sm">
{name} {name}
</span> </span>
<NodeDropdown id={id} label={label}></NodeDropdown> </div>
</Flex>
</section> </section>
); );
}; };
@ -80,37 +32,3 @@ const InnerNodeHeader = ({
const NodeHeader = memo(InnerNodeHeader); const NodeHeader = memo(InnerNodeHeader);
export default NodeHeader; export default NodeHeader;
function IconWrapper({ children }: PropsWithChildren) {
return (
<div className="p-1.5 bg-text-title rounded-sm cursor-pointer">
{children}
</div>
);
}
type ToolBarProps = {
selected?: boolean | undefined;
} & PropsWithChildren;
export function ToolBar({ selected, children }: ToolBarProps) {
return (
<TooltipNode selected={selected}>
<TooltipTrigger>{children}</TooltipTrigger>
<TooltipContent position={Position.Top}>
<section className="flex gap-2 items-center">
<IconWrapper>
<Play className="size-3.5" />
</IconWrapper>
<IconWrapper>
<Copy className="size-3.5" />
</IconWrapper>
<IconWrapper>
<Trash2 className="size-3.5" />
</IconWrapper>
</section>
</TooltipContent>
</TooltipNode>
);
}

View File

@ -0,0 +1,18 @@
import { cn } from '@/lib/utils';
import { HTMLAttributes, PropsWithChildren } from 'react';
export function NodeWrapper({
children,
className,
}: PropsWithChildren & HTMLAttributes<HTMLDivElement>) {
return (
<section
className={cn(
'bg-background-header-bar p-2.5 rounded-md w-[200px]',
className,
)}
>
{children}
</section>
);
}

View File

@ -1,15 +1,17 @@
import { useTheme } from '@/components/theme-provider';
import { useFetchKnowledgeList } from '@/hooks/knowledge-hooks'; import { useFetchKnowledgeList } from '@/hooks/knowledge-hooks';
import { IRetrievalNode } from '@/interfaces/database/flow'; import { IRetrievalNode } from '@/interfaces/database/flow';
import { UserOutlined } from '@ant-design/icons'; import { UserOutlined } from '@ant-design/icons';
import { Handle, NodeProps, Position } from '@xyflow/react'; import { NodeProps, Position } from '@xyflow/react';
import { Avatar, Flex } from 'antd'; import { Avatar, Flex } from 'antd';
import classNames from 'classnames'; import classNames from 'classnames';
import { get } from 'lodash'; import { get } from 'lodash';
import { memo, useMemo } from 'react'; import { memo, useMemo } from 'react';
import { CommonHandle } from './handle';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon'; import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import styles from './index.less'; import styles from './index.less';
import NodeHeader from './node-header'; import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
function InnerRetrievalNode({ function InnerRetrievalNode({
id, id,
@ -18,7 +20,6 @@ function InnerRetrievalNode({
selected, selected,
}: NodeProps<IRetrievalNode>) { }: NodeProps<IRetrievalNode>) {
const knowledgeBaseIds: string[] = get(data, 'form.kb_ids', []); const knowledgeBaseIds: string[] = get(data, 'form.kb_ids', []);
const { theme } = useTheme();
const { list: knowledgeList } = useFetchKnowledgeList(true); const { list: knowledgeList } = useFetchKnowledgeList(true);
const knowledgeBases = useMemo(() => { const knowledgeBases = useMemo(() => {
return knowledgeBaseIds.map((x) => { return knowledgeBaseIds.map((x) => {
@ -32,31 +33,24 @@ function InnerRetrievalNode({
}, [knowledgeList, knowledgeBaseIds]); }, [knowledgeList, knowledgeBaseIds]);
return ( return (
<section <ToolBar selected={selected} id={id} label={data.label}>
className={classNames( <NodeWrapper>
styles.logicNode, <CommonHandle
theme === 'dark' ? styles.dark : '',
{
[styles.selectedNode]: selected,
},
)}
>
<Handle
id="c" id="c"
type="source" type="source"
position={Position.Left} position={Position.Left}
isConnectable={isConnectable} isConnectable={isConnectable}
className={styles.handle} className={styles.handle}
style={LeftHandleStyle} style={LeftHandleStyle}
></Handle> ></CommonHandle>
<Handle <CommonHandle
type="source" type="source"
position={Position.Right} position={Position.Right}
isConnectable={isConnectable} isConnectable={isConnectable}
className={styles.handle} className={styles.handle}
style={RightHandleStyle} style={RightHandleStyle}
id="b" id="b"
></Handle> ></CommonHandle>
<NodeHeader <NodeHeader
id={id} id={id}
name={data.name} name={data.name}
@ -83,7 +77,8 @@ function InnerRetrievalNode({
); );
})} })}
</Flex> </Flex>
</section> </NodeWrapper>
</ToolBar>
); );
} }

View File

@ -1,16 +1,16 @@
import { IconFont } from '@/components/icon-font'; import { IconFont } from '@/components/icon-font';
import { useTheme } from '@/components/theme-provider';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { ISwitchCondition, ISwitchNode } from '@/interfaces/database/flow'; import { ISwitchCondition, ISwitchNode } from '@/interfaces/database/flow';
import { Handle, NodeProps, Position } from '@xyflow/react'; import { NodeProps, Position } from '@xyflow/react';
import classNames from 'classnames';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { SwitchOperatorOptions } from '../../constant'; import { SwitchOperatorOptions } from '../../constant';
import { useGetComponentLabelByValue } from '../../hooks/use-get-begin-query'; import { useGetComponentLabelByValue } from '../../hooks/use-get-begin-query';
import { CommonHandle } from './handle';
import { RightHandleStyle } from './handle-icon'; import { RightHandleStyle } from './handle-icon';
import { useBuildSwitchHandlePositions } from './hooks'; import { useBuildSwitchHandlePositions } from './hooks';
import styles from './index.less'; import NodeHeader from './node-header';
import NodeHeader, { ToolBar } from './node-header'; import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
const getConditionKey = (idx: number, length: number) => { const getConditionKey = (idx: number, length: number) => {
if (idx === 0 && length !== 1) { if (idx === 0 && length !== 1) {
@ -58,32 +58,16 @@ const ConditionBlock = ({
function InnerSwitchNode({ id, data, selected }: NodeProps<ISwitchNode>) { function InnerSwitchNode({ id, data, selected }: NodeProps<ISwitchNode>) {
const { positions } = useBuildSwitchHandlePositions({ data, id }); const { positions } = useBuildSwitchHandlePositions({ data, id });
const { theme } = useTheme();
return ( return (
<ToolBar selected={selected}> <ToolBar selected={selected} id={id} label={data.label}>
<section <NodeWrapper>
className={classNames( <CommonHandle
styles.logicNode,
theme === 'dark' ? styles.dark : '',
{
[styles.selectedNode]: selected,
},
'group/operator hover:bg-slate-100',
)}
>
<Handle
type="target" type="target"
position={Position.Left} position={Position.Left}
isConnectable isConnectable
className={styles.handle}
id={'a'} id={'a'}
></Handle> ></CommonHandle>
<NodeHeader <NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
id={id}
name={data.name}
label={data.label}
className={styles.nodeHeader}
></NodeHeader>
<section className="gap-2.5 flex flex-col"> <section className="gap-2.5 flex flex-col">
{positions.map((position, idx) => { {positions.map((position, idx) => {
return ( return (
@ -103,20 +87,19 @@ function InnerSwitchNode({ id, data, selected }: NodeProps<ISwitchNode>) {
></ConditionBlock> ></ConditionBlock>
)} )}
</section> </section>
<Handle <CommonHandle
key={position.text} key={position.text}
id={position.text} id={position.text}
type="source" type="source"
position={Position.Right} position={Position.Right}
isConnectable isConnectable
className={styles.handle}
style={{ ...RightHandleStyle, top: position.top }} style={{ ...RightHandleStyle, top: position.top }}
></Handle> ></CommonHandle>
</div> </div>
); );
})} })}
</section> </section>
</section> </NodeWrapper>
</ToolBar> </ToolBar>
); );
} }

View File

@ -0,0 +1,74 @@
import {
TooltipContent,
TooltipNode,
TooltipTrigger,
} from '@/components/xyflow/tooltip-node';
import { Position } from '@xyflow/react';
import { Copy, Play, Trash2 } from 'lucide-react';
import { MouseEventHandler, PropsWithChildren, useCallback } from 'react';
import { Operator } from '../../constant';
import { useDuplicateNode } from '../../hooks';
import useGraphStore from '../../store';
function IconWrapper({ children }: PropsWithChildren) {
return (
<div className="p-1.5 bg-text-title rounded-sm cursor-pointer">
{children}
</div>
);
}
type ToolBarProps = {
selected?: boolean | undefined;
label: string;
id: string;
} & PropsWithChildren;
export function ToolBar({ selected, children, label, id }: ToolBarProps) {
const deleteNodeById = useGraphStore((store) => store.deleteNodeById);
const deleteIterationNodeById = useGraphStore(
(store) => store.deleteIterationNodeById,
);
const deleteNode: MouseEventHandler<SVGElement> = useCallback(
(e) => {
e.stopPropagation();
if (label === Operator.Iteration) {
deleteIterationNodeById(id);
} else {
deleteNodeById(id);
}
},
[deleteIterationNodeById, deleteNodeById, id, label],
);
const duplicateNode = useDuplicateNode();
const handleDuplicate: MouseEventHandler<SVGElement> = useCallback(
(e) => {
e.stopPropagation();
duplicateNode(id, label);
},
[duplicateNode, id, label],
);
return (
<TooltipNode selected={selected}>
<TooltipTrigger>{children}</TooltipTrigger>
<TooltipContent position={Position.Top}>
<section className="flex gap-2 items-center">
<IconWrapper>
<Play className="size-3.5" />
</IconWrapper>
<IconWrapper>
<Copy className="size-3.5" onClick={handleDuplicate} />
</IconWrapper>
<IconWrapper>
<Trash2 className="size-3.5" onClick={deleteNode} />
</IconWrapper>
</section>
</TooltipContent>
</TooltipNode>
);
}

View File

@ -9,7 +9,7 @@ interface IProps {
} }
export const OperatorIconMap = { export const OperatorIconMap = {
[Operator.Retrieval]: 'retrival-0', [Operator.Retrieval]: 'KR',
// [Operator.Generate]: MergeCellsOutlined, // [Operator.Generate]: MergeCellsOutlined,
// [Operator.Answer]: SendOutlined, // [Operator.Answer]: SendOutlined,
[Operator.Begin]: CirclePlay, [Operator.Begin]: CirclePlay,