Feat: Add tool nodes and tool drop-down menu #3221 (#8335)

### What problem does this PR solve?

Feat: Add tool nodes and tool drop-down menu #3221

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
balibabu
2025-06-18 12:36:44 +08:00
committed by GitHub
parent 6ce282d462
commit 371f61972d
8 changed files with 453 additions and 2 deletions

View File

@ -43,6 +43,7 @@ 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';
const nodeTypes: NodeTypes = {
ragNode: RagNode,
@ -63,6 +64,7 @@ const nodeTypes: NodeTypes = {
group: IterationNode,
iterationStartNode: IterationStartNode,
agentNode: AgentNode,
toolNode: ToolNode,
};
const edgeTypes = {

View File

@ -0,0 +1,34 @@
import { IToolNode } from '@/interfaces/database/agent';
import { NodeProps, Position } from '@xyflow/react';
import { memo } from 'react';
import { NodeHandleId } from '../../constant';
import { CommonHandle } from './handle';
import { LeftHandleStyle } from './handle-icon';
import NodeHeader from './node-header';
import { NodeWrapper } from './node-wrapper';
import { ToolBar } from './toolbar';
function InnerToolNode({
id,
data,
isConnectable = true,
selected,
}: NodeProps<IToolNode>) {
return (
<ToolBar selected={selected} id={id} label={data.label}>
<NodeWrapper>
<CommonHandle
id={NodeHandleId.End}
type="target"
position={Position.Top}
isConnectable={isConnectable}
style={LeftHandleStyle}
nodeId={id}
></CommonHandle>
<NodeHeader id={id} name={data.name} label={data.label}></NodeHeader>
</NodeWrapper>
</ToolBar>
);
}
export const ToolNode = memo(InnerToolNode);

View File

@ -0,0 +1,63 @@
import { BlockButton, Button } from '@/components/ui/button';
import {
FormControl,
FormField,
FormItem,
FormMessage,
} from '@/components/ui/form';
import { X } from 'lucide-react';
import { memo } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { PromptEditor } from '../components/prompt-editor';
const DynamicTool = () => {
const form = useFormContext();
const name = 'tools';
const { fields, append, remove } = useFieldArray({
name: name,
control: form.control,
});
return (
<FormItem>
<div className="space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="flex">
<div className="space-y-2 flex-1">
<FormField
control={form.control}
name={`${name}.${index}.component_name`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<section>
<PromptEditor
{...field}
showToolbar={false}
></PromptEditor>
</section>
</FormControl>
</FormItem>
)}
/>
</div>
<Button
type="button"
variant={'ghost'}
onClick={() => remove(index)}
>
<X />
</Button>
</div>
))}
</div>
<FormMessage />
<BlockButton onClick={() => append({ component_name: '' })}>
Add
</BlockButton>
</FormItem>
);
};
export default memo(DynamicTool);

View File

@ -21,7 +21,8 @@ import { AgentInstanceContext } from '../../context';
import { INextOperatorForm } from '../../interface';
import { Output } from '../components/output';
import { PromptEditor } from '../components/prompt-editor';
import { useValues } from './use-values';
import { ToolPopover } from './tool-popover';
import { useToolOptions, useValues } from './use-values';
import { useWatchFormChange } from './use-watch-change';
const FormSchema = z.object({
@ -66,6 +67,8 @@ const AgentForm = ({ node }: INextOperatorForm) => {
const { addCanvasNode } = useContext(AgentInstanceContext);
const toolOptions = useToolOptions();
return (
<Form {...form}>
<form
@ -110,6 +113,9 @@ const AgentForm = ({ node }: INextOperatorForm) => {
)}
/>
</FormContainer>
<ToolPopover>
<BlockButton>Add Tool</BlockButton>
</ToolPopover>
<BlockButton
onClick={addCanvasNode(Operator.Agent, {
nodeId: node?.id,

View File

@ -0,0 +1,18 @@
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { PropsWithChildren } from 'react';
import { ToolCommand } from './tool-command';
export function ToolPopover({ children }: PropsWithChildren) {
return (
<Popover>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent className="w-80 p-0">
<ToolCommand></ToolCommand>
</PopoverContent>
</Popover>
);
}

View File

@ -0,0 +1,118 @@
import { Calendar, CheckIcon } from 'lucide-react';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { cn } from '@/lib/utils';
import { Operator } from '@/pages/flow/constant';
import { useCallback, useEffect, useState } from 'react';
const Menus = [
{
label: 'Search',
list: [
Operator.Google,
Operator.Bing,
Operator.DuckDuckGo,
Operator.Wikipedia,
Operator.YahooFinance,
Operator.PubMed,
Operator.GoogleScholar,
],
},
{
label: 'Communication',
list: [Operator.Email],
},
{
label: 'Productivity',
list: [],
},
{
label: 'Developer',
list: [
Operator.GitHub,
Operator.ExeSQL,
Operator.Invoke,
Operator.Crawler,
Operator.Code,
],
},
];
const Options = Menus.reduce<string[]>((pre, cur) => {
pre.push(...cur.list);
return pre;
}, []);
type ToolCommandProps = {
value?: string[];
onChange?(values: string[]): void;
};
export function ToolCommand({ value, onChange }: ToolCommandProps) {
const [currentValue, setCurrentValue] = useState<string[]>([]);
console.log('🚀 ~ ToolCommand ~ currentValue:', currentValue);
const toggleOption = useCallback(
(option: string) => {
const newSelectedValues = currentValue.includes(option)
? currentValue.filter((value) => value !== option)
: [...currentValue, option];
setCurrentValue(newSelectedValues);
onChange?.(newSelectedValues);
},
[currentValue, onChange],
);
useEffect(() => {
if (Array.isArray(value)) {
setCurrentValue(value);
}
}, [value]);
return (
<Command className="rounded-lg border shadow-md md:min-w-[450px]">
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{Menus.map((x) => (
<CommandGroup heading={x.label} key={x.label}>
{x.list.map((y) => {
const isSelected = currentValue.includes(y);
return (
<CommandItem
key={y}
className="cursor-pointer"
onSelect={() => toggleOption(y)}
>
<div
className={cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
isSelected
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible',
)}
>
<CheckIcon className="h-4 w-4" />
</div>
{/* {option.icon && (
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)} */}
{/* <span>{option.label}</span> */}
<Calendar />
<span>{y}</span>
</CommandItem>
);
})}
</CommandGroup>
))}
</CommandList>
</Command>
);
}

View File

@ -2,7 +2,7 @@ import { useFetchModelId } from '@/hooks/logic-hooks';
import { RAGFlowNodeType } from '@/interfaces/database/flow';
import { get, isEmpty } from 'lodash';
import { useMemo } from 'react';
import { initialAgentValues } from '../../constant';
import { Operator, initialAgentValues } from '../../constant';
export function useValues(node?: RAGFlowNodeType) {
const llmId = useFetchModelId();
@ -28,3 +28,48 @@ export function useValues(node?: RAGFlowNodeType) {
return values;
}
function buildOptions(list: string[]) {
return list.map((x) => ({ label: x, value: x }));
}
export function useToolOptions() {
const options = useMemo(() => {
const options = [
{
label: 'Search',
options: buildOptions([
Operator.Google,
Operator.Bing,
Operator.DuckDuckGo,
Operator.Wikipedia,
Operator.YahooFinance,
Operator.PubMed,
Operator.GoogleScholar,
]),
},
{
label: 'Communication',
options: buildOptions([Operator.Email]),
},
{
label: 'Productivity',
options: [],
},
{
label: 'Developer',
options: buildOptions([
Operator.GitHub,
Operator.ExeSQL,
Operator.Invoke,
Operator.Crawler,
Operator.Code,
]),
},
];
return options;
}, []);
return options;
}