### What problem does this PR solve? Feat: Render the mcp list on the agent page #3221 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.20.0
| @@ -159,6 +159,10 @@ export interface IAgentForm { | |||
| component_name: string; | |||
| params: Record<string, any>; | |||
| }>; | |||
| mcp: Array<{ | |||
| mcp_id: string; | |||
| tools: Record<string, Record<string, any>>; | |||
| }>; | |||
| outputs: { | |||
| structured_output: Record<string, Record<string, any>>; | |||
| content: Record<string, any>; | |||
| @@ -4,18 +4,15 @@ import { get } from 'lodash'; | |||
| import { memo, useCallback } from 'react'; | |||
| import { NodeHandleId } from '../../constant'; | |||
| import { ToolCard } from '../../form/agent-form/agent-tools'; | |||
| import { useFindMcpById } from '../../hooks/use-find-mcp-by-id'; | |||
| import useGraphStore from '../../store'; | |||
| import { NodeWrapper } from './node-wrapper'; | |||
| function InnerToolNode({ | |||
| id, | |||
| data, | |||
| isConnectable = true, | |||
| selected, | |||
| }: NodeProps<IToolNode>) { | |||
| function InnerToolNode({ id, isConnectable = true }: 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(() => {}, []); | |||
| @@ -25,6 +22,12 @@ function InnerToolNode({ | |||
| [], | |||
| ); | |||
| const mcpList: IAgentForm['mcp'] = get( | |||
| upstreamAgentNode, | |||
| 'data.form.mcp', | |||
| [], | |||
| ); | |||
| return ( | |||
| <NodeWrapper> | |||
| <Handle | |||
| @@ -44,6 +47,16 @@ function InnerToolNode({ | |||
| {x.component_name} | |||
| </ToolCard> | |||
| ))} | |||
| {mcpList.map((x) => ( | |||
| <ToolCard | |||
| key={x.mcp_id} | |||
| onClick={handleClick} | |||
| className="cursor-pointer" | |||
| data-tool={x.mcp_id} | |||
| > | |||
| {findMcpById(x.mcp_id)?.name} | |||
| </ToolCard> | |||
| ))} | |||
| </ul> | |||
| </NodeWrapper> | |||
| ); | |||
| @@ -697,6 +697,7 @@ export const initialAgentValues = { | |||
| exception_comment: '', | |||
| exception_goto: '', | |||
| tools: [], | |||
| mcp: [], | |||
| outputs: { | |||
| structured_output: { | |||
| // topic: { | |||
| @@ -5,12 +5,14 @@ import { PencilLine, X } from 'lucide-react'; | |||
| import { PropsWithChildren, useCallback, useContext, useMemo } from 'react'; | |||
| import { Operator } from '../../constant'; | |||
| import { AgentInstanceContext } from '../../context'; | |||
| import { useFindMcpById } from '../../hooks/use-find-mcp-by-id'; | |||
| import { INextOperatorForm } from '../../interface'; | |||
| import useGraphStore from '../../store'; | |||
| import { filterDownstreamAgentNodeIds } from '../../utils/filter-downstream-nodes'; | |||
| import { ToolPopover } from './tool-popover'; | |||
| import { useDeleteAgentNodeMCP } from './tool-popover/use-update-mcp'; | |||
| import { useDeleteAgentNodeTools } from './tool-popover/use-update-tools'; | |||
| import { useGetAgentToolNames } from './use-get-tools'; | |||
| import { useGetAgentMCPIds, useGetAgentToolNames } from './use-get-tools'; | |||
| export function ToolCard({ | |||
| children, | |||
| @@ -59,6 +61,9 @@ function ActionButton<T>({ edit, deleteRecord, record }: ActionButtonProps<T>) { | |||
| export function AgentTools() { | |||
| const { toolNames } = useGetAgentToolNames(); | |||
| const { deleteNodeTool } = useDeleteAgentNodeTools(); | |||
| const { mcpIds } = useGetAgentMCPIds(); | |||
| const { findMcpById } = useFindMcpById(); | |||
| const { deleteNodeMCP } = useDeleteAgentNodeMCP(); | |||
| return ( | |||
| <section className="space-y-2.5"> | |||
| @@ -74,6 +79,16 @@ export function AgentTools() { | |||
| ></ActionButton> | |||
| </ToolCard> | |||
| ))} | |||
| {mcpIds.map((id) => ( | |||
| <ToolCard key={id}> | |||
| {findMcpById(id)?.name} | |||
| <ActionButton | |||
| record={id} | |||
| edit={() => {}} | |||
| deleteRecord={deleteNodeMCP(id)} | |||
| ></ActionButton> | |||
| </ToolCard> | |||
| ))} | |||
| </ul> | |||
| <ToolPopover> | |||
| <BlockButton>Add Tool</BlockButton> | |||
| @@ -3,15 +3,22 @@ import { | |||
| PopoverContent, | |||
| PopoverTrigger, | |||
| } from '@/components/ui/popover'; | |||
| import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; | |||
| import { Operator } from '@/pages/agent/constant'; | |||
| import { AgentFormContext, AgentInstanceContext } from '@/pages/agent/context'; | |||
| import useGraphStore from '@/pages/agent/store'; | |||
| import { Position } from '@xyflow/react'; | |||
| import { PropsWithChildren, useCallback, useContext } from 'react'; | |||
| import { useGetAgentToolNames } from '../use-get-tools'; | |||
| import { ToolCommand } from './tool-command'; | |||
| import { PropsWithChildren, useCallback, useContext, useEffect } from 'react'; | |||
| import { useGetAgentMCPIds, useGetAgentToolNames } from '../use-get-tools'; | |||
| import { MCPCommand, ToolCommand } from './tool-command'; | |||
| import { useUpdateAgentNodeMCP } from './use-update-mcp'; | |||
| import { useUpdateAgentNodeTools } from './use-update-tools'; | |||
| enum ToolType { | |||
| Common = 'common', | |||
| MCP = 'mcp', | |||
| } | |||
| export function ToolPopover({ children }: PropsWithChildren) { | |||
| const { addCanvasNode } = useContext(AgentInstanceContext); | |||
| const node = useContext(AgentFormContext); | |||
| @@ -20,29 +27,57 @@ export function ToolPopover({ children }: PropsWithChildren) { | |||
| const deleteAgentToolNodeById = useGraphStore( | |||
| (state) => state.deleteAgentToolNodeById, | |||
| ); | |||
| const { mcpIds } = useGetAgentMCPIds(); | |||
| const { updateNodeMCP } = useUpdateAgentNodeMCP(); | |||
| const handleChange = useCallback( | |||
| (value: string[]) => { | |||
| if (Array.isArray(value) && node?.id) { | |||
| updateNodeTools(value); | |||
| if (value.length > 0) { | |||
| addCanvasNode(Operator.Tool, { | |||
| position: Position.Bottom, | |||
| nodeId: node?.id, | |||
| })(); | |||
| } else { | |||
| deleteAgentToolNodeById(node.id); // TODO: The tool node should be derived from the agent tools data | |||
| } | |||
| } | |||
| }, | |||
| [addCanvasNode, deleteAgentToolNodeById, node?.id, updateNodeTools], | |||
| [node?.id, updateNodeTools], | |||
| ); | |||
| useEffect(() => { | |||
| const total = toolNames.length + mcpIds.length; | |||
| if (node?.id) { | |||
| if (total > 0) { | |||
| addCanvasNode(Operator.Tool, { | |||
| position: Position.Bottom, | |||
| nodeId: node?.id, | |||
| })(); | |||
| } else { | |||
| deleteAgentToolNodeById(node.id); | |||
| } | |||
| } | |||
| }, [ | |||
| addCanvasNode, | |||
| deleteAgentToolNodeById, | |||
| mcpIds.length, | |||
| node?.id, | |||
| toolNames.length, | |||
| ]); | |||
| return ( | |||
| <Popover> | |||
| <PopoverTrigger asChild>{children}</PopoverTrigger> | |||
| <PopoverContent className="w-80 p-0"> | |||
| <ToolCommand onChange={handleChange} value={toolNames}></ToolCommand> | |||
| <PopoverContent className="w-80 p-4"> | |||
| <Tabs defaultValue={ToolType.Common}> | |||
| <TabsList> | |||
| <TabsTrigger value={ToolType.Common}>Built-in</TabsTrigger> | |||
| <TabsTrigger value={ToolType.MCP}>MCP</TabsTrigger> | |||
| </TabsList> | |||
| <TabsContent value={ToolType.Common}> | |||
| <ToolCommand | |||
| onChange={handleChange} | |||
| value={toolNames} | |||
| ></ToolCommand> | |||
| </TabsContent> | |||
| <TabsContent value={ToolType.MCP}> | |||
| <MCPCommand value={mcpIds} onChange={updateNodeMCP}></MCPCommand> | |||
| </TabsContent> | |||
| </Tabs> | |||
| </PopoverContent> | |||
| </Popover> | |||
| ); | |||
| @@ -8,9 +8,10 @@ import { | |||
| CommandItem, | |||
| CommandList, | |||
| } from '@/components/ui/command'; | |||
| import { useListMcpServer } from '@/hooks/use-mcp-request'; | |||
| import { cn } from '@/lib/utils'; | |||
| import { Operator } from '@/pages/agent/constant'; | |||
| import { useCallback, useEffect, useState } from 'react'; | |||
| import { PropsWithChildren, useCallback, useEffect, useState } from 'react'; | |||
| const Menus = [ | |||
| { | |||
| @@ -52,7 +53,36 @@ type ToolCommandProps = { | |||
| onChange?(values: string[]): void; | |||
| }; | |||
| export function ToolCommand({ value, onChange }: ToolCommandProps) { | |||
| type ToolCommandItemProps = { | |||
| toggleOption(id: string): void; | |||
| id: string; | |||
| isSelected: boolean; | |||
| } & ToolCommandProps; | |||
| function ToolCommandItem({ | |||
| toggleOption, | |||
| id, | |||
| isSelected, | |||
| children, | |||
| }: ToolCommandItemProps & PropsWithChildren) { | |||
| return ( | |||
| <CommandItem className="cursor-pointer" onSelect={() => toggleOption(id)}> | |||
| <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> | |||
| {children} | |||
| </CommandItem> | |||
| ); | |||
| } | |||
| function useHandleSelectChange({ onChange, value }: ToolCommandProps) { | |||
| const [currentValue, setCurrentValue] = useState<string[]>([]); | |||
| const toggleOption = useCallback( | |||
| @@ -72,8 +102,20 @@ export function ToolCommand({ value, onChange }: ToolCommandProps) { | |||
| } | |||
| }, [value]); | |||
| return { | |||
| toggleOption, | |||
| currentValue, | |||
| }; | |||
| } | |||
| export function ToolCommand({ value, onChange }: ToolCommandProps) { | |||
| const { toggleOption, currentValue } = useHandleSelectChange({ | |||
| onChange, | |||
| value, | |||
| }); | |||
| return ( | |||
| <Command className="rounded-lg border shadow-md md:min-w-[450px]"> | |||
| <Command> | |||
| <CommandInput placeholder="Type a command or search..." /> | |||
| <CommandList> | |||
| <CommandEmpty>No results found.</CommandEmpty> | |||
| @@ -82,28 +124,17 @@ export function ToolCommand({ value, onChange }: ToolCommandProps) { | |||
| {x.list.map((y) => { | |||
| const isSelected = currentValue.includes(y); | |||
| return ( | |||
| <CommandItem | |||
| <ToolCommandItem | |||
| key={y} | |||
| className="cursor-pointer" | |||
| onSelect={() => toggleOption(y)} | |||
| id={y} | |||
| toggleOption={toggleOption} | |||
| isSelected={isSelected} | |||
| > | |||
| <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> | |||
| <> | |||
| <Calendar /> | |||
| <span>{y}</span> | |||
| </> | |||
| </ToolCommandItem> | |||
| ); | |||
| })} | |||
| </CommandGroup> | |||
| @@ -112,3 +143,34 @@ export function ToolCommand({ value, onChange }: ToolCommandProps) { | |||
| </Command> | |||
| ); | |||
| } | |||
| export function MCPCommand({ onChange, value }: ToolCommandProps) { | |||
| const { data } = useListMcpServer(); | |||
| const { toggleOption, currentValue } = useHandleSelectChange({ | |||
| onChange, | |||
| value, | |||
| }); | |||
| return ( | |||
| <Command> | |||
| <CommandInput placeholder="Type a command or search..." /> | |||
| <CommandList> | |||
| <CommandEmpty>No results found.</CommandEmpty> | |||
| {data.mcp_servers.map((item) => { | |||
| const isSelected = currentValue.includes(item.id); | |||
| return ( | |||
| <ToolCommandItem | |||
| key={item.id} | |||
| id={item.id} | |||
| isSelected={isSelected} | |||
| toggleOption={toggleOption} | |||
| > | |||
| {item.name} | |||
| </ToolCommandItem> | |||
| ); | |||
| })} | |||
| </CommandList> | |||
| </Command> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,74 @@ | |||
| import { useListMcpServer } from '@/hooks/use-mcp-request'; | |||
| import { IAgentForm } from '@/interfaces/database/agent'; | |||
| import { AgentFormContext } from '@/pages/agent/context'; | |||
| import useGraphStore from '@/pages/agent/store'; | |||
| import { get } from 'lodash'; | |||
| import { useCallback, useContext, useMemo } from 'react'; | |||
| export function useGetNodeMCP() { | |||
| const node = useContext(AgentFormContext); | |||
| return useMemo(() => { | |||
| const mcp: IAgentForm['mcp'] = get(node, 'data.form.mcp'); | |||
| return mcp; | |||
| }, [node]); | |||
| } | |||
| export function useUpdateAgentNodeMCP() { | |||
| const { updateNodeForm } = useGraphStore((state) => state); | |||
| const node = useContext(AgentFormContext); | |||
| const mcpList = useGetNodeMCP(); | |||
| const { data } = useListMcpServer(); | |||
| const mcpServers = data.mcp_servers; | |||
| const findMcpTools = useCallback( | |||
| (mcpId: string) => { | |||
| const mcp = mcpServers.find((x) => x.id === mcpId); | |||
| return mcp?.variables.tools; | |||
| }, | |||
| [mcpServers], | |||
| ); | |||
| const updateNodeMCP = useCallback( | |||
| (value: string[]) => { | |||
| if (node?.id) { | |||
| const nextValue = value.reduce<IAgentForm['mcp']>((pre, cur) => { | |||
| const mcp = mcpList.find((x) => x.mcp_id === cur); | |||
| const tools = findMcpTools(cur); | |||
| if (mcp) { | |||
| pre.push(mcp); | |||
| } else if (tools) { | |||
| pre.push({ | |||
| mcp_id: cur, | |||
| tools, | |||
| }); | |||
| } | |||
| return pre; | |||
| }, []); | |||
| updateNodeForm(node?.id, nextValue, ['mcp']); | |||
| } | |||
| }, | |||
| [node?.id, updateNodeForm, mcpList, findMcpTools], | |||
| ); | |||
| return { updateNodeMCP }; | |||
| } | |||
| export function useDeleteAgentNodeMCP() { | |||
| const { updateNodeForm } = useGraphStore((state) => state); | |||
| const mcpList = useGetNodeMCP(); | |||
| const node = useContext(AgentFormContext); | |||
| const deleteNodeMCP = useCallback( | |||
| (value: string) => () => { | |||
| const nextMCP = mcpList.filter((x) => x.mcp_id !== value); | |||
| if (node?.id) { | |||
| updateNodeForm(node?.id, nextMCP, ['mcp']); | |||
| } | |||
| }, | |||
| [node?.id, mcpList, updateNodeForm], | |||
| ); | |||
| return { deleteNodeMCP }; | |||
| } | |||
| @@ -45,35 +45,22 @@ export function useUpdateAgentNodeTools() { | |||
| [node?.id, tools, updateNodeForm], | |||
| ); | |||
| const deleteNodeTool = useCallback( | |||
| (value: string) => { | |||
| updateNodeTools([value]); | |||
| }, | |||
| [updateNodeTools], | |||
| ); | |||
| return { updateNodeTools, deleteNodeTool }; | |||
| return { updateNodeTools }; | |||
| } | |||
| export function useDeleteAgentNodeTools() { | |||
| const { updateNodeForm } = useGraphStore((state) => state); | |||
| const tools = useGetNodeTools(); | |||
| const node = useContext(AgentFormContext); | |||
| const deleteAgentToolNodeById = useGraphStore( | |||
| (state) => state.deleteAgentToolNodeById, | |||
| ); | |||
| const deleteNodeTool = useCallback( | |||
| (value: string) => () => { | |||
| const nextTools = tools.filter((x) => x.component_name !== value); | |||
| if (node?.id) { | |||
| updateNodeForm(node?.id, nextTools, ['tools']); | |||
| if (nextTools.length === 0) { | |||
| deleteAgentToolNodeById(node?.id); | |||
| } | |||
| } | |||
| }, | |||
| [deleteAgentToolNodeById, node?.id, tools, updateNodeForm], | |||
| [node?.id, tools, updateNodeForm], | |||
| ); | |||
| return { deleteNodeTool }; | |||
| @@ -13,3 +13,14 @@ export function useGetAgentToolNames() { | |||
| return { toolNames }; | |||
| } | |||
| export function useGetAgentMCPIds() { | |||
| const node = useContext(AgentFormContext); | |||
| const mcpIds = useMemo(() => { | |||
| const ids: IAgentForm['mcp'] = get(node, 'data.form.mcp', []); | |||
| return ids.map((x) => x.mcp_id); | |||
| }, [node]); | |||
| return { mcpIds }; | |||
| } | |||
| @@ -0,0 +1,12 @@ | |||
| import { useListMcpServer } from '@/hooks/use-mcp-request'; | |||
| export function useFindMcpById() { | |||
| const { data } = useListMcpServer(); | |||
| const findMcpById = (id: string) => | |||
| data.mcp_servers.find((item) => item.id === id); | |||
| return { | |||
| findMcpById, | |||
| }; | |||
| } | |||
| @@ -90,7 +90,7 @@ export default function Dataset() { | |||
| </DropdownMenuItem> | |||
| <DropdownMenuSeparator /> | |||
| <DropdownMenuItem onClick={showCreateModal}> | |||
| {t('fileManager.newFolder')} | |||
| {t('knowledgeDetails.emptyFiles')} | |||
| </DropdownMenuItem> | |||
| </DropdownMenuContent> | |||
| </DropdownMenu> | |||
| @@ -15,9 +15,12 @@ import { Input } from '@/components/ui/input'; | |||
| import { RAGFlowSelect } from '@/components/ui/select'; | |||
| import { IModalProps } from '@/interfaces/common'; | |||
| import { buildOptions } from '@/utils/form'; | |||
| import { Editor, loader } from '@monaco-editor/react'; | |||
| import { Dispatch, SetStateAction } from 'react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| loader.config({ paths: { vs: '/vs' } }); | |||
| export const FormId = 'EditMcpForm'; | |||
| export enum ServerType { | |||
| @@ -50,7 +53,7 @@ export function useBuildFormSchema() { | |||
| message: t('common.namePlaceholder'), | |||
| }) | |||
| .trim(), | |||
| // variables: z.object({}).optional(), | |||
| headers: z.record(z.string(), z.any()).optional(), | |||
| }); | |||
| return FormSchema; | |||
| @@ -137,6 +140,28 @@ export function EditMcpForm({ | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| <FormField | |||
| control={form.control} | |||
| name="headers" | |||
| render={({ field }) => ( | |||
| <FormItem> | |||
| <FormLabel>Headers</FormLabel> | |||
| <FormControl> | |||
| <Editor | |||
| height={200} | |||
| defaultLanguage="json" | |||
| theme="vs-dark" | |||
| {...field} | |||
| onChange={(value) => { | |||
| field.onChange(value); | |||
| setFieldChanged(true); | |||
| }} | |||
| /> | |||
| </FormControl> | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| </form> | |||
| </Form> | |||
| ); | |||