feat(web): add list-operator node & support file type variables

This commit is contained in:
zhaoying
2026-04-03 18:52:06 +08:00
parent 1732fc7af5
commit b20a65ce29
27 changed files with 1347 additions and 350 deletions

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>编组 13</title>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="应用管理-工作流-配置-开始" transform="translate(-696, -719)">
<g id="编组-13" transform="translate(696, 719)">
<rect id="矩形" fill="#155EEF" x="0" y="0" width="24" height="24" rx="8"></rect>
<g id="编组-37" transform="translate(6, 6)" stroke="#FFFFFF" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2">
<rect id="矩形" x="0" y="0" width="5" height="5" rx="1"></rect>
<rect id="矩形备份-6" x="0" y="7" width="5" height="5" rx="1"></rect>
<line x1="8.00267756" y1="1" x2="12" y2="1" id="路径-15"></line>
<line x1="8.00267756" y1="4" x2="12" y2="4" id="路径-15备份"></line>
<line x1="8.00267756" y1="8" x2="12" y2="8" id="路径-15"></line>
<line x1="8.00267756" y1="11" x2="12" y2="11" id="路径-15备份"></line>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -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;

View File

@@ -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',

View File

@@ -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: '值',

View File

@@ -26,7 +26,7 @@ interface FileUploadSettingModalProps {
capability?: Capability[];
source?: Application['type']
}
const documentType = {
export const documentType = {
type: 'document',
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/txt.svg')]"></div>,
formats: [
@@ -41,7 +41,7 @@ const documentType = {
"md",
],
}
const imageType = {
export const imageType = {
type: 'image',
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/image.svg')]"></div>,
formats: [
@@ -50,7 +50,7 @@ const imageType = {
"jpeg"
],
}
const audioType = {
export const audioType = {
type: 'audio',
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/audio.svg')]"></div>,
formats: [
@@ -59,7 +59,7 @@ const audioType = {
"m4a",
],
}
const videoType = {
export const videoType = {
type: 'video',
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/video.svg')]"></div>,
formats: [
@@ -68,7 +68,7 @@ const videoType = {
],
}
const defaultValues: FileUpload = {
export const defaultValues: FileUpload = {
enabled: false,
image_enabled: false,
image_max_size_mb: 20,

View File

@@ -92,7 +92,7 @@ const MySharing: React.FC = () => {
label: (
<Flex align="center" gap={12}>
{workspace.target_workspace_icon
? <img src={workspace.target_workspace_icon} className="rb:w-8 rb:h-8 rb:rounded-lg rb:object-cover" />
? <img src={workspace.target_workspace_icon} alt={workspace.target_workspace_icon} className="rb:w-8 rb:h-8 rb:rounded-lg rb:object-cover" />
: <div className="rb:w-8 rb:h-8 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white">
{workspace.target_workspace_name[0]}
</div>

View File

@@ -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<UploadProps, 'onChange'> {
/** Custom file removal callback */
onRemove?: (file: UploadFile) => boolean | void | Promise<boolean | void>;
featureConfig: FeaturesConfigForm['file_upload']
featureConfig: FeaturesConfigForm['file_upload'];
textType?: 'button' | 'text';
block?: boolean;
}
export const transform_file_type: Record<string, string> = {
@@ -149,6 +151,8 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
onRemove: customOnRemove,
requestConfig,
featureConfig,
textType = 'text',
block,
...props
}, ref) => {
const { t } = useTranslation();
@@ -159,11 +163,11 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
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<UploadFilesRef, UploadFilesProps>(({
return Upload.LIST_IGNORE;
}
}
console.log('onChange', isAutoUpload)
if (!isAutoUpload) {
const newFileList = [...fileList, file as UploadFile];
@@ -238,11 +243,11 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
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<UploadFilesRef, UploadFilesProps>(({
<Upload
{...uploadProps}
>
<div>{t('memoryConversation.uploadFile')}</div>
{textType === 'text'
? <div>{t('memoryConversation.uploadFile')}</div>
: <Button disabled={disabled} block={block}>{t('memoryConversation.uploadFile')}</Button>
}
</Upload>
);
});

View File

@@ -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<ChatVariableModalRef, ChatVariableModalProps>(({
refresh
}, ref) => {
const { t } = useTranslation();
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null);
// State management
const [visible, setVisible] = useState(false); // Modal visibility
const [form] = Form.useForm<ChatVariable>(); // Form instance
const [loading, setLoading] = useState(false); // Loading state
const [editIndex, setEditIndex] = useState<number | undefined>(undefined); // Index of variable being edited
const type = Form.useWatch('type', form); // Current selected type
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<ChatVariable>();
const [loading, setLoading] = useState(false);
const [fileList, setFileList] = useState<any[]>([]);
const [editIndex, setEditIndex] = useState<number | undefined>(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 (
<RbModal
@@ -118,7 +214,6 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
layout="vertical"
scrollToFirstError={{ behavior: 'instant', block: 'end', focus: true }}
>
{/* Variable name field */}
<FormItem
name="name"
label={t('workflow.config.parameter-extractor.name')}
@@ -129,8 +224,7 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
>
<Input placeholder={t('common.enter')} />
</FormItem>
{/* Variable type field */}
<FormItem
name="type"
label={t('workflow.config.parameter-extractor.type')}
@@ -138,42 +232,114 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
>
<Select
placeholder={t('common.pleaseSelect')}
onChange={() => form.setFieldValue('defaultValue', undefined)}
onChange={(value) => {
form.setFieldValue('defaultValue', undefined);
setFileList([]);
if (value === 'file' || value === 'array[file]') form.setFieldsValue(defaultFileUploadValues as any);
}}
options={types.map(key => ({
value: key,
label: t(`workflow.config.parameter-extractor.${key}`),
}))}
/>
</FormItem>
{/* Default value field - dynamic based on type */}
<Form.Item
name="defaultValue"
label={t('workflow.config.parameter-extractor.default')}
>
{type === 'number'
? <InputNumber
placeholder={t('common.enter')}
style={{ width: '100%' }}
onChange={(value) => form.setFieldValue('defaultValue', value)}
{type === 'file' || type === 'array[file]' ? (
<>
<UploadFileListModal
ref={uploadFileListModalRef}
featureConfig={featureConfig}
refresh={addFileList}
/>
: type === 'boolean'
? <Select
placeholder={t('common.pleaseSelect')}
options={[
{ value: true, label: 'true' },
{ value: false, label: 'false' }
]}
/>
: <Input placeholder={t('common.enter')} />
}
</Form.Item>
{/* Variable description field */}
<FormItem
name="description"
label={t('workflow.config.parameter-extractor.desc')}
>
<Form.Item name="defaultValue" hidden noStyle />
<Form.Item label={t('workflow.config.parameter-extractor.default')}>
<Row gutter={8}>
<Col span={12}>
<UploadFiles
featureConfig={featureConfig}
onChange={fileChange}
block={true}
textType="button"
disabled={type === 'file' && fileList.length > 0}
/>
</Col>
<Col span={12}>
<Button block
disabled={type === 'file' && fileList.length > 0}
onClick={() => uploadFileListModalRef.current?.handleOpen()}>
{t('memoryConversation.addRemoteFile')}
</Button>
</Col>
</Row>
{previewFileList.length > 0 && (
<Flex gap={8} wrap className="rb:mt-2!">
{previewFileList.map((file) => (
<Spin key={`${file.url || file.uid}_${file.status}`} spinning={file.status === 'uploading'}>
{file.type?.includes('image') ? (
<div className={clsx('rb:inline-block rb:group rb:relative rb:rounded-lg rb:border', {
'rb:border-[#FF5D34]': file.status === 'error',
'rb:border-[#F6F6F6]': file.status !== 'error',
})}>
<img src={file.url} alt={file.name} className="rb:size-12! rb:rounded-lg rb:object-cover" />
<div
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')] rb:hover:bg-[url('@/assets/images/conversation/delete_hover.svg')]"
onClick={() => handleDelete(file)}
/>
</div>
) : (
<Flex
align="center"
gap={10}
className={clsx('rb:w-45 rb:text-[12px] rb:group rb:relative rb:rounded-lg rb:bg-[#F6F6F6] rb:py-2! rb:px-2.5! rb:border', {
'rb:border-[#FF5D34]': file.status === 'error',
'rb:border-[#F6F6F6]': file.status !== 'error',
})}
>
<div className={clsx(
"rb:size-5 rb:bg-cover rb:bg-[url('@/assets/images/conversation/pdf_disabled.svg')]",
file.type?.includes('pdf') ? "rb:bg-[url('@/assets/images/file/pdf.svg')]" :
(file.type?.includes('excel') || file.type?.includes('spreadsheetml')) ? "rb:bg-[url('@/assets/images/file/excel.svg')]" :
file.type?.includes('csv') ? "rb:bg-[url('@/assets/images/file/csv.svg')]" :
file.type?.includes('json') ? "rb:bg-[url('@/assets/images/file/json.svg')]" :
file.type?.includes('ppt') ? "rb:bg-[url('@/assets/images/file/ppt.svg')]" :
file.type?.includes('text') ? "rb:bg-[url('@/assets/images/file/txt.svg')]" :
file.type?.includes('markdown') ? "rb:bg-[url('@/assets/images/file/md.svg')]" :
(file.type?.includes('doc') || file.type?.includes('word')) ? "rb:bg-[url('@/assets/images/file/word.svg')]" : null
)} />
<div className="rb:flex-1 rb:w-32.5">
<div className="rb:leading-4 rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{file.name}</div>
<div className="rb:leading-3.5 rb:mt-0.5 rb:text-[#5B6167] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">
{file.type?.split('/').pop()} · {file.size}
</div>
</div>
<div
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')]"
onClick={() => handleDelete(file)}
/>
</Flex>
)}
</Spin>
))}
</Flex>
)}
</Form.Item>
</>
) : (
<Form.Item name="defaultValue" label={t('workflow.config.parameter-extractor.default')}>
{type === 'number'
? <InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} />
: type === 'boolean'
? <Select
placeholder={t('common.pleaseSelect')}
options={[{ value: true, label: 'true' }, { value: false, label: 'false' }]}
/>
: <Input placeholder={t('common.enter')} />
}
</Form.Item>
)}
<FormItem name="description" label={t('workflow.config.parameter-extractor.desc')}>
<Input.TextArea placeholder={t('common.enter')} />
</FormItem>
</Form>
@@ -181,4 +347,4 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
);
});
export default ChatVariableModal;
export default ChatVariableModal;

View File

@@ -57,6 +57,7 @@ const AddChatVariable = forwardRef<AddChatVariableRef, AddChatVariableProps>(({
title={t('workflow.addvariable')}
open={open}
onClose={() => setOpen(false)}
width={480}
>
<div>
<Button

View File

@@ -149,23 +149,8 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
return
}
setLoading(true)
const message = msg
const files = (toolbarRef.current?.getFiles() || []).filter(item => !['uploading', 'error'].includes(item.status))
setChatList(prev => [...prev, {
role: 'user',
content: message,
created_at: Date.now(),
meta_data: {
files
},
}])
setChatList(prev => [...prev, {
role: 'assistant',
content: '',
created_at: Date.now(),
subContent: [],
}])
/**
* Handles SSE stream messages from workflow execution
@@ -362,6 +347,24 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
}
})
}
setChatList(prev => [
...prev,
{
role: 'user',
content: message,
created_at: Date.now(),
meta_data: {
files
},
},
{
role: 'assistant',
content: '',
created_at: Date.now(),
subContent: [],
}
])
setLoading(true)
setStreamLoading(true)
draftRun(appId, data, handleStreamMessage)
.catch((error) => {

View File

@@ -74,15 +74,16 @@ const Editor: FC<LexicalEditorProps> =({
);
}
// Lexical editor configuration
const initialConfig = {
// Lexical editor configuration — must be stable (never recreated)
const initialConfig = useMemo(() => ({
namespace: 'AutocompleteEditor',
theme,
nodes: [VariableNode],
onError: (error: Error) => {
console.error(error);
},
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}), []);
// Calculate minimum height based on type and size
const minheight = useMemo(() => {
@@ -130,7 +131,7 @@ const Editor: FC<LexicalEditorProps> =({
style={{
minHeight: placeHolderMinheight,
position: 'absolute',
top: variant === 'borderless' ? '0' : '6px',
top: variant === 'borderless' ? '2px' : '6px',
left: variant === 'borderless' ? '0' : '11px',
color: '#A8A9AA',
fontSize: fontSize,

View File

@@ -33,6 +33,7 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({
setSelected(!isSelected);
};
console.log('data', data)
return (
<span
onClick={handleClick}
@@ -44,13 +45,21 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({
>
{data.isContext ? (
<span style={{ fontSize: '12px', marginRight: '4px' }}>📄</span>
) : data.group !== 'CONVERSATION' ? (
<div className={`rb:size-4 rb:mr-1 rb:bg-cover ${data.nodeData?.icon}`} />
) : null}
) : data.group !== 'CONVERSATION' && !data.value.includes('conv') ? (
<span className={`rb:size-4 rb:mr-1 rb:bg-cover rb:inline-block rb:flex-shrink-0 ${data.nodeData?.icon}`} />
) : <span className="rb:inline-block rb:h-4"></span>}
{!data.isContext && data.group !== 'CONVERSATION' && (
<>
<span className="rb:wrap-break-word rb:line-clamp-1">{data.nodeData?.name}</span>
<span style={{ color: '#DFE4ED', margin: '0 2px' }}>/</span>
{!data.value.includes('conv') && <>
<span className="rb:wrap-break-word rb:line-clamp-1">{data.nodeData?.name}</span>
<span style={{ color: '#DFE4ED', margin: '0 2px' }}>/</span>
</>}
{data.parentLabel && (
<>
<span className="rb:wrap-break-word rb:line-clamp-1">{data.parentLabel}</span>
<span style={{ color: '#DFE4ED', margin: '0 2px' }}>/</span>
</>
)}
</>
)}
<span className="rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:flex-1 rb:text-[#171719]">{data.label}</span>
@@ -62,7 +71,7 @@ export class VariableNode extends DecoratorNode<React.JSX.Element> {
__data: Suggestion;
static getType(): string {
return 'tag';
return 'variable';
}
static clone(node: VariableNode): VariableNode {
@@ -100,7 +109,7 @@ export class VariableNode extends DecoratorNode<React.JSX.Element> {
exportJSON(): SerializedVariableNode {
return {
data: this.__data,
type: 'tag',
type: 'variable',
version: 1,
};
}

View File

@@ -4,7 +4,7 @@
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-25 16:13:37
*/
import { useEffect, useState, useRef, type FC } from 'react';
import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getSelection, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical';
import { Space, Flex } from 'antd';
@@ -23,6 +23,8 @@ export interface Suggestion {
nodeData: NodeProperties;
isContext?: boolean; // Flag for context variable
disabled?: boolean; // Flag for disabled state
children?: Suggestion[]; // Sub-variables (e.g. file fields)
parentLabel?: string; // Parent variable label (for child display)
}
// Autocomplete plugin for variable suggestions triggered by '/' character
@@ -30,8 +32,45 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
const [editor] = useLexicalComposerContext();
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 });
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0, anchorBottom: 0 });
const [expandedParent, setExpandedParent] = useState<Suggestion | null>(null);
const [childPanelTop, setChildPanelTop] = useState(0);
const popupRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Map<string, HTMLElement>>(new Map());
// Adjust popup position after render based on actual height
useLayoutEffect(() => {
if (!popupRef.current || !showSuggestions) return;
const { top, anchorBottom } = popupPosition;
const popupHeight = popupRef.current.offsetHeight;
const viewportHeight = window.innerHeight;
const MARGIN = 10;
let finalTop: number;
if (top - popupHeight - MARGIN >= 0) {
// Enough space above: show above cursor
finalTop = top - popupHeight - MARGIN;
} else {
// Not enough space above: show below cursor
finalTop = anchorBottom + MARGIN;
if (finalTop + popupHeight > viewportHeight - MARGIN) {
finalTop = viewportHeight - popupHeight - MARGIN;
}
}
if (finalTop !== top) {
setPopupPosition(prev => ({ ...prev, top: finalTop }));
}
}, [showSuggestions, popupPosition.anchorBottom]);
const CHILD_PANEL_HEIGHT = 280; // max-h-60 (240) + header (~40)
const calcChildPanelTop = (elRect: DOMRect, popupRect: DOMRect) => {
const relativeTop = elRect.top - popupRect.top;
const absoluteBottom = popupRect.top + relativeTop + CHILD_PANEL_HEIGHT;
const overflow = absoluteBottom - (window.innerHeight - 10);
return overflow > 0 ? relativeTop - overflow : relativeTop;
};
const scrollSelectedIntoView = () => {
if (!popupRef.current) return;
@@ -77,6 +116,8 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
setShowSuggestions(shouldShow);
if (!shouldShow) {
setSelectedIndex(0);
setExpandedParent(null);
setChildPanelTop(0);
}
// Calculate popup position to keep it within viewport bounds
@@ -87,28 +128,15 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
const rect = range.getBoundingClientRect();
const popupWidth = 280;
const popupHeight = 200;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = rect.left;
let top = rect.top - 10;
if (left + popupWidth > viewportWidth) {
left = viewportWidth - popupWidth - 10;
}
if (left < 10) {
left = 10;
}
if (left < 10) left = 10;
if (top - popupHeight < 10) {
top = rect.bottom + 10;
if (top + popupHeight > viewportHeight) {
top = viewportHeight - popupHeight - 10;
}
}
setPopupPosition({ top, left });
setPopupPosition({ top: rect.top, left, anchorBottom: rect.bottom });
}
}
});
@@ -121,6 +149,8 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
CLOSE_AUTOCOMPLETE_COMMAND,
() => {
setShowSuggestions(false);
setExpandedParent(null);
setChildPanelTop(0);
return true;
},
COMMAND_PRIORITY_HIGH
@@ -158,6 +188,8 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion });
}
setShowSuggestions(false);
setExpandedParent(null);
setChildPanelTop(0);
};
// Group suggestions by node ID
@@ -171,17 +203,23 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
return groups;
}, {});
// Flat list for keyboard navigation
const flatOptions = Object.values(groupedSuggestions).flat().flatMap(option => {
if (option.key === expandedParent?.key && option.children?.length) {
return [option, ...option.children];
}
return [option];
});
// Handle Enter key to select suggestion
useEffect(() => {
if (!showSuggestions) return;
const allOptions = Object.values(groupedSuggestions).flat();
return editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
if (showSuggestions && allOptions.length > 0) {
const selectedOption = allOptions[selectedIndex];
if (showSuggestions && flatOptions.length > 0) {
const selectedOption = flatOptions[selectedIndex];
if (selectedOption && !selectedOption.disabled) {
event?.preventDefault();
insertMention(selectedOption);
@@ -192,26 +230,24 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
},
COMMAND_PRIORITY_HIGH
);
}, [showSuggestions, selectedIndex, groupedSuggestions, insertMention, editor]);
}, [showSuggestions, selectedIndex, flatOptions, insertMention, editor]);
// Handle keyboard navigation (Arrow Up/Down, Escape)
useEffect(() => {
if (!showSuggestions) return;
const allOptions = Object.values(groupedSuggestions).flat();
// Navigate down through suggestions, skip disabled items
const unregisterArrowDown = editor.registerCommand(
KEY_ARROW_DOWN_COMMAND,
(event) => {
if (showSuggestions && allOptions.length > 0) {
if (showSuggestions && flatOptions.length > 0) {
event?.preventDefault();
setSelectedIndex(prev => {
let nextIndex = prev + 1;
while (nextIndex < allOptions.length && allOptions[nextIndex].disabled) {
while (nextIndex < flatOptions.length && flatOptions[nextIndex].disabled) {
nextIndex++;
}
const newIndex = nextIndex >= allOptions.length ? prev : nextIndex;
const newIndex = nextIndex >= flatOptions.length ? prev : nextIndex;
setTimeout(() => scrollSelectedIntoView(), 0);
return newIndex;
});
@@ -226,11 +262,11 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
const unregisterArrowUp = editor.registerCommand(
KEY_ARROW_UP_COMMAND,
(event) => {
if (showSuggestions && allOptions.length > 0) {
if (showSuggestions && flatOptions.length > 0) {
event?.preventDefault();
setSelectedIndex(prev => {
let prevIndex = prev - 1;
while (prevIndex >= 0 && allOptions[prevIndex].disabled) {
while (prevIndex >= 0 && flatOptions[prevIndex].disabled) {
prevIndex--;
}
const newIndex = prevIndex < 0 ? prev : prevIndex;
@@ -263,7 +299,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
unregisterArrowUp();
unregisterEscape();
};
}, [showSuggestions, selectedIndex, groupedSuggestions, editor]);
}, [showSuggestions, selectedIndex, flatOptions, editor]);
if (!showSuggestions) return null;
@@ -275,12 +311,13 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
ref={popupRef}
data-autocomplete-popup="true"
onMouseDown={(e) => e.preventDefault()}
className="rb:fixed rb:z-1000 rb:py-1 rb:bg-white rb:rounded-xl rb:min-w-70 rb:max-h-50 rb:overflow-y-auto rb:transform-[translateY(-100%)] rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-xl rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
style={{
top: popupPosition.top,
left: popupPosition.left,
}}
>
<div className="rb:py-1 rb:min-w-70 rb:max-h-50 rb:overflow-y-auto">
<Flex vertical gap={12}>
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => {
const nodeName = nodeOptions[0]?.nodeData?.name || nodeId;
@@ -292,31 +329,49 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
{nodeName}
</Flex>
{nodeOptions.map((option) => {
const globalIndex = Object.values(groupedSuggestions).flat().indexOf(option);
const globalIndex = flatOptions.indexOf(option);
const isExpanded = expandedParent?.key === option.key;
const hasChildren = !!option.children?.length;
return (
<Flex
key={option.key}
ref={(el) => { if (el) itemRefs.current.set(option.key, el); }}
data-selected={selectedIndex === globalIndex}
className="rb:pl-6! rb:pr-3! rb:py-2! "
className="rb:pl-6! rb:pr-3! rb:py-2!"
align="center"
justify="space-between"
style={{
cursor: option.disabled ? 'not-allowed' : 'pointer',
background: selectedIndex === globalIndex ? '#f0f8ff' : 'white',
background: (selectedIndex === globalIndex || isExpanded) ? '#f0f8ff' : 'white',
opacity: option.disabled ? 0.5 : 1,
}}
onClick={() => !option.disabled && insertMention(option)}
onMouseEnter={() => setSelectedIndex(globalIndex)}
onClick={() => {
if (option.disabled) return;
insertMention(option);
}}
onMouseEnter={() => {
setSelectedIndex(globalIndex);
if (hasChildren) {
const el = itemRefs.current.get(option.key);
if (el && popupRef.current) {
const elRect = el.getBoundingClientRect();
const popupRect = popupRef.current.getBoundingClientRect();
setChildPanelTop(calcChildPanelTop(elRect, popupRect));
}
setExpandedParent(option);
} else {
setExpandedParent(null);
}
}}
>
<Space size={4}>
<span className="rb:text-[#155EEF]">{option.isContext ? '📄' : `{x}`}</span>
<span>{option.label}</span>
</Space>
{option.dataType && (
<span className="rb:text-[#5B6167]">
{option.dataType}
</span>
)}
<Space size={4}>
{option.dataType && <span className="rb:text-[#5B6167]">{option.dataType}</span>}
{hasChildren && <span className="rb:text-[#5B6167] rb:ml-1"></span>}
</Space>
</Flex>
);
})}
@@ -324,6 +379,49 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
);
})}
</Flex>
</div>
{/* Child variables panel - floats to the left */}
{expandedParent?.children?.length && (
<div
className="rb:absolute rb:bg-white rb:rounded-xl rb:py-1 rb:min-w-60 rb:max-h-60 rb:overflow-y-auto rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
style={{
top: childPanelTop,
right: 'calc(100% + 8px)',
transform: 'translateY(-8px)',
}}
onMouseEnter={() => setExpandedParent(expandedParent)}
>
{/* Header */}
<div className="rb:px-3 rb:py-2 rb:text-[12px] rb:font-medium rb:text-[#5B6167] rb:border-b rb:border-[#F0F0F0]">
<Flex justify="space-between" align="center">
<span>{expandedParent.nodeData.name}.{expandedParent.label}</span>
<span>{expandedParent.dataType}</span>
</Flex>
</div>
{expandedParent.children.map((child) => {
const childIndex = flatOptions.indexOf(child);
return (
<Flex
key={child.key}
data-selected={selectedIndex === childIndex}
className="rb:px-3! rb:py-2!"
align="center"
justify="space-between"
style={{
cursor: child.disabled ? 'not-allowed' : 'pointer',
background: selectedIndex === childIndex ? '#f0f8ff' : 'white',
opacity: child.disabled ? 0.5 : 1,
}}
onClick={() => !child.disabled && insertMention(child)}
onMouseEnter={() => setSelectedIndex(childIndex)}
>
<span>{child.label}</span>
{child.dataType && <span className="rb:text-[#5B6167]">{child.dataType}</span>}
</Flex>
);
})}
</div>
)}
</div>
);
}

View File

@@ -87,9 +87,20 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ 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 {

View File

@@ -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"

View File

@@ -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"
/>

View File

@@ -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"
/>

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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!"
/>

View File

@@ -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

View File

@@ -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);
});
}
});

View File

@@ -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

View File

@@ -313,8 +313,7 @@ export const nodeLibrary: NodeLibrary[] = [
},
{ type: "cycle-start", icon: 'rb:bg-[url("@/assets/images/workflow/start.svg")]'},
{ type: "break", icon: 'rb:bg-[url("@/assets/images/workflow/break.svg")]'},
{
type: "var-aggregator", icon: 'rb:bg-[url("@/assets/images/workflow/aggregator.svg")]',
{ type: "var-aggregator", icon: 'rb:bg-[url("@/assets/images/workflow/aggregator.svg")]',
config: {
group: {
type: 'switch',
@@ -462,6 +461,42 @@ export const nodeLibrary: NodeLibrary[] = [
}
}
},
{ type: "list-operator", icon: 'rb:bg-[url("@/assets/images/workflow/list-operator.svg")]',
config: {
variable: {
type: 'variableList',
},
filter_by: {
type: 'define',
defaultValue: {
enabled: false,
conditions: [{}]
}
},
order_by: {
type: 'define',
defaultValue: {
"enabled": false,
"key": "",
"value": "asc"
}
},
limit: {
type: 'define',
defaultValue: {
"enabled": false,
"size": 1
}
},
extract_by: {
type: 'define',
defaultValue: {
"enabled": false,
"serial": ""
}
},
}
},
]
},
];

View File

@@ -144,7 +144,7 @@ export const useWorkflowGraph = ({
const { id, type, name, position, config = {} } = node
let nodeLibraryConfig: NodeProperties | undefined = [...nodeLibrary, { nodes: [unknownNode, notesConfig] }]
.flatMap(category => category.nodes)
.find(n => n.type === type) as NodeProperties
.find(n => n.type === type) as NodeProperties || unknownNode
nodeLibraryConfig = JSON.parse(JSON.stringify({ ...nodeLibraryConfig, config: nodeLibraryConfig.config || {} }))
if (nodeLibraryConfig?.config) {

View File

@@ -8,10 +8,20 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
server: {
host: '0.0.0.0', // 支持通过IP地址访问
port: 5175,
proxy: {
// 主要API代理支持 /api 和 /api/* 格式
'/api': {
target: 'http://0.0.0.0:5173', // 后端服务地址
// target: 'http://192.168.110.83:8000', // wxy
// target: 'http://192.168.110.86:8000', // lxy
// target: 'http://192.168.110.2:8000', // xjn
// target: 'http://192.168.110.72:8000', // llq
// target: 'http://192.168.110.39:8000', // myh
target: 'https://devmemorybear.redbearai.com/', // 开发后端服务地址
// target: 'https://devcopymemorybear.redbearai.com/', // 开发sass后端服务地址
// target: 'https://testmemorybear.redbearai.com/', // 测试后端服务地址
// target: 'https://memorybear.redbearai.com/', // 预发服务地址
// target: 'https://cloud.memorybear.ai/', // AMAZON 生产地址
changeOrigin: true,
// 匹配所有以/api开头的请求包括/api/token