feat: the frontend part of mcp (#22131)

Co-authored-by: jZonG <jzongcode@gmail.com>
Co-authored-by: Novice <novice12185727@gmail.com>
Co-authored-by: nite-knite <nkCoding@gmail.com>
Co-authored-by: Hanqing Zhao <sherry9277@gmail.com>
This commit is contained in:
Joel
2025-07-10 14:14:02 +08:00
committed by GitHub
parent 535fff62f3
commit 5375d9bb27
152 changed files with 6340 additions and 695 deletions

View File

@@ -68,6 +68,7 @@ function formatStrategy(input: StrategyPluginDetail[], getIcon: (i: string) => s
icon: getIcon(item.declaration.identity.icon),
label: item.declaration.identity.label as any,
type: CollectionType.all,
meta: item.meta,
tools: item.declaration.strategies.map(strategy => ({
name: strategy.identity.name,
author: strategy.identity.author,
@@ -89,10 +90,13 @@ function formatStrategy(input: StrategyPluginDetail[], getIcon: (i: string) => s
export type AgentStrategySelectorProps = {
value?: Strategy,
onChange: (value?: Strategy) => void,
canChooseMCPTool: boolean,
}
export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) => {
const { value, onChange } = props
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
const { value, onChange, canChooseMCPTool } = props
const [open, setOpen] = useState(false)
const [viewType, setViewType] = useState<ViewType>(ViewType.flat)
const [query, setQuery] = useState('')
@@ -132,8 +136,6 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) =>
plugins: notInstalledPlugins = [],
} = useMarketplacePlugins()
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
useEffect(() => {
if (!enable_marketplace) return
if (query) {
@@ -214,21 +216,25 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) =>
agent_strategy_label: tool!.tool_label,
agent_output_schema: tool!.output_schema,
plugin_unique_identifier: tool!.provider_id,
meta: tool!.meta,
})
setOpen(false)
}}
className='h-full max-h-full max-w-none overflow-y-auto'
indexBarClassName='top-0 xl:top-36' showWorkflowEmpty={false} hasSearchText={false} />
{enable_marketplace
&& <PluginList
indexBarClassName='top-0 xl:top-36'
hasSearchText={false}
canNotSelectMultiple
canChooseMCPTool={canChooseMCPTool}
isAgent
/>
{enable_marketplace && <PluginList
ref={pluginRef}
wrapElemRef={wrapElemRef}
list={notInstalledPlugins}
searchText={query}
tags={DEFAULT_TAGS}
disableMaxWidth
/>
}
/>}
</main>
</div>
</PortalToFollowElemContent>

View File

@@ -19,6 +19,8 @@ import { useWorkflowStore } from '../../../store'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import type { NodeOutPutVar } from '../../../types'
import type { Node } from 'reactflow'
import type { PluginMeta } from '@/app/components/plugins/types'
import { noop } from 'lodash'
import { useDocLink } from '@/context/i18n'
export type Strategy = {
@@ -27,6 +29,7 @@ export type Strategy = {
agent_strategy_label: string
agent_output_schema: Record<string, any>
plugin_unique_identifier: string
meta?: PluginMeta
}
export type AgentStrategyProps = {
@@ -38,6 +41,7 @@ export type AgentStrategyProps = {
nodeOutputVars?: NodeOutPutVar[],
availableNodes?: Node[],
nodeId?: string
canChooseMCPTool: boolean
}
type CustomSchema<Type, Field = {}> = Omit<CredentialFormSchema, 'type'> & { type: Type } & Field
@@ -48,7 +52,7 @@ type MultipleToolSelectorSchema = CustomSchema<'array[tools]'>
type CustomField = ToolSelectorSchema | MultipleToolSelectorSchema
export const AgentStrategy = memo((props: AgentStrategyProps) => {
const { strategy, onStrategyChange, formSchema, formValue, onFormValueChange, nodeOutputVars, availableNodes, nodeId } = props
const { strategy, onStrategyChange, formSchema, formValue, onFormValueChange, nodeOutputVars, availableNodes, nodeId, canChooseMCPTool } = props
const { t } = useTranslation()
const docLink = useDocLink()
const defaultModel = useDefaultModel(ModelTypeEnum.textGeneration)
@@ -57,6 +61,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
const {
setControlPromptEditorRerenderKey,
} = workflowStore.getState()
const override: ComponentProps<typeof Form<CustomField>>['override'] = [
[FormTypeEnum.textNumber, FormTypeEnum.textInput],
(schema, props) => {
@@ -168,6 +173,8 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
value={value}
onSelect={item => onChange(item)}
onDelete={() => onChange(null)}
canChooseMCPTool={canChooseMCPTool}
onSelectMultiple={noop}
/>
</Field>
)
@@ -189,13 +196,14 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
onChange={onChange}
supportCollapse
required={schema.required}
canChooseMCPTool={canChooseMCPTool}
/>
)
}
}
}
return <div className='space-y-2'>
<AgentStrategySelector value={strategy} onChange={onStrategyChange} />
<AgentStrategySelector value={strategy} onChange={onStrategyChange} canChooseMCPTool={canChooseMCPTool} />
{
strategy
? <div>
@@ -215,6 +223,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
nodeId={nodeId}
nodeOutputVars={nodeOutputVars || []}
availableNodes={availableNodes || []}
canChooseMCPTool={canChooseMCPTool}
/>
</div>
: <ListEmpty

View File

@@ -76,7 +76,7 @@ const Base: FC<Props> = ({
return (
<Wrap className={cn(wrapClassName)} style={wrapStyle} isInNode={isInNode} isExpand={isExpand}>
<div ref={ref} className={cn(className, isExpand && 'h-full', 'rounded-lg border', isFocus ? 'border-transparent bg-components-input-bg-normal' : 'overflow-hidden border-components-input-border-hover bg-components-input-bg-hover')}>
<div ref={ref} className={cn(className, isExpand && 'h-full', 'rounded-lg border', !isFocus ? 'border-transparent bg-components-input-bg-normal' : 'overflow-hidden border-components-input-border-hover bg-components-input-bg-hover')}>
<div className='flex h-7 items-center justify-between pl-3 pr-2 pt-1'>
<div className='system-xs-semibold-uppercase text-text-secondary'>{title}</div>
<div className='flex items-center' onClick={(e) => {

View File

@@ -23,7 +23,7 @@ export type Props = {
value?: string | object
placeholder?: React.JSX.Element | string
onChange?: (value: string) => void
title?: React.JSX.Element
title?: string | React.JSX.Element
language: CodeLanguage
headerRight?: React.JSX.Element
readOnly?: boolean

View File

@@ -0,0 +1,35 @@
'use client'
import type { FC } from 'react'
import cn from '@/utils/classnames'
type Props = {
value: boolean
onChange: (value: boolean) => void
}
const FormInputBoolean: FC<Props> = ({
value,
onChange,
}) => {
return (
<div className='flex w-full space-x-1'>
<div
className={cn(
'system-sm-regular flex h-8 grow cursor-default items-center justify-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 text-text-secondary',
!value && 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
value && 'system-sm-medium border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-xs',
)}
onClick={() => onChange(true)}
>True</div>
<div
className={cn(
'system-sm-regular flex h-8 grow cursor-default items-center justify-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 text-text-secondary',
value && 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
!value && 'system-sm-medium border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-xs',
)}
onClick={() => onChange(false)}
>False</div>
</div>
)
}
export default FormInputBoolean

View File

@@ -0,0 +1,279 @@
'use client'
import type { FC } from 'react'
import type { ToolVarInputs } from '@/app/components/workflow/nodes/tool/types'
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
import { VarType } from '@/app/components/workflow/types'
import type { ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types'
import FormInputTypeSwitch from './form-input-type-switch'
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
import Input from '@/app/components/base/input'
import { SimpleSelect } from '@/app/components/base/select'
import MixedVariableTextInput from '@/app/components/workflow/nodes/tool/components/mixed-variable-text-input'
import FormInputBoolean from './form-input-boolean'
import AppSelector from '@/app/components/plugins/plugin-detail-panel/app-selector'
import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import cn from '@/utils/classnames'
import type { Tool } from '@/app/components/tools/types'
type Props = {
readOnly: boolean
nodeId: string
schema: CredentialFormSchema
value: ToolVarInputs
onChange: (value: any) => void
inPanel?: boolean
currentTool?: Tool
currentProvider?: ToolWithProvider
}
const FormInputItem: FC<Props> = ({
readOnly,
nodeId,
schema,
value,
onChange,
inPanel,
currentTool,
currentProvider,
}) => {
const language = useLanguage()
const {
placeholder,
variable,
type,
default: defaultValue,
options,
scope,
} = schema as any
const varInput = value[variable]
const isString = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput
const isNumber = type === FormTypeEnum.textNumber
const isObject = type === FormTypeEnum.object
const isArray = type === FormTypeEnum.array
const isShowJSONEditor = isObject || isArray
const isFile = type === FormTypeEnum.file || type === FormTypeEnum.files
const isBoolean = type === FormTypeEnum.boolean
const isSelect = type === FormTypeEnum.select || type === FormTypeEnum.dynamicSelect
const isAppSelector = type === FormTypeEnum.appSelector
const isModelSelector = type === FormTypeEnum.modelSelector
const showTypeSwitch = isNumber || isObject || isArray
const isConstant = varInput?.type === VarKindType.constant || !varInput?.type
const showVariableSelector = isFile || varInput?.type === VarKindType.variable
const { availableVars, availableNodesWithParent } = useAvailableVarList(nodeId, {
onlyLeafNodeVar: false,
filterVar: (varPayload: Var) => {
return [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
},
})
const targetVarType = () => {
if (isString)
return VarType.string
else if (isNumber)
return VarType.number
else if (type === FormTypeEnum.files)
return VarType.arrayFile
else if (type === FormTypeEnum.file)
return VarType.file
// else if (isSelect)
// return VarType.select
// else if (isAppSelector)
// return VarType.appSelector
// else if (isModelSelector)
// return VarType.modelSelector
// else if (isBoolean)
// return VarType.boolean
else if (isObject)
return VarType.object
else if (isArray)
return VarType.arrayObject
else
return VarType.string
}
const getFilterVar = () => {
if (isNumber)
return (varPayload: any) => varPayload.type === VarType.number
else if (isString)
return (varPayload: any) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
else if (isFile)
return (varPayload: any) => [VarType.file, VarType.arrayFile].includes(varPayload.type)
else if (isBoolean)
return (varPayload: any) => varPayload.type === VarType.boolean
else if (isObject)
return (varPayload: any) => varPayload.type === VarType.object
else if (isArray)
return (varPayload: any) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type)
return undefined
}
const getVarKindType = () => {
if (isFile)
return VarKindType.variable
if (isSelect || isBoolean || isNumber || isArray || isObject)
return VarKindType.constant
if (isString)
return VarKindType.mixed
}
const handleTypeChange = (newType: string) => {
if (newType === VarKindType.variable) {
onChange({
...value,
[variable]: {
...varInput,
type: VarKindType.variable,
value: '',
},
})
}
else {
onChange({
...value,
[variable]: {
...varInput,
type: VarKindType.constant,
value: defaultValue,
},
})
}
}
const handleValueChange = (newValue: any) => {
onChange({
...value,
[variable]: {
...varInput,
type: getVarKindType(),
value: isNumber ? Number.parseFloat(newValue) : newValue,
},
})
}
const handleAppOrModelSelect = (newValue: any) => {
onChange({
...value,
[variable]: {
...varInput,
...newValue,
},
})
}
const handleVariableSelectorChange = (newValue: ValueSelector | string, variable: string) => {
onChange({
...value,
[variable]: {
...varInput,
type: VarKindType.variable,
value: newValue || '',
},
})
}
return (
<div className={cn('gap-1', !(isShowJSONEditor && isConstant) && 'flex')}>
{showTypeSwitch && (
<FormInputTypeSwitch value={varInput?.type || VarKindType.constant} onChange={handleTypeChange}/>
)}
{isString && (
<MixedVariableTextInput
readOnly={readOnly}
value={varInput?.value as string || ''}
onChange={handleValueChange}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
/>
)}
{isNumber && isConstant && (
<Input
className='h-8 grow'
type='number'
value={varInput?.value || ''}
onChange={e => handleValueChange(e.target.value)}
placeholder={placeholder?.[language] || placeholder?.en_US}
/>
)}
{isBoolean && (
<FormInputBoolean
value={varInput?.value as boolean}
onChange={handleValueChange}
/>
)}
{isSelect && (
<SimpleSelect
wrapperClassName='h-8 grow'
disabled={readOnly}
defaultValue={varInput?.value}
items={options.filter((option: { show_on: any[] }) => {
if (option.show_on.length)
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
return true
}).map((option: { value: any; label: { [x: string]: any; en_US: any } }) => ({ value: option.value, name: option.label[language] || option.label.en_US }))}
onSelect={item => handleValueChange(item.value as string)}
placeholder={placeholder?.[language] || placeholder?.en_US}
/>
)}
{isShowJSONEditor && isConstant && (
<div className='mt-1 w-full'>
<CodeEditor
title='JSON'
value={varInput?.value as any}
isExpand
isInNode
language={CodeLanguage.json}
onChange={handleValueChange}
className='w-full'
placeholder={<div className='whitespace-pre'>{placeholder?.[language] || placeholder?.en_US}</div>}
/>
</div>
)}
{isAppSelector && (
<AppSelector
disabled={readOnly}
scope={scope || 'all'}
value={varInput?.value as any}
onSelect={handleAppOrModelSelect}
/>
)}
{isModelSelector && isConstant && (
<ModelParameterModal
popupClassName='!w-[387px]'
isAdvancedMode
isInWorkflow
value={varInput?.value as any}
setModel={handleAppOrModelSelect}
readonly={readOnly}
scope={scope}
/>
)}
{showVariableSelector && (
<VarReferencePicker
zIndex={inPanel ? 1000 : undefined}
className='h-8 grow'
readonly={readOnly}
isShowNodeName
nodeId={nodeId}
value={varInput?.value || []}
onChange={value => handleVariableSelectorChange(value, variable)}
filterVar={getFilterVar()}
schema={schema}
valueTypePlaceHolder={targetVarType()}
currentTool={currentTool}
currentProvider={currentProvider}
/>
)}
</div>
)
}
export default FormInputItem

View File

@@ -0,0 +1,47 @@
'use client'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiEditLine,
} from '@remixicon/react'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import Tooltip from '@/app/components/base/tooltip'
import { VarType } from '@/app/components/workflow/nodes/tool/types'
import cn from '@/utils/classnames'
type Props = {
value: VarType
onChange: (value: VarType) => void
}
const FormInputTypeSwitch: FC<Props> = ({
value,
onChange,
}) => {
const { t } = useTranslation()
return (
<div className='inline-flex h-8 shrink-0 gap-px rounded-[10px] bg-components-segmented-control-bg-normal p-0.5'>
<Tooltip
popupContent={value === VarType.variable ? '' : t('workflow.nodes.common.typeSwitch.variable')}
>
<div
className={cn('cursor-pointer rounded-lg px-2.5 py-1.5 text-text-tertiary hover:bg-state-base-hover', value === VarType.variable && 'bg-components-segmented-control-item-active-bg text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg')}
onClick={() => onChange(VarType.variable)}
>
<Variable02 className='h-4 w-4' />
</div>
</Tooltip>
<Tooltip
popupContent={value === VarType.constant ? '' : t('workflow.nodes.common.typeSwitch.input')}
>
<div
className={cn('cursor-pointer rounded-lg px-2.5 py-1.5 text-text-tertiary hover:bg-state-base-hover', value === VarType.constant && 'bg-components-segmented-control-item-active-bg text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg')}
onClick={() => onChange(VarType.constant)}
>
<RiEditLine className='h-4 w-4' />
</div>
</Tooltip>
</div>
)
}
export default FormInputTypeSwitch

View File

@@ -0,0 +1,22 @@
'use client'
import Tooltip from '@/app/components/base/tooltip'
import { RiAlertFill } from '@remixicon/react'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
const McpToolNotSupportTooltip: FC = () => {
const { t } = useTranslation()
return (
<Tooltip
popupContent={
<div className='w-[256px]'>
{t('plugin.detailPanel.toolSelector.unsupportedMCPTool')}
</div>
}
>
<RiAlertFill className='size-4 text-text-warning-secondary' />
</Tooltip>
)
}
export default React.memo(McpToolNotSupportTooltip)

View File

@@ -13,7 +13,7 @@ export const SettingItem = memo(({ label, children, status, tooltip }: SettingIt
const indicator: ComponentProps<typeof Indicator>['color'] = status === 'error' ? 'red' : status === 'warning' ? 'yellow' : undefined
const needTooltip = ['error', 'warning'].includes(status as any)
return <div className='relative flex items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1.5 py-1 text-xs font-normal'>
<div className={classNames('shrink-0 truncate text-text-tertiary system-xs-medium-uppercase', !!children && 'max-w-[100px]')}>
<div className={classNames('system-xs-medium-uppercase max-w-full shrink-0 truncate text-text-tertiary', !!children && 'max-w-[100px]')}>
{label}
</div>
<Tooltip popupContent={tooltip} disabled={!needTooltip}>

View File

@@ -528,6 +528,7 @@ const VarReferencePicker: FC<Props> = ({
onChange={handleVarReferenceChange}
itemWidth={isAddBtnTrigger ? 260 : (minWidth || triggerWidth)}
isSupportFileVar={isSupportFileVar}
zIndex={zIndex}
/>
)}
</PortalToFollowElemContent>

View File

@@ -13,6 +13,7 @@ type Props = {
onChange: (value: ValueSelector, varDetail: Var) => void
itemWidth?: number
isSupportFileVar?: boolean
zIndex?: number
}
const VarReferencePopup: FC<Props> = ({
vars,
@@ -20,6 +21,7 @@ const VarReferencePopup: FC<Props> = ({
onChange,
itemWidth,
isSupportFileVar = true,
zIndex,
}) => {
const { t } = useTranslation()
const docLink = useDocLink()
@@ -60,6 +62,7 @@ const VarReferencePopup: FC<Props> = ({
onChange={onChange}
itemWidth={itemWidth}
isSupportFileVar={isSupportFileVar}
zIndex={zIndex}
/>
}
</div >

View File

@@ -46,6 +46,7 @@ type ItemProps = {
isSupportFileVar?: boolean
isException?: boolean
isLoopVar?: boolean
zIndex?: number
}
const objVarTypes = [VarType.object, VarType.file]
@@ -60,6 +61,7 @@ const Item: FC<ItemProps> = ({
isSupportFileVar,
isException,
isLoopVar,
zIndex,
}) => {
const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties
const isFile = itemData.type === VarType.file && !isStructureOutput
@@ -171,7 +173,7 @@ const Item: FC<ItemProps> = ({
</div >
</PortalToFollowElemTrigger >
<PortalToFollowElemContent style={{
zIndex: 100,
zIndex: zIndex || 100,
}}>
{(isStructureOutput || isObj) && (
<PickerStructurePanel
@@ -260,6 +262,7 @@ type Props = {
maxHeightClass?: string
onClose?: () => void
onBlur?: () => void
zIndex?: number
autoFocus?: boolean
}
const VarReferenceVars: FC<Props> = ({
@@ -272,6 +275,7 @@ const VarReferenceVars: FC<Props> = ({
maxHeightClass,
onClose,
onBlur,
zIndex,
autoFocus = true,
}) => {
const { t } = useTranslation()
@@ -357,6 +361,7 @@ const VarReferenceVars: FC<Props> = ({
isSupportFileVar={isSupportFileVar}
isException={v.isException}
isLoopVar={item.isLoop}
zIndex={zIndex}
/>
))}
</div>))

View File

@@ -32,6 +32,7 @@ import {
import { useNodeIterationInteractions } from '../iteration/use-interactions'
import { useNodeLoopInteractions } from '../loop/use-interactions'
import type { IterationNodeType } from '../iteration/types'
import CopyID from '../tool/components/copy-id'
import {
NodeSourceHandle,
NodeTargetHandle,
@@ -321,6 +322,11 @@ const BaseNode: FC<BaseNodeProps> = ({
</div>
)
}
{data.type === BlockEnum.Tool && (
<div className='px-3 pb-2'>
<CopyID content={data.provider_id || ''} />
</div>
)}
</div>
</div>
)