diff --git a/web/src/assets/images/workflow/list-operator.svg b/web/src/assets/images/workflow/list-operator.svg
new file mode 100644
index 00000000..8091c04b
--- /dev/null
+++ b/web/src/assets/images/workflow/list-operator.svg
@@ -0,0 +1,19 @@
+
+
\ No newline at end of file
diff --git a/web/src/components/SiderMenu/index.tsx b/web/src/components/SiderMenu/index.tsx
index 21f7fd36..c85f3c9f 100644
--- a/web/src/components/SiderMenu/index.tsx
+++ b/web/src/components/SiderMenu/index.tsx
@@ -241,7 +241,7 @@ const Menu: FC<{
const findMatchingKey = (menuList: MenuItem[], parentPaths: string[] = []): { key: string | null; } => {
for (const menu of menuList) {
if (menu.path) {
- const menuPath = menu.path[0] !== '/' ? '/' + menu.path : menu.path;
+ const menuPath = menu.path?.[0] !== '/' ? '/' + menu.path : menu.path;
/** Exact match or path prefix match (ensure complete path segment match) */
const isExactMatch = menuPath === currentPath;
diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts
index ac917bae..caaaad89 100644
--- a/web/src/i18n/en.ts
+++ b/web/src/i18n/en.ts
@@ -2179,6 +2179,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
unknown: 'Unknown Node',
notes: 'Sticky Note',
'document-extractor': 'Document Extractor',
+ 'list-operator': 'List Operator',
clickToConfigure: 'Click to configure node parameters',
nodeProperties: 'Node Properties',
@@ -2252,6 +2253,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
'string': 'String',
'number': 'Number',
'boolean': 'Boolean',
+ 'file': 'File',
+ 'array[file]': 'Array[File]',
'array[string]': 'Array[String]',
'array[number]': 'Array[Number]',
'array[boolean]': 'Array[Boolean]',
@@ -2380,6 +2383,20 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
'document-extractor': {
file_selector: 'Input Variable',
},
+ 'list-operator': {
+ variable: 'Input Variable',
+ filter_by: 'Filter Conditions',
+ addCondition: 'Add Filter Condition',
+ order_by: 'Sort',
+ asc: 'asc',
+ desc: 'desc',
+ extract_by: 'Extract Nth Item',
+ limit: 'Take First N Items',
+ type: {
+ eq: 'In',
+ ne: 'Not In',
+ }
+ },
name: 'Key',
type: 'Type',
value: 'Value',
diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts
index f862c64b..50f35813 100644
--- a/web/src/i18n/zh.ts
+++ b/web/src/i18n/zh.ts
@@ -2175,6 +2175,7 @@ export const zh = {
unknown: '未知节点',
notes: '便签',
'document-extractor': '文档提取器',
+ 'list-operator': '列表操作',
clickToConfigure: '点击配置节点参数',
nodeProperties: '节点属性',
@@ -2248,6 +2249,8 @@ export const zh = {
'string': 'String',
'number': 'Number',
'boolean': 'Boolean',
+ 'file': 'File',
+ 'array[file]': 'Array[File]',
'array[string]': 'Array[String]',
'array[number]': 'Array[Number]',
'array[boolean]': 'Array[Boolean]',
@@ -2379,6 +2382,20 @@ export const zh = {
'document-extractor': {
file_selector: '输入变量',
},
+ 'list-operator': {
+ variable: '输入变量',
+ filter_by: '过滤条件',
+ addCondition: '添加过滤条件',
+ order_by: '排序',
+ asc: 'asc',
+ desc: 'desc',
+ extract_by: '取第 N 项',
+ limit: '取前 N 项',
+ type: {
+ eq: '在',
+ ne: '不在',
+ }
+ },
name: '键',
type: '类型',
value: '值',
diff --git a/web/src/views/ApplicationConfig/components/FeaturesConfig/FileUploadSettingModal.tsx b/web/src/views/ApplicationConfig/components/FeaturesConfig/FileUploadSettingModal.tsx
index d30e33a5..5c17aa53 100644
--- a/web/src/views/ApplicationConfig/components/FeaturesConfig/FileUploadSettingModal.tsx
+++ b/web/src/views/ApplicationConfig/components/FeaturesConfig/FileUploadSettingModal.tsx
@@ -26,7 +26,7 @@ interface FileUploadSettingModalProps {
capability?: Capability[];
source?: Application['type']
}
-const documentType = {
+export const documentType = {
type: 'document',
icon:
,
formats: [
@@ -41,7 +41,7 @@ const documentType = {
"md",
],
}
-const imageType = {
+export const imageType = {
type: 'image',
icon: ,
formats: [
@@ -50,7 +50,7 @@ const imageType = {
"jpeg"
],
}
-const audioType = {
+export const audioType = {
type: 'audio',
icon: ,
formats: [
@@ -59,7 +59,7 @@ const audioType = {
"m4a",
],
}
-const videoType = {
+export const videoType = {
type: 'video',
icon: ,
formats: [
@@ -68,7 +68,7 @@ const videoType = {
],
}
-const defaultValues: FileUpload = {
+export const defaultValues: FileUpload = {
enabled: false,
image_enabled: false,
image_max_size_mb: 20,
diff --git a/web/src/views/ApplicationManagement/MySharing.tsx b/web/src/views/ApplicationManagement/MySharing.tsx
index 434bf465..e24cce0b 100644
--- a/web/src/views/ApplicationManagement/MySharing.tsx
+++ b/web/src/views/ApplicationManagement/MySharing.tsx
@@ -92,7 +92,7 @@ const MySharing: React.FC = () => {
label: (
{workspace.target_workspace_icon
- ?
+ ?
:
{workspace.target_workspace_name[0]}
diff --git a/web/src/views/Conversation/components/FileUpload.tsx b/web/src/views/Conversation/components/FileUpload.tsx
index 3c494437..67fff913 100644
--- a/web/src/views/Conversation/components/FileUpload.tsx
+++ b/web/src/views/Conversation/components/FileUpload.tsx
@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-06 21:09:42
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-27 18:23:04
+ * @Last Modified time: 2026-04-02 18:29:48
*/
/**
* File Upload Component
@@ -21,7 +21,7 @@
* @component
*/
import { useState, useEffect, forwardRef, useImperativeHandle, useMemo } from 'react';
-import { Upload, Progress, App, Flex } from 'antd';
+import { Upload, Progress, App, Flex, Button } from 'antd';
import type { UploadProps, UploadFile } from 'antd';
import type { UploadProps as RcUploadProps, RcFile, UploadFileStatus } from 'antd/es/upload/interface';
import { useTranslation } from 'react-i18next';
@@ -56,7 +56,9 @@ interface UploadFilesProps extends Omit {
/** Custom file removal callback */
onRemove?: (file: UploadFile) => boolean | void | Promise;
- featureConfig: FeaturesConfigForm['file_upload']
+ featureConfig: FeaturesConfigForm['file_upload'];
+ textType?: 'button' | 'text';
+ block?: boolean;
}
export const transform_file_type: Record = {
@@ -149,6 +151,8 @@ const UploadFiles = forwardRef(({
onRemove: customOnRemove,
requestConfig,
featureConfig,
+ textType = 'text',
+ block,
...props
}, ref) => {
const { t } = useTranslation();
@@ -159,11 +163,11 @@ const UploadFiles = forwardRef(({
const fileType = useMemo(() => {
let types: string[] = [];
['image', 'document', 'video', 'audio'].forEach(type => {
- if (featureConfig[`${type}_enabled` as keyof FeaturesConfigForm['file_upload']]) {
- types = types.concat(featureConfig[`${type}_allowed_extensions` as keyof FeaturesConfigForm['file_upload']] as string[])
+ if (featureConfig?.[`${type}_enabled` as keyof FeaturesConfigForm['file_upload']]) {
+ const exts = featureConfig[`${type}_allowed_extensions` as keyof FeaturesConfigForm['file_upload']] as string[];
+ if (Array.isArray(exts)) types = types.concat(exts.filter(Boolean));
}
})
-
return types
}, [featureConfig])
@@ -205,6 +209,7 @@ const UploadFiles = forwardRef(({
return Upload.LIST_IGNORE;
}
}
+ console.log('onChange', isAutoUpload)
if (!isAutoUpload) {
const newFileList = [...fileList, file as UploadFile];
@@ -238,11 +243,11 @@ const UploadFiles = forwardRef(({
request.uploadFile(action, formData, requestConfig)
.then(res => {
onSuccess?.({ data: res });
+ onChange?.({ ...fileVo, status: 'done', response: { data: res } })
})
.catch((error) => {
onError?.(error as Error);
- fileVo.status = 'error'
- onChange?.(fileVo)
+ onChange?.({ ...fileVo, status: 'error' })
})
};
@@ -327,7 +332,10 @@ const UploadFiles = forwardRef(({
- {t('memoryConversation.uploadFile')}
+ {textType === 'text'
+ ? {t('memoryConversation.uploadFile')}
+ :
+ }
);
});
diff --git a/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx b/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx
index 15933d4a..18495295 100644
--- a/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx
+++ b/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx
@@ -2,107 +2,203 @@
* @Author: ZhaoYing
* @Date: 2025-12-30 13:59:36
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-02-28 16:19:26
+ * @Last Modified time: 2026-04-02 19:01:12
*/
-/**
- * ChatVariableModal Component
- *
- * This component provides a modal for adding or editing chat variables in workflows.
- * It supports various variable types and provides appropriate input fields based on the selected type.
- */
-import { forwardRef, useImperativeHandle, useState } from 'react';
-import { Form, Input, Select, InputNumber } from 'antd';
+import { forwardRef, useImperativeHandle, useState, useRef, useMemo } from 'react';
+import { Form, Input, Select, InputNumber, Button, Row, Col, Flex, Spin } from 'antd';
+import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import type { ChatVariableModalRef } from './types'
import type { ChatVariable } from '../../types';
import RbModal from '@/components/RbModal'
+import { defaultValues as defaultFileUploadValues } from '@/views/ApplicationConfig/components/FeaturesConfig/FileUploadSettingModal'
+import UploadFiles from '@/views/Conversation/components/FileUpload'
+import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
+import type { UploadFileListModalRef } from '@/views/Conversation/types'
+import { getFileInfoByUrl } from '@/api/fileStorage'
+import { transform_file_type } from '@/views/Conversation/components/FileUpload'
const FormItem = Form.Item;
-/**
- * Props for ChatVariableModal component
- */
interface ChatVariableModalProps {
- /**
- * Callback function to refresh variable list
- * @param {ChatVariable} value - The variable data
- * @param {number} [editIndex] - Optional index for editing existing variable
- */
refresh: (value: ChatVariable, editIndex?: number) => void;
}
-/**
- * Supported variable types
- */
const types = [
- 'string', // String type
- 'number', // Number type
- 'boolean', // Boolean type
- 'object', // Object type
- 'array[string]', // Array of strings
- 'array[number]', // Array of numbers
- 'array[boolean]', // Array of booleans
- 'array[object]', // Array of objects
+ 'string',
+ 'number',
+ 'boolean',
+ 'object',
+ 'file',
+ 'array[file]',
+ 'array[string]',
+ 'array[number]',
+ 'array[boolean]',
+ 'array[object]',
]
-/**
- * ChatVariableModal component
- */
const ChatVariableModal = forwardRef(({
refresh
}, ref) => {
const { t } = useTranslation();
+ const uploadFileListModalRef = useRef(null);
- // State management
- const [visible, setVisible] = useState(false); // Modal visibility
- const [form] = Form.useForm(); // Form instance
- const [loading, setLoading] = useState(false); // Loading state
- const [editIndex, setEditIndex] = useState(undefined); // Index of variable being edited
- const type = Form.useWatch('type', form); // Current selected type
+ const [visible, setVisible] = useState(false);
+ const [form] = Form.useForm();
+ const [loading, setLoading] = useState(false);
+ const [fileList, setFileList] = useState([]);
+ const [editIndex, setEditIndex] = useState(undefined);
+
+ const type = Form.useWatch('type', form);
+ const allowed_transfer_methods = Form.useWatch('allowed_transfer_methods', form);
+ const image_enabled = Form.useWatch('image_enabled', form);
+ const audio_enabled = Form.useWatch('audio_enabled', form);
+ const document_enabled = Form.useWatch('document_enabled', form);
+ const video_enabled = Form.useWatch('video_enabled', form);
+ const image_max_size_mb = Form.useWatch('image_max_size_mb', form);
+ const audio_max_size_mb = Form.useWatch('audio_max_size_mb', form);
+ const document_max_size_mb = Form.useWatch('document_max_size_mb', form);
+ const video_max_size_mb = Form.useWatch('video_max_size_mb', form);
+ const image_allowed_extensions = Form.useWatch('image_allowed_extensions', form);
+ const audio_allowed_extensions = Form.useWatch('audio_allowed_extensions', form);
+ const document_allowed_extensions = Form.useWatch('document_allowed_extensions', form);
+ const video_allowed_extensions = Form.useWatch('video_allowed_extensions', form);
+ const max_file_count = Form.useWatch('max_file_count', form);
+
+ const hasEnabledFileType = !!(image_enabled || audio_enabled || document_enabled || video_enabled);
+
+ const featureConfig = useMemo(() => ({
+ enabled: hasEnabledFileType,
+ allowed_transfer_methods,
+ max_file_count,
+ image_enabled, image_max_size_mb, image_allowed_extensions,
+ audio_enabled, audio_max_size_mb, audio_allowed_extensions,
+ document_enabled, document_max_size_mb, document_allowed_extensions,
+ video_enabled, video_max_size_mb, video_allowed_extensions,
+ }), [
+ hasEnabledFileType, allowed_transfer_methods, max_file_count,
+ image_enabled, image_max_size_mb, image_allowed_extensions,
+ audio_enabled, audio_max_size_mb, audio_allowed_extensions,
+ document_enabled, document_max_size_mb, document_allowed_extensions,
+ video_enabled, video_max_size_mb, video_allowed_extensions,
+ ]);
- /**
- * Handle modal close
- */
const handleClose = () => {
+ setFileList([]);
setVisible(false);
form.resetFields();
setLoading(false);
setEditIndex(undefined);
};
- /**
- * Handle modal open
- */
const handleOpen = (variable?: ChatVariable, index?: number) => {
setVisible(true);
if (variable) {
- // Exclude 'default' property and set form values
const { default: _, ...rest } = variable;
form.setFieldsValue({ ...rest });
setEditIndex(index);
+ if (variable.type === 'file' || variable.type === 'array[file]') {
+ const defaultVal = variable.defaultValue;
+ if (defaultVal) {
+ const list = Array.isArray(defaultVal) ? defaultVal : [defaultVal];
+ setFileList(list);
+ }
+ }
} else {
- // Reset form for new variable
form.resetFields();
setEditIndex(undefined);
}
};
- /**
- * Handle save/submit action
- */
const handleSave = () => {
form.validateFields().then((values) => {
- // Create variable with 'default' property mapped from 'defaultValue'
refresh({ ...values, default: values.defaultValue }, editIndex);
handleClose();
});
};
- // Expose handleOpen method to parent component via ref
- useImperativeHandle(ref, () => ({
- handleOpen
- }));
+ useImperativeHandle(ref, () => ({ handleOpen }));
+
+ const setFormFileValue = (updated: any[]) => {
+ const isSingle = form.getFieldValue('type') === 'file';
+ form.setFieldValue('defaultValue', isSingle ? (updated[0] ?? null) : updated);
+ };
+
+ const fileChange = (file?: any) => {
+ const fileObj = file ? {
+ ...file,
+ type: file.type,
+ transfer_method: "local_file",
+ upload_file_id: file.response?.data?.file_id,
+ } : undefined
+ if (form.getFieldValue('type') === 'file') {
+ const updated = [fileObj];
+ setFileList(updated);
+ setTimeout(() => setFormFileValue(updated), 0);
+ return;
+ }
+ setFileList(prev => {
+ const index = prev.findIndex((item: any) => item.uid === fileObj.uid);
+ const updated = index > -1
+ ? prev.map((item, i) => i === index ? fileObj : item)
+ : [...prev, fileObj];
+ setTimeout(() => setFormFileValue(updated), 0);
+ return updated;
+ });
+ };
+
+ const addFileList = (list?: any[]) => {
+ if (!list?.length) return;
+ const uploadingList = list.map(f => ({ ...f, status: 'uploading' }));
+ setFileList(prev => {
+ const isSingle = form.getFieldValue('type') === 'file';
+ const updated = isSingle ? [uploadingList[0]] : [...prev, ...uploadingList];
+ setTimeout(() => setFormFileValue(updated), 0);
+ return updated;
+ });
+ const isSingle = form.getFieldValue('type') === 'file';
+ (isSingle ? [uploadingList[0]] : uploadingList).forEach(file => {
+ getFileInfoByUrl(file.url)
+ .then((res) => {
+ const { file_name, file_size, content_type } = res as { file_name: string; file_size: number; content_type: string };
+ setFileList(prev => {
+ const updated = prev.map(f =>
+ f.uid === file.uid
+ ? { ...f, status: 'done', name: file_name, size: file_size, type: transform_file_type[content_type] || content_type }
+ : f
+ );
+ setFormFileValue(updated);
+ return updated;
+ });
+ })
+ .catch(() => {
+ setFileList(prev => {
+ const updated = prev.map(f => f.uid === file.uid ? { ...f, status: 'error' } : f);
+ setFormFileValue(updated);
+ return updated;
+ });
+ });
+ });
+ };
+
+
+ const previewFileList = useMemo(() => {
+ return fileList.map(file => ({
+ ...file,
+ url: file.thumbUrl || file.url || (file.originFileObj ? URL.createObjectURL(file.originFileObj) : undefined)
+ }));
+ }, [fileList]);
+
+ const handleDelete = (file: any) => {
+ const updated = fileList.filter(item =>
+ item.thumbUrl && file.thumbUrl ? item.thumbUrl !== file.thumbUrl
+ : item.url && file.url ? item.url !== file.url
+ : item.uid !== file.uid
+ );
+ setFileList(updated);
+ setFormFileValue(updated);
+ };
return (
- {/* Variable name field */}
-
- {/* Variable type field */}
+
-
- {/* Default value field - dynamic based on type */}
-
- {type === 'number'
- ? form.setFieldValue('defaultValue', value)}
+
+ {type === 'file' || type === 'array[file]' ? (
+ <>
+
- : type === 'boolean'
- ?
- :
- }
-
-
- {/* Variable description field */}
-
+
+
+
+
+
+ 0}
+ />
+
+
+
+
+
+ {previewFileList.length > 0 && (
+
+ {previewFileList.map((file) => (
+
+ {file.type?.includes('image') ? (
+
+

+
handleDelete(file)}
+ />
+
+ ) : (
+
+
+
+
{file.name}
+
+ {file.type?.split('/').pop()} · {file.size}
+
+
+ handleDelete(file)}
+ />
+
+ )}
+
+ ))}
+
+ )}
+
+ >
+ ) : (
+
+ {type === 'number'
+ ?
+ : type === 'boolean'
+ ?
+ :
+ }
+
+ )}
+
+
@@ -181,4 +347,4 @@ const ChatVariableModal = forwardRef
(({
title={t('workflow.addvariable')}
open={open}
onClose={() => setOpen(false)}
+ width={480}
>
);
}
diff --git a/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx
index 0ebcbe77..da2b2819 100644
--- a/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx
+++ b/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx
@@ -87,9 +87,20 @@ const InitialValuePlugin: React.FC = ({ value, options
if (conversationMatch) {
const [_, variableName] = conversationMatch;
- const conversationSuggestion = optionsRef.current.find(s =>
+ const fullValue = `conv.${variableName}`;
+ // First try direct match on top-level label
+ let conversationSuggestion = optionsRef.current.find(s =>
s.group === 'CONVERSATION' && s.label === variableName
);
+ // Then search children by value (e.g. conv.api_key.url)
+ if (!conversationSuggestion) {
+ for (const s of optionsRef.current) {
+ if (s.group === 'CONVERSATION' && s.children) {
+ const child = s.children.find(c => c.value === fullValue);
+ if (child) { conversationSuggestion = child; break; }
+ }
+ }
+ }
if (conversationSuggestion) {
paragraph.append($createVariableNode(conversationSuggestion));
} else {
diff --git a/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx b/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx
index d6ccae23..4fe38714 100644
--- a/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx
+++ b/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx
@@ -75,7 +75,6 @@ const AssignmentList: FC = ({
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 = ({
? vo.dataType === dataType) : options}
- popupMatchSelectWidth={false}
size={size}
variant="borderless"
className="select"
@@ -153,7 +151,6 @@ const AssignmentList: FC = ({
: vo.dataType === dataType) : options}
- popupMatchSelectWidth={false}
size={size}
variant="borderless"
className="select"
diff --git a/web/src/views/Workflow/components/Properties/CaseList/index.tsx b/web/src/views/Workflow/components/Properties/CaseList/index.tsx
index 12ca38d5..88eace06 100644
--- a/web/src/views/Workflow/components/Properties/CaseList/index.tsx
+++ b/web/src/views/Workflow/components/Properties/CaseList/index.tsx
@@ -281,7 +281,6 @@ const CaseList: FC = ({
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 = ({
placeholder={t('common.pleaseSelect')}
options={options.filter(vo => vo.dataType === 'number')}
allowClear={false}
- popupMatchSelectWidth={false}
variant="borderless"
size="small"
/>
diff --git a/web/src/views/Workflow/components/Properties/ConditionList/index.tsx b/web/src/views/Workflow/components/Properties/ConditionList/index.tsx
index 84beb561..d484da09 100644
--- a/web/src/views/Workflow/components/Properties/ConditionList/index.tsx
+++ b/web/src/views/Workflow/components/Properties/ConditionList/index.tsx
@@ -153,7 +153,6 @@ const ConditionList: FC = ({
)}
size="small"
allowClear={false}
- popupMatchSelectWidth={false}
placeholder={t('common.pleaseSelect')}
onChange={(val) => handleLeftFieldChange(index, val)}
variant="borderless"
@@ -201,7 +200,6 @@ const ConditionList: FC = ({
placeholder={t('common.pleaseSelect')}
options={options.filter(vo => vo.dataType === 'number')}
allowClear={false}
- popupMatchSelectWidth={false}
variant="borderless"
size="small"
/>
diff --git a/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx b/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx
index 3f215bca..6c30502c 100644
--- a/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx
+++ b/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx
@@ -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 = ({
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 = ({
@@ -168,15 +177,27 @@ const GroupVariableList: FC = ({
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}
/>
diff --git a/web/src/views/Workflow/components/Properties/ListOperator/FilterConditions/index.tsx b/web/src/views/Workflow/components/Properties/ListOperator/FilterConditions/index.tsx
new file mode 100644
index 00000000..11a1d479
--- /dev/null
+++ b/web/src/views/Workflow/components/Properties/ListOperator/FilterConditions/index.tsx
@@ -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 = ({
+ 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 (
+ <>
+
+ {(fields, { add, remove }) => {
+ return (
+ <>
+
+ {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 (
+
+
+ {variableType === 'array[file]' &&
+
+
+
+
+
+
+ }
+
+
+
+
+
+ {!hideValueField && (
+
+
+ {variableType?.includes('boolean')
+ ?
+ : keyFieldValue === 'type'
+ ?
+
+ )}
+
+
+ remove(field.name)}
+ >
+
+ )
+ })}
+
+
+
+ >
+ )
+ }}
+
+ >
+ )
+}
+
+export default FilterConditions
\ No newline at end of file
diff --git a/web/src/views/Workflow/components/Properties/ListOperator/index.tsx b/web/src/views/Workflow/components/Properties/ListOperator/index.tsx
new file mode 100644
index 00000000..8ebdc891
--- /dev/null
+++ b/web/src/views/Workflow/components/Properties/ListOperator/index.tsx
@@ -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 = ({ 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 (
+ <>
+
+ vo.dataType.includes('array') && vo.dataType !== 'array[object]')}
+ size="small"
+ />
+
+
+
+
+
+
+ {values?.filter_by?.enabled &&
+
+ }
+
+
+
+
+
+ {values?.order_by?.enabled &&
+
+ {/* 仅 array[file]有效 */}
+ {variableType === 'array[file]' &&
+
+
+
+
+
+ }
+
+
+ ({ label: t(`workflow.config.list-operator.${key}`), value: key }))}
+ />
+
+
+
+ }
+
+
+
+
+
+ {values?.extract_by?.enabled &&
+
+
+
+ }
+
+
+
+
+
+ {values?.limit?.enabled &&
+
+
+
+ }
+
+
+ >
+ )
+}
+
+export default ListOperator
diff --git a/web/src/views/Workflow/components/Properties/MappingList/index.tsx b/web/src/views/Workflow/components/Properties/MappingList/index.tsx
index d4395736..f46d6114 100644
--- a/web/src/views/Workflow/components/Properties/MappingList/index.tsx
+++ b/web/src/views/Workflow/components/Properties/MappingList/index.tsx
@@ -57,7 +57,6 @@ const MappingList: FC = ({ label, name, options, extra, valueK
diff --git a/web/src/views/Workflow/components/Properties/VariableSelect.tsx b/web/src/views/Workflow/components/Properties/VariableSelect.tsx
index 51101736..01215dc3 100644
--- a/web/src/views/Workflow/components/Properties/VariableSelect.tsx
+++ b/web/src/views/Workflow/components/Properties/VariableSelect.tsx
@@ -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 = ({
placeholder,
options,
@@ -40,109 +33,378 @@ const VariableSelect: FC = ({
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(null);
+ const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0, width: 0 });
+ const [childPanelPos, setChildPanelPos] = useState({ top: 0, right: 0 });
+ const containerRef = useRef(null);
+ const dropdownRef = useRef(null);
+ const itemRefs = useRef