diff --git a/web/src/assets/images/workflow/list-operator.svg b/web/src/assets/images/workflow/list-operator.svg new file mode 100644 index 00000000..8091c04b --- /dev/null +++ b/web/src/assets/images/workflow/list-operator.svg @@ -0,0 +1,19 @@ + + + 编组 13 + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/components/SiderMenu/index.tsx b/web/src/components/SiderMenu/index.tsx index 21f7fd36..c85f3c9f 100644 --- a/web/src/components/SiderMenu/index.tsx +++ b/web/src/components/SiderMenu/index.tsx @@ -241,7 +241,7 @@ const Menu: FC<{ const findMatchingKey = (menuList: MenuItem[], parentPaths: string[] = []): { key: string | null; } => { for (const menu of menuList) { if (menu.path) { - const menuPath = menu.path[0] !== '/' ? '/' + menu.path : menu.path; + const menuPath = menu.path?.[0] !== '/' ? '/' + menu.path : menu.path; /** Exact match or path prefix match (ensure complete path segment match) */ const isExactMatch = menuPath === currentPath; diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index ac917bae..caaaad89 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -2179,6 +2179,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re unknown: 'Unknown Node', notes: 'Sticky Note', 'document-extractor': 'Document Extractor', + 'list-operator': 'List Operator', clickToConfigure: 'Click to configure node parameters', nodeProperties: 'Node Properties', @@ -2252,6 +2253,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re 'string': 'String', 'number': 'Number', 'boolean': 'Boolean', + 'file': 'File', + 'array[file]': 'Array[File]', 'array[string]': 'Array[String]', 'array[number]': 'Array[Number]', 'array[boolean]': 'Array[Boolean]', @@ -2380,6 +2383,20 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re 'document-extractor': { file_selector: 'Input Variable', }, + 'list-operator': { + variable: 'Input Variable', + filter_by: 'Filter Conditions', + addCondition: 'Add Filter Condition', + order_by: 'Sort', + asc: 'asc', + desc: 'desc', + extract_by: 'Extract Nth Item', + limit: 'Take First N Items', + type: { + eq: 'In', + ne: 'Not In', + } + }, name: 'Key', type: 'Type', value: 'Value', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index f862c64b..50f35813 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -2175,6 +2175,7 @@ export const zh = { unknown: '未知节点', notes: '便签', 'document-extractor': '文档提取器', + 'list-operator': '列表操作', clickToConfigure: '点击配置节点参数', nodeProperties: '节点属性', @@ -2248,6 +2249,8 @@ export const zh = { 'string': 'String', 'number': 'Number', 'boolean': 'Boolean', + 'file': 'File', + 'array[file]': 'Array[File]', 'array[string]': 'Array[String]', 'array[number]': 'Array[Number]', 'array[boolean]': 'Array[Boolean]', @@ -2379,6 +2382,20 @@ export const zh = { 'document-extractor': { file_selector: '输入变量', }, + 'list-operator': { + variable: '输入变量', + filter_by: '过滤条件', + addCondition: '添加过滤条件', + order_by: '排序', + asc: 'asc', + desc: 'desc', + extract_by: '取第 N 项', + limit: '取前 N 项', + type: { + eq: '在', + ne: '不在', + } + }, name: '键', type: '类型', value: '值', diff --git a/web/src/views/ApplicationConfig/TestChat/index.tsx b/web/src/views/ApplicationConfig/TestChat/index.tsx index b3fca33f..70296e4e 100644 --- a/web/src/views/ApplicationConfig/TestChat/index.tsx +++ b/web/src/views/ApplicationConfig/TestChat/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-03-13 17:27:52 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-31 16:04:15 + * @Last Modified time: 2026-04-02 17:58:07 */ import { type FC, useState, useRef, useEffect } from 'react' import { useTranslation } from 'react-i18next' @@ -63,6 +63,12 @@ interface NodeData { state: Record; status?: 'completed' | 'failed'; audio_url?: string; + citations?: { + document_id: string; + file_name: string; + knowledge_id: string; + score: string; + }[] } const TestChat: FC = ({ @@ -111,8 +117,7 @@ const TestChat: FC = ({ } }]) } - - + let initVariables: Variable[] = [] switch (application.type) { @@ -162,7 +167,7 @@ const TestChat: FC = ({ }]) } - const updateAssistantMessage = (content: string, audio_url?: string, audio_status?: string, citations?: any[]) => { + const updateAssistantMessage = (content: string, audio_url?: string, audio_status?: string, citations?: NodeData['citations']) => { setChatList(prev => { const newList = [...prev] const lastMsg = newList[newList.length - 1] @@ -281,12 +286,7 @@ const TestChat: FC = ({ data.map(item => { const { conversation_id, content, message_length, audio_url, citations } = item.data as { conversation_id: string, content: string, message_length: number; audio_url?: string; - citations?: { - document_id: string; - file_name: string; - knowledge_id: string; - score: string; - }[] + citations?: NodeData['citations'] }; switch (item.event) { case 'start': @@ -344,15 +344,15 @@ const TestChat: FC = ({ }) } - const handleWorkflowSend = () => { - if (loading || !application || !message || !message?.trim()) return + const handleWorkflowSend = (msg?: string) => { + if (loading || !application || !((message && message?.trim() !== '') || (msg && msg?.trim() !== ''))) return const files = (toolbarRef.current?.getFiles() || []).filter(item => !['uploading', 'error'].includes(item.status)) const variables = toolbarRef.current?.getVariables() || [] const { isCanSend, params } = buildVariableParams(variables) if (!isCanSend) return setLoading(true) - addUserMessage(message, files) + addUserMessage((msg || message) as string, files) addAssistantMessage() toolbarRef.current?.setFiles([]) setFileList([]) @@ -361,7 +361,7 @@ const TestChat: FC = ({ draftRun( application.id, - formatParams(message, conversationId, files, params), + formatParams((msg || message) as string, conversationId, files, params), handleWorkflowStreamMessage ) .catch((error) => { @@ -383,7 +383,7 @@ const TestChat: FC = ({ const handleWorkflowStreamMessage = (data: SSEMessage[]) => { data.forEach(item => { - const { content, conversation_id } = item.data as NodeData; + const { content, conversation_id, citations } = item.data as NodeData; switch (item.event) { // Append streaming text chunks to assistant message case 'message': @@ -412,6 +412,9 @@ const TestChat: FC = ({ // Mark workflow as complete case 'workflow_end': updateWorkflowEndMessage(item.data as NodeData) + if (citations && citations.length > 0) { + updateWorkflowEndMessage(item.data as NodeData, citations) + } setStreamLoading(false) setLoading(false) break @@ -536,7 +539,7 @@ const TestChat: FC = ({ }) } - const updateWorkflowEndMessage = (data: NodeData) => { + const updateWorkflowEndMessage = (data: NodeData, citations?: NodeData['citations']) => { const { error, status } = data; setChatList(prev => { const newList = [...prev] @@ -547,6 +550,10 @@ const TestChat: FC = ({ status, error, content: newList[lastIndex].content === '' ? null : newList[lastIndex].content, + meta_data: { + ...newList[lastIndex].meta_data || {}, + citations + } } } return newList diff --git a/web/src/views/ApplicationConfig/components/FeaturesConfig/FeaturesConfigModal.tsx b/web/src/views/ApplicationConfig/components/FeaturesConfig/FeaturesConfigModal.tsx index 15a8b4e0..3e1e726a 100644 --- a/web/src/views/ApplicationConfig/components/FeaturesConfig/FeaturesConfigModal.tsx +++ b/web/src/views/ApplicationConfig/components/FeaturesConfig/FeaturesConfigModal.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:27:56 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-27 17:32:10 + * @Last Modified time: 2026-04-02 17:49:51 */ /** * Copy Application Modal @@ -116,24 +116,24 @@ const FeaturesConfigModal = forwardRef + + + {values?.opening_statement?.enabled && (() => { + const statement = values.opening_statement?.statement + return statement && statement.trim() !== '' ? <> + + {statement} + + {t('application.editOpeningStatement')} + > : {t('application.editOpeningStatement')} + })()} + + {source !== 'workflow' && <> - - - {values?.opening_statement?.enabled && (() => { - const statement = values.opening_statement?.statement - return statement && statement.trim() !== '' ? <> - - {statement} - - {t('application.editOpeningStatement')} - > : {t('application.editOpeningStatement')} - })()} - - - - - >} + + + , formats: [ @@ -41,7 +41,7 @@ const documentType = { "md", ], } -const imageType = { +export const imageType = { type: 'image', icon: , formats: [ @@ -50,7 +50,7 @@ const imageType = { "jpeg" ], } -const audioType = { +export const audioType = { type: 'audio', icon: , formats: [ @@ -59,7 +59,7 @@ const audioType = { "m4a", ], } -const videoType = { +export const videoType = { type: 'video', icon: , formats: [ @@ -68,7 +68,7 @@ const videoType = { ], } -const defaultValues: FileUpload = { +export const defaultValues: FileUpload = { enabled: false, image_enabled: false, image_max_size_mb: 20, diff --git a/web/src/views/ApplicationManagement/MySharing.tsx b/web/src/views/ApplicationManagement/MySharing.tsx index 434bf465..e24cce0b 100644 --- a/web/src/views/ApplicationManagement/MySharing.tsx +++ b/web/src/views/ApplicationManagement/MySharing.tsx @@ -92,7 +92,7 @@ const MySharing: React.FC = () => { label: ( {workspace.target_workspace_icon - ? + ? : {workspace.target_workspace_name[0]} diff --git a/web/src/views/Conversation/components/FileUpload.tsx b/web/src/views/Conversation/components/FileUpload.tsx index 3c494437..67fff913 100644 --- a/web/src/views/Conversation/components/FileUpload.tsx +++ b/web/src/views/Conversation/components/FileUpload.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-06 21:09:42 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-27 18:23:04 + * @Last Modified time: 2026-04-02 18:29:48 */ /** * File Upload Component @@ -21,7 +21,7 @@ * @component */ import { useState, useEffect, forwardRef, useImperativeHandle, useMemo } from 'react'; -import { Upload, Progress, App, Flex } from 'antd'; +import { Upload, Progress, App, Flex, Button } from 'antd'; import type { UploadProps, UploadFile } from 'antd'; import type { UploadProps as RcUploadProps, RcFile, UploadFileStatus } from 'antd/es/upload/interface'; import { useTranslation } from 'react-i18next'; @@ -56,7 +56,9 @@ interface UploadFilesProps extends Omit { /** Custom file removal callback */ onRemove?: (file: UploadFile) => boolean | void | Promise; - featureConfig: FeaturesConfigForm['file_upload'] + featureConfig: FeaturesConfigForm['file_upload']; + textType?: 'button' | 'text'; + block?: boolean; } export const transform_file_type: Record = { @@ -149,6 +151,8 @@ const UploadFiles = forwardRef(({ onRemove: customOnRemove, requestConfig, featureConfig, + textType = 'text', + block, ...props }, ref) => { const { t } = useTranslation(); @@ -159,11 +163,11 @@ const UploadFiles = forwardRef(({ const fileType = useMemo(() => { let types: string[] = []; ['image', 'document', 'video', 'audio'].forEach(type => { - if (featureConfig[`${type}_enabled` as keyof FeaturesConfigForm['file_upload']]) { - types = types.concat(featureConfig[`${type}_allowed_extensions` as keyof FeaturesConfigForm['file_upload']] as string[]) + if (featureConfig?.[`${type}_enabled` as keyof FeaturesConfigForm['file_upload']]) { + const exts = featureConfig[`${type}_allowed_extensions` as keyof FeaturesConfigForm['file_upload']] as string[]; + if (Array.isArray(exts)) types = types.concat(exts.filter(Boolean)); } }) - return types }, [featureConfig]) @@ -205,6 +209,7 @@ const UploadFiles = forwardRef(({ return Upload.LIST_IGNORE; } } + console.log('onChange', isAutoUpload) if (!isAutoUpload) { const newFileList = [...fileList, file as UploadFile]; @@ -238,11 +243,11 @@ const UploadFiles = forwardRef(({ request.uploadFile(action, formData, requestConfig) .then(res => { onSuccess?.({ data: res }); + onChange?.({ ...fileVo, status: 'done', response: { data: res } }) }) .catch((error) => { onError?.(error as Error); - fileVo.status = 'error' - onChange?.(fileVo) + onChange?.({ ...fileVo, status: 'error' }) }) }; @@ -327,7 +332,10 @@ const UploadFiles = forwardRef(({ - {t('memoryConversation.uploadFile')} + {textType === 'text' + ? {t('memoryConversation.uploadFile')} + : {t('memoryConversation.uploadFile')} + } ); }); diff --git a/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx b/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx index 15933d4a..18495295 100644 --- a/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx +++ b/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx @@ -2,107 +2,203 @@ * @Author: ZhaoYing * @Date: 2025-12-30 13:59:36 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-28 16:19:26 + * @Last Modified time: 2026-04-02 19:01:12 */ -/** - * ChatVariableModal Component - * - * This component provides a modal for adding or editing chat variables in workflows. - * It supports various variable types and provides appropriate input fields based on the selected type. - */ -import { forwardRef, useImperativeHandle, useState } from 'react'; -import { Form, Input, Select, InputNumber } from 'antd'; +import { forwardRef, useImperativeHandle, useState, useRef, useMemo } from 'react'; +import { Form, Input, Select, InputNumber, Button, Row, Col, Flex, Spin } from 'antd'; +import clsx from 'clsx'; import { useTranslation } from 'react-i18next'; import type { ChatVariableModalRef } from './types' import type { ChatVariable } from '../../types'; import RbModal from '@/components/RbModal' +import { defaultValues as defaultFileUploadValues } from '@/views/ApplicationConfig/components/FeaturesConfig/FileUploadSettingModal' +import UploadFiles from '@/views/Conversation/components/FileUpload' +import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal' +import type { UploadFileListModalRef } from '@/views/Conversation/types' +import { getFileInfoByUrl } from '@/api/fileStorage' +import { transform_file_type } from '@/views/Conversation/components/FileUpload' const FormItem = Form.Item; -/** - * Props for ChatVariableModal component - */ interface ChatVariableModalProps { - /** - * Callback function to refresh variable list - * @param {ChatVariable} value - The variable data - * @param {number} [editIndex] - Optional index for editing existing variable - */ refresh: (value: ChatVariable, editIndex?: number) => void; } -/** - * Supported variable types - */ const types = [ - 'string', // String type - 'number', // Number type - 'boolean', // Boolean type - 'object', // Object type - 'array[string]', // Array of strings - 'array[number]', // Array of numbers - 'array[boolean]', // Array of booleans - 'array[object]', // Array of objects + 'string', + 'number', + 'boolean', + 'object', + 'file', + 'array[file]', + 'array[string]', + 'array[number]', + 'array[boolean]', + 'array[object]', ] -/** - * ChatVariableModal component - */ const ChatVariableModal = forwardRef(({ refresh }, ref) => { const { t } = useTranslation(); + const uploadFileListModalRef = useRef(null); - // State management - const [visible, setVisible] = useState(false); // Modal visibility - const [form] = Form.useForm(); // Form instance - const [loading, setLoading] = useState(false); // Loading state - const [editIndex, setEditIndex] = useState(undefined); // Index of variable being edited - const type = Form.useWatch('type', form); // Current selected type + const [visible, setVisible] = useState(false); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [fileList, setFileList] = useState([]); + const [editIndex, setEditIndex] = useState(undefined); + + const type = Form.useWatch('type', form); + const allowed_transfer_methods = Form.useWatch('allowed_transfer_methods', form); + const image_enabled = Form.useWatch('image_enabled', form); + const audio_enabled = Form.useWatch('audio_enabled', form); + const document_enabled = Form.useWatch('document_enabled', form); + const video_enabled = Form.useWatch('video_enabled', form); + const image_max_size_mb = Form.useWatch('image_max_size_mb', form); + const audio_max_size_mb = Form.useWatch('audio_max_size_mb', form); + const document_max_size_mb = Form.useWatch('document_max_size_mb', form); + const video_max_size_mb = Form.useWatch('video_max_size_mb', form); + const image_allowed_extensions = Form.useWatch('image_allowed_extensions', form); + const audio_allowed_extensions = Form.useWatch('audio_allowed_extensions', form); + const document_allowed_extensions = Form.useWatch('document_allowed_extensions', form); + const video_allowed_extensions = Form.useWatch('video_allowed_extensions', form); + const max_file_count = Form.useWatch('max_file_count', form); + + const hasEnabledFileType = !!(image_enabled || audio_enabled || document_enabled || video_enabled); + + const featureConfig = useMemo(() => ({ + enabled: hasEnabledFileType, + allowed_transfer_methods, + max_file_count, + image_enabled, image_max_size_mb, image_allowed_extensions, + audio_enabled, audio_max_size_mb, audio_allowed_extensions, + document_enabled, document_max_size_mb, document_allowed_extensions, + video_enabled, video_max_size_mb, video_allowed_extensions, + }), [ + hasEnabledFileType, allowed_transfer_methods, max_file_count, + image_enabled, image_max_size_mb, image_allowed_extensions, + audio_enabled, audio_max_size_mb, audio_allowed_extensions, + document_enabled, document_max_size_mb, document_allowed_extensions, + video_enabled, video_max_size_mb, video_allowed_extensions, + ]); - /** - * Handle modal close - */ const handleClose = () => { + setFileList([]); setVisible(false); form.resetFields(); setLoading(false); setEditIndex(undefined); }; - /** - * Handle modal open - */ const handleOpen = (variable?: ChatVariable, index?: number) => { setVisible(true); if (variable) { - // Exclude 'default' property and set form values const { default: _, ...rest } = variable; form.setFieldsValue({ ...rest }); setEditIndex(index); + if (variable.type === 'file' || variable.type === 'array[file]') { + const defaultVal = variable.defaultValue; + if (defaultVal) { + const list = Array.isArray(defaultVal) ? defaultVal : [defaultVal]; + setFileList(list); + } + } } else { - // Reset form for new variable form.resetFields(); setEditIndex(undefined); } }; - /** - * Handle save/submit action - */ const handleSave = () => { form.validateFields().then((values) => { - // Create variable with 'default' property mapped from 'defaultValue' refresh({ ...values, default: values.defaultValue }, editIndex); handleClose(); }); }; - // Expose handleOpen method to parent component via ref - useImperativeHandle(ref, () => ({ - handleOpen - })); + useImperativeHandle(ref, () => ({ handleOpen })); + + const setFormFileValue = (updated: any[]) => { + const isSingle = form.getFieldValue('type') === 'file'; + form.setFieldValue('defaultValue', isSingle ? (updated[0] ?? null) : updated); + }; + + const fileChange = (file?: any) => { + const fileObj = file ? { + ...file, + type: file.type, + transfer_method: "local_file", + upload_file_id: file.response?.data?.file_id, + } : undefined + if (form.getFieldValue('type') === 'file') { + const updated = [fileObj]; + setFileList(updated); + setTimeout(() => setFormFileValue(updated), 0); + return; + } + setFileList(prev => { + const index = prev.findIndex((item: any) => item.uid === fileObj.uid); + const updated = index > -1 + ? prev.map((item, i) => i === index ? fileObj : item) + : [...prev, fileObj]; + setTimeout(() => setFormFileValue(updated), 0); + return updated; + }); + }; + + const addFileList = (list?: any[]) => { + if (!list?.length) return; + const uploadingList = list.map(f => ({ ...f, status: 'uploading' })); + setFileList(prev => { + const isSingle = form.getFieldValue('type') === 'file'; + const updated = isSingle ? [uploadingList[0]] : [...prev, ...uploadingList]; + setTimeout(() => setFormFileValue(updated), 0); + return updated; + }); + const isSingle = form.getFieldValue('type') === 'file'; + (isSingle ? [uploadingList[0]] : uploadingList).forEach(file => { + getFileInfoByUrl(file.url) + .then((res) => { + const { file_name, file_size, content_type } = res as { file_name: string; file_size: number; content_type: string }; + setFileList(prev => { + const updated = prev.map(f => + f.uid === file.uid + ? { ...f, status: 'done', name: file_name, size: file_size, type: transform_file_type[content_type] || content_type } + : f + ); + setFormFileValue(updated); + return updated; + }); + }) + .catch(() => { + setFileList(prev => { + const updated = prev.map(f => f.uid === file.uid ? { ...f, status: 'error' } : f); + setFormFileValue(updated); + return updated; + }); + }); + }); + }; + + + const previewFileList = useMemo(() => { + return fileList.map(file => ({ + ...file, + url: file.thumbUrl || file.url || (file.originFileObj ? URL.createObjectURL(file.originFileObj) : undefined) + })); + }, [fileList]); + + const handleDelete = (file: any) => { + const updated = fileList.filter(item => + item.thumbUrl && file.thumbUrl ? item.thumbUrl !== file.thumbUrl + : item.url && file.url ? item.url !== file.url + : item.uid !== file.uid + ); + setFileList(updated); + setFormFileValue(updated); + }; return ( - {/* Variable name field */} - - {/* Variable type field */} + 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}`), }))} /> - - {/* Default value field - dynamic based on type */} - - {type === 'number' - ? form.setFieldValue('defaultValue', value)} + + {type === 'file' || type === 'array[file]' ? ( + <> + - : type === 'boolean' - ? - : - } - - - {/* Variable description field */} - + + + + + + 0} + /> + + + 0} + onClick={() => uploadFileListModalRef.current?.handleOpen()}> + {t('memoryConversation.addRemoteFile')} + + + + {previewFileList.length > 0 && ( + + {previewFileList.map((file) => ( + + {file.type?.includes('image') ? ( + + + handleDelete(file)} + /> + + ) : ( + + + + {file.name} + + {file.type?.split('/').pop()} · {file.size} + + + handleDelete(file)} + /> + + )} + + ))} + + )} + + > + ) : ( + + {type === 'number' + ? + : type === 'boolean' + ? + : + } + + )} + + @@ -181,4 +347,4 @@ const ChatVariableModal = forwardRef(({ title={t('workflow.addvariable')} open={open} onClose={() => setOpen(false)} + width={480} > { setOpen(true) + + if (features?.opening_statement?.statement && features?.opening_statement?.statement.trim() !== '') { + setChatList(prev => [...prev, { + role: 'assistant', + created_at: Date.now(), + content: features?.opening_statement?.statement, + meta_data: { + suggested_questions: features?.opening_statement?.suggested_questions || [] + } + }]) + } } useEffect(() => { @@ -149,23 +160,8 @@ const Chat = forwardRef !['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 @@ -179,7 +175,7 @@ const Chat = forwardRef { data.forEach(item => { - const { content, conversation_id, node_id, cycle_id, cycle_idx, input, output, error, elapsed_time, status } = item.data as { + const { content, conversation_id, node_id, cycle_id, cycle_idx, input, output, error, elapsed_time, status, citations } = item.data as { content: string; conversation_id: string | null; cycle_id: string; @@ -192,7 +188,13 @@ const Chat = forwardRef; - status?: 'completed' | 'failed' + status?: 'completed' | 'failed', + citations?: { + document_id: string; + file_name: string; + knowledge_id: string; + score: string; + }[] }; const node = graphRef.current?.getNodes().find(n => n.id === node_id); @@ -327,6 +329,10 @@ const Chat = forwardRef [ + ...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) => { @@ -418,6 +442,7 @@ const Chat = forwardRef { return }} + onSend={handleSend} /> =({ ); } - // 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(() => { @@ -133,7 +134,7 @@ const Editor: FC =({ style={{ minHeight: placeHolderMinheight, position: 'absolute', - top: variant === 'borderless' ? '0' : '6px', + top: variant === 'borderless' ? '2px' : '6px', left: variant === 'borderless' ? '0' : '11px', color: '#A8A9AA', fontSize: fontSize, diff --git a/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx b/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx index 1a1a09c4..2763bec7 100644 --- a/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx +++ b/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx @@ -33,6 +33,7 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({ setSelected(!isSelected); }; + console.log('data', data) return ( = ({ > {data.isContext ? ( 📄 - ) : data.group !== 'CONVERSATION' ? ( - - ) : null} + ) : data.group !== 'CONVERSATION' && !data.value.includes('conv') ? ( + + ) : } {!data.isContext && data.group !== 'CONVERSATION' && ( <> - {data.nodeData?.name} - / + {!data.value.includes('conv') && <> + {data.nodeData?.name} + / + >} + {data.parentLabel && ( + <> + {data.parentLabel} + / + > + )} > )} {data.label} @@ -62,7 +71,7 @@ export class VariableNode extends DecoratorNode { __data: Suggestion; static getType(): string { - return 'tag'; + return 'variable'; } static clone(node: VariableNode): VariableNode { @@ -100,7 +109,7 @@ export class VariableNode extends DecoratorNode { exportJSON(): SerializedVariableNode { return { data: this.__data, - type: 'tag', + type: 'variable', version: 1, }; } diff --git a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx index ea1efe2c..f49ab17a 100644 --- a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx @@ -4,7 +4,7 @@ * @Last Modified by: ZhaoYing * @Last Modified time: 2026-04-02 17:12:41 */ -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, 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[] }> = ({ options }) => { 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(null); + const [childPanelTop, setChildPanelTop] = useState(0); const popupRef = useRef(null); + const itemRefs = useRef>(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[] }> = ({ options }) => { 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[] }> = ({ options }) => { 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[] }> = ({ options }) => { CLOSE_AUTOCOMPLETE_COMMAND, () => { setShowSuggestions(false); + setExpandedParent(null); + setChildPanelTop(0); return true; }, COMMAND_PRIORITY_HIGH @@ -131,6 +161,8 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { const insertMention = (suggestion: Suggestion) => { editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion }); setShowSuggestions(false); + setExpandedParent(null); + setChildPanelTop(0); }; // Group suggestions by node ID @@ -144,17 +176,23 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { 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); @@ -165,26 +203,24 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { }, 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; }); @@ -199,11 +235,11 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { 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; @@ -236,7 +272,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { unregisterArrowUp(); unregisterEscape(); }; - }, [showSuggestions, selectedIndex, groupedSuggestions, editor]); + }, [showSuggestions, selectedIndex, flatOptions, editor]); if (!showSuggestions) return null; @@ -248,12 +284,13 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { 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, }} > + {Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => { const nodeName = nodeOptions[0]?.nodeData?.name || nodeId; @@ -265,31 +302,49 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { {nodeName} {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 ( { 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); + } + }} > {option.isContext ? '📄' : `{x}`} {option.label} - {option.dataType && ( - - {option.dataType} - - )} + + {option.dataType && {option.dataType}} + {hasChildren && ›} + ); })} @@ -297,6 +352,49 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { ); })} + + {/* Child variables panel - floats to the left */} + {expandedParent?.children?.length && ( + setExpandedParent(expandedParent)} + > + {/* Header */} + + + {expandedParent.nodeData.name}.{expandedParent.label} + {expandedParent.dataType} + + + {expandedParent.children.map((child) => { + const childIndex = flatOptions.indexOf(child); + return ( + !child.disabled && insertMention(child)} + onMouseEnter={() => setSelectedIndex(childIndex)} + > + {child.label} + {child.dataType && {child.dataType}} + + ); + })} + + )} ); } diff --git a/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx index 4186e80c..9a3eaa94 100644 --- a/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx @@ -77,9 +77,20 @@ const InitialValuePlugin: React.FC = ({ value, options if (conversationMatch) { const [_, variableName] = conversationMatch; - const conversationSuggestion = optionsRef.current.find(s => + const fullValue = `conv.${variableName}`; + // First try direct match on top-level label + let conversationSuggestion = optionsRef.current.find(s => s.group === 'CONVERSATION' && s.label === variableName ); + // Then search children by value (e.g. conv.api_key.url) + if (!conversationSuggestion) { + for (const s of optionsRef.current) { + if (s.group === 'CONVERSATION' && s.children) { + const child = s.children.find(c => c.value === fullValue); + if (child) { conversationSuggestion = child; break; } + } + } + } if (conversationSuggestion) { paragraph.append($createVariableNode(conversationSuggestion)); } else { diff --git a/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx b/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx index d6ccae23..4fe38714 100644 --- a/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx +++ b/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx @@ -75,7 +75,6 @@ const AssignmentList: FC = ({ vo.nodeData.type === 'loop' || vo.value.includes('conv.') || (vo.nodeData.type === 'iteration' && (vo.label === 'item' || vo.label === 'index')))} - popupMatchSelectWidth={false} onChange={() => { form.setFieldValue([parentName, name, 'operation'], undefined); form.setFieldValue([parentName, name, 'value'], undefined); @@ -121,7 +120,6 @@ const AssignmentList: FC = ({ ? vo.dataType === dataType) : options} - popupMatchSelectWidth={false} size={size} variant="borderless" className="select" @@ -153,7 +151,6 @@ const AssignmentList: FC = ({ : vo.dataType === dataType) : options} - popupMatchSelectWidth={false} size={size} variant="borderless" className="select" diff --git a/web/src/views/Workflow/components/Properties/CaseList/index.tsx b/web/src/views/Workflow/components/Properties/CaseList/index.tsx index 12ca38d5..88eace06 100644 --- a/web/src/views/Workflow/components/Properties/CaseList/index.tsx +++ b/web/src/views/Workflow/components/Properties/CaseList/index.tsx @@ -281,7 +281,6 @@ const CaseList: FC = ({ options={options} size="small" allowClear={false} - popupMatchSelectWidth={false} onChange={(val) => handleLeftFieldChange(caseIndex, conditionIndex, val)} variant="borderless" className="rb:w-36!" @@ -326,7 +325,6 @@ const CaseList: FC = ({ placeholder={t('common.pleaseSelect')} options={options.filter(vo => vo.dataType === 'number')} allowClear={false} - popupMatchSelectWidth={false} variant="borderless" size="small" /> diff --git a/web/src/views/Workflow/components/Properties/ConditionList/index.tsx b/web/src/views/Workflow/components/Properties/ConditionList/index.tsx index 84beb561..d484da09 100644 --- a/web/src/views/Workflow/components/Properties/ConditionList/index.tsx +++ b/web/src/views/Workflow/components/Properties/ConditionList/index.tsx @@ -153,7 +153,6 @@ const ConditionList: FC = ({ )} size="small" allowClear={false} - popupMatchSelectWidth={false} placeholder={t('common.pleaseSelect')} onChange={(val) => handleLeftFieldChange(index, val)} variant="borderless" @@ -201,7 +200,6 @@ const ConditionList: FC = ({ placeholder={t('common.pleaseSelect')} options={options.filter(vo => vo.dataType === 'number')} allowClear={false} - popupMatchSelectWidth={false} variant="borderless" size="small" /> diff --git a/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx b/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx index 3f215bca..6c30502c 100644 --- a/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx +++ b/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:17:39 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-03 10:54:15 + * @Last Modified time: 2026-04-03 18:39:07 */ import { useEffect, type FC } from 'react' import { useTranslation } from 'react-i18next'; @@ -87,9 +87,18 @@ const GroupVariableList: FC = ({ let filteredOptions = options; if (value.length > 0) { const firstVariableValue = value[0]; - const firstVariable = options.find(opt => `{{${opt.value}}}` === firstVariableValue); + const allSuggestions = options.flatMap(opt => opt.children ? [opt, ...opt.children] : [opt]); + const firstVariable = allSuggestions.find(opt => `{{${opt.value}}}` === firstVariableValue); if (firstVariable) { - filteredOptions = options.filter(opt => opt.dataType === firstVariable.dataType); + filteredOptions = options.flatMap(opt => { + if (opt.dataType === 'file' && opt.children?.length) { + return [{ ...opt, children: opt.children.map(c => ({ ...c, disabled: c.dataType !== firstVariable.dataType })) }]; + } + if (opt.dataType === firstVariable.dataType && !opt.children?.length) return [opt]; + const filteredChildren = opt.children?.filter(c => c.dataType === firstVariable.dataType); + if (filteredChildren?.length) return [{ ...opt, children: filteredChildren }]; + return []; + }); } } @@ -106,7 +115,7 @@ const GroupVariableList: FC = ({ @@ -168,15 +177,27 @@ const GroupVariableList: FC = ({ const currentGroupValue = value[name]?.value || []; if (currentGroupValue.length > 0) { const firstVariableValue = currentGroupValue[0]; - const firstVariable = options.find(opt => `{{${opt.value}}}` === firstVariableValue); + const allSuggestions = options.flatMap(opt => opt.children ? [opt, ...opt.children] : [opt]); + const firstVariable = allSuggestions.find(opt => `{{${opt.value}}}` === firstVariableValue); + if (firstVariable) { - return options.filter(opt => opt.dataType === firstVariable.dataType); + return options.flatMap(vo => { + if (vo.dataType === 'file' && vo.children?.length) { + return [{ ...vo, children: vo.children.map(c => ({ ...c, disabled: c.dataType !== firstVariable.dataType })) }]; + } + if (vo.dataType === firstVariable.dataType && (!vo.children || vo.children.length < 1)) return [vo]; + const filteredChildren = vo.children?.filter(sub => sub.dataType === firstVariable.dataType); + if (filteredChildren?.length) return [{ ...vo, children: filteredChildren }]; + return []; + }); } + return [] } return options; })() } - mode="multiple" + + multiple={true} size={size} /> diff --git a/web/src/views/Workflow/components/Properties/ListOperator/FilterConditions/index.tsx b/web/src/views/Workflow/components/Properties/ListOperator/FilterConditions/index.tsx new file mode 100644 index 00000000..11a1d479 --- /dev/null +++ b/web/src/views/Workflow/components/Properties/ListOperator/FilterConditions/index.tsx @@ -0,0 +1,197 @@ +import { type FC } from 'react' +import clsx from 'clsx' +import { useTranslation } from 'react-i18next'; +import { Form, Button, Select, type SelectProps, Flex, Row, Col } from 'antd' + +import type { Suggestion } from '../../../Editor/plugin/AutocompletePlugin' +import RadioGroupBtn from '../../RadioGroupBtn' +import { fileSubVariable } from '../../hooks/useVariableList' +import Editor from '../../../Editor' + +interface Case { + filter_by: Array<{ + key: string; + comparison_operator: string; + value: string + }> +} + +interface FilterConditionsProps { + value?: Case; + onChange?: (value: Case) => void; + options: Suggestion[]; + parentName: string; + variableType?: string; +} +const operatorsObj: { [key: string]: SelectProps['options'] } = { + default: [ + { value: 'empty', label: 'workflow.config.if-else.empty' }, + { value: 'not_empty', label: 'workflow.config.if-else.not_empty' }, + { value: 'contains', label: 'workflow.config.if-else.contains' }, + { value: 'not_contains', label: 'workflow.config.if-else.not_contains' }, + { value: 'startwith', label: 'workflow.config.if-else.startwith' }, + { value: 'endwith', label: 'workflow.config.if-else.endwith' }, + { value: 'eq', label: 'workflow.config.if-else.eq' }, + { value: 'ne', label: 'workflow.config.if-else.ne' }, + ], + number: [ + { value: 'eq', label: 'workflow.config.if-else.num.eq' }, + { value: 'ne', label: 'workflow.config.if-else.num.ne' }, + { value: 'lt', label: 'workflow.config.if-else.num.lt' }, + { value: 'le', label: 'workflow.config.if-else.num.le' }, + { value: 'gt', label: 'workflow.config.if-else.num.gt' }, + { value: 'ge', label: 'workflow.config.if-else.num.ge' }, + { value: 'empty', label: 'workflow.config.if-else.empty' }, + { value: 'not_empty', label: 'workflow.config.if-else.not_empty' }, + ], + boolean: [ + { value: 'eq', label: 'workflow.config.if-else.boolean.eq' }, + { value: 'ne', label: 'workflow.config.if-else.boolean.ne' }, + { value: 'empty', label: 'workflow.config.if-else.empty' }, + { value: 'not_empty', label: 'workflow.config.if-else.not_empty' }, + ], + type: [ + { value: 'eq', label: 'workflow.config.list-operator.type.eq' }, + { value: 'ne', label: 'workflow.config.list-operator.type.ne' }, + ] +} + +const typeOptions = ['image', 'document', 'video', 'audio'] + +const FilterConditions: FC = ({ + options, + parentName, + variableType, +}) => { + const { t } = useTranslation(); + const form = Form.useFormInstance(); + + const handleKeyFieldChange = (index: number, newValue: string) => { + form.setFieldValue(['filter_by', index], { + key: newValue, + comparison_operator: undefined, + value: undefined, + value_type: undefined, + }); + }; + + return ( + <> + + {(fields, { add, remove }) => { + return ( + <> + + {fields.map((field, index) => { + const filter_by = form.getFieldValue(['filter_by']) || []; + const currentCondition = filter_by[index] || {}; + const currentOperator = currentCondition.comparison_operator; + const hideValueField = currentOperator === 'empty' || currentOperator === 'not_empty'; + const keyFieldValue = currentCondition.key; + const keyFieldOption = fileSubVariable.find(option => option.filed === keyFieldValue); + const keyFieldType = keyFieldOption?.dataType; + const operatorList = operatorsObj[keyFieldValue === 'type' ? 'type' : keyFieldType || 'default'] || operatorsObj.default || []; + + return ( + + + {variableType === 'array[file]' && + + + + handleKeyFieldChange(index, value)} + variant="borderless" + className="rb:w-full!" + /> + + + + } + + + + ({ + ...vo, + label: t(String(vo?.label || '')) + }))} + size="small" + popupMatchSelectWidth={false} + placeholder={t('common.pleaseSelect')} + variant="borderless" + className="rb:w-full!" + /> + + + {!hideValueField && ( + + + {variableType?.includes('boolean') + ? + : keyFieldValue === 'type' + ? ({ value: vo, label: t(`application.${vo}`) } ))} + variant="borderless" + className="rb:w-full!" + /> + : { + 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')} + /> + } + + + )} + + + remove(field.name)} + > + + ) + })} + + + add({})} + className="rb:text-[12px]!" + > + + {t('workflow.config.list-operator.addCondition')} + + > + ) + }} + + > + ) +} + +export default FilterConditions \ No newline at end of file diff --git a/web/src/views/Workflow/components/Properties/ListOperator/index.tsx b/web/src/views/Workflow/components/Properties/ListOperator/index.tsx new file mode 100644 index 00000000..8ebdc891 --- /dev/null +++ b/web/src/views/Workflow/components/Properties/ListOperator/index.tsx @@ -0,0 +1,107 @@ +import { type FC } from 'react' +import { useTranslation } from 'react-i18next' +import { Form, Switch, Select, Row, Col, Divider, InputNumber } from 'antd' +import { Node } from '@antv/x6' + +import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin' +import VariableSelect from '../VariableSelect' +import { fileSubVariable } from '../hooks/useVariableList' +import FilterConditions from './FilterConditions' +import RadioGroupBtn from '../RadioGroupBtn' +import RbSlider from '@/components/RbSlider' + + +interface ListOperatorProps { + options: Suggestion[] + selectedNode: Node +} + +const ListOperator: FC = ({ options }) => { + const { t } = useTranslation() + const form = Form.useFormInstance() + const values = Form.useWatch([], form) || {} + const variableOption = options.find(option => `{{${option.value}}}` === values?.variable) + const variableType = variableOption?.dataType + + return ( + <> + + vo.dataType.includes('array') && vo.dataType !== 'array[object]')} + size="small" + /> + + + + + + + {values?.filter_by?.enabled && + + } + + + + + + {values?.order_by?.enabled && + + {/* 仅 array[file]有效 */} + {variableType === 'array[file]' && + + + + + + } + + + ({ label: t(`workflow.config.list-operator.${key}`), value: key }))} + /> + + + + } + + + + + + {values?.extract_by?.enabled && + + + + } + + + + + + {values?.limit?.enabled && + + + + } + + + > + ) +} + +export default ListOperator diff --git a/web/src/views/Workflow/components/Properties/MappingList/index.tsx b/web/src/views/Workflow/components/Properties/MappingList/index.tsx index d4395736..f46d6114 100644 --- a/web/src/views/Workflow/components/Properties/MappingList/index.tsx +++ b/web/src/views/Workflow/components/Properties/MappingList/index.tsx @@ -57,7 +57,6 @@ const MappingList: FC = ({ label, name, options, extra, valueK diff --git a/web/src/views/Workflow/components/Properties/VariableSelect.tsx b/web/src/views/Workflow/components/Properties/VariableSelect.tsx index 51101736..01215dc3 100644 --- a/web/src/views/Workflow/components/Properties/VariableSelect.tsx +++ b/web/src/views/Workflow/components/Properties/VariableSelect.tsx @@ -2,36 +2,29 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:40:13 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-25 16:54:44 + * @Last Modified time: 2026-04-03 18:51:17 */ -import { type FC } from 'react' +import { useState, useRef, useEffect, useLayoutEffect, type FC } from 'react' +import { createPortal } from 'react-dom' import clsx from 'clsx'; -import { Select, type SelectProps, Flex, Space } from 'antd' +import { Flex, Space, Checkbox } from 'antd' +import { useTranslation } from 'react-i18next'; import type { Suggestion } from '../Editor/plugin/AutocompletePlugin' -type LabelRender = SelectProps['labelRender']; -/** - * Props for VariableSelect component - */ -interface VariableSelectProps extends SelectProps { - /** Available variable options */ +interface VariableSelectProps { options: Suggestion[]; - /** Current selected value */ - value?: string; - /** Whether to show clear button */ + value?: string | string[]; allowClear?: boolean; - /** Filter out boolean type variables */ filterBooleanType?: boolean; - /** Size of the select component */ - size?: 'small' | 'middle' | 'large' + multiple?: boolean; + size?: 'small' | 'middle' | 'large'; + placeholder?: string; + variant?: 'outlined' | 'borderless'; + className?: string; + onChange?: (value: string | string[], option: Suggestion | Suggestion[] | undefined) => void; } -/** - * VariableSelect component - * Custom select component for workflow variables with grouped options and custom rendering - * @param props - Component props - */ const VariableSelect: FC = ({ placeholder, options, @@ -40,109 +33,378 @@ const VariableSelect: FC = ({ onChange, size = 'middle', filterBooleanType = false, - mode, - ...resetPorps + multiple = false, + variant = 'outlined', + className, }) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const [expandedParent, setExpandedParent] = useState(null); + const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0, width: 0 }); + const [childPanelPos, setChildPanelPos] = useState({ top: 0, right: 0 }); + const containerRef = useRef(null); + const dropdownRef = useRef(null); + const itemRefs = useRef>(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 ( - - {filterOption.nodeData?.icon && filterOption.nodeData?.name && ( - <> - - {filterOption.nodeData.name} - / - > - )} - {filterOption.label} - - ) + // 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, 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, 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: - {suggestions[0].nodeData.icon && } - {suggestions[0].nodeData.name} - , - options: suggestions.map(s => ({ - label: - - {`{x}`} - {s.label} - - {s.dataType} - , - value: `{{${s.value}}}` - })) - })); - + const filteredGroups = search + ? Object.entries(groupedSuggestions).reduce((acc: Record, [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 = /; + 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 ( - { - if (input === '/') return true; - const value = 'value' in option! ? option.value as string : ''; - return value.toLowerCase().includes(input.toLowerCase()); - }} - /> - ) -} + + {/* Trigger */} + setOpen(o => !o)} + > + {multiple ? ( + selectedValues.length > 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 ( + + {!isConv && nd?.icon && } + {!isConv && nd?.name && {nd.name}{sep}} + + {parent ? <>{parent.label}{sep}{s.label}> : s.label} + + { e.stopPropagation(); handleSelect(s); }} + >✕ + + ); + })} + + ) : ( + {placeholder} + ) + ) : selectedSuggestion ? ( + + + {!isConversation && nodeData?.icon && } + {!isConversation && nodeData?.name && {nodeData.name}{sep}} + + {parentOfSelected ? <>{parentOfSelected.label}{sep}{selectedSuggestion.label}> : selectedSuggestion.label} + + + + ) : ( + {placeholder} + )} + + {allowClear && ( + 0 : !!selectedSuggestion) ? 'rb:opacity-100 rb:cursor-pointer' : 'rb:opacity-0 rb:pointer-events-none' + )} + onClick={handleClear} + >✕ + )} + + + + + {/* Dropdown via portal */} + {open && createPortal( + + + {Object.entries(filteredGroups).map(([nodeId, suggestions]) => { + const nd = suggestions[0].nodeData; + return ( + + + {nd.icon && } + {nd.name} + + {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 ( + { 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); + } + }} + > + + {multiple && ( + + )} + {`{x}`} + {s.label} + + + {s.dataType && {s.dataType}} + + {hasChildren && } + + + ); + })} + + ); + })} + {Object.keys(filteredGroups).length === 0 && ( + + {t('workflow.variableSelect.empty', '暂无变量')} + + )} + + , + document.body + )} + + {/* Child panel via portal — escapes overflow clipping */} + {open && expandedParent?.children?.length && createPortal( + setExpandedParent(expandedParent)} + > + !expandedParent.disabled && handleSelect(expandedParent)} + > + + + {expandedParent.nodeData.name}.{expandedParent.label} + + {expandedParent.dataType} + + + {expandedParent.children.map(child => { + const isSelected = multiple + ? selectedValues.includes(`{{${child.value}}}`) + : `{{${child.value}}}` === value; + const hasGrandChildren = !!child.children?.length; + return ( + !child.disabled && handleSelect(child)} + > + + {multiple && ( + + )} + {child.label} + + + {child.dataType && {child.dataType}} + {hasGrandChildren && ›} + + + ); + })} + , + document.body + )} + + ); +}; export default VariableSelect diff --git a/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts b/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts index dbda2759..0c09136d 100644 --- a/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts +++ b/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts @@ -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} [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, @@ -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(); + const list: Suggestion[] = [...upstreamVariables]; + const keys = new Set(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); }); } }); diff --git a/web/src/views/Workflow/components/Properties/index.tsx b/web/src/views/Workflow/components/Properties/index.tsx index e38331db..b2a82318 100644 --- a/web/src/views/Workflow/components/Properties/index.tsx +++ b/web/src/views/Workflow/components/Properties/index.tsx @@ -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 = ({ */ 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 = ({ - {selectedNode?.data?.type === 'unknown' + {selectedNode?.data?.type === 'list-operator' + ? + : selectedNode?.data?.type === 'unknown' ? <> 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) { diff --git a/web/vite.config.ts b/web/vite.config.ts index 88b3cd75..e5eee0c0 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -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