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