feat: add activation conditions for agent tools

- Implemented activation conditions for agent tools, allowing tools to be enabled based on specified conditions.
- Introduced new components for managing conditions, including ConditionAdd, ConditionList, ConditionItem, and ConditionOperator.
- Enhanced the AgentNode to process activation conditions when filtering tools.
- Updated the ToolSelector to include activation condition management.
- Added translations for new UI elements related to activation conditions.
- Refactored utility functions to support condition operators and default values based on variable types.
This commit is contained in:
GuanMu 2025-11-05 08:37:14 +00:00
parent 775d2e14fc
commit f263492c03
16 changed files with 970 additions and 81 deletions

View File

@ -1,6 +1,6 @@
import json
from collections.abc import Generator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any, Literal, cast
from packaging.version import Version
from pydantic import ValidationError
@ -44,6 +44,8 @@ from core.workflow.nodes.base.entities import BaseNodeData, RetryConfig
from core.workflow.nodes.base.node import Node
from core.workflow.nodes.base.variable_template_parser import VariableTemplateParser
from core.workflow.runtime import VariablePool
from core.workflow.utils.condition.entities import Condition
from core.workflow.utils.condition.processor import ConditionProcessor
from extensions.ext_database import db
from factories import file_factory
from factories.agent_factory import get_plugin_agent_strategy
@ -206,6 +208,7 @@ class AgentNode(Node):
"""
agent_parameters_dictionary = {parameter.name: parameter for parameter in agent_parameters}
condition_processor = ConditionProcessor()
result: dict[str, Any] = {}
for parameter_name in node_data.agent_parameters:
@ -243,7 +246,32 @@ class AgentNode(Node):
value = parameter_value
if parameter.type == "array[tools]":
value = cast(list[dict[str, Any]], value)
value = [tool for tool in value if tool.get("enabled", False)]
filtered_tools: list[dict[str, Any]] = []
for tool in value:
activation_condition = tool.get("activation_condition")
include_tool = True
if activation_condition and activation_condition.get("enabled"):
logical_operator = activation_condition.get("logical_operator", "and")
if logical_operator not in {"and", "or"}:
logical_operator = "and"
try:
conditions_raw = activation_condition.get("conditions", []) or []
conditions = [Condition.model_validate(condition) for condition in conditions_raw]
if conditions:
_, _, include_tool = condition_processor.process_conditions(
variable_pool=variable_pool,
conditions=conditions,
operator=cast(Literal["and", "or"], logical_operator),
)
else:
include_tool = False
except (ValidationError, ValueError):
include_tool = False
tool.pop("activation_condition", None)
if include_tool:
filtered_tools.append(tool)
value = [tool for tool in filtered_tools if tool.get("enabled", False)]
value = self._filter_mcp_type_tool(strategy, value)
for tool in value:
if "schemas" in tool:

View File

@ -202,10 +202,10 @@ const SimpleSelect: FC<ISelectProps> = ({
setSelectedItem(defaultSelect)
}, [defaultValue])
const listboxRef = useRef<HTMLDivElement>(null)
const openRef = useRef(false)
return (
<Listbox ref={listboxRef}
<Listbox
value={selectedItem}
onChange={(value: Item) => {
if (!disabled) {
@ -214,83 +214,100 @@ const SimpleSelect: FC<ISelectProps> = ({
}
}}
>
{({ open }) => (
<div className={classNames('group/simple-select relative h-9', wrapperClassName)}>
{renderTrigger && <ListboxButton className='w-full'>{renderTrigger(selectedItem)}</ListboxButton>}
{!renderTrigger && (
<ListboxButton onClick={() => {
onOpenChange?.(open)
}} className={classNames(`flex h-full w-full items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`, className)}>
<span className={classNames('system-sm-regular block truncate text-left text-components-input-text-filled', !selectedItem?.name && 'text-components-input-text-placeholder')}>{selectedItem?.name ?? localPlaceholder}</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
{isLoading ? <RiLoader4Line className='h-3.5 w-3.5 animate-spin text-text-secondary' />
: (selectedItem && !notClearable)
? (
<XMarkIcon
onClick={(e) => {
e.stopPropagation()
setSelectedItem(null)
onSelect({ name: '', value: '' })
}}
className="h-4 w-4 cursor-pointer text-text-quaternary"
aria-hidden="false"
/>
)
: (
open ? (
<ChevronUpIcon
className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
aria-hidden="true"
/>
) : (
<ChevronDownIcon
className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
aria-hidden="true"
/>
)
)}
</span>
</ListboxButton>
)}
{({ open }) => {
if (openRef.current !== open) {
openRef.current = open
onOpenChange?.(open)
}
{(!disabled) && (
<ListboxOptions className={classNames('absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-sm focus:outline-none sm:text-sm', optionWrapClassName)}>
{items.map((item: Item) => (
<ListboxOption
key={item.value}
className={
classNames(
'relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover',
optionClassName,
)
}
value={item}
disabled={disabled}
>
{({ /* active, */ selected }) => (
<>
{renderOption
? renderOption({ item, selected })
: (<>
<span className={classNames('block', selected && 'font-normal')}>{item.name}</span>
{selected && !hideChecked && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4 text-text-accent',
)}
>
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
</span>
)}
</>)}
</>
return (
<PortalToFollowElem
open={open}
placement='bottom-start'
offset={4}
triggerPopupSameWidth
>
<div className={classNames('group/simple-select relative h-9', wrapperClassName)}>
<PortalToFollowElemTrigger asChild>
{renderTrigger
? <ListboxButton className='w-full'>{renderTrigger(selectedItem)}</ListboxButton>
: (
<ListboxButton className={classNames(`relative flex h-full w-full items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`, className)}>
<span className={classNames('system-sm-regular block truncate text-left text-components-input-text-filled', !selectedItem?.name && 'text-components-input-text-placeholder')}>{selectedItem?.name ?? localPlaceholder}</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
{isLoading ? <RiLoader4Line className='h-3.5 w-3.5 animate-spin text-text-secondary' />
: (selectedItem && !notClearable)
? (
<XMarkIcon
onClick={(e) => {
e.stopPropagation()
setSelectedItem(null)
onSelect({ name: '', value: '' })
}}
className="h-4 w-4 cursor-pointer text-text-quaternary"
aria-hidden="false"
/>
)
: (
open ? (
<ChevronUpIcon
className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
aria-hidden="true"
/>
) : (
<ChevronDownIcon
className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
aria-hidden="true"
/>
)
)}
</span>
</ListboxButton>
)}
</ListboxOption>
))}
</ListboxOptions>
)}
</div>
)}
</PortalToFollowElemTrigger>
{(!disabled) && (
<PortalToFollowElemContent className={classNames('z-10 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm', optionWrapClassName)}>
<ListboxOptions className='max-h-60 w-full overflow-auto px-1 py-1 text-base focus:outline-none sm:text-sm'>
{items.map((item: Item) => (
<ListboxOption
key={item.value}
className={
classNames(
'relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover',
optionClassName,
)
}
value={item}
disabled={disabled}
>
{({ selected }) => (
<>
{renderOption
? renderOption({ item, selected })
: (<>
<span className={classNames('block', selected && 'font-normal')}>{item.name}</span>
{selected && !hideChecked && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4 text-text-accent',
)}
>
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
</span>
)}
</>)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</PortalToFollowElemContent>
)}
</div>
</PortalToFollowElem>
)
}}
</Listbox>
)
}

View File

@ -40,6 +40,7 @@ import {
AuthCategory,
PluginAuthInAgent,
} from '@/app/components/plugins/plugin-auth'
import { AgentToolConditionEditor } from '@/app/components/workflow/nodes/agent/components/tool-condition'
type Props = {
disabled?: boolean
@ -128,6 +129,7 @@ const ToolSelector: FC<Props> = ({
description: tool.tool_description,
},
schemas: tool.paramSchemas,
activation_condition: value?.activation_condition,
}
}
const handleSelectTool = (tool: ToolDefaultValue) => {
@ -150,6 +152,15 @@ const ToolSelector: FC<Props> = ({
} as any)
}
const handleActivationConditionChange = (condition?: ToolValue['activation_condition']) => {
if (!value)
return
onSelect({
...value,
activation_condition: condition,
} as any)
}
// tool settings & params
const currentToolSettings = useMemo(() => {
if (!currentProvider) return []
@ -266,7 +277,7 @@ const ToolSelector: FC<Props> = ({
)}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-10'>
<div className={cn('relative max-h-[642px] min-h-20 w-[361px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-4 shadow-lg backdrop-blur-sm', 'overflow-y-auto pb-2')}>
<div className={cn('relative max-h-[642px] min-h-20 w-[480px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-4 shadow-lg backdrop-blur-sm', 'overflow-y-auto pb-2')}>
<>
<div className='system-xl-semibold px-4 pb-1 pt-3.5 text-text-primary'>{t(`plugin.detailPanel.toolSelector.${isEdit ? 'toolSetting' : 'title'}`)}</div>
{/* base form */}
@ -391,6 +402,20 @@ const ToolSelector: FC<Props> = ({
)}
</>
)}
{value?.provider_name && nodeId && (
<>
<Divider className='my-1 w-full' />
<div className='px-4 py-2'>
<AgentToolConditionEditor
value={value.activation_condition}
onChange={handleActivationConditionChange}
availableVars={nodeOutputVars}
availableNodes={availableNodes}
disabled={disabled}
/>
</div>
</>
)}
</>
</div>
</PortalToFollowElemContent>

View File

@ -1,4 +1,5 @@
import type { PluginMeta } from '../../plugins/types'
import type { AgentToolActivationCondition } from '@/app/components/workflow/nodes/agent/types'
import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations'
@ -61,6 +62,7 @@ export type ToolValue = {
enabled?: boolean
extra?: Record<string, any>
credential_id?: string
activation_condition?: AgentToolActivationCondition
}
export type DataSourceItem = {

View File

@ -0,0 +1,68 @@
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiAddLine } from '@remixicon/react'
import type {
NodeOutPutVar,
ValueSelector,
Var,
} from '@/app/components/workflow/types'
import Button from '@/app/components/base/button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
type Props = {
variables: NodeOutPutVar[]
onSelect: (valueSelector: ValueSelector, varItem: Var) => void
disabled?: boolean
}
const ConditionAdd = ({
variables,
onSelect,
disabled,
}: Props) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleSelect = useCallback((valueSelector: ValueSelector, varItem: Var) => {
onSelect(valueSelector, varItem)
setOpen(false)
}, [onSelect])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger onClick={() => !disabled && setOpen(!open)}>
<Button
size='small'
disabled={disabled}
>
<RiAddLine className='mr-1 h-3.5 w-3.5' />
{t('workflow.nodes.agent.toolCondition.addCondition')}
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className='w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg'>
<VarReferenceVars
vars={variables}
isSupportFileVar
onChange={handleSelect}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default ConditionAdd

View File

@ -0,0 +1,57 @@
import { useTranslation } from 'react-i18next'
import { useStore } from '@/app/components/workflow/store'
import PromptEditor from '@/app/components/base/prompt-editor'
import { BlockEnum } from '@/app/components/workflow/types'
import type {
Node,
} from '@/app/components/workflow/types'
type ConditionInputProps = {
disabled?: boolean
value: string
onChange: (value: string) => void
availableNodes: Node[]
}
const ConditionInput = ({
value,
onChange,
disabled,
availableNodes,
}: ConditionInputProps) => {
const { t } = useTranslation()
const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey)
const pipelineId = useStore(s => s.pipelineId)
const setShowInputFieldPanel = useStore(s => s.setShowInputFieldPanel)
return (
<PromptEditor
key={controlPromptEditorRerenderKey}
compact
value={value}
placeholder={t('workflow.nodes.ifElse.enterValue') || ''}
workflowVariableBlock={{
show: true,
variables: [],
workflowNodesMap: availableNodes.reduce((acc, node) => {
acc[node.id] = {
title: node.data.title,
type: node.data.type,
}
if (node.data.type === BlockEnum.Start) {
acc.sys = {
title: t('workflow.blocks.start'),
type: BlockEnum.Start,
}
}
return acc
}, {} as any),
showManageInputField: !!pipelineId,
onManageInputField: () => setShowInputFieldPanel?.(true),
}}
onChange={onChange}
editable={!disabled}
/>
)
}
export default ConditionInput

View File

@ -0,0 +1,182 @@
import {
useCallback,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { RiDeleteBinLine } from '@remixicon/react'
import { produce } from 'immer'
import ConditionVarSelector from './condition-var-selector'
import ConditionOperator from './condition-operator'
import {
getConditionOperators,
getDefaultValueByType,
operatorNeedsValue,
} from '../../utils'
import type {
AgentToolCondition,
} from '../../types'
import type {
Node,
NodeOutPutVar,
ValueSelector,
Var,
} from '@/app/components/workflow/types'
import { VarType } from '@/app/components/workflow/types'
import Input from '@/app/components/base/input'
import { SimpleSelect as Select } from '@/app/components/base/select'
import ConditionInput from './condition-input'
import cn from '@/utils/classnames'
type Props = {
className?: string
condition: AgentToolCondition
availableVars: NodeOutPutVar[]
availableNodes: Node[]
disabled?: boolean
onChange: (condition: AgentToolCondition) => void
onRemove: () => void
}
const ConditionItem = ({
className,
condition,
availableVars,
availableNodes,
disabled,
onChange,
onRemove,
}: Props) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const needsValue = operatorNeedsValue(condition.comparison_operator)
const booleanOptions = useMemo(() => ([
{ name: t('common.operation.yes'), value: true },
{ name: t('common.operation.no'), value: false },
]), [t])
const handleSelectVar = useCallback((valueSelector: ValueSelector, varItem: Var) => {
const operators = getConditionOperators(varItem.type)
const defaultOperator = operators[0]
const nextCondition = produce(condition, (draft) => {
draft.variable_selector = valueSelector
draft.varType = varItem.type
draft.comparison_operator = defaultOperator
draft.value = operatorNeedsValue(defaultOperator) ? getDefaultValueByType(varItem.type) : undefined
})
onChange(nextCondition)
}, [condition, onChange])
const handleOperatorChange = useCallback((operator: string) => {
const nextCondition = produce(condition, (draft) => {
draft.comparison_operator = operator
if (operatorNeedsValue(operator))
draft.value = draft.varType ? getDefaultValueByType(draft.varType) : ''
else
draft.value = undefined
})
onChange(nextCondition)
}, [condition, onChange])
const handleValueChange = useCallback((value: string | boolean) => {
const nextCondition = produce(condition, (draft) => {
draft.value = value
})
onChange(nextCondition)
}, [condition, onChange])
const handleTextValueChange = useCallback((value: string) => {
handleValueChange(value)
}, [handleValueChange])
const handleBooleanValueChange = useCallback((value: boolean) => {
handleValueChange(value)
}, [handleValueChange])
const renderValueInput = () => {
if (!needsValue)
return <div className='system-xs-regular text-text-tertiary'>{t('workflow.nodes.agent.toolCondition.noValueNeeded')}</div>
if (condition.varType === VarType.boolean) {
return (
<Select
className='h-8'
optionWrapClassName='w-32'
value={condition.value as boolean | undefined}
items={booleanOptions}
onSelect={item => handleBooleanValueChange(Boolean(item.value))}
disabled={disabled}
hideChecked
notClearable
/>
)
}
const normalizedValue = typeof condition.value === 'string' || typeof condition.value === 'number'
? String(condition.value)
: ''
if (condition.varType === VarType.number) {
return (
<Input
value={normalizedValue}
onChange={event => handleTextValueChange(event.target.value)}
disabled={disabled}
/>
)
}
const textValue = typeof condition.value === 'string' ? condition.value : ''
return (
<ConditionInput
value={textValue}
onChange={handleTextValueChange}
disabled={disabled}
availableNodes={availableNodes}
/>
)
}
return (
<div className={cn('mb-1 flex w-full last-of-type:mb-0', className)}>
<div className='flex-1 rounded-lg bg-components-input-bg-normal'>
<div className='flex items-center p-1'>
<div className='w-0 grow'>
<ConditionVarSelector
open={open}
onOpenChange={setOpen}
valueSelector={condition.variable_selector}
varType={condition.varType}
availableVars={availableVars}
availableNodes={availableNodes}
onSelect={handleSelectVar}
disabled={disabled}
/>
</div>
<div className='mx-1 h-3 w-[1px] bg-divider-regular' />
<ConditionOperator
varType={condition.varType}
value={condition.comparison_operator}
onSelect={handleOperatorChange}
disabled={disabled || !condition.variable_selector}
/>
</div>
<div className='border-t border-divider-subtle px-3 py-2'>
{renderValueInput()}
</div>
</div>
<button
type='button'
className='ml-1 mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-lg text-text-tertiary transition-colors hover:bg-state-destructive-hover hover:text-text-destructive'
onClick={onRemove}
disabled={disabled}
>
<RiDeleteBinLine className='h-4 w-4' />
</button>
</div>
)
}
export default ConditionItem

View File

@ -0,0 +1,72 @@
import { RiLoopLeftLine } from '@remixicon/react'
import { useMemo } from 'react'
import ConditionItem from './condition-item'
import type {
AgentToolCondition,
AgentToolConditionLogicalOperator,
} from '../../types'
import type {
Node,
NodeOutPutVar,
} from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
type Props = {
conditions: AgentToolCondition[]
logicalOperator: AgentToolConditionLogicalOperator
availableVars: NodeOutPutVar[]
availableNodes: Node[]
disabled?: boolean
onChange: (condition: AgentToolCondition) => void
onRemove: (conditionId: string) => void
onToggleLogicalOperator: () => void
}
const ConditionList = ({
conditions,
logicalOperator,
availableVars,
availableNodes,
disabled,
onChange,
onRemove,
onToggleLogicalOperator,
}: Props) => {
const hasMultiple = conditions.length > 1
const containerClassName = useMemo(() => cn('relative', hasMultiple && 'pl-[60px]'), [hasMultiple])
return (
<div className={containerClassName}>
{hasMultiple && (
<div className='absolute bottom-0 left-0 top-0 w-[60px]'>
<div className='absolute bottom-4 left-[46px] top-4 w-2.5 rounded-l-[8px] border border-r-0 border-divider-deep'></div>
<div className='absolute right-0 top-1/2 h-[29px] w-4 -translate-y-1/2 bg-components-panel-bg'></div>
<button
type='button'
className='absolute right-1 top-1/2 flex h-5 -translate-y-1/2 items-center gap-1 rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-1.5 text-[10px] font-semibold uppercase text-text-accent-secondary shadow-xs disabled:cursor-not-allowed disabled:opacity-60'
onClick={onToggleLogicalOperator}
disabled={disabled}
>
{logicalOperator.toUpperCase()}
<RiLoopLeftLine className='h-3 w-3' />
</button>
</div>
)}
{conditions.map(condition => (
<ConditionItem
key={condition.id}
className=''
condition={condition}
availableVars={availableVars}
availableNodes={availableNodes}
disabled={disabled}
onChange={onChange}
onRemove={() => onRemove(condition.id)}
/>
))}
</div>
)
}
export default ConditionList

View File

@ -0,0 +1,92 @@
import {
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { RiArrowDownSLine } from '@remixicon/react'
import type { VarType } from '@/app/components/workflow/types'
import { getConditionOperators } from '../../utils'
import Button from '@/app/components/base/button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import cn from '@/utils/classnames'
type Props = {
varType?: VarType
value?: string
onSelect: (operator: string) => void
disabled?: boolean
}
const ConditionOperator = ({
varType,
value,
onSelect,
disabled,
}: Props) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const options = useMemo(() => {
return getConditionOperators(varType).map((option) => {
const key = `workflow.nodes.ifElse.comparisonOperator.${option}`
const translated = t(key)
return {
value: option,
label: translated === key ? option : translated,
}
})
}, [t, varType])
const selectedOption = options.find(option => option.value === value)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger
onClick={() => {
if (!disabled)
setOpen(v => !v)
}}
>
<Button
className={cn('h-7 shrink-0 px-2', !selectedOption && 'opacity-50')}
size='small'
variant='ghost'
disabled={disabled}
>
{selectedOption?.label ?? t('workflow.nodes.agent.toolCondition.operatorPlaceholder')}
<RiArrowDownSLine className='ml-1 h-3.5 w-3.5' />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-10'>
<div className='rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg'>
{options.map(option => (
<div
key={option.value}
className='flex h-7 cursor-pointer items-center rounded-lg px-3 py-1.5 text-[13px] font-medium text-text-secondary hover:bg-state-base-hover'
onClick={() => {
onSelect(option.value)
setOpen(false)
}}
>
{option.label}
</div>
))}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default ConditionOperator

View File

@ -0,0 +1,97 @@
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import VariableTag from '@/app/components/workflow/nodes/_base/components/variable-tag'
import type {
Node,
NodeOutPutVar,
ValueSelector,
Var,
} from '@/app/components/workflow/types'
import { VarType } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
type Props = {
open: boolean
onOpenChange: (open: boolean) => void
valueSelector?: ValueSelector
varType?: VarType
availableVars: NodeOutPutVar[]
availableNodes: Node[]
onSelect: (valueSelector: ValueSelector, varItem: Var) => void
disabled?: boolean
}
const ConditionVarSelector = ({
open,
onOpenChange,
valueSelector,
varType,
availableVars,
availableNodes,
onSelect,
disabled,
}: Props) => {
const { t } = useTranslation()
const handleTriggerClick = useCallback(() => {
if (disabled)
return
onOpenChange(!open)
}, [disabled, onOpenChange, open])
const handleSelect = useCallback((selector: ValueSelector, varItem: Var) => {
if (disabled)
return
onSelect(selector, varItem)
onOpenChange(false)
}, [disabled, onOpenChange, onSelect])
return (
<PortalToFollowElem
open={open}
onOpenChange={(state) => {
if (!disabled)
onOpenChange(state)
}}
placement='bottom-start'
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger onClick={handleTriggerClick}>
<div className={cn('cursor-pointer', disabled && '!cursor-not-allowed opacity-60')}>
{valueSelector && valueSelector.length > 0 ? (
<VariableTag
valueSelector={valueSelector}
varType={varType ?? VarType.string}
isShort
availableNodes={availableNodes}
/>
) : (
<div className='inline-flex h-6 items-center rounded-md border border-dashed border-divider-subtle px-2 text-xs text-text-tertiary'>
{t('workflow.nodes.agent.toolCondition.selectVariable')}
</div>
)}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className='w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg'>
<VarReferenceVars
vars={availableVars}
isSupportFileVar
onChange={handleSelect}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(ConditionVarSelector)

View File

@ -0,0 +1,158 @@
'use client'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { v4 as uuid4 } from 'uuid'
import { produce } from 'immer'
import Switch from '@/app/components/base/switch'
import ConditionAdd from './condition-add'
import ConditionList from './condition-list'
import type {
AgentToolActivationCondition,
AgentToolCondition,
} from '../../types'
import { AgentToolConditionLogicalOperator } from '../../types'
import type { Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import { VarType } from '@/app/components/workflow/types'
import {
getConditionOperators,
getDefaultValueByType,
operatorNeedsValue,
} from '../../utils'
import cn from '@/utils/classnames'
type Props = {
value?: AgentToolActivationCondition
onChange: (value: AgentToolActivationCondition | undefined) => void
availableVars: NodeOutPutVar[]
availableNodes: Node[]
disabled?: boolean
}
const AgentToolConditionEditor = ({
value,
onChange,
availableVars,
availableNodes,
disabled,
}: Props) => {
const { t } = useTranslation()
const currentValue = useMemo<AgentToolActivationCondition>(() => value ?? ({
enabled: false,
logical_operator: AgentToolConditionLogicalOperator.And,
conditions: [],
}), [value])
const handleToggle = useCallback((state: boolean) => {
const next = produce(currentValue, (draft) => {
draft.enabled = state
})
onChange(next)
}, [currentValue, onChange])
const handleAddCondition = useCallback((valueSelector: ValueSelector, varItem: Var) => {
const operators = getConditionOperators(varItem.type)
const defaultOperator = operators[0]
const newCondition: AgentToolCondition = {
id: uuid4(),
varType: varItem.type ?? VarType.string,
variable_selector: valueSelector,
comparison_operator: defaultOperator,
value: operatorNeedsValue(defaultOperator) ? getDefaultValueByType(varItem.type ?? VarType.string) : undefined,
}
const next = produce(currentValue, (draft) => {
draft.enabled = true
draft.conditions.push(newCondition)
})
onChange(next)
}, [currentValue, onChange])
const handleConditionChange = useCallback((updated: AgentToolCondition) => {
const next = produce(currentValue, (draft) => {
const targetIndex = draft.conditions.findIndex(item => item.id === updated.id)
if (targetIndex !== -1)
draft.conditions[targetIndex] = updated
})
onChange(next)
}, [currentValue, onChange])
const handleRemoveCondition = useCallback((conditionId: string) => {
const next = produce(currentValue, (draft) => {
draft.conditions = draft.conditions.filter(item => item.id !== conditionId)
})
onChange(next)
}, [currentValue, onChange])
const handleToggleLogicalOperator = useCallback(() => {
const next = produce(currentValue, (draft) => {
draft.logical_operator = draft.logical_operator === AgentToolConditionLogicalOperator.And
? AgentToolConditionLogicalOperator.Or
: AgentToolConditionLogicalOperator.And
})
onChange(next)
}, [currentValue, onChange])
const isEnabled = currentValue.enabled
const hasConditions = currentValue.conditions.length > 0
return (
<div className=''>
<div className='flex items-start justify-between gap-3'>
<div>
<div className='system-sm-semibold text-text-primary'>{t('workflow.nodes.agent.toolCondition.title')}</div>
<div className='system-xs-regular text-text-tertiary'>{t('workflow.nodes.agent.toolCondition.description')}</div>
</div>
<Switch
defaultValue={isEnabled}
onChange={handleToggle}
disabled={disabled}
/>
</div>
{isEnabled && (
<div className='space-y-3'>
<div className='rounded-[10px] bg-components-panel-bg px-3 py-2'>
{hasConditions && (
<div className='mb-2'>
<ConditionList
conditions={currentValue.conditions}
logicalOperator={currentValue.logical_operator}
availableVars={availableVars}
availableNodes={availableNodes}
disabled={disabled}
onChange={handleConditionChange}
onRemove={handleRemoveCondition}
onToggleLogicalOperator={handleToggleLogicalOperator}
/>
</div>
)}
<div className={cn(
'flex items-center justify-between pr-[30px]',
hasConditions && currentValue.conditions.length > 1 && 'ml-[60px]',
!hasConditions && 'mt-1',
)}>
<ConditionAdd
variables={availableVars}
onSelect={handleAddCondition}
disabled={disabled}
/>
</div>
</div>
{!hasConditions && (
<div className='system-xs-regular text-text-tertiary'>
{t('workflow.nodes.agent.toolCondition.addFirstCondition')}
</div>
)}
{hasConditions && currentValue.conditions.length <= 1 && (
<div className='system-xs-regular text-text-tertiary'>
{t('workflow.nodes.agent.toolCondition.singleConditionTip')}
</div>
)}
</div>
)}
</div>
)
}
export default AgentToolConditionEditor

View File

@ -0,0 +1 @@
export { default as AgentToolConditionEditor } from './editor'

View File

@ -1,4 +1,4 @@
import type { CommonNodeType, Memory } from '@/app/components/workflow/types'
import type { CommonNodeType, Memory, ValueSelector, VarType } from '@/app/components/workflow/types'
import type { ToolVarInputs } from '../tool/types'
import type { PluginMeta } from '@/app/components/plugins/types'
@ -18,3 +18,22 @@ export type AgentNodeType = CommonNodeType & {
export enum AgentFeature {
HISTORY_MESSAGES = 'history-messages',
}
export enum AgentToolConditionLogicalOperator {
And = 'and',
Or = 'or',
}
export type AgentToolCondition = {
id: string
varType: VarType
variable_selector?: ValueSelector
comparison_operator?: string
value?: string | string[] | boolean
}
export type AgentToolActivationCondition = {
enabled: boolean
logical_operator: AgentToolConditionLogicalOperator
conditions: AgentToolCondition[]
}

View File

@ -0,0 +1,49 @@
import { VarType } from '@/app/components/workflow/types'
const COMPARISON_OPERATOR_WITHOUT_VALUE = new Set([
'empty',
'not empty',
'null',
'not null',
'exists',
'not exists',
])
export const getConditionOperators = (varType?: VarType): string[] => {
switch (varType) {
case VarType.number:
return ['=', '≠', '>', '<', '≥', '≤']
case VarType.boolean:
return ['is', 'is not']
case VarType.arrayString:
case VarType.arrayNumber:
case VarType.arrayBoolean:
case VarType.array:
case VarType.arrayAny:
return ['contains', 'not contains', 'empty', 'not empty']
case VarType.arrayFile:
return ['contains', 'not contains', 'empty', 'not empty']
case VarType.file:
return ['exists', 'not exists']
case VarType.object:
return ['empty', 'not empty']
case VarType.any:
return ['is', 'is not', 'empty', 'not empty']
default:
return ['contains', 'not contains', 'start with', 'end with', 'is', 'is not', 'empty', 'not empty']
}
}
export const operatorNeedsValue = (operator?: string): boolean => {
if (!operator)
return false
return !COMPARISON_OPERATOR_WITHOUT_VALUE.has(operator)
}
export const getDefaultValueByType = (varType: VarType): string | boolean => {
if (varType === VarType.boolean)
return true
return ''
}

View File

@ -900,6 +900,17 @@ const translation = {
notAuthorized: 'Not Authorized',
model: 'model',
toolbox: 'toolbox',
toolCondition: {
title: 'Activation Condition',
description: 'When enabled, load this tool only when the conditions are met.',
selectVariable: 'Select variable...',
operatorPlaceholder: 'Select operator',
noValueNeeded: 'No value is required for this operator.',
addCondition: 'Add Condition',
logicalOperator: 'Logical operator: {{value}}',
singleConditionTip: 'Add at least one condition to start.',
addFirstCondition: 'No conditions yet. Add your first condition.',
},
strategyNotSet: 'Agentic strategy Not Set',
tools: 'Tools',
maxIterations: 'Max Iterations',

View File

@ -898,6 +898,17 @@ const translation = {
},
model: '模型',
toolbox: '工具箱',
toolCondition: {
title: '启用条件',
description: '开启后,根据条件加载该工具。',
selectVariable: '选择变量...',
operatorPlaceholder: '选择运算符',
noValueNeeded: '该运算符无需填写值。',
addCondition: '新增条件',
logicalOperator: '当前逻辑:{{value}}',
singleConditionTip: '请添加至少一个条件。',
addFirstCondition: '暂未添加条件,请先新增一个条件。',
},
strategyNotSet: '代理策略未设置',
configureModel: '配置模型',
notAuthorized: '未授权',