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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 270 additions and 291 deletions

View File

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

View File

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

View File

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

View File

@ -1,13 +1,15 @@
import { useTheme } from '@/components/theme-provider';
import { IMessageNode } from '@/interfaces/database/flow';
import { Handle, NodeProps, Position } from '@xyflow/react';
import { NodeProps, Position } from '@xyflow/react';
import { Flex } from 'antd';
import classNames from 'classnames';
import { get } from 'lodash';
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 InnerMessageNode({
id,
@ -16,52 +18,43 @@ function InnerMessageNode({
selected,
}: NodeProps<IMessageNode>) {
const messages: string[] = get(data, 'form.messages', []);
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}
className={classNames({
[styles.nodeHeader]: messages.length > 0,
})}
></NodeHeader>
<ToolBar selected={selected} id={id} label={data.label}>
<NodeWrapper>
<CommonHandle
id="c"
type="source"
position={Position.Left}
isConnectable={isConnectable}
style={LeftHandleStyle}
></CommonHandle>
<CommonHandle
type="source"
position={Position.Right}
isConnectable={isConnectable}
style={RightHandleStyle}
id="b"
></CommonHandle>
<NodeHeader
id={id}
name={data.name}
label={data.label}
className={classNames({
[styles.nodeHeader]: messages.length > 0,
})}
></NodeHeader>
<Flex vertical gap={8} className={styles.messageNodeContainer}>
{messages.map((message, idx) => {
return (
<div className={styles.nodeText} key={idx}>
{message}
</div>
);
})}
</Flex>
</section>
<Flex vertical gap={8} className={styles.messageNodeContainer}>
{messages.map((message, idx) => {
return (
<div className={styles.nodeText} key={idx}>
{message}
</div>
);
})}
</Flex>
</NodeWrapper>
</ToolBar>
);
}

View File

@ -1,20 +1,7 @@
import { useTranslate } from '@/hooks/common-hooks';
import { Flex } from 'antd';
import { Copy, Play, Trash2 } from 'lucide-react';
import { Operator, operatorMap } from '../../constant';
import { cn } from '@/lib/utils';
import { memo } from 'react';
import { Operator } from '../../constant';
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 {
id: string;
label: string;
@ -24,55 +11,20 @@ interface IProps {
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 = ({
label,
id,
name,
gap = 4,
className,
wrapperClassName,
}: IProps) => {
return (
<section className={wrapperClassName}>
{!ExcludedRunStateOperators.includes(label as Operator) && (
<RunStatus id={id} name={name} label={label}></RunStatus>
)}
<Flex
flex={1}
align="center"
justify={'space-between'}
gap={gap}
className={className}
>
<OperatorIcon
name={label as Operator}
color={operatorMap[label as Operator]?.color}
></OperatorIcon>
<section className={cn(wrapperClassName, 'pb-4')}>
<div className={cn(className, 'flex gap-2.5')}>
<OperatorIcon name={label as Operator}></OperatorIcon>
<span className="truncate text-center font-semibold text-sm">
{name}
</span>
<NodeDropdown id={id} label={label}></NodeDropdown>
</Flex>
</div>
</section>
);
};
@ -80,37 +32,3 @@ const InnerNodeHeader = ({
const NodeHeader = memo(InnerNodeHeader);
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 { IRetrievalNode } from '@/interfaces/database/flow';
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 classNames from 'classnames';
import { get } from 'lodash';
import { memo, useMemo } 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 InnerRetrievalNode({
id,
@ -18,7 +20,6 @@ function InnerRetrievalNode({
selected,
}: NodeProps<IRetrievalNode>) {
const knowledgeBaseIds: string[] = get(data, 'form.kb_ids', []);
const { theme } = useTheme();
const { list: knowledgeList } = useFetchKnowledgeList(true);
const knowledgeBases = useMemo(() => {
return knowledgeBaseIds.map((x) => {
@ -32,58 +33,52 @@ function InnerRetrievalNode({
}, [knowledgeList, knowledgeBaseIds]);
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}
className={classNames({
[styles.nodeHeader]: knowledgeBaseIds.length > 0,
})}
></NodeHeader>
<Flex vertical gap={8}>
{knowledgeBases.map((knowledge) => {
return (
<div className={styles.nodeText} key={knowledge.id}>
<Flex align={'center'} gap={6}>
<Avatar
size={26}
icon={<UserOutlined />}
src={knowledge.avatar}
/>
<Flex className={styles.knowledgeNodeName} flex={1}>
{knowledge.name}
<ToolBar selected={selected} id={id} label={data.label}>
<NodeWrapper>
<CommonHandle
id="c"
type="source"
position={Position.Left}
isConnectable={isConnectable}
className={styles.handle}
style={LeftHandleStyle}
></CommonHandle>
<CommonHandle
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
style={RightHandleStyle}
id="b"
></CommonHandle>
<NodeHeader
id={id}
name={data.name}
label={data.label}
className={classNames({
[styles.nodeHeader]: knowledgeBaseIds.length > 0,
})}
></NodeHeader>
<Flex vertical gap={8}>
{knowledgeBases.map((knowledge) => {
return (
<div className={styles.nodeText} key={knowledge.id}>
<Flex align={'center'} gap={6}>
<Avatar
size={26}
icon={<UserOutlined />}
src={knowledge.avatar}
/>
<Flex className={styles.knowledgeNodeName} flex={1}>
{knowledge.name}
</Flex>
</Flex>
</Flex>
</div>
);
})}
</Flex>
</section>
</div>
);
})}
</Flex>
</NodeWrapper>
</ToolBar>
);
}

View File

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