Feat: Initialize the data pipeline canvas. #9869 (#9870)

### What problem does this PR solve?
Feat: Initialize the data pipeline canvas. #9869

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
balibabu
2025-09-02 15:47:33 +08:00
committed by GitHub
parent c2567844ea
commit cb14dafaca
196 changed files with 21201 additions and 0 deletions

View File

@ -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);
}
}

View File

@ -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 (
<div
style={{ top, left, right, bottom }}
className={styles.contextMenu}
{...props}
>
<p style={{ margin: '0.5em' }}>
<small>node: {id}</small>
</p>
<button onClick={duplicateNode} type={'button'}>
duplicate
</button>
<button onClick={deleteNode} type={'button'}>
delete
</button>
</div>
);
}
/* @deprecated
*/
export const useHandleNodeContextMenu = (sideWidth: number) => {
const [menu, setMenu] = useState<INodeContextMenu>({} as INodeContextMenu);
const ref = useRef<any>(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 };
};

View File

@ -0,0 +1,56 @@
import {
createContext,
ReactNode,
useCallback,
useContext,
useRef,
} from 'react';
interface DropdownContextType {
canShowDropdown: () => boolean;
setActiveDropdown: (type: 'handle' | 'drag') => void;
clearActiveDropdown: () => void;
}
const DropdownContext = createContext<DropdownContextType | null>(null);
export const useDropdownManager = () => {
const context = useContext(DropdownContext);
if (!context) {
throw new Error('useDropdownManager must be used within DropdownProvider');
}
return context;
};
interface DropdownProviderProps {
children: ReactNode;
}
export const DropdownProvider = ({ children }: DropdownProviderProps) => {
const activeDropdownRef = useRef<'handle' | 'drag' | null>(null);
const canShowDropdown = useCallback(() => {
const current = activeDropdownRef.current;
return !current;
}, []);
const setActiveDropdown = useCallback((type: 'handle' | 'drag') => {
activeDropdownRef.current = type;
}, []);
const clearActiveDropdown = useCallback(() => {
activeDropdownRef.current = null;
}, []);
const value: DropdownContextType = {
canShowDropdown,
setActiveDropdown,
clearActiveDropdown,
};
return (
<DropdownContext.Provider value={value}>
{children}
</DropdownContext.Provider>
);
};

View File

@ -0,0 +1,126 @@
import {
BaseEdge,
Edge,
EdgeLabelRenderer,
EdgeProps,
getBezierPath,
} from '@xyflow/react';
import { memo } from 'react';
import useGraphStore from '../../store';
import { useFetchAgent } from '@/hooks/use-agent-request';
import { cn } from '@/lib/utils';
import { useMemo } from 'react';
import { NodeHandleId, Operator } from '../../constant';
function InnerButtonEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
source,
target,
style = {},
markerEnd,
selected,
data,
sourceHandleId,
}: EdgeProps<Edge<{ isHovered: boolean }>>) {
const deleteEdgeById = useGraphStore((state) => state.deleteEdgeById);
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const selectedStyle = useMemo(() => {
return selected ? { strokeWidth: 1, stroke: 'rgba(76, 164, 231, 1)' } : {};
}, [selected]);
const onEdgeClick = () => {
deleteEdgeById(id);
};
// highlight the nodes that the workflow passes through
const { data: flowDetail } = useFetchAgent();
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 Array.isArray(graphPath) ? 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: 1, stroke: 'red' };
}
}
return {};
}, [source, target, graphPath]);
const visible = useMemo(() => {
return (
data?.isHovered &&
sourceHandleId !== NodeHandleId.Tool &&
sourceHandleId !== NodeHandleId.AgentBottom && // The connection between the agent node and the tool node does not need to display the delete button
!target.startsWith(Operator.Tool)
);
}, [data?.isHovered, sourceHandleId, target]);
return (
<>
<BaseEdge
path={edgePath}
markerEnd={markerEnd}
style={{ ...style, ...selectedStyle, ...highlightStyle }}
className="text-text-secondary"
/>
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
fontSize: 12,
// everything inside EdgeLabelRenderer has no pointer events by default
// if you have an interactive element, set pointer-events: all
pointerEvents: 'all',
zIndex: 1001, // https://github.com/xyflow/xyflow/discussions/3498
}}
className="nodrag nopan"
>
<button
className={cn(
'size-3.5 border border-state-error text-state-error rounded-full leading-none',
'invisible',
{ visible },
)}
type="button"
onClick={onEdgeClick}
>
×
</button>
</div>
</EdgeLabelRenderer>
</>
);
}
export const ButtonEdge = memo(InnerButtonEdge);

View File

@ -0,0 +1,11 @@
.canvasWrapper {
position: relative;
height: calc(100% - 64px);
:global(.react-flow__node-group) {
.commonNode();
border-radius: 0 0 10px 10px;
padding: 0;
border: 0;
background-color: transparent;
}
}

View File

@ -0,0 +1,331 @@
import { useIsDarkTheme, useTheme } from '@/components/theme-provider';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useSetModalState } from '@/hooks/common-hooks';
import { cn } from '@/lib/utils';
import {
Connection,
ConnectionMode,
ControlButton,
Controls,
NodeTypes,
Position,
ReactFlow,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { NotebookPen } from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AgentBackground } from '../components/background';
import { AgentInstanceContext, HandleContext } from '../context';
import FormSheet from '../form-sheet/next';
import {
useHandleDrop,
useSelectCanvasData,
useValidateConnection,
} from '../hooks';
import { useAddNode } from '../hooks/use-add-node';
import { useBeforeDelete } from '../hooks/use-before-delete';
import { useMoveNote } from '../hooks/use-move-note';
import { useDropdownManager } from './context';
import {
useHideFormSheetOnNodeDeletion,
useShowDrawer,
} from '../hooks/use-show-drawer';
import RunSheet from '../run-sheet';
import { ButtonEdge } from './edge';
import styles from './index.less';
import { RagNode } from './node';
import { AgentNode } from './node/agent-node';
import { BeginNode } from './node/begin-node';
import { CategorizeNode } from './node/categorize-node';
import { InnerNextStepDropdown } from './node/dropdown/next-step-dropdown';
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';
import { ToolNode } from './node/tool-node';
export 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,
agentNode: AgentNode,
toolNode: ToolNode,
};
const edgeTypes = {
buttonEdge: ButtonEdge,
};
interface IProps {
drawerVisible: boolean;
hideDrawer(): void;
}
function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
const { t } = useTranslation();
const {
nodes,
edges,
onConnect: originalOnConnect,
onEdgesChange,
onNodesChange,
onSelectionChange,
onEdgeMouseEnter,
onEdgeMouseLeave,
} = useSelectCanvasData();
const isValidConnection = useValidateConnection();
const { onDrop, onDragOver, setReactFlowInstance, reactFlowInstance } =
useHandleDrop();
const {
onNodeClick,
clickedNode,
formDrawerVisible,
hideFormDrawer,
singleDebugDrawerVisible,
hideSingleDebugDrawer,
showSingleDebugDrawer,
chatVisible,
runVisible,
hideRunOrChatDrawer,
showChatModal,
showFormDrawer,
} = useShowDrawer({
drawerVisible,
hideDrawer,
});
const { handleBeforeDelete } = useBeforeDelete();
const { addCanvasNode, addNoteNode } = useAddNode(reactFlowInstance);
const { ref, showImage, hideImage, imgVisible, mouse } = useMoveNote();
const { theme } = useTheme();
const isDarkTheme = useIsDarkTheme();
useHideFormSheetOnNodeDeletion({ hideFormDrawer });
const { visible, hideModal, showModal } = useSetModalState();
const [dropdownPosition, setDropdownPosition] = useState({ x: 0, y: 0 });
const isConnectedRef = useRef(false);
const connectionStartRef = useRef<{
nodeId: string;
handleId: string;
} | null>(null);
const preventCloseRef = useRef(false);
const { setActiveDropdown, clearActiveDropdown } = useDropdownManager();
const onPaneClick = useCallback(() => {
hideFormDrawer();
if (visible && !preventCloseRef.current) {
hideModal();
clearActiveDropdown();
}
if (imgVisible) {
addNoteNode(mouse);
hideImage();
}
}, [
hideFormDrawer,
visible,
hideModal,
imgVisible,
addNoteNode,
mouse,
hideImage,
clearActiveDropdown,
]);
const onConnect = (connection: Connection) => {
originalOnConnect(connection);
isConnectedRef.current = true;
};
const OnConnectStart = (event: any, params: any) => {
isConnectedRef.current = false;
if (params && params.nodeId && params.handleId) {
connectionStartRef.current = {
nodeId: params.nodeId,
handleId: params.handleId,
};
} else {
connectionStartRef.current = null;
}
};
const OnConnectEnd = (event: MouseEvent | TouchEvent) => {
if ('clientX' in event && 'clientY' in event) {
const { clientX, clientY } = event;
setDropdownPosition({ x: clientX, y: clientY });
if (!isConnectedRef.current) {
setActiveDropdown('drag');
showModal();
preventCloseRef.current = true;
setTimeout(() => {
preventCloseRef.current = false;
}, 300);
}
}
};
return (
<div className={styles.canvasWrapper}>
<svg
xmlns="http://www.w3.org/2000/svg"
style={{ position: 'absolute', top: 10, left: 0 }}
>
<defs>
<marker
fill="rgb(157 149 225)"
id="logo"
viewBox="0 0 40 40"
refX="8"
refY="5"
markerUnits="strokeWidth"
markerWidth="20"
markerHeight="20"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
</defs>
</svg>
<AgentInstanceContext.Provider value={{ addCanvasNode, showFormDrawer }}>
<ReactFlow
connectionMode={ConnectionMode.Loose}
nodes={nodes}
onNodesChange={onNodesChange}
edges={edges}
onEdgesChange={onEdgesChange}
fitView
onConnect={onConnect}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onDrop={onDrop}
onConnectStart={OnConnectStart}
onConnectEnd={OnConnectEnd}
onDragOver={onDragOver}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
onInit={setReactFlowInstance}
onSelectionChange={onSelectionChange}
nodeOrigin={[0.5, 0]}
isValidConnection={isValidConnection}
onEdgeMouseEnter={onEdgeMouseEnter}
onEdgeMouseLeave={onEdgeMouseLeave}
className="h-full"
colorMode={theme}
defaultEdgeOptions={{
type: 'buttonEdge',
markerEnd: 'logo',
style: {
strokeWidth: 1,
stroke: isDarkTheme
? 'rgba(91, 93, 106, 1)'
: 'rgba(151, 154, 171, 1)',
},
zIndex: 1001, // https://github.com/xyflow/xyflow/discussions/3498
}}
deleteKeyCode={['Delete', 'Backspace']}
onBeforeDelete={handleBeforeDelete}
>
<AgentBackground></AgentBackground>
<Controls position={'bottom-center'} orientation="horizontal">
<ControlButton>
<Tooltip>
<TooltipTrigger asChild>
<NotebookPen className="!fill-none" onClick={showImage} />
</TooltipTrigger>
<TooltipContent>{t('flow.note')}</TooltipContent>
</Tooltip>
</ControlButton>
</Controls>
</ReactFlow>
{visible && (
<HandleContext.Provider
value={{
nodeId: connectionStartRef.current?.nodeId || '',
id: connectionStartRef.current?.handleId || '',
type: 'source',
position: Position.Right,
isFromConnectionDrag: true,
}}
>
<InnerNextStepDropdown
hideModal={() => {
hideModal();
clearActiveDropdown();
}}
position={dropdownPosition}
>
<span></span>
</InnerNextStepDropdown>
</HandleContext.Provider>
)}
</AgentInstanceContext.Provider>
<NotebookPen
className={cn('hidden absolute size-6', { block: imgVisible })}
ref={ref}
></NotebookPen>
{formDrawerVisible && (
<AgentInstanceContext.Provider
value={{ addCanvasNode, showFormDrawer }}
>
<FormSheet
node={clickedNode}
visible={formDrawerVisible}
hideModal={hideFormDrawer}
chatVisible={chatVisible}
singleDebugDrawerVisible={singleDebugDrawerVisible}
hideSingleDebugDrawer={hideSingleDebugDrawer}
showSingleDebugDrawer={showSingleDebugDrawer}
></FormSheet>
</AgentInstanceContext.Provider>
)}
{runVisible && (
<RunSheet
hideModal={hideRunOrChatDrawer}
showModal={showChatModal}
></RunSheet>
)}
</div>
);
}
export default AgentCanvas;

View File

@ -0,0 +1,116 @@
import LLMLabel from '@/components/llm-select/llm-label';
import { IAgentNode } from '@/interfaces/database/flow';
import { Handle, NodeProps, Position } from '@xyflow/react';
import { get } from 'lodash';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { AgentExceptionMethod, NodeHandleId } from '../../constant';
import useGraphStore from '../../store';
import { isBottomSubAgent } from '../../utils';
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 InnerAgentNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IAgentNode>) {
const edges = useGraphStore((state) => state.edges);
const { t } = useTranslation();
const isHeadAgent = useMemo(() => {
return !isBottomSubAgent(edges, id);
}, [edges, id]);
const exceptionMethod = useMemo(() => {
return get(data, 'form.exception_method');
}, [data]);
const isGotoMethod = useMemo(() => {
return exceptionMethod === AgentExceptionMethod.Goto;
}, [exceptionMethod]);
return (
<ToolBar selected={selected} id={id} label={data.label}>
<NodeWrapper selected={selected}>
{isHeadAgent && (
<>
<CommonHandle
type="target"
position={Position.Left}
isConnectable={isConnectable}
style={LeftHandleStyle}
nodeId={id}
id={NodeHandleId.End}
></CommonHandle>
<CommonHandle
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
style={RightHandleStyle}
nodeId={id}
id={NodeHandleId.Start}
isConnectableEnd={false}
></CommonHandle>
</>
)}
<Handle
type="target"
position={Position.Top}
isConnectable={false}
id={NodeHandleId.AgentTop}
></Handle>
<Handle
type="source"
position={Position.Bottom}
isConnectable={false}
id={NodeHandleId.AgentBottom}
style={{ left: 180 }}
></Handle>
<Handle
type="source"
position={Position.Bottom}
isConnectable={false}
id={NodeHandleId.Tool}
style={{ left: 20 }}
></Handle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
<section className="flex flex-col gap-2">
<div className={'bg-bg-card rounded-sm p-1'}>
<LLMLabel value={get(data, 'form.llm_id')}></LLMLabel>
</div>
{(isGotoMethod ||
exceptionMethod === AgentExceptionMethod.Comment) && (
<div className="bg-bg-card rounded-sm p-1 flex justify-between gap-2">
<span className="text-text-secondary">{t('flow.onFailure')}</span>
<span className="truncate flex-1 text-right">
{t(`flow.${exceptionMethod}`)}
</span>
</div>
)}
</section>
{isGotoMethod && (
<CommonHandle
type="source"
position={Position.Right}
isConnectable={isConnectable}
className="!bg-state-error"
style={{ ...RightHandleStyle, top: 94 }}
nodeId={id}
id={NodeHandleId.AgentException}
isConnectableEnd={false}
></CommonHandle>
)}
</NodeWrapper>
</ToolBar>
);
}
export const AgentNode = memo(InnerAgentNode);

View File

@ -0,0 +1,62 @@
import { IBeginNode } from '@/interfaces/database/flow';
import { cn } from '@/lib/utils';
import { NodeProps, Position } from '@xyflow/react';
import get from 'lodash/get';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import {
BeginQueryType,
BeginQueryTypeIconMap,
NodeHandleId,
Operator,
} 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({ data, id, selected }: NodeProps<IBeginNode>) {
const { t } = useTranslation();
const inputs: Record<string, BeginQuery> = get(data, 'form.inputs', {});
return (
<NodeWrapper selected={selected}>
<CommonHandle
type="source"
position={Position.Right}
isConnectable
style={RightHandleStyle}
nodeId={id}
id={NodeHandleId.Start}
></CommonHandle>
<section className="flex items-center gap-2">
<OperatorIcon name={data.label as Operator}></OperatorIcon>
<div className="truncate text-center font-semibold text-sm">
{t(`flow.begin`)}
</div>
</section>
<section className={cn(styles.generateParameters, 'flex gap-2 flex-col')}>
{Object.entries(inputs).map(([key, val], idx) => {
const Icon = BeginQueryTypeIconMap[val.type as BeginQueryType];
return (
<div
key={idx}
className={cn(styles.conditionBlock, 'flex gap-1.5 items-center')}
>
<Icon className="size-4" />
<label htmlFor="">{key}</label>
<span className={styles.parameterValue}>{val.name}</span>
<span className="flex-1">{val.optional ? 'Yes' : 'No'}</span>
</div>
);
})}
</section>
</NodeWrapper>
);
}
export const BeginNode = memo(InnerBeginNode);

View File

@ -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 (
<Card className="w-[350px]">
<CardHeader>
<CardTitle>Create project</CardTitle>
<CardDescription>Deploy your new project in one-click.</CardDescription>
</CardHeader>
<CardContent>
<form>
<div className="grid w-full items-center gap-4">
<div className="flex flex-col space-y-1.5">
<Label htmlFor="name">Name</Label>
<Input id="name" placeholder="Name of your project" />
</div>
<div className="flex flex-col space-y-1.5">
<Label htmlFor="framework">Framework</Label>
<Select>
<SelectTrigger id="framework">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="next">Next.js</SelectItem>
<SelectItem value="sveltekit">SvelteKit</SelectItem>
<SelectItem value="astro">Astro</SelectItem>
<SelectItem value="nuxt">Nuxt.js</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</form>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline">Cancel</Button>
<Button>Deploy</Button>
</CardFooter>
</Card>
);
}

View File

@ -0,0 +1,62 @@
import LLMLabel from '@/components/llm-select/llm-label';
import { ICategorizeNode } from '@/interfaces/database/flow';
import { NodeProps, Position } from '@xyflow/react';
import { get } from 'lodash';
import { memo } from 'react';
import { NodeHandleId } from '../../constant';
import { CommonHandle } from './handle';
import { RightHandleStyle } from './handle-icon';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
import { useBuildCategorizeHandlePositions } from './use-build-categorize-handle-positions';
export function InnerCategorizeNode({
id,
data,
selected,
}: NodeProps<ICategorizeNode>) {
const { positions } = useBuildCategorizeHandlePositions({ data, id });
return (
<ToolBar selected={selected} id={id} label={data.label}>
<NodeWrapper selected={selected}>
<CommonHandle
type="target"
position={Position.Left}
isConnectable
id={NodeHandleId.End}
nodeId={id}
></CommonHandle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
<section className="flex flex-col gap-2">
<div className={'bg-bg-card rounded-sm px-1'}>
<LLMLabel value={get(data, 'form.llm_id')}></LLMLabel>
</div>
{positions.map((position) => {
return (
<div key={position.uuid}>
<div className={'bg-bg-card rounded-sm p-1 truncate'}>
{position.name}
</div>
<CommonHandle
// key={position.text}
id={position.uuid}
type="source"
position={Position.Right}
isConnectable
style={{ ...RightHandleStyle, top: position.top }}
nodeId={id}
isConnectableEnd={false}
></CommonHandle>
</div>
);
})}
</section>
</NodeWrapper>
</ToolBar>
);
}
export const CategorizeNode = memo(InnerCategorizeNode);

View File

@ -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: (
<Flex justify={'space-between'}>
{t('common.copy')}
<CopyOutlined />
</Flex>
),
},
];
return (
<OperateDropdown
iconFontSize={22}
height={14}
deleteItem={deleteNode}
items={items}
needsDeletionValidation={false}
iconFontColor={iconFontColor}
></OperateDropdown>
);
};
export default NodeDropdown;

View File

@ -0,0 +1,295 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { IModalProps } from '@/interfaces/common';
import { Operator } from '@/pages/agent/constant';
import { AgentInstanceContext, HandleContext } from '@/pages/agent/context';
import OperatorIcon from '@/pages/agent/operator-icon';
import { Position } from '@xyflow/react';
import { t } from 'i18next';
import { lowerFirst } from 'lodash';
import {
PropsWithChildren,
createContext,
memo,
useContext,
useEffect,
useRef,
} from 'react';
import { useTranslation } from 'react-i18next';
type OperatorItemProps = {
operators: Operator[];
isCustomDropdown?: boolean;
mousePosition?: { x: number; y: number };
};
const HideModalContext = createContext<IModalProps<any>['showModal']>(() => {});
const OnNodeCreatedContext = createContext<
((newNodeId: string) => void) | undefined
>(undefined);
function OperatorItemList({
operators,
isCustomDropdown = false,
mousePosition,
}: OperatorItemProps) {
const { addCanvasNode } = useContext(AgentInstanceContext);
const handleContext = useContext(HandleContext);
const hideModal = useContext(HideModalContext);
const onNodeCreated = useContext(OnNodeCreatedContext);
const { t } = useTranslation();
const handleClick = (operator: Operator) => {
const contextData = handleContext || {
nodeId: '',
id: '',
type: 'source' as const,
position: Position.Right,
isFromConnectionDrag: true,
};
const mockEvent = mousePosition
? {
clientX: mousePosition.x,
clientY: mousePosition.y,
}
: undefined;
const newNodeId = addCanvasNode(operator, contextData)(mockEvent);
if (onNodeCreated && newNodeId) {
onNodeCreated(newNodeId);
}
hideModal?.();
};
const renderOperatorItem = (operator: Operator) => {
const commonContent = (
<div className="hover:bg-background-card py-1 px-3 cursor-pointer rounded-sm flex gap-2 items-center justify-start">
<OperatorIcon name={operator} />
{t(`flow.${lowerFirst(operator)}`)}
</div>
);
return (
<Tooltip key={operator}>
<TooltipTrigger asChild>
{isCustomDropdown ? (
<li onClick={() => handleClick(operator)}>{commonContent}</li>
) : (
<DropdownMenuItem
key={operator}
className="hover:bg-background-card py-1 px-3 cursor-pointer rounded-sm flex gap-2 items-center justify-start"
onClick={() => handleClick(operator)}
onSelect={() => hideModal?.()}
>
<OperatorIcon name={operator} />
{t(`flow.${lowerFirst(operator)}`)}
</DropdownMenuItem>
)}
</TooltipTrigger>
<TooltipContent side="right">
<p>{t(`flow.${lowerFirst(operator)}Description`)}</p>
</TooltipContent>
</Tooltip>
);
};
return <ul className="space-y-2">{operators.map(renderOperatorItem)}</ul>;
}
function AccordionOperators({
isCustomDropdown = false,
mousePosition,
}: {
isCustomDropdown?: boolean;
mousePosition?: { x: number; y: number };
}) {
return (
<Accordion
type="multiple"
className="px-2 text-text-title max-h-[45vh] overflow-auto"
defaultValue={['item-1', 'item-2', 'item-3', 'item-4', 'item-5']}
>
<AccordionItem value="item-1">
<AccordionTrigger className="text-xl">
{t('flow.foundation')}
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList
operators={[Operator.Agent, Operator.Retrieval]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger className="text-xl">
{t('flow.dialog')}
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList
operators={[Operator.Message, Operator.UserFillUp]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger className="text-xl">
{t('flow.flow')}
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList
operators={[
Operator.Switch,
Operator.Iteration,
Operator.Categorize,
]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-4">
<AccordionTrigger className="text-xl">
{t('flow.dataManipulation')}
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList
operators={[Operator.Code, Operator.StringTransform]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-5">
<AccordionTrigger className="text-xl">
{t('flow.tools')}
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList
operators={[
Operator.TavilySearch,
Operator.TavilyExtract,
Operator.ExeSQL,
Operator.Google,
Operator.YahooFinance,
Operator.Email,
Operator.DuckDuckGo,
Operator.Wikipedia,
Operator.GoogleScholar,
Operator.ArXiv,
Operator.PubMed,
Operator.GitHub,
Operator.Invoke,
Operator.WenCai,
Operator.SearXNG,
]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
</Accordion>
);
}
export function InnerNextStepDropdown({
children,
hideModal,
position,
onNodeCreated,
}: PropsWithChildren &
IModalProps<any> & {
position?: { x: number; y: number };
onNodeCreated?: (newNodeId: string) => void;
}) {
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (position && hideModal) {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
hideModal();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}
}, [position, hideModal]);
if (position) {
return (
<div
ref={dropdownRef}
style={{
position: 'fixed',
left: position.x,
top: position.y + 10,
zIndex: 1000,
}}
onClick={(e) => e.stopPropagation()}
>
<div className="w-[300px] font-semibold bg-bg-base border border-border rounded-md shadow-lg">
<div className="px-3 py-2 border-b border-border">
<div className="text-sm font-medium">{t('flow.nextStep')}</div>
</div>
<HideModalContext.Provider value={hideModal}>
<OnNodeCreatedContext.Provider value={onNodeCreated}>
<AccordionOperators
isCustomDropdown={true}
mousePosition={position}
></AccordionOperators>
</OnNodeCreatedContext.Provider>
</HideModalContext.Provider>
</div>
</div>
);
}
return (
<DropdownMenu
open={true}
onOpenChange={(open) => {
if (!open && hideModal) {
hideModal();
}
}}
>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
onClick={(e) => e.stopPropagation()}
className="w-[300px] font-semibold"
>
<DropdownMenuLabel>{t('flow.nextStep')}</DropdownMenuLabel>
<HideModalContext.Provider value={hideModal}>
<AccordionOperators></AccordionOperators>
</HideModalContext.Provider>
</DropdownMenuContent>
</DropdownMenu>
);
}
export const NextStepDropdown = memo(InnerNextStepDropdown);

View File

@ -0,0 +1,80 @@
import { IEmailNode } from '@/interfaces/database/flow';
import { Handle, NodeProps, Position } from '@xyflow/react';
import { Flex } from 'antd';
import classNames from 'classnames';
import { memo, useState } from 'react';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
export function InnerEmailNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IEmailNode>) {
const [showDetails, setShowDetails] = useState(false);
return (
<section
className={classNames(styles.ragNode, {
[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>
<Flex vertical gap={8} className={styles.emailNodeContainer}>
<div
className={styles.emailConfig}
onClick={() => setShowDetails(!showDetails)}
>
<div className={styles.configItem}>
<span className={styles.configLabel}>SMTP:</span>
<span className={styles.configValue}>{data.form?.smtp_server}</span>
</div>
<div className={styles.configItem}>
<span className={styles.configLabel}>Port:</span>
<span className={styles.configValue}>{data.form?.smtp_port}</span>
</div>
<div className={styles.configItem}>
<span className={styles.configLabel}>From:</span>
<span className={styles.configValue}>{data.form?.email}</span>
</div>
<div className={styles.expandIcon}>{showDetails ? '▼' : '▶'}</div>
</div>
{showDetails && (
<div className={styles.jsonExample}>
<div className={styles.jsonTitle}>Expected Input JSON:</div>
<pre className={styles.jsonContent}>
{`{
"to_email": "...",
"cc_email": "...",
"subject": "...",
"content": "..."
}`}
</pre>
</div>
)}
</Flex>
</section>
);
}
export const EmailNode = memo(InnerEmailNode);

View File

@ -0,0 +1,60 @@
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 { memo } from 'react';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
export function InnerGenerateNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IGenerateNode>) {
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={styles.nodeHeader}
></NodeHeader>
<div className={styles.nodeText}>
<LLMLabel value={get(data, 'form.llm_id')}></LLMLabel>
</div>
</section>
);
}
export const GenerateNode = memo(InnerGenerateNode);

View File

@ -0,0 +1,20 @@
import { PlusOutlined } from '@ant-design/icons';
import { CSSProperties } from 'react';
export const HandleIcon = () => {
return (
<PlusOutlined
style={{ fontSize: 6, color: 'white', position: 'absolute', zIndex: 10 }}
/>
);
};
export const RightHandleStyle: CSSProperties = {
right: 0,
};
export const LeftHandleStyle: CSSProperties = {
left: 0,
};
export default HandleIcon;

View File

@ -0,0 +1,64 @@
import { useSetModalState } from '@/hooks/common-hooks';
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 { useDropdownManager } from '../context';
import { InnerNextStepDropdown } from './dropdown/next-step-dropdown';
export function CommonHandle({
className,
nodeId,
...props
}: HandleProps & { nodeId: string }) {
const { visible, hideModal, showModal } = useSetModalState();
const { canShowDropdown, setActiveDropdown, clearActiveDropdown } =
useDropdownManager();
const value = useMemo(
() => ({
nodeId,
id: props.id || undefined,
type: props.type,
position: props.position,
isFromConnectionDrag: false,
}),
[nodeId, props.id, props.position, props.type],
);
return (
<HandleContext.Provider value={value}>
<Handle
{...props}
className={cn(
'inline-flex justify-center items-center !bg-accent-primary !size-4 !rounded-sm !border-none ',
className,
)}
onClick={(e) => {
e.stopPropagation();
if (!canShowDropdown()) {
return;
}
setActiveDropdown('handle');
showModal();
}}
>
<Plus className="size-3 pointer-events-none text-text-title-invert" />
{visible && (
<InnerNextStepDropdown
hideModal={() => {
hideModal();
clearActiveDropdown();
}}
>
<span></span>
</InnerNextStepDropdown>
)}
</Handle>
</HandleContext.Provider>
);
}

View File

@ -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);
}
}

View File

@ -0,0 +1,49 @@
import { IRagNode } from '@/interfaces/database/flow';
import { NodeProps, Position } from '@xyflow/react';
import { memo } from 'react';
import { NodeHandleId } from '../../constant';
import { needsSingleStepDebugging } from '../../utils';
import { CommonHandle } from './handle';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
function InnerRagNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IRagNode>) {
return (
<ToolBar
selected={selected}
id={id}
label={data.label}
showRun={needsSingleStepDebugging(data.label)}
>
<NodeWrapper selected={selected}>
<CommonHandle
id={NodeHandleId.End}
type="target"
position={Position.Left}
isConnectable={isConnectable}
style={LeftHandleStyle}
nodeId={id}
></CommonHandle>
<CommonHandle
type="source"
position={Position.Right}
isConnectable={isConnectable}
id={NodeHandleId.Start}
style={RightHandleStyle}
nodeId={id}
isConnectableEnd={false}
></CommonHandle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
</NodeWrapper>
</ToolBar>
);
}
export const RagNode = memo(InnerRagNode);

View File

@ -0,0 +1,62 @@
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 { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
function InnerInvokeNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IInvokeNode>) {
const { t } = useTranslation();
const { theme } = useTheme();
const url = get(data, 'form.url');
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}
className={styles.nodeHeader}
></NodeHeader>
<Flex vertical>
<div>{t('flow.url')}</div>
<div className={styles.nodeText}>{url}</div>
</Flex>
</section>
);
}
export const InvokeNode = memo(InnerInvokeNode);

View File

@ -0,0 +1,93 @@
import {
IIterationNode,
IIterationStartNode,
} from '@/interfaces/database/flow';
import { cn } from '@/lib/utils';
import { NodeProps, NodeResizeControl, Position } from '@xyflow/react';
import { memo } from 'react';
import { NodeHandleId, Operator } from '../../constant';
import OperatorIcon from '../../operator-icon';
import { CommonHandle } from './handle';
import { RightHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ResizeIcon, controlStyle } from './resize-icon';
import { ToolBar } from './toolbar';
export function InnerIterationNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IIterationNode>) {
return (
<ToolBar selected={selected} id={id} label={data.label} showRun={false}>
<section
className={cn('h-full bg-transparent rounded-b-md ', {
[styles.selectedHeader]: selected,
})}
>
<NodeResizeControl style={controlStyle} minWidth={100} minHeight={50}>
<ResizeIcon />
</NodeResizeControl>
<CommonHandle
id={NodeHandleId.End}
type="target"
position={Position.Left}
isConnectable={isConnectable}
className={styles.handle}
nodeId={id}
></CommonHandle>
<CommonHandle
id={NodeHandleId.Start}
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
nodeId={id}
></CommonHandle>
<NodeHeader
id={id}
name={data.name}
label={data.label}
wrapperClassName={cn(
'bg-background-header-bar p-2 rounded-t-[10px] absolute w-full top-[-44px] left-[-0.3px]',
{
[styles.selectedHeader]: selected,
},
)}
></NodeHeader>
</section>
</ToolBar>
);
}
function InnerIterationStartNode({
isConnectable = true,
id,
selected,
}: NodeProps<IIterationStartNode>) {
return (
<NodeWrapper className="w-20" selected={selected}>
<CommonHandle
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
style={RightHandleStyle}
isConnectableEnd={false}
id={NodeHandleId.Start}
nodeId={id}
></CommonHandle>
<div>
<OperatorIcon name={Operator.Begin}></OperatorIcon>
</div>
</NodeWrapper>
);
}
export const IterationStartNode = memo(InnerIterationStartNode);
export const IterationNode = memo(InnerIterationNode);

View File

@ -0,0 +1,60 @@
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 { memo } from 'react';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
export function InnerKeywordNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IKeywordNode>) {
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={styles.nodeHeader}
></NodeHeader>
<div className={styles.nodeText}>
<LLMLabel value={get(data, 'form.llm_id')}></LLMLabel>
</div>
</section>
);
}
export const KeywordNode = memo(InnerKeywordNode);

View File

@ -0,0 +1,41 @@
import { ILogicNode } from '@/interfaces/database/flow';
import { NodeProps, Position } from '@xyflow/react';
import { memo } from 'react';
import { CommonHandle } from './handle';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
export function InnerLogicNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<ILogicNode>) {
return (
<ToolBar selected={selected} id={id} label={data.label}>
<NodeWrapper selected={selected}>
<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>
);
}
export const LogicNode = memo(InnerLogicNode);

View File

@ -0,0 +1,65 @@
import { IMessageNode } from '@/interfaces/database/flow';
import { NodeProps, Position } from '@xyflow/react';
import { Flex } from 'antd';
import classNames from 'classnames';
import { get } from 'lodash';
import { memo } from 'react';
import { NodeHandleId } from '../../constant';
import { CommonHandle } from './handle';
import { LeftHandleStyle } 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,
data,
isConnectable = true,
selected,
}: NodeProps<IMessageNode>) {
const messages: string[] = get(data, 'form.messages', []);
return (
<ToolBar selected={selected} id={id} label={data.label}>
<NodeWrapper selected={selected}>
<CommonHandle
type="target"
position={Position.Left}
isConnectable={isConnectable}
style={LeftHandleStyle}
nodeId={id}
id={NodeHandleId.End}
></CommonHandle>
{/* <CommonHandle
type="source"
position={Position.Right}
isConnectable={isConnectable}
style={RightHandleStyle}
id={NodeHandleId.Start}
nodeId={id}
isConnectableEnd={false}
></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>
</NodeWrapper>
</ToolBar>
);
}
export const MessageNode = memo(InnerMessageNode);

View File

@ -0,0 +1,34 @@
import { cn } from '@/lib/utils';
import { memo } from 'react';
import { Operator } from '../../constant';
import OperatorIcon from '../../operator-icon';
interface IProps {
id: string;
label: string;
name: string;
gap?: number;
className?: string;
wrapperClassName?: string;
}
const InnerNodeHeader = ({
label,
name,
className,
wrapperClassName,
}: IProps) => {
return (
<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>
</div>
</section>
);
};
const NodeHeader = memo(InnerNodeHeader);
export default NodeHeader;

View File

@ -0,0 +1,18 @@
import { cn } from '@/lib/utils';
import { HTMLAttributes } from 'react';
type IProps = HTMLAttributes<HTMLDivElement> & { selected?: boolean };
export function NodeWrapper({ children, className, selected }: IProps) {
return (
<section
className={cn(
'bg-text-title-invert p-2.5 rounded-sm w-[200px] text-xs',
{ 'border border-accent-primary': selected },
className,
)}
>
{children}
</section>
);
}

View File

@ -0,0 +1,104 @@
import { NodeProps, NodeResizeControl } from '@xyflow/react';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { INoteNode } from '@/interfaces/database/flow';
import { zodResolver } from '@hookform/resolvers/zod';
import { NotebookPen } from 'lucide-react';
import { memo } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { NodeWrapper } from '../node-wrapper';
import { ResizeIcon, controlStyle } from '../resize-icon';
import { useWatchFormChange, useWatchNameFormChange } from './use-watch-change';
const FormSchema = z.object({
text: z.string(),
});
const NameFormSchema = z.object({
name: z.string(),
});
function NoteNode({ data, id, selected }: NodeProps<INoteNode>) {
const { t } = useTranslation();
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: data.form,
});
const nameForm = useForm<z.infer<typeof NameFormSchema>>({
resolver: zodResolver(NameFormSchema),
defaultValues: { name: data.name },
});
useWatchFormChange(id, form);
useWatchNameFormChange(id, nameForm);
return (
<NodeWrapper
className="p-0 w-full h-full flex flex-col"
selected={selected}
>
<NodeResizeControl minWidth={190} minHeight={128} style={controlStyle}>
<ResizeIcon />
</NodeResizeControl>
<section className="p-2 flex gap-2 bg-background-note items-center note-drag-handle rounded-t">
<NotebookPen className="size-4" />
<Form {...nameForm}>
<form className="flex-1">
<FormField
control={nameForm.control}
name="name"
render={({ field }) => (
<FormItem className="h-full">
<FormControl>
<Input
placeholder={t('flow.notePlaceholder')}
{...field}
type="text"
className="bg-transparent border-none focus-visible:outline focus-visible:outline-text-sub-title"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</section>
<Form {...form}>
<form className="flex-1 p-1">
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem className="h-full">
<FormControl>
<Textarea
placeholder={t('flow.notePlaceholder')}
className="resize-none rounded-none p-1 h-full overflow-auto bg-transparent focus-visible:ring-0 border-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</NodeWrapper>
);
}
export default memo(NoteNode);

View File

@ -0,0 +1,30 @@
import useGraphStore from '@/pages/agent/store';
import { useEffect } from 'react';
import { UseFormReturn, useWatch } from 'react-hook-form';
export function useWatchFormChange(id?: string, form?: UseFormReturn<any>) {
let values = useWatch({ control: form?.control });
const updateNodeForm = useGraphStore((state) => state.updateNodeForm);
useEffect(() => {
// Manually triggered form updates are synchronized to the canvas
if (id) {
values = form?.getValues() || {};
let nextValues: any = values;
updateNodeForm(id, nextValues);
}
}, [id, updateNodeForm, values]);
}
export function useWatchNameFormChange(id?: string, form?: UseFormReturn<any>) {
let values = useWatch({ control: form?.control });
const updateNodeName = useGraphStore((state) => state.updateNodeName);
useEffect(() => {
// Manually triggered form updates are synchronized to the canvas
if (id) {
updateNodeName(id, values.name);
}
}, [id, updateNodeName, values]);
}

View File

@ -0,0 +1,121 @@
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 { useFetchAgent } from '@/hooks/use-agent-request';
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 } = useFetchAgent();
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 (
<Popover>
<PopoverTrigger onClick={stopPropagation} asChild>
{children}
</PopoverTrigger>
<PopoverContent
align={'start'}
side={'right'}
sideOffset={20}
onClick={stopPropagation}
className="w-[400px]"
>
<div className="mb-3 font-semibold text-[16px]">
{name} {t('operationResults')}
</div>
<div className="flex w-full gap-4 flex-col">
<div className="flex flex-col space-y-1.5">
<span className="font-semibold text-[14px]">{t('input')}</span>
<div
style={
theme === 'dark'
? {
backgroundColor: 'rgba(150, 150, 150, 0.2)',
}
: {}
}
className={`bg-gray-100 p-1 rounded`}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('componentId')}</TableHead>
<TableHead className="w-[60px]">{t('content')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{inputs.map((x, idx) => (
<TableRow key={idx}>
<TableCell>{getLabel(x.component_id)}</TableCell>
<TableCell className="truncate">{x.content}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<div className="flex flex-col space-y-1.5">
<span className="font-semibold text-[14px]">{t('output')}</span>
<div
style={
theme === 'dark'
? {
backgroundColor: 'rgba(150, 150, 150, 0.2)',
}
: {}
}
className="bg-gray-100 p-1 rounded"
>
<JsonView
src={replacedOutput}
displaySize={30}
className="w-full max-h-[300px] break-words overflow-auto"
/>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}

View File

@ -0,0 +1,73 @@
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 { memo } from 'react';
import { useReplaceIdWithName } from '../../hooks';
import styles from './index.less';
import NodeHeader from './node-header';
function InnerRelevantNode({ id, data, selected }: NodeProps<IRelevantNode>) {
const yes = get(data, 'form.yes');
const no = get(data, 'form.no');
const replaceIdWithName = useReplaceIdWithName();
const { theme } = useTheme();
return (
<section
className={classNames(
styles.logicNode,
theme === 'dark' ? styles.dark : '',
{
[styles.selectedNode]: selected,
},
)}
>
<Handle
type="target"
position={Position.Left}
isConnectable
className={styles.handle}
id={'a'}
></Handle>
<Handle
type="source"
position={Position.Right}
isConnectable
className={styles.handle}
id={'yes'}
style={{ ...RightHandleStyle, top: 57 + 20 }}
></Handle>
<Handle
type="source"
position={Position.Right}
isConnectable
className={styles.handle}
id={'no'}
style={{ ...RightHandleStyle, top: 115 + 20 }}
></Handle>
<NodeHeader
id={id}
name={data.name}
label={data.label}
className={styles.nodeHeader}
></NodeHeader>
<Flex vertical gap={10}>
<Flex vertical>
<div className={styles.relevantLabel}>Yes</div>
<div className={styles.nodeText}>{replaceIdWithName(yes)}</div>
</Flex>
<Flex vertical>
<div className={styles.relevantLabel}>No</div>
<div className={styles.nodeText}>{replaceIdWithName(no)}</div>
</Flex>
</Flex>
</section>
);
}
export const RelevantNode = memo(InnerRelevantNode);

View File

@ -0,0 +1,32 @@
export function ResizeIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="rgba(76, 164, 231, 1)"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
style={{
position: 'absolute',
right: 5,
bottom: 5,
}}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="16 20 20 20 20 16" />
<line x1="14" y1="14" x2="20" y2="20" />
<polyline points="8 4 4 4 4 8" />
<line x1="4" y1="4" x2="10" y2="10" />
</svg>
);
}
export const controlStyle = {
background: 'transparent',
border: 'none',
cursor: 'nwse-resize',
};

View File

@ -0,0 +1,84 @@
import { RAGFlowAvatar } from '@/components/ragflow-avatar';
import { useFetchKnowledgeList } from '@/hooks/knowledge-hooks';
import { IRetrievalNode } from '@/interfaces/database/flow';
import { NodeProps, Position } from '@xyflow/react';
import classNames from 'classnames';
import { get } from 'lodash';
import { memo } from 'react';
import { NodeHandleId } from '../../constant';
import { useGetVariableLabelByValue } from '../../hooks/use-get-begin-query';
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,
data,
isConnectable = true,
selected,
}: NodeProps<IRetrievalNode>) {
const knowledgeBaseIds: string[] = get(data, 'form.kb_ids', []);
const { list: knowledgeList } = useFetchKnowledgeList(true);
const getLabel = useGetVariableLabelByValue(id);
return (
<ToolBar selected={selected} id={id} label={data.label}>
<NodeWrapper selected={selected}>
<CommonHandle
id={NodeHandleId.End}
type="target"
position={Position.Left}
isConnectable={isConnectable}
className={styles.handle}
style={LeftHandleStyle}
nodeId={id}
></CommonHandle>
<CommonHandle
id={NodeHandleId.Start}
type="source"
position={Position.Right}
isConnectable={isConnectable}
className={styles.handle}
style={RightHandleStyle}
nodeId={id}
isConnectableEnd={false}
></CommonHandle>
<NodeHeader
id={id}
name={data.name}
label={data.label}
className={classNames({
[styles.nodeHeader]: knowledgeBaseIds.length > 0,
})}
></NodeHeader>
<section className="flex flex-col gap-2">
{knowledgeBaseIds.map((id) => {
const item = knowledgeList.find((y) => id === y.id);
const label = getLabel(id);
return (
<div className={styles.nodeText} key={id}>
<div className="flex items-center gap-1.5">
<RAGFlowAvatar
className="size-6 rounded-lg"
avatar={id}
name={item?.name || (label as string) || 'CN'}
isPerson={true}
/>
<div className={'truncate flex-1'}>{label || item?.name}</div>
</div>
</div>
);
})}
</section>
</NodeWrapper>
</ToolBar>
);
}
export const RetrievalNode = memo(InnerRetrievalNode);

View File

@ -0,0 +1,60 @@
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 { memo } from 'react';
import { LeftHandleStyle, RightHandleStyle } from './handle-icon';
import styles from './index.less';
import NodeHeader from './node-header';
function InnerRewriteNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IRewriteNode>) {
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={styles.nodeHeader}
></NodeHeader>
<div className={styles.nodeText}>
<LLMLabel value={get(data, 'form.llm_id')}></LLMLabel>
</div>
</section>
);
}
export const RewriteNode = memo(InnerRewriteNode);

View File

@ -0,0 +1,118 @@
import { Card, CardContent } from '@/components/ui/card';
import { ISwitchCondition, ISwitchNode } from '@/interfaces/database/flow';
import { NodeProps, Position } from '@xyflow/react';
import { memo, useCallback } from 'react';
import { NodeHandleId, SwitchOperatorOptions } from '../../constant';
import { LogicalOperatorIcon } from '../../form/switch-form';
import { useGetVariableLabelByValue } from '../../hooks/use-get-begin-query';
import { CommonHandle } from './handle';
import { RightHandleStyle } from './handle-icon';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
import { useBuildSwitchHandlePositions } from './use-build-switch-handle-positions';
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 = useGetVariableLabelByValue(nodeId);
const renderOperatorIcon = useCallback((operator?: string) => {
const item = SwitchOperatorOptions.find((x) => x.value === operator);
if (item) {
return (
<LogicalOperatorIcon
icon={item?.icon}
value={item?.value}
></LogicalOperatorIcon>
);
}
return <></>;
}, []);
return (
<Card>
<CardContent className="p-0 divide-y divide-background-card">
{items.map((x, idx) => (
<div key={idx}>
<section className="flex justify-between gap-2 items-center text-xs p-1">
<div className="flex-1 truncate text-accent-primary">
{getLabel(x?.cpn_id)}
</div>
<span>{renderOperatorIcon(x?.operator)}</span>
<div className="flex-1 truncate">{x?.value}</div>
</section>
</div>
))}
</CardContent>
</Card>
);
};
function InnerSwitchNode({ id, data, selected }: NodeProps<ISwitchNode>) {
const { positions } = useBuildSwitchHandlePositions({ data, id });
return (
<ToolBar selected={selected} id={id} label={data.label} showRun={false}>
<NodeWrapper selected={selected}>
<CommonHandle
type="target"
position={Position.Left}
isConnectable
nodeId={id}
id={NodeHandleId.End}
></CommonHandle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
<section className="gap-2.5 flex flex-col">
{positions.map((position, idx) => {
return (
<div key={idx}>
<section className="flex flex-col text-xs">
<div className="text-right">
<span>{getConditionKey(idx, positions.length)}</span>
<div className="text-text-secondary">
{idx < positions.length - 1 && position.text}
</div>
</div>
<span className="text-accent-primary">
{idx < positions.length - 1 &&
position.condition?.logical_operator?.toUpperCase()}
</span>
{position.condition && (
<ConditionBlock
condition={position.condition}
nodeId={id}
></ConditionBlock>
)}
</section>
<CommonHandle
key={position.text}
id={position.text}
type="source"
position={Position.Right}
isConnectable
style={{ ...RightHandleStyle, top: position.top }}
nodeId={id}
isConnectableEnd={false}
></CommonHandle>
</div>
);
})}
</section>
</NodeWrapper>
</ToolBar>
);
}
export const SwitchNode = memo(InnerSwitchNode);

View File

@ -0,0 +1,78 @@
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 { memo } from 'react';
import styles from './index.less';
function InnerTemplateNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<ITemplateNode>) {
const parameters: IGenerateParameter[] = get(data, 'form.parameters', []);
const getLabel = useGetComponentLabelByValue(id);
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={styles.nodeHeader}
></NodeHeader>
<Flex gap={8} vertical className={styles.generateParameters}>
{parameters.map((x) => (
<Flex
key={x.id}
align="center"
gap={6}
className={styles.conditionBlock}
>
<label htmlFor="">{x.key}</label>
<span className={styles.parameterValue}>
{getLabel(x.component_id)}
</span>
</Flex>
))}
</Flex>
</section>
);
}
export const TemplateNode = memo(InnerTemplateNode);

View File

@ -0,0 +1,83 @@
import { IAgentForm, IToolNode } from '@/interfaces/database/agent';
import { Handle, NodeProps, Position } from '@xyflow/react';
import { get } from 'lodash';
import { MouseEventHandler, memo, useCallback } from 'react';
import { NodeHandleId, Operator } from '../../constant';
import { ToolCard } from '../../form/agent-form/agent-tools';
import { useFindMcpById } from '../../hooks/use-find-mcp-by-id';
import OperatorIcon from '../../operator-icon';
import useGraphStore from '../../store';
import { NodeWrapper } from './node-wrapper';
function InnerToolNode({
id,
isConnectable = true,
selected,
}: NodeProps<IToolNode>) {
const { edges, getNode } = useGraphStore((state) => state);
const upstreamAgentNodeId = edges.find((x) => x.target === id)?.source;
const upstreamAgentNode = getNode(upstreamAgentNodeId);
const { findMcpById } = useFindMcpById();
const handleClick = useCallback(
(operator: string): MouseEventHandler<HTMLLIElement> =>
(e) => {
if (operator === Operator.Code) {
e.preventDefault();
e.stopPropagation();
}
},
[],
);
const tools: IAgentForm['tools'] = get(
upstreamAgentNode,
'data.form.tools',
[],
);
const mcpList: IAgentForm['mcp'] = get(
upstreamAgentNode,
'data.form.mcp',
[],
);
return (
<NodeWrapper selected={selected}>
<Handle
id={NodeHandleId.End}
type="target"
position={Position.Top}
isConnectable={isConnectable}
></Handle>
<ul className="space-y-2">
{tools.map((x) => (
<ToolCard
key={x.component_name}
onClick={handleClick(x.component_name)}
className="cursor-pointer"
data-tool={x.component_name}
>
<div className="flex gap-1 items-center pointer-events-none">
<OperatorIcon name={x.component_name as Operator}></OperatorIcon>
{x.component_name}
</div>
</ToolCard>
))}
{mcpList.map((x) => (
<ToolCard
key={x.mcp_id}
onClick={handleClick(x.mcp_id)}
className="cursor-pointer"
data-tool={x.mcp_id}
>
{findMcpById(x.mcp_id)?.name}
</ToolCard>
))}
</ul>
</NodeWrapper>
);
}
export const ToolNode = memo(InnerToolNode);

View File

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

View File

@ -0,0 +1,48 @@
import { RAGFlowNodeType } from '@/interfaces/database/flow';
import { useUpdateNodeInternals } from '@xyflow/react';
import { get } from 'lodash';
import { useEffect, useMemo } from 'react';
import { z } from 'zod';
import { useCreateCategorizeFormSchema } from '../../form/categorize-form/use-form-schema';
export const useBuildCategorizeHandlePositions = ({
data,
id,
}: {
id: string;
data: RAGFlowNodeType['data'];
}) => {
const updateNodeInternals = useUpdateNodeInternals();
const FormSchema = useCreateCategorizeFormSchema();
type FormSchemaType = z.infer<typeof FormSchema>;
const items: Required<FormSchemaType['items']> = useMemo(() => {
return get(data, `form.items`, []);
}, [data]);
const positions = useMemo(() => {
const list: Array<{
top: number;
name: string;
uuid: string;
}> &
Required<FormSchemaType['items']> = [];
items.forEach((x, idx) => {
list.push({
...x,
top: idx === 0 ? 86 : list[idx - 1].top + 8 + 24,
});
});
return list;
}, [items]);
useEffect(() => {
updateNodeInternals(id);
}, [id, updateNodeInternals, items]);
return { positions };
};

View File

@ -0,0 +1,59 @@
import { ISwitchCondition, RAGFlowNodeType } from '@/interfaces/database/flow';
import { useUpdateNodeInternals } from '@xyflow/react';
import get from 'lodash/get';
import { useEffect, useMemo } from 'react';
import { SwitchElseTo } from '../../constant';
import { generateSwitchHandleText } from '../../utils';
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 ? 53 : list[idx - 1].top + 10 + 14 + 16 + 16; // case number (Case 1) height + flex gap
if (idx >= 1) {
const previousItems = conditions[idx - 1]?.items ?? [];
if (previousItems.length > 0) {
// top += 12; // ConditionBlock padding
top += previousItems.length * 26; // 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 };
};