瀏覽代碼

Feat:Can directly generate an agent node by dragging and dropping the connecting line (#9226) (#9357)

…e connecting line (#9226)

### What problem does this PR solve?

Can directly generate an agent node by dragging and dropping the
connecting line (#9226)

### Type of change

- [ ] Bug Fix (non-breaking change which fixes an issue)
- [x] New Feature (non-breaking change which adds functionality)
- [ ] Documentation Update
- [ ] Refactoring
- [ ] Performance Improvement
- [ ] Other (please describe):
tags/v0.20.2
FatMii 2 月之前
父節點
當前提交
618d6bc924
沒有連結到貢獻者的電子郵件帳戶。

+ 56
- 0
web/src/pages/agent/canvas/context.tsx 查看文件

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

+ 100
- 10
web/src/pages/agent/canvas/index.tsx 查看文件

@@ -4,17 +4,20 @@ import {
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, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChatSheet } from '../chat/chat-sheet';
import { AgentBackground } from '../components/background';
@@ -22,7 +25,9 @@ import {
AgentChatContext,
AgentChatLogContext,
AgentInstanceContext,
HandleContext,
} from '../context';

import FormSheet from '../form-sheet/next';
import {
useHandleDrop,
@@ -33,6 +38,8 @@ import { useAddNode } from '../hooks/use-add-node';
import { useBeforeDelete } from '../hooks/use-before-delete';
import { useCacheChatLog } from '../hooks/use-cache-chat-log';
import { useMoveNote } from '../hooks/use-move-note';
import { useDropdownManager } from './context';

import {
useHideFormSheetOnNodeDeletion,
useShowDrawer,
@@ -46,6 +53,7 @@ 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';
@@ -96,7 +104,7 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
const {
nodes,
edges,
onConnect,
onConnect: originalOnConnect,
onEdgesChange,
onNodesChange,
onSelectionChange,
@@ -147,14 +155,6 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {

const { theme } = useTheme();

const onPaneClick = useCallback(() => {
hideFormDrawer();
if (imgVisible) {
addNoteNode(mouse);
hideImage();
}
}, [addNoteNode, hideFormDrawer, hideImage, imgVisible, mouse]);

useEffect(() => {
if (!chatVisible) {
clearEventList();
@@ -172,6 +172,73 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {

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
@@ -206,6 +273,8 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onDrop={onDrop}
onConnectStart={OnConnectStart}
onConnectEnd={OnConnectEnd}
onDragOver={onDragOver}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
@@ -243,6 +312,27 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
</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 })}

+ 162
- 36
web/src/pages/agent/canvas/node/dropdown/next-step-dropdown.tsx 查看文件

@@ -20,55 +20,111 @@ 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 { lowerFirst } from 'lodash';
import { PropsWithChildren, createContext, memo, useContext } from 'react';
import {
PropsWithChildren,
createContext,
memo,
useContext,
useEffect,
useRef,
} from 'react';
import { useTranslation } from 'react-i18next';

type OperatorItemProps = { operators: Operator[] };
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 }: OperatorItemProps) {
function OperatorItemList({
operators,
isCustomDropdown = false,
mousePosition,
}: OperatorItemProps) {
const { addCanvasNode } = useContext(AgentInstanceContext);
const { nodeId, id, position } = useContext(HandleContext);
const handleContext = useContext(HandleContext);
const hideModal = useContext(HideModalContext);
const onNodeCreated = useContext(OnNodeCreatedContext);
const { t } = useTranslation();

return (
<ul className="space-y-2">
{operators.map((x) => {
return (
<Tooltip key={x}>
<TooltipTrigger asChild>
<DropdownMenuItem
key={x}
className="hover:bg-bg-card py-1 px-3 cursor-pointer rounded-sm flex gap-2 items-center justify-start"
onClick={addCanvasNode(x, {
nodeId,
id,
position,
})}
onSelect={() => hideModal?.()}
>
<OperatorIcon name={x}></OperatorIcon>
{t(`flow.${lowerFirst(x)}`)}
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="right">
<p>{t(`flow.${lowerFirst(x)}Description`)}</p>
</TooltipContent>
</Tooltip>
);
})}
</ul>
);
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() {
function AccordionOperators({
isCustomDropdown = false,
mousePosition,
}: {
isCustomDropdown?: boolean;
mousePosition?: { x: number; y: number };
}) {
return (
<Accordion
type="multiple"
className="px-2 text-text-primary max-h-[45vh] overflow-auto"
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">
@@ -76,6 +132,8 @@ function AccordionOperators() {
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList
operators={[Operator.Agent, Operator.Retrieval]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
@@ -84,6 +142,8 @@ function AccordionOperators() {
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList
operators={[Operator.Message, Operator.UserFillUp]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
@@ -96,6 +156,8 @@ function AccordionOperators() {
Operator.Iteration,
Operator.Categorize,
]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
@@ -106,6 +168,8 @@ function AccordionOperators() {
<AccordionContent className="flex flex-col gap-4 text-balance">
<OperatorItemList
operators={[Operator.Code, Operator.StringTransform]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
@@ -129,6 +193,8 @@ function AccordionOperators() {
Operator.Invoke,
Operator.WenCai,
]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}
></OperatorItemList>
</AccordionContent>
</AccordionItem>
@@ -139,9 +205,69 @@ function AccordionOperators() {
export function InnerNextStepDropdown({
children,
hideModal,
}: PropsWithChildren & IModalProps<any>) {
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-white border border-border rounded-md shadow-lg">
<div className="px-3 py-2 border-b border-border">
<div className="text-sm font-medium">Next Step</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 onOpenChange={hideModal}>
<DropdownMenu
open={true}
onOpenChange={(open) => {
if (!open && hideModal) {
hideModal();
}
}}
>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
onClick={(e) => e.stopPropagation()}

+ 18
- 2
web/src/pages/agent/canvas/node/handle.tsx 查看文件

@@ -4,6 +4,7 @@ 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({
@@ -13,12 +14,16 @@ export function CommonHandle({
}: HandleProps & { nodeId: string }) {
const { visible, hideModal, showModal } = useSetModalState();

const { canShowDropdown, setActiveDropdown, clearActiveDropdown } =
useDropdownManager();

const value = useMemo(
() => ({
nodeId,
id: props.id,
id: props.id || undefined,
type: props.type,
position: props.position,
isFromConnectionDrag: false,
}),
[nodeId, props.id, props.position, props.type],
);
@@ -33,12 +38,23 @@ export function CommonHandle({
)}
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}>
<InnerNextStepDropdown
hideModal={() => {
hideModal();
clearActiveDropdown();
}}
>
<span></span>
</InnerNextStepDropdown>
)}

+ 1
- 0
web/src/pages/agent/context.ts 查看文件

@@ -42,6 +42,7 @@ export type HandleContextType = {
id?: string;
type: HandleType;
position: Position;
isFromConnectionDrag: boolean;
};

export const HandleContext = createContext<HandleContextType>(

+ 22
- 7
web/src/pages/agent/hooks/use-add-node.ts 查看文件

@@ -208,7 +208,7 @@ function useAddToolNode() {
);

const addToolNode = useCallback(
(newNode: Node<any>, nodeId?: string) => {
(newNode: Node<any>, nodeId?: string): boolean => {
const agentNode = getNode(nodeId);

if (agentNode) {
@@ -222,7 +222,7 @@ function useAddToolNode() {
childToolNodeIds.length > 0 &&
nodes.some((x) => x.id === childToolNodeIds[0])
) {
return;
return false;
}

newNode.position = {
@@ -239,7 +239,9 @@ function useAddToolNode() {
targetHandle: NodeHandleId.End,
});
}
return true;
}
return false;
},
[addEdge, addNode, edges, getNode, nodes],
);
@@ -295,13 +297,17 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) {
const addCanvasNode = useCallback(
(
type: string,
params: { nodeId?: string; position: Position; id?: string } = {
params: {
nodeId?: string;
position: Position;
id?: string;
isFromConnectionDrag?: boolean;
} = {
position: Position.Right,
},
) =>
(event?: CanvasMouseEvent) => {
(event?: CanvasMouseEvent): string | undefined => {
const nodeId = params.nodeId;

const node = getNode(nodeId);

// reactFlowInstance.project was renamed to reactFlowInstance.screenToFlowPosition
@@ -312,7 +318,11 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) {
y: event?.clientY || 0,
});

if (params.position === Position.Right && type !== Operator.Note) {
if (
params.position === Position.Right &&
type !== Operator.Note &&
!params.isFromConnectionDrag
) {
position = calculateNewlyBackChildPosition(nodeId, params.id);
}

@@ -371,6 +381,7 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) {
targetHandle: NodeHandleId.End,
});
}
return newNode.id;
} else if (
type === Operator.Agent &&
params.position === Position.Bottom
@@ -406,8 +417,10 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) {
targetHandle: NodeHandleId.AgentTop,
});
}
return newNode.id;
} else if (type === Operator.Tool) {
addToolNode(newNode, params.nodeId);
const toolNodeAdded = addToolNode(newNode, params.nodeId);
return toolNodeAdded ? newNode.id : undefined;
} else {
addNode(newNode);
addChildEdge(params.position, {
@@ -416,6 +429,8 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) {
sourceHandle: params.id,
});
}

return newNode.id;
},
[
addChildEdge,

+ 7
- 4
web/src/pages/agent/index.tsx 查看文件

@@ -34,6 +34,7 @@ import { ComponentPropsWithoutRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'umi';
import AgentCanvas from './canvas';
import { DropdownProvider } from './canvas/context';
import EmbedDialog from './embed-dialog';
import { useHandleExportOrImportJsonFile } from './hooks/use-export-json';
import { useFetchDataOnMount } from './hooks/use-fetch-data';
@@ -185,10 +186,12 @@ export default function Agent() {
</div>
</PageHeader>
<ReactFlowProvider>
<AgentCanvas
drawerVisible={chatDrawerVisible}
hideDrawer={hideChatDrawer}
></AgentCanvas>
<DropdownProvider>
<AgentCanvas
drawerVisible={chatDrawerVisible}
hideDrawer={hideChatDrawer}
></AgentCanvas>
</DropdownProvider>
</ReactFlowProvider>
{fileUploadVisible && (
<UploadAgentDialog

Loading…
取消
儲存