feat(web): add list-operator node & support file type variables
This commit is contained in:
@@ -75,7 +75,6 @@ const AssignmentList: FC<AssignmentListProps> = ({
|
||||
<VariableSelect
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={options.filter(vo => vo.nodeData.type === 'loop' || vo.value.includes('conv.') || (vo.nodeData.type === 'iteration' && (vo.label === 'item' || vo.label === 'index')))}
|
||||
popupMatchSelectWidth={false}
|
||||
onChange={() => {
|
||||
form.setFieldValue([parentName, name, 'operation'], undefined);
|
||||
form.setFieldValue([parentName, name, 'value'], undefined);
|
||||
@@ -121,7 +120,6 @@ const AssignmentList: FC<AssignmentListProps> = ({
|
||||
? <VariableSelect
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={dataType ? options.filter(vo => vo.dataType === dataType) : options}
|
||||
popupMatchSelectWidth={false}
|
||||
size={size}
|
||||
variant="borderless"
|
||||
className="select"
|
||||
@@ -153,7 +151,6 @@ const AssignmentList: FC<AssignmentListProps> = ({
|
||||
: <VariableSelect
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={dataType ? options.filter(vo => vo.dataType === dataType) : options}
|
||||
popupMatchSelectWidth={false}
|
||||
size={size}
|
||||
variant="borderless"
|
||||
className="select"
|
||||
|
||||
@@ -281,7 +281,6 @@ const CaseList: FC<CaseListProps> = ({
|
||||
options={options}
|
||||
size="small"
|
||||
allowClear={false}
|
||||
popupMatchSelectWidth={false}
|
||||
onChange={(val) => handleLeftFieldChange(caseIndex, conditionIndex, val)}
|
||||
variant="borderless"
|
||||
className="rb:w-36!"
|
||||
@@ -326,7 +325,6 @@ const CaseList: FC<CaseListProps> = ({
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={options.filter(vo => vo.dataType === 'number')}
|
||||
allowClear={false}
|
||||
popupMatchSelectWidth={false}
|
||||
variant="borderless"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
@@ -153,7 +153,6 @@ const ConditionList: FC<CaseListProps> = ({
|
||||
)}
|
||||
size="small"
|
||||
allowClear={false}
|
||||
popupMatchSelectWidth={false}
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
onChange={(val) => handleLeftFieldChange(index, val)}
|
||||
variant="borderless"
|
||||
@@ -201,7 +200,6 @@ const ConditionList: FC<CaseListProps> = ({
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={options.filter(vo => vo.dataType === 'number')}
|
||||
allowClear={false}
|
||||
popupMatchSelectWidth={false}
|
||||
variant="borderless"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:17:39
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-03 10:54:15
|
||||
* @Last Modified time: 2026-04-03 18:39:07
|
||||
*/
|
||||
import { useEffect, type FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -87,9 +87,18 @@ const GroupVariableList: FC<GroupVariableListProps> = ({
|
||||
let filteredOptions = options;
|
||||
if (value.length > 0) {
|
||||
const firstVariableValue = value[0];
|
||||
const firstVariable = options.find(opt => `{{${opt.value}}}` === firstVariableValue);
|
||||
const allSuggestions = options.flatMap(opt => opt.children ? [opt, ...opt.children] : [opt]);
|
||||
const firstVariable = allSuggestions.find(opt => `{{${opt.value}}}` === firstVariableValue);
|
||||
if (firstVariable) {
|
||||
filteredOptions = options.filter(opt => opt.dataType === firstVariable.dataType);
|
||||
filteredOptions = options.flatMap(opt => {
|
||||
if (opt.dataType === 'file' && opt.children?.length) {
|
||||
return [{ ...opt, children: opt.children.map(c => ({ ...c, disabled: c.dataType !== firstVariable.dataType })) }];
|
||||
}
|
||||
if (opt.dataType === firstVariable.dataType && !opt.children?.length) return [opt];
|
||||
const filteredChildren = opt.children?.filter(c => c.dataType === firstVariable.dataType);
|
||||
if (filteredChildren?.length) return [{ ...opt, children: filteredChildren }];
|
||||
return [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +115,7 @@ const GroupVariableList: FC<GroupVariableListProps> = ({
|
||||
<VariableSelect
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={filteredOptions}
|
||||
mode="multiple"
|
||||
multiple={true}
|
||||
size={size}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -168,15 +177,27 @@ const GroupVariableList: FC<GroupVariableListProps> = ({
|
||||
const currentGroupValue = value[name]?.value || [];
|
||||
if (currentGroupValue.length > 0) {
|
||||
const firstVariableValue = currentGroupValue[0];
|
||||
const firstVariable = options.find(opt => `{{${opt.value}}}` === firstVariableValue);
|
||||
const allSuggestions = options.flatMap(opt => opt.children ? [opt, ...opt.children] : [opt]);
|
||||
const firstVariable = allSuggestions.find(opt => `{{${opt.value}}}` === firstVariableValue);
|
||||
|
||||
if (firstVariable) {
|
||||
return options.filter(opt => opt.dataType === firstVariable.dataType);
|
||||
return options.flatMap(vo => {
|
||||
if (vo.dataType === 'file' && vo.children?.length) {
|
||||
return [{ ...vo, children: vo.children.map(c => ({ ...c, disabled: c.dataType !== firstVariable.dataType })) }];
|
||||
}
|
||||
if (vo.dataType === firstVariable.dataType && (!vo.children || vo.children.length < 1)) return [vo];
|
||||
const filteredChildren = vo.children?.filter(sub => sub.dataType === firstVariable.dataType);
|
||||
if (filteredChildren?.length) return [{ ...vo, children: filteredChildren }];
|
||||
return [];
|
||||
});
|
||||
}
|
||||
return []
|
||||
}
|
||||
return options;
|
||||
})()
|
||||
}
|
||||
mode="multiple"
|
||||
|
||||
multiple={true}
|
||||
size={size}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { type FC } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Button, Select, type SelectProps, Flex, Row, Col } from 'antd'
|
||||
|
||||
import type { Suggestion } from '../../../Editor/plugin/AutocompletePlugin'
|
||||
import RadioGroupBtn from '../../RadioGroupBtn'
|
||||
import { fileSubVariable } from '../../hooks/useVariableList'
|
||||
import Editor from '../../../Editor'
|
||||
|
||||
interface Case {
|
||||
filter_by: Array<{
|
||||
key: string;
|
||||
comparison_operator: string;
|
||||
value: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface FilterConditionsProps {
|
||||
value?: Case;
|
||||
onChange?: (value: Case) => void;
|
||||
options: Suggestion[];
|
||||
parentName: string;
|
||||
variableType?: string;
|
||||
}
|
||||
const operatorsObj: { [key: string]: SelectProps['options'] } = {
|
||||
default: [
|
||||
{ value: 'empty', label: 'workflow.config.if-else.empty' },
|
||||
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
|
||||
{ value: 'contains', label: 'workflow.config.if-else.contains' },
|
||||
{ value: 'not_contains', label: 'workflow.config.if-else.not_contains' },
|
||||
{ value: 'startwith', label: 'workflow.config.if-else.startwith' },
|
||||
{ value: 'endwith', label: 'workflow.config.if-else.endwith' },
|
||||
{ value: 'eq', label: 'workflow.config.if-else.eq' },
|
||||
{ value: 'ne', label: 'workflow.config.if-else.ne' },
|
||||
],
|
||||
number: [
|
||||
{ value: 'eq', label: 'workflow.config.if-else.num.eq' },
|
||||
{ value: 'ne', label: 'workflow.config.if-else.num.ne' },
|
||||
{ value: 'lt', label: 'workflow.config.if-else.num.lt' },
|
||||
{ value: 'le', label: 'workflow.config.if-else.num.le' },
|
||||
{ value: 'gt', label: 'workflow.config.if-else.num.gt' },
|
||||
{ value: 'ge', label: 'workflow.config.if-else.num.ge' },
|
||||
{ value: 'empty', label: 'workflow.config.if-else.empty' },
|
||||
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
|
||||
],
|
||||
boolean: [
|
||||
{ value: 'eq', label: 'workflow.config.if-else.boolean.eq' },
|
||||
{ value: 'ne', label: 'workflow.config.if-else.boolean.ne' },
|
||||
{ value: 'empty', label: 'workflow.config.if-else.empty' },
|
||||
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
|
||||
],
|
||||
type: [
|
||||
{ value: 'eq', label: 'workflow.config.list-operator.type.eq' },
|
||||
{ value: 'ne', label: 'workflow.config.list-operator.type.ne' },
|
||||
]
|
||||
}
|
||||
|
||||
const typeOptions = ['image', 'document', 'video', 'audio']
|
||||
|
||||
const FilterConditions: FC<FilterConditionsProps> = ({
|
||||
options,
|
||||
parentName,
|
||||
variableType,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const form = Form.useFormInstance();
|
||||
|
||||
const handleKeyFieldChange = (index: number, newValue: string) => {
|
||||
form.setFieldValue(['filter_by', index], {
|
||||
key: newValue,
|
||||
comparison_operator: undefined,
|
||||
value: undefined,
|
||||
value_type: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.List name={[parentName, 'conditions']}>
|
||||
{(fields, { add, remove }) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="rb:relative"
|
||||
>
|
||||
{fields.map((field, index) => {
|
||||
const filter_by = form.getFieldValue(['filter_by']) || [];
|
||||
const currentCondition = filter_by[index] || {};
|
||||
const currentOperator = currentCondition.comparison_operator;
|
||||
const hideValueField = currentOperator === 'empty' || currentOperator === 'not_empty';
|
||||
const keyFieldValue = currentCondition.key;
|
||||
const keyFieldOption = fileSubVariable.find(option => option.filed === keyFieldValue);
|
||||
const keyFieldType = keyFieldOption?.dataType;
|
||||
const operatorList = operatorsObj[keyFieldValue === 'type' ? 'type' : keyFieldType || 'default'] || operatorsObj.default || [];
|
||||
|
||||
return (
|
||||
<Flex
|
||||
key={field.key}
|
||||
gap={4}
|
||||
align="start"
|
||||
className="rb:mb-2!"
|
||||
>
|
||||
<div className="rb:flex-1 rb:bg-[#F6F6F6] rb:rounded-lg">
|
||||
{variableType === 'array[file]' &&
|
||||
<Row className={clsx("rb:p-1!", {
|
||||
'rb-border-b': !hideValueField
|
||||
})}>
|
||||
<Col span={24}>
|
||||
<Form.Item name={[field.name, 'key']} noStyle>
|
||||
<Select
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={fileSubVariable}
|
||||
fieldNames={{ value: 'filed', label: 'label' }}
|
||||
onChange={(value) => handleKeyFieldChange(index, value)}
|
||||
variant="borderless"
|
||||
className="rb:w-full!"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
}
|
||||
<Row>
|
||||
<Col flex="96px">
|
||||
<Form.Item name={[field.name, 'comparison_operator']} noStyle>
|
||||
<Select
|
||||
options={operatorList.map(vo => ({
|
||||
...vo,
|
||||
label: t(String(vo?.label || ''))
|
||||
}))}
|
||||
size="small"
|
||||
popupMatchSelectWidth={false}
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
variant="borderless"
|
||||
className="rb:w-full!"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{!hideValueField && (
|
||||
<Col flex="1">
|
||||
<Form.Item name={[field.name, 'value']} className="rb:pt-0.5! rb:mb-0! rb:pl-2!">
|
||||
{variableType?.includes('boolean')
|
||||
? <RadioGroupBtn options={[{ value: true, label: 'True' }, { value: false, label: 'False' }]} />
|
||||
: keyFieldValue === 'type'
|
||||
? <Select
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={typeOptions.map(vo => ({ value: vo, label: t(`application.${vo}`) } ))}
|
||||
variant="borderless"
|
||||
className="rb:w-full!"
|
||||
/>
|
||||
: <Editor
|
||||
variant="borderless"
|
||||
type="input"
|
||||
size="small"
|
||||
height={24}
|
||||
options={keyFieldType ? options.flatMap(vo => {
|
||||
if (vo.dataType === keyFieldType) return [vo];
|
||||
const filteredChildren = vo.children?.filter(sub => sub.dataType === keyFieldType);
|
||||
if (filteredChildren?.length) return [{ ...vo, children: filteredChildren }];
|
||||
return [];
|
||||
}) : options
|
||||
}
|
||||
placeholder={t('common.pleaseEnter')}
|
||||
/>
|
||||
}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</div>
|
||||
<div
|
||||
className="rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/workflow/deleteBg.svg')] rb:hover:bg-[url('@/assets/images/workflow/deleteBg_hover.svg')]"
|
||||
onClick={() => remove(field.name)}
|
||||
></div>
|
||||
</Flex>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="dashed"
|
||||
size="middle"
|
||||
block
|
||||
onClick={() => add({})}
|
||||
className="rb:text-[12px]!"
|
||||
>
|
||||
+ {t('workflow.config.list-operator.addCondition')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Form.List>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterConditions
|
||||
@@ -0,0 +1,107 @@
|
||||
import { type FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Form, Switch, Select, Row, Col, Divider, InputNumber } from 'antd'
|
||||
import { Node } from '@antv/x6'
|
||||
|
||||
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin'
|
||||
import VariableSelect from '../VariableSelect'
|
||||
import { fileSubVariable } from '../hooks/useVariableList'
|
||||
import FilterConditions from './FilterConditions'
|
||||
import RadioGroupBtn from '../RadioGroupBtn'
|
||||
import RbSlider from '@/components/RbSlider'
|
||||
|
||||
|
||||
interface ListOperatorProps {
|
||||
options: Suggestion[]
|
||||
selectedNode: Node
|
||||
}
|
||||
|
||||
const ListOperator: FC<ListOperatorProps> = ({ options }) => {
|
||||
const { t } = useTranslation()
|
||||
const form = Form.useFormInstance()
|
||||
const values = Form.useWatch([], form) || {}
|
||||
const variableOption = options.find(option => `{{${option.value}}}` === values?.variable)
|
||||
const variableType = variableOption?.dataType
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item name="variable" label={t('workflow.config.list-operator.variable')} required>
|
||||
<VariableSelect
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={options.filter(vo => vo.dataType.includes('array') && vo.dataType !== 'array[object]')}
|
||||
size="small"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider />
|
||||
<Form.Item layout="horizontal" name={['filter_by', 'enabled']} label={t('workflow.config.list-operator.filter_by')} className="rb:mb-0!">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
{values?.filter_by?.enabled &&
|
||||
<FilterConditions
|
||||
variableType={variableType}
|
||||
parentName="filter_by"
|
||||
options={options}
|
||||
/>
|
||||
}
|
||||
|
||||
<Divider />
|
||||
<Form.Item layout="horizontal" name={['order_by', 'enabled']} label={t('workflow.config.list-operator.order_by')} className="rb:mb-0!">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
{values?.order_by?.enabled &&
|
||||
<Row gutter={8}>
|
||||
{/* 仅 array[file]有效 */}
|
||||
{variableType === 'array[file]' &&
|
||||
<Col flex="200px">
|
||||
<Form.Item name={['order_by', 'key']} className="rb:mb-0!">
|
||||
<Select
|
||||
options={fileSubVariable}
|
||||
fieldNames={{ value: 'filed', label: 'label' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
}
|
||||
<Col flex="1">
|
||||
<Form.Item name={['order_by', 'value']} className="rb:mb-0!">
|
||||
<RadioGroupBtn
|
||||
options={['asc', 'desc'].map(key => ({ label: t(`workflow.config.list-operator.${key}`), value: key }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
}
|
||||
|
||||
<Divider />
|
||||
<Form.Item layout="horizontal" name={['extract_by', "enabled"]} label={t('workflow.config.list-operator.extract_by')} className="rb:mb-0!">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
{values?.extract_by?.enabled &&
|
||||
<Form.Item name={['extract_by', "serial"]} className="rb:mb-0!">
|
||||
<InputNumber placeholder={t('common.pleaseEnter')} className="rb:w-full!" />
|
||||
</Form.Item>
|
||||
}
|
||||
|
||||
<Divider />
|
||||
<Form.Item layout="horizontal" name={['limit', "enabled"]} label={t('workflow.config.list-operator.limit')} className="rb:mb-2!">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
{values?.limit?.enabled &&
|
||||
<Form.Item name={['limit', "size"]} className="rb:mb-0!">
|
||||
<RbSlider
|
||||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
isInput={true}
|
||||
size="small"
|
||||
className="rb:-mt-2!"
|
||||
/>
|
||||
</Form.Item>
|
||||
}
|
||||
|
||||
<Divider />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ListOperator
|
||||
@@ -57,7 +57,6 @@ const MappingList: FC<MappingListProps> = ({ label, name, options, extra, valueK
|
||||
<VariableSelect
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={options}
|
||||
popupMatchSelectWidth={false}
|
||||
size="small"
|
||||
className="rb:w-51!"
|
||||
/>
|
||||
|
||||
@@ -2,36 +2,29 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:40:13
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-25 16:54:44
|
||||
* @Last Modified time: 2026-04-03 18:51:17
|
||||
*/
|
||||
import { type FC } from 'react'
|
||||
import { useState, useRef, useEffect, useLayoutEffect, type FC } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx';
|
||||
import { Select, type SelectProps, Flex, Space } from 'antd'
|
||||
import { Flex, Space, Checkbox } from 'antd'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { Suggestion } from '../Editor/plugin/AutocompletePlugin'
|
||||
type LabelRender = SelectProps['labelRender'];
|
||||
|
||||
/**
|
||||
* Props for VariableSelect component
|
||||
*/
|
||||
interface VariableSelectProps extends SelectProps {
|
||||
/** Available variable options */
|
||||
interface VariableSelectProps {
|
||||
options: Suggestion[];
|
||||
/** Current selected value */
|
||||
value?: string;
|
||||
/** Whether to show clear button */
|
||||
value?: string | string[];
|
||||
allowClear?: boolean;
|
||||
/** Filter out boolean type variables */
|
||||
filterBooleanType?: boolean;
|
||||
/** Size of the select component */
|
||||
size?: 'small' | 'middle' | 'large'
|
||||
multiple?: boolean;
|
||||
size?: 'small' | 'middle' | 'large';
|
||||
placeholder?: string;
|
||||
variant?: 'outlined' | 'borderless';
|
||||
className?: string;
|
||||
onChange?: (value: string | string[], option: Suggestion | Suggestion[] | undefined) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* VariableSelect component
|
||||
* Custom select component for workflow variables with grouped options and custom rendering
|
||||
* @param props - Component props
|
||||
*/
|
||||
const VariableSelect: FC<VariableSelectProps> = ({
|
||||
placeholder,
|
||||
options,
|
||||
@@ -40,109 +33,378 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
||||
onChange,
|
||||
size = 'middle',
|
||||
filterBooleanType = false,
|
||||
mode,
|
||||
...resetPorps
|
||||
multiple = false,
|
||||
variant = 'outlined',
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [expandedParent, setExpandedParent] = useState<Suggestion | null>(null);
|
||||
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0, width: 0 });
|
||||
const [childPanelPos, setChildPanelPos] = useState({ top: 0, right: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const itemRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||
|
||||
/**
|
||||
* Handle value change and pass selected option to parent
|
||||
* @param value - Selected value
|
||||
*/
|
||||
const handleChange: SelectProps['onChange'] = (value: string) => {
|
||||
const filterItem = options.find(option => `{{${option.value}}}` === value)
|
||||
onChange?.(value, filterItem);
|
||||
}
|
||||
/**
|
||||
* Custom label renderer for selected value
|
||||
* Displays node icon, name and variable label
|
||||
* @param props - Label render props
|
||||
*/
|
||||
const labelRender: LabelRender = (props) => {
|
||||
const { value } = props
|
||||
const filterOption = filteredOptions.find(vo => `{{${vo.value}}}` === value)
|
||||
const CHILD_PANEL_HEIGHT = 280; // max-h-60 (240) + header (~40)
|
||||
|
||||
if (filterOption) {
|
||||
return (
|
||||
<span
|
||||
className={clsx("rb:max-w-full rb:wrap-break-word rb:line-clamp-1 rb:rounded-md rb:bg-white rb:text-[12px] rb:inline-flex rb:items-center rb:px-1.5 rb:cursor-pointer", {
|
||||
'rb:leading-5.5!': size !== 'small',
|
||||
'rb:leading-4! rb:text-[10px]!': size === 'small',
|
||||
'rb-border': mode !== "multiple"
|
||||
})}
|
||||
contentEditable={false}
|
||||
>
|
||||
{filterOption.nodeData?.icon && filterOption.nodeData?.name && (
|
||||
<>
|
||||
<div className={`rb:size-3 rb:mr-1 rb:bg-cover ${filterOption.nodeData.icon}`} />
|
||||
{filterOption.nodeData.name}
|
||||
<span className="rb:text-[#DFE4ED] rb:mx-0.5">/</span>
|
||||
</>
|
||||
)}
|
||||
<span className="rb:text-[#171719]">{filterOption.label}</span>
|
||||
</span>
|
||||
)
|
||||
// Calculate dropdown position when opening
|
||||
useEffect(() => {
|
||||
if (!open || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
setDropdownPos({ top: rect.bottom + 8, left: rect.left, width: rect.width });
|
||||
}, [open]);
|
||||
|
||||
// Adjust dropdown vertical position after render
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !dropdownRef.current || !containerRef.current) return;
|
||||
const triggerRect = containerRef.current.getBoundingClientRect();
|
||||
const dropdownHeight = dropdownRef.current.offsetHeight;
|
||||
const dropdownWidth = dropdownRef.current.offsetWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const MARGIN = 8;
|
||||
|
||||
// Horizontal: left-align to trigger, clamp to viewport
|
||||
const left = Math.min(triggerRect.left, window.innerWidth - dropdownWidth - 10);
|
||||
|
||||
const spaceBelow = viewportHeight - triggerRect.bottom - MARGIN;
|
||||
const spaceAbove = triggerRect.top - MARGIN;
|
||||
|
||||
let finalTop: number;
|
||||
if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) {
|
||||
finalTop = triggerRect.bottom + MARGIN;
|
||||
} else {
|
||||
finalTop = triggerRect.top - dropdownHeight - MARGIN;
|
||||
if (finalTop < MARGIN) finalTop = MARGIN;
|
||||
}
|
||||
return null
|
||||
}
|
||||
// Filter options based on boolean type if needed
|
||||
const filteredOptions = filterBooleanType
|
||||
? options.filter(option => option.dataType !== 'boolean')
|
||||
setDropdownPos(prev => ({ ...prev, top: finalTop, left }));
|
||||
}, [open, search, Array.isArray(value) ? value.length : 0]);
|
||||
|
||||
const filteredOptions = filterBooleanType
|
||||
? options.filter(o => o.dataType !== 'boolean')
|
||||
: options;
|
||||
|
||||
/**
|
||||
* Group suggestions by node ID
|
||||
*/
|
||||
const groupedSuggestions = filteredOptions.reduce((groups: Record<string, any[]>, suggestion) => {
|
||||
const { nodeData } = suggestion
|
||||
const nodeId = nodeData.id as string;
|
||||
if (!groups[nodeId]) {
|
||||
groups[nodeId] = [];
|
||||
}
|
||||
groups[nodeId].push(suggestion);
|
||||
const allSuggestions = filteredOptions.flatMap(o => o.children ? [o, ...o.children] : [o]);
|
||||
const suggestionMap = new Map(allSuggestions.map(s => [`{{${s.value}}}`, s]));
|
||||
|
||||
const selectedValues = multiple ? (Array.isArray(value) ? value : []) : [];
|
||||
const selectedSuggestion = !multiple && value ? suggestionMap.get(value as string) : undefined;
|
||||
const parentOfSelected = !multiple && value
|
||||
? filteredOptions.find(o => o.children?.some(c => `{{${c.value}}}` === value))
|
||||
: undefined;
|
||||
|
||||
const groupedSuggestions = filteredOptions.reduce((groups: Record<string, Suggestion[]>, s) => {
|
||||
const nodeId = s.nodeData.id as string;
|
||||
if (!groups[nodeId]) groups[nodeId] = [];
|
||||
groups[nodeId].push(s);
|
||||
return groups;
|
||||
}, {});
|
||||
|
||||
/**
|
||||
* Format grouped options for Select component
|
||||
*/
|
||||
const groupedOptions = Object.entries(groupedSuggestions).map(([_nodeId, suggestions]) => ({
|
||||
label: <Flex align="center" gap={4}>
|
||||
{suggestions[0].nodeData.icon && <div className={`rb:size-3 ${suggestions[0].nodeData.icon}`} />}
|
||||
{suggestions[0].nodeData.name}
|
||||
</Flex>,
|
||||
options: suggestions.map(s => ({
|
||||
label: <Flex align="center" justify="space-between" gap={4}>
|
||||
<Space size={8}>
|
||||
<span className="rb:text-[#155EEF]">{`{x}`}</span>
|
||||
{s.label}
|
||||
</Space>
|
||||
<span className="rb:text-[#5B6167]">{s.dataType}</span>
|
||||
</Flex>,
|
||||
value: `{{${s.value}}}`
|
||||
}))
|
||||
}));
|
||||
|
||||
const filteredGroups = search
|
||||
? Object.entries(groupedSuggestions).reduce((acc: Record<string, Suggestion[]>, [nodeId, suggestions]) => {
|
||||
const matched = suggestions.filter(s =>
|
||||
s.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.value.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.children?.some(c => c.label.toLowerCase().includes(search.toLowerCase()))
|
||||
);
|
||||
if (matched.length) acc[nodeId] = matched;
|
||||
return acc;
|
||||
}, {})
|
||||
: groupedSuggestions;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const updatePos = () => {
|
||||
if (!containerRef.current || !dropdownRef.current) return;
|
||||
const triggerRect = containerRef.current.getBoundingClientRect();
|
||||
const dropdownHeight = dropdownRef.current.offsetHeight;
|
||||
const dropdownWidth = dropdownRef.current.offsetWidth;
|
||||
const MARGIN = 8;
|
||||
const left = Math.min(triggerRect.left, window.innerWidth - dropdownWidth - 10);
|
||||
const spaceBelow = window.innerHeight - triggerRect.bottom - MARGIN;
|
||||
const spaceAbove = triggerRect.top - MARGIN;
|
||||
let top: number;
|
||||
if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) {
|
||||
top = triggerRect.bottom + MARGIN;
|
||||
} else {
|
||||
top = triggerRect.top - dropdownHeight - MARGIN;
|
||||
if (top < MARGIN) top = MARGIN;
|
||||
}
|
||||
setDropdownPos(prev => ({ ...prev, top, left }));
|
||||
};
|
||||
document.addEventListener('scroll', updatePos, true);
|
||||
return () => document.removeEventListener('scroll', updatePos, true);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
const childPanel = document.getElementById('variable-select-child-panel');
|
||||
if (
|
||||
!containerRef.current?.contains(target) &&
|
||||
!dropdownRef.current?.contains(target) &&
|
||||
!childPanel?.contains(target)
|
||||
) {
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
setExpandedParent(null);
|
||||
setChildPanelPos({ top: 0, right: 0 });
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
const handleSelect = (suggestion: Suggestion) => {
|
||||
if (multiple) {
|
||||
const key = `{{${suggestion.value}}}`;
|
||||
const next = selectedValues.includes(key)
|
||||
? selectedValues.filter(v => v !== key)
|
||||
: [...selectedValues, key];
|
||||
const nextOptions = next.map(v => suggestionMap.get(v)).filter(Boolean) as Suggestion[];
|
||||
onChange?.(next, nextOptions);
|
||||
} else {
|
||||
onChange?.(`{{${suggestion.value}}}`, suggestion);
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
setExpandedParent(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onChange?.(multiple ? [] : '', multiple ? [] : undefined);
|
||||
};
|
||||
|
||||
const updateChildPos = (key: string) => {
|
||||
const el = itemRefs.current.get(key);
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const absoluteBottom = rect.top + CHILD_PANEL_HEIGHT;
|
||||
const overflow = absoluteBottom - (window.innerHeight - 10);
|
||||
const top = overflow > 0 ? rect.top - overflow : rect.top;
|
||||
setChildPanelPos({ top, right: window.innerWidth - rect.left + 8 });
|
||||
}
|
||||
};
|
||||
|
||||
const sep = <span className="rb:text-[#DFE4ED] rb:mx-0.5">/</span>;
|
||||
const isConversation = (parentOfSelected ?? selectedSuggestion)?.group === 'CONVERSATION' ||
|
||||
(selectedSuggestion ? filteredOptions.some(o => o.group === 'CONVERSATION' && o.children?.some(c => `{{${c.value}}}` === value)) : false);
|
||||
const nodeData = (parentOfSelected ?? selectedSuggestion)?.nodeData;
|
||||
|
||||
return (
|
||||
<Select
|
||||
{...resetPorps}
|
||||
mode={mode}
|
||||
size={size}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
style={{ width: '100%' }}
|
||||
options={groupedOptions}
|
||||
labelRender={labelRender}
|
||||
onChange={handleChange}
|
||||
showSearch
|
||||
allowClear={allowClear}
|
||||
optionFilterProp="value"
|
||||
filterOption={(input, option) => {
|
||||
if (input === '/') return true;
|
||||
const value = 'value' in option! ? option.value as string : '';
|
||||
return value.toLowerCase().includes(input.toLowerCase());
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div ref={containerRef} className="rb:relative rb:w-full">
|
||||
{/* Trigger */}
|
||||
<div
|
||||
className={clsx(
|
||||
'rb:w-full rb:flex rb:items-center rb:justify-between rb:cursor-pointer rb:rounded-md rb:bg-white rb:px-2 rb:transition-colors',
|
||||
variant === 'outlined' && 'rb:border rb:border-[#d9d9d9] hover:rb:border-[#4096ff]',
|
||||
variant === 'outlined' && open && 'rb:border-[#4096ff] rb:shadow-[0_0_0_2px_rgba(5,145,255,0.1)]',
|
||||
variant === 'borderless' && 'rb:border-none rb:shadow-none rb:bg-transparent',
|
||||
multiple ? 'rb:min-h-8 rb:py-1' : size === 'small' ? 'rb:h-6 rb:text-[10px]' : size === 'large' ? 'rb:h-10' : 'rb:h-8 rb:text-[12px]',
|
||||
!multiple && (size === 'small' ? 'rb:text-[10px]' : 'rb:text-[12px]'),
|
||||
className
|
||||
)}
|
||||
onClick={() => setOpen(o => !o)}
|
||||
>
|
||||
{multiple ? (
|
||||
selectedValues.length > 0 ? (
|
||||
<span className="rb:flex rb:flex-wrap rb:gap-1 rb:flex-1 rb:min-w-0">
|
||||
{selectedValues.map(v => {
|
||||
const s = suggestionMap.get(v);
|
||||
if (!s) return null;
|
||||
const parent = filteredOptions.find(o => o.children?.some(c => `{{${c.value}}}` === v));
|
||||
const nd = s.nodeData;
|
||||
const isConv = (parent ?? s)?.group === 'CONVERSATION' ||
|
||||
filteredOptions.some(o => o.group === 'CONVERSATION' && o.children?.some(c => `{{${c.value}}}` === v));
|
||||
return (
|
||||
<span
|
||||
key={v}
|
||||
className="rb:inline-flex rb:items-center rb:gap-0.5 rb:bg-[#f0f8ff] rb:rounded rb:px-1 rb:py-0.5 rb:text-[11px] rb:max-w-full"
|
||||
>
|
||||
{!isConv && nd?.icon && <div className={`rb:size-3 rb:shrink-0 rb:bg-cover ${nd.icon}`} />}
|
||||
{!isConv && nd?.name && <span className="rb:text-[#5B6167]">{nd.name}{sep}</span>}
|
||||
<span className="rb:text-[#171719]">
|
||||
{parent ? <>{parent.label}{sep}{s.label}</> : s.label}
|
||||
</span>
|
||||
<span
|
||||
className="rb:cursor-pointer rb:text-[#bfbfbf] hover:rb:text-[#999] rb:leading-none rb:ml-0.5"
|
||||
onClick={(e) => { e.stopPropagation(); handleSelect(s); }}
|
||||
>✕</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rb:text-[#bfbfbf] rb:flex-1 rb:text-[12px]">{placeholder}</span>
|
||||
)
|
||||
) : selectedSuggestion ? (
|
||||
<span className="rb:flex rb:flex-1 rb:min-w-0">
|
||||
<span className="rb:inline-flex rb:items-center rb:gap-0.5 rb:bg-[#f0f8ff] rb:rounded rb:px-1 rb:py-0.5 rb:text-[11px] rb:max-w-full">
|
||||
{!isConversation && nodeData?.icon && <div className={`rb:size-3 rb:shrink-0 rb:bg-cover ${nodeData.icon}`} />}
|
||||
{!isConversation && nodeData?.name && <span className="rb:text-[#5B6167]">{nodeData.name}{sep}</span>}
|
||||
<span className="rb:text-[#171719]">
|
||||
{parentOfSelected ? <>{parentOfSelected.label}{sep}{selectedSuggestion.label}</> : selectedSuggestion.label}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="rb:text-[#bfbfbf] rb:flex-1">{placeholder}</span>
|
||||
)}
|
||||
<Space size={4} className="rb:shrink-0 rb:ml-1">
|
||||
{allowClear && (
|
||||
<span
|
||||
className={clsx('rb:text-[#bfbfbf] rb:text-[10px] hover:rb:text-[#999] rb:leading-none rb:transition-opacity',
|
||||
(multiple ? selectedValues.length > 0 : !!selectedSuggestion) ? 'rb:opacity-100 rb:cursor-pointer' : 'rb:opacity-0 rb:pointer-events-none'
|
||||
)}
|
||||
onClick={handleClear}
|
||||
>✕</span>
|
||||
)}
|
||||
<div className={clsx("rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/arrow_up.svg')]", {
|
||||
'rb:rotate-0': open,
|
||||
'rb:rotate-180': !open,
|
||||
})}></div>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Dropdown via portal */}
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="rb:fixed rb:z-9999 rb:bg-white rb:text-[14px] rb:rounded-xl rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left, minWidth: dropdownPos.width }}
|
||||
>
|
||||
<div className="rb:min-w-70 rb:max-h-60 rb:overflow-y-auto rb:py-1">
|
||||
{Object.entries(filteredGroups).map(([nodeId, suggestions]) => {
|
||||
const nd = suggestions[0].nodeData;
|
||||
return (
|
||||
<div key={nodeId}>
|
||||
<Flex align="center" gap={4} className="rb:px-3! rb:py-1.25! rb:text-[12px] rb:font-medium rb:text-[#5B6167]">
|
||||
{nd.icon && <div className={`rb:size-3 rb:bg-cover ${nd.icon}`} />}
|
||||
{nd.name}
|
||||
</Flex>
|
||||
{suggestions.map(s => {
|
||||
const isSelected = multiple
|
||||
? selectedValues.includes(`{{${s.value}}}`)
|
||||
: `{{${s.value}}}` === value;
|
||||
const isExpanded = expandedParent?.key === s.key;
|
||||
const hasChildren = !!s.children?.length;
|
||||
return (
|
||||
<Flex
|
||||
key={s.key}
|
||||
ref={(el) => { if (el) itemRefs.current.set(s.key, el); }}
|
||||
className="rb:mx-3! rb:pl-3! rb:pr-3! rb:py-1.5! rb:rounded-lg!"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
cursor: s.disabled ? 'not-allowed' : 'pointer',
|
||||
background: isSelected || isExpanded ? '#f0f8ff' : 'white',
|
||||
opacity: s.disabled ? 0.5 : 1,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (s.disabled) return;
|
||||
if (hasChildren) {
|
||||
updateChildPos(s.key);
|
||||
setExpandedParent(prev => prev?.key === s.key ? null : s);
|
||||
}
|
||||
handleSelect(s);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (hasChildren) {
|
||||
updateChildPos(s.key);
|
||||
setExpandedParent(s);
|
||||
} else {
|
||||
setExpandedParent(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Space size={4}>
|
||||
{multiple && (
|
||||
<Checkbox checked={isSelected} />
|
||||
)}
|
||||
<span className="rb:text-[#155EEF]">{`{x}`}</span>
|
||||
<span>{s.label}</span>
|
||||
</Space>
|
||||
<Space size={4} className="rb:text-[#5B6167] rb:text-[12px]">
|
||||
{s.dataType && <span>{s.dataType}</span>}
|
||||
|
||||
{hasChildren && <div className="rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/arrow_up.svg')] rb:rotate-90"></div>}
|
||||
</Space>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{Object.keys(filteredGroups).length === 0 && (
|
||||
<div className="rb:px-3 rb:py-4 rb:text-center rb:text-[#bfbfbf] rb:text-[14px]">
|
||||
{t('workflow.variableSelect.empty', '暂无变量')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Child panel via portal — escapes overflow clipping */}
|
||||
{open && expandedParent?.children?.length && createPortal(
|
||||
<div
|
||||
id="variable-select-child-panel"
|
||||
className="rb:fixed rb:z-9999 rb:bg-white rb:rounded-xl rb:py-1 rb:min-w-60 rb:max-h-60 rb:overflow-y-auto rb:text-[14px] rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
|
||||
style={{ top: childPanelPos.top, right: childPanelPos.right }}
|
||||
onMouseEnter={() => setExpandedParent(expandedParent)}
|
||||
>
|
||||
<div
|
||||
className="rb:px-3 rb:py-2 rb:text-[14px] rb:font-medium rb:text-[#5B6167] rb:border-b rb:border-[#F0F0F0] rb:cursor-pointer rb:hover:bg-[#f0f8ff]"
|
||||
onClick={() => !expandedParent.disabled && handleSelect(expandedParent)}
|
||||
>
|
||||
<Flex justify="space-between" align="center" gap={8}>
|
||||
<Flex align="center" gap={6}>
|
||||
<span>{expandedParent.nodeData.name}.{expandedParent.label}</span>
|
||||
</Flex>
|
||||
<span>{expandedParent.dataType}</span>
|
||||
</Flex>
|
||||
</div>
|
||||
{expandedParent.children.map(child => {
|
||||
const isSelected = multiple
|
||||
? selectedValues.includes(`{{${child.value}}}`)
|
||||
: `{{${child.value}}}` === value;
|
||||
const hasGrandChildren = !!child.children?.length;
|
||||
return (
|
||||
<Flex
|
||||
key={child.key}
|
||||
className={clsx("rb:px-3! rb:py-2! rb:hover:bg-[#f0f8ff]!", {
|
||||
'rb:bg-[#f0f8ff]': isSelected,
|
||||
'rb:white': !isSelected
|
||||
})}
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
cursor: child.disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: child.disabled ? 0.5 : 1,
|
||||
}}
|
||||
onClick={() => !child.disabled && handleSelect(child)}
|
||||
>
|
||||
<Flex align="center" gap={6}>
|
||||
{multiple && (
|
||||
<Checkbox checked={isSelected} />
|
||||
)}
|
||||
<span>{child.label}</span>
|
||||
</Flex>
|
||||
<Flex align="center" gap={4}>
|
||||
{child.dataType && <span className="rb:text-[#5B6167]">{child.dataType}</span>}
|
||||
{hasGrandChildren && <span className="rb:text-[#5B6167] rb:ml-1">›</span>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VariableSelect
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-01-19 17:00:26
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-28 16:24:31
|
||||
* @Last Modified time: 2026-04-02 16:58:40
|
||||
*/
|
||||
/**
|
||||
* useVariableList Hook
|
||||
@@ -19,6 +19,16 @@ import { Graph, Node } from '@antv/x6';
|
||||
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin';
|
||||
import type { ChatVariable } from '../../../types';
|
||||
|
||||
export const fileSubVariable = [
|
||||
{ label: 'type', dataType: 'string', filed: 'type' },
|
||||
{ label: 'size', dataType: 'number', filed: 'size' },
|
||||
{ label: 'name', dataType: 'string', filed: 'name' },
|
||||
{ label: 'url', dataType: 'string', filed: 'url' },
|
||||
{ label: 'extension', dataType: 'string', filed: 'extension' },
|
||||
{ label: 'mime_type', dataType: 'string', filed: 'mime_type' },
|
||||
{ label: 'related_id', dataType: 'string', filed: 'related_id' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Node variable definitions
|
||||
*
|
||||
@@ -45,7 +55,12 @@ const NODE_VARIABLES = {
|
||||
],
|
||||
'document-extractor': [
|
||||
{ label: 'text', dataType: 'string', field: 'text' },
|
||||
]
|
||||
],
|
||||
'list-operator': [
|
||||
{ label: 'result', dataType: 'array[string]', field: 'result' },
|
||||
{ label: 'first_record', dataType: 'string', field: 'first_record' },
|
||||
{ label: 'last_record', dataType: 'string', field: 'last_record' },
|
||||
] // dataType will be overridden dynamically
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -60,6 +75,17 @@ const NODE_VARIABLES = {
|
||||
* @param {any} nodeData - Node data associated with the variable
|
||||
* @param {Partial<Suggestion>} [extra] - Additional suggestion properties
|
||||
*/
|
||||
const buildFileChildren = (key: string, value: string, nodeData: any, parentLabel: string): Suggestion[] =>
|
||||
fileSubVariable.map(sub => ({
|
||||
key: `${key}_${sub.filed}`,
|
||||
label: sub.label,
|
||||
type: 'variable',
|
||||
dataType: sub.dataType,
|
||||
value: `${value}.${sub.filed}`,
|
||||
nodeData,
|
||||
parentLabel,
|
||||
}));
|
||||
|
||||
const addVariable = (
|
||||
list: Suggestion[],
|
||||
keys: Set<string>,
|
||||
@@ -72,7 +98,10 @@ const addVariable = (
|
||||
) => {
|
||||
if (!keys.has(key)) {
|
||||
keys.add(key);
|
||||
list.push({ key, label, type: 'variable', dataType, value, nodeData, ...extra });
|
||||
const children = dataType === 'file'
|
||||
? buildFileChildren(key, value, nodeData, label)
|
||||
: undefined;
|
||||
list.push({ key, label, type: 'variable', dataType, value, nodeData, children, ...extra });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,9 +123,26 @@ const processNodeVariables = (
|
||||
|
||||
// Add node-specific variables
|
||||
if (type in NODE_VARIABLES) {
|
||||
NODE_VARIABLES[type as keyof typeof NODE_VARIABLES].forEach(({ label, dataType, field }) => {
|
||||
addVariable(variableList, addedKeys, `${dataNodeId}_${label}`, label, dataType, `${dataNodeId}.${field}`, nodeData);
|
||||
});
|
||||
if (type === 'list-operator') {
|
||||
// Determine output type from the first variable in config
|
||||
const variableValue = config?.variable;
|
||||
let itemType = 'string';
|
||||
if (variableValue) {
|
||||
const refVar = variableList.find(v => `{{${v.value}}}` === variableValue);
|
||||
if (refVar?.dataType.startsWith('array[')) {
|
||||
itemType = refVar.dataType.replace(/^array\[(.+)\]$/, '$1');
|
||||
} else if (refVar) {
|
||||
itemType = refVar.dataType;
|
||||
}
|
||||
}
|
||||
addVariable(variableList, addedKeys, `${dataNodeId}_result`, 'result', `array[${itemType}]`, `${dataNodeId}.result`, nodeData);
|
||||
addVariable(variableList, addedKeys, `${dataNodeId}_first_record`, 'first_record', itemType, `${dataNodeId}.first_record`, nodeData);
|
||||
addVariable(variableList, addedKeys, `${dataNodeId}_last_record`, 'last_record', itemType, `${dataNodeId}.last_record`, nodeData);
|
||||
} else {
|
||||
NODE_VARIABLES[type as keyof typeof NODE_VARIABLES].forEach(({ label, dataType, field }) => {
|
||||
addVariable(variableList, addedKeys, `${dataNodeId}_${label}`, label, dataType, `${dataNodeId}.${field}`, nodeData);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process special node types
|
||||
@@ -181,7 +227,8 @@ const hasOutputNodeTypes = [
|
||||
'http-request',
|
||||
'tool',
|
||||
'jinja-render',
|
||||
'document-extractor'
|
||||
'document-extractor',
|
||||
'list-operator'
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -191,10 +238,10 @@ const hasOutputNodeTypes = [
|
||||
* @param {any} values - Additional values to merge with node config
|
||||
* @returns {Suggestion[]} List of node variables
|
||||
*/
|
||||
export const getCurrentNodeVariables = (nodeData: any, values: any): Suggestion[] => {
|
||||
export const getCurrentNodeVariables = (nodeData: any, values: any, upstreamVariables: Suggestion[] = []): Suggestion[] => {
|
||||
if (!nodeData || !hasOutputNodeTypes.includes(nodeData.type)) return [];
|
||||
const list: Suggestion[] = [];
|
||||
const keys = new Set<string>();
|
||||
const list: Suggestion[] = [...upstreamVariables];
|
||||
const keys = new Set<string>(upstreamVariables.map(v => v.key));
|
||||
const dataNodeId = nodeData.id;
|
||||
|
||||
processNodeVariables({
|
||||
@@ -206,7 +253,8 @@ export const getCurrentNodeVariables = (nodeData: any, values: any): Suggestion[
|
||||
}, dataNodeId, list, keys);
|
||||
|
||||
// Special case: var-aggregator without group enabled returns no variables
|
||||
return nodeData.type === 'var-aggregator' && !nodeData.config.group.defaultValue ? [] : list;
|
||||
const result = list.filter(v => v.nodeData?.id === dataNodeId);
|
||||
return nodeData.type === 'var-aggregator' && !nodeData.config.group.defaultValue ? [] : result;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -263,52 +311,21 @@ export const getChildNodeVariables = (
|
||||
// Add node-specific variables
|
||||
if (type in NODE_VARIABLES) {
|
||||
NODE_VARIABLES[type as keyof typeof NODE_VARIABLES].forEach(({ label, dataType, field }) => {
|
||||
const varKey = `${nodeId}_${label}`;
|
||||
if (!keys.has(varKey)) {
|
||||
keys.add(varKey);
|
||||
list.push({
|
||||
key: varKey,
|
||||
label,
|
||||
type: 'variable',
|
||||
dataType,
|
||||
value: `${nodeId}.${field}`,
|
||||
nodeData,
|
||||
});
|
||||
}
|
||||
addVariable(list, keys, `${nodeId}_${label}`, label, dataType, `${nodeId}.${field}`, nodeData);
|
||||
});
|
||||
}
|
||||
|
||||
// Add parameter-extractor variables
|
||||
if (type === 'parameter-extractor') {
|
||||
(nodeData.config?.params?.defaultValue || []).forEach((p: any) => {
|
||||
if (p?.name && !keys.has(`${nodeId}_${p.name}`)) {
|
||||
keys.add(`${nodeId}_${p.name}`);
|
||||
list.push({
|
||||
key: `${nodeId}_${p.name}`,
|
||||
label: p.name,
|
||||
type: 'variable',
|
||||
dataType: p.type || 'string',
|
||||
value: `${nodeId}.${p.name}`,
|
||||
nodeData,
|
||||
});
|
||||
}
|
||||
if (p?.name) addVariable(list, keys, `${nodeId}_${p.name}`, p.name, p.type || 'string', `${nodeId}.${p.name}`, nodeData);
|
||||
});
|
||||
}
|
||||
|
||||
// Add code node variables
|
||||
if (type === 'code') {
|
||||
(nodeData.config?.output_variables?.defaultValue || []).forEach((p: any) => {
|
||||
if (p?.name && !keys.has(`${nodeId}_${p.name}`)) {
|
||||
keys.add(`${nodeId}_${p.name}`);
|
||||
list.push({
|
||||
key: `${nodeId}_${p.name}`,
|
||||
label: p.name,
|
||||
type: 'variable',
|
||||
dataType: p.type || 'string',
|
||||
value: `${nodeId}.${p.name}`,
|
||||
nodeData,
|
||||
});
|
||||
}
|
||||
if (p?.name) addVariable(list, keys, `${nodeId}_${p.name}`, p.name, p.type || 'string', `${nodeId}.${p.name}`, nodeData);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ import { nodeLibrary } from '../../constant';
|
||||
import RbCard from '@/components/RbCard/Card';
|
||||
import ModelConfig from './ModelConfig'
|
||||
import ModelSelect from '@/components/ModelSelect'
|
||||
import ListOperator from './ListOperator'
|
||||
|
||||
/**
|
||||
* Props for Properties component
|
||||
@@ -362,7 +363,7 @@ const Properties: FC<PropertiesProps> = ({
|
||||
*/
|
||||
const currentNodeVariables = useMemo(() => {
|
||||
if (!selectedNode) return []
|
||||
return getCurrentNodeVariables(selectedNode?.getData(), values)
|
||||
return getCurrentNodeVariables(selectedNode?.getData(), values, variableList)
|
||||
}, [selectedNode?.getData(), values])
|
||||
|
||||
const [outputCollapsed, setOutputCollapsed] = useState(true)
|
||||
@@ -466,7 +467,12 @@ const Properties: FC<PropertiesProps> = ({
|
||||
<Form.Item name="id" label="ID">
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
{selectedNode?.data?.type === 'unknown'
|
||||
{selectedNode?.data?.type === 'list-operator'
|
||||
? <ListOperator
|
||||
options={variableList}
|
||||
selectedNode={selectedNode}
|
||||
/>
|
||||
: selectedNode?.data?.type === 'unknown'
|
||||
? <>
|
||||
<Form.Item name="replaceNode" label={t('workflow.config.unknown.replaceNodeType')}>
|
||||
<Select
|
||||
|
||||
Reference in New Issue
Block a user