From b79fe07052c8065ba3c04d6db627e5a6d8732c85 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Sat, 28 Feb 2026 18:01:00 +0800 Subject: [PATCH] feat(web): workflow import & export --- web/src/api/application.ts | 16 +- web/src/i18n/en.ts | 12 +- web/src/i18n/zh.ts | 13 +- .../components/ConfigHeader.tsx | 38 +- web/src/views/ApplicationConfig/types.ts | 5 +- .../components/UploadWorkflowModal.tsx | 348 +++++++++++------- web/src/views/ApplicationManagement/index.tsx | 10 +- web/src/views/ApplicationManagement/types.ts | 60 ++- .../AddChatVariable/ChatVariableModal.tsx | 93 +++-- .../views/Workflow/components/Chat/Chat.tsx | 10 +- .../Workflow/components/Chat/Runtime.tsx | 10 +- .../Workflow/components/Chat/chat.module.css | 5 +- .../components/Properties/CaseList/index.tsx | 10 +- .../Properties/ConditionList/index.tsx | 7 +- .../Properties/hooks/useVariableList.ts | 129 ++++++- web/src/views/Workflow/constant.ts | 2 +- .../views/Workflow/hooks/useWorkflowGraph.ts | 21 +- web/src/views/Workflow/index.tsx | 3 +- 18 files changed, 586 insertions(+), 206 deletions(-) diff --git a/web/src/api/application.ts b/web/src/api/application.ts index 244f3503..f019103e 100644 --- a/web/src/api/application.ts +++ b/web/src/api/application.ts @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-03 13:59:45 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-03 13:59:45 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-28 16:34:15 */ import { request } from '@/utils/request' import type { ApplicationModalData } from '@/views/ApplicationManagement/types' @@ -120,3 +120,15 @@ export const copyApplication = (app_id: string, new_name: string) => { export const getAppStatistics = (app_id: string, data: { start_date: number; end_date: number; }) => { return request.get(`/apps/${app_id}/statistics`, data) } +// 导出工作流 +export const exportWorkflow = (app_id: string, fileName: string) => { + return request.downloadFile(`/apps/${app_id}/workflow/export`, fileName, undefined, undefined, 'GET') +} +// 工作流上传+兼容性分析 +export const importWorkflow = (formData: FormData) => { + return request.uploadFile(`/apps/workflow/import`, formData) +} +// 完成工作流导入 +export const completeImportWorkflow = (data: { temp_id: string; name?: string; description?: string }) => { + return request.post(`/apps/workflow/import/save`, data) +} diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 8dfb68db..9df6d018 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1340,12 +1340,20 @@ export const en = { dynamicMatchSkill: 'Dynamic Match Skill', executeTask: 'Execute Task', + importWorkflow: 'Import Workflow', + platform: 'Source Platform', upload: 'Upload & Parse', complex: 'Compatibility Analysis', - node: 'Node Mapping', - configCheck: 'Configuration Validation', sureInfo: 'Information Confirmation', completed: 'Import Completed', + workflowName: 'Workflow Name', + fileName: 'File Name', + fileSize: 'File Size', + importSuccess: 'Import Success', + importSuccessDesc: 'Workflow imported successfully, you can view and manage it in the application management', + gotoList: 'Return to Application List', + gotoDetail: 'View Details', + dify: 'Dify', }, userMemory: { userMemory: 'User Memory', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index feefc843..3fe37ea8 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -736,12 +736,21 @@ export const zh = { dynamicMatchSkill: '动态匹配技能', executeTask: '执行任务', + importWorkflow: '导入工作流', + platform: '来源平台', upload: '上传与解析', complex: '兼容性分析', - node: '节点映射', - configCheck: '配置校验', sureInfo: '信息确认', completed: '完成导入', + baseInfo: '基本信息', + workflowName: '工作流名称', + fileName: '文件名称', + fileSize: '文件大小', + importSuccess: '导入成功', + importSuccessDesc: '您的工作流已成功导入,可以在应用管理中查看和管理', + gotoList: '返回应用列表', + gotoDetail: '查看详情', + dify: 'Dify', }, table: { totalRecords: '共 {{total}} 条记录' diff --git a/web/src/views/ApplicationConfig/components/ConfigHeader.tsx b/web/src/views/ApplicationConfig/components/ConfigHeader.tsx index 374c87e8..42031d85 100644 --- a/web/src/views/ApplicationConfig/components/ConfigHeader.tsx +++ b/web/src/views/ApplicationConfig/components/ConfigHeader.tsx @@ -1,10 +1,10 @@ /* * @Author: ZhaoYing * @Date: 2026-02-03 16:27:52 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-03 16:27:52 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-28 16:48:52 */ -import { type FC, useRef } from 'react'; +import { type FC, useRef, useMemo } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { Layout, Tabs, Dropdown, Button, Flex } from 'antd'; import type { MenuProps } from 'antd'; @@ -21,6 +21,7 @@ import ApplicationModal from '@/views/ApplicationManagement/components/Applicati import type { CopyModalRef, AgentRef, ClusterRef, WorkflowRef } from '../types' import { deleteApplication } from '@/api/application' import CopyModal from './CopyModal' +import { exportToYaml } from '@/utils/yamlExport'; const { Header } = Layout; @@ -80,20 +81,6 @@ const ConfigHeader: FC = ({ label: t(`application.${key}`), })) } - /** - * Format dropdown menu items - */ - const formatMenuItems = () => { - const items = ['edit', 'copy', 'export', 'delete'].map(key => ({ - key, - icon: , - label: t(`common.${key}`), - })) - return { - items, - onClick: handleClick - } - } /** * Handle menu item click */ @@ -106,6 +93,8 @@ const ConfigHeader: FC = ({ copyModalRef.current?.handleOpen() break; case 'export': + console.log('export', workflowRef?.current?.config) + exportToYaml(workflowRef?.current?.config, application?.name ?`${application?.name}.yml`: undefined) break; case 'delete': handleDelete() @@ -160,6 +149,19 @@ const ConfigHeader: FC = ({ const addvariable = () => { workflowRef?.current?.addVariable() } + /** + * Format dropdown menu items + */ + const formatMenuItems = useMemo(() => { + const items = (application?.type === 'workflow' ? ['edit', 'copy', 'export', 'delete'] : ['edit', 'copy', 'delete']).map(key => ({ + key, + icon: , + label: t(`common.${key}`), + })) + return items + }, [t, handleClick, application]) + + console.log('formatMenuItems', formatMenuItems) return ( <>
@@ -170,7 +172,7 @@ const ConfigHeader: FC = ({
{application?.name}
diff --git a/web/src/views/ApplicationConfig/types.ts b/web/src/views/ApplicationConfig/types.ts index 2d09f739..36d40a40 100644 --- a/web/src/views/ApplicationConfig/types.ts +++ b/web/src/views/ApplicationConfig/types.ts @@ -2,13 +2,13 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:29:49 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-05 10:31:10 + * @Last Modified time: 2026-02-28 16:40:30 */ import type { KnowledgeConfig } from './components/Knowledge/types' import type { Variable } from './components/VariableList/types' import type { ToolOption } from './components/ToolList/types' import type { ChatItem } from '@/components/Chat/types' -import type { GraphRef } from '@/views/Workflow/types'; +import type { GraphRef, WorkflowConfig } from '@/views/Workflow/types'; import type { ApiKey } from '@/views/ApiKeyManagement/types' import type { SkillConfigForm } from './components/Skill/types' @@ -155,6 +155,7 @@ export interface WorkflowRef { graphRef: GraphRef; /** Add variable */ addVariable: () => void; + config: WorkflowConfig | null; } /** diff --git a/web/src/views/ApplicationManagement/components/UploadWorkflowModal.tsx b/web/src/views/ApplicationManagement/components/UploadWorkflowModal.tsx index 2f2f56b2..68bca452 100644 --- a/web/src/views/ApplicationManagement/components/UploadWorkflowModal.tsx +++ b/web/src/views/ApplicationManagement/components/UploadWorkflowModal.tsx @@ -1,98 +1,203 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-02-28 14:08:14 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-28 16:20:40 + */ +/** + * UploadWorkflowModal Component + * + * This component provides a modal for uploading workflow files with a multi-step process: + * 1. Upload - Select platform and file + * 2. Complex - Show warnings and errors if any + * 3. SureInfo - Confirm and edit workflow information + * 4. Completed - Show success message and options + */ import { forwardRef, useImperativeHandle, useState, useMemo } from 'react'; -import { Form, Select, Steps, Flex, Alert, Row, Col, Statistic, Input, Button } from 'antd'; +import { Form, Select, Steps, Flex, Alert, Input, Button, Result } from 'antd'; import { useTranslation } from 'react-i18next'; -import type { UploadWorkflowModalData, UploadWorkflowModalRef } from '../types' +import type { UploadWorkflowModalData, UploadData, UploadWorkflowModalRef } from '../types' import RbModal from '@/components/RbModal' import UploadFiles from '@/components/Upload/UploadFiles' -import { fileUploadUrl } from '@/api/fileStorage' -import RbCard from '@/components/RbCard/Card' +import { importWorkflow, completeImportWorkflow } from '@/api/application' +/** + * Props for UploadWorkflowModal component + */ interface UploadWorkflowModalProps { + /** Function to refresh the parent component after workflow import */ refresh: () => void; } + +/** + * Steps definition for the upload process + */ const steps = [ - 'upload', - 'complex', - 'node', - 'configCheck', - 'sureInfo', - 'completed' + 'upload', // Step 1: File upload + 'complex', // Step 2: Error/warning display + 'sureInfo', // Step 3: Information confirmation + 'completed' // Step 4: Success message ] + +/** + * UploadWorkflowModal component + * + * @param {UploadWorkflowModalProps} props - Component props + * @param {React.Ref} ref - Ref for imperative methods + */ const UploadWorkflowModal = forwardRef(({ refresh }, ref) => { const { t } = useTranslation(); - const [visible, setVisible] = useState(false); - const [form] = Form.useForm(); - const [loading, setLoading] = useState(false) - const [current, setCurrent] = useState(5); + + // State management + const [visible, setVisible] = useState(false); // Modal visibility + const [form] = Form.useForm(); // Form instance + const [loading, setLoading] = useState(false); // Loading state + const [current, setCurrent] = useState(0); // Current step + const [data, setData] = useState(null); // Upload response data + const [firstFormData, setFirstFormData] = useState(null); // First step form data + const [appId, setAppId] = useState(null); // Imported application ID - // 封装取消方法,添加关闭弹窗逻辑 + /** + * Handle modal close + * Resets all states and form fields + */ const handleClose = () => { setVisible(false); form.resetFields(); - setLoading(false) + setData(null); + setCurrent(0); + setFirstFormData(null); + setAppId(null); + setLoading(false); }; + /** + * Handle modal open + * Resets form fields and shows modal + */ const handleOpen = () => { form.resetFields(); setVisible(true); }; - // 封装保存方法,添加提交逻辑 + + /** + * Handle save/submit action + * Processes different logic based on current step + */ const handleSave = () => { + const values = form.getFieldsValue(); + switch(current) { - case 0: - setCurrent(1) + case 0: // Step 1: Upload file + const formData = new FormData(); + setFirstFormData(values); + formData.append('platform', values.platform); + formData.append('file', values.file[0]); + + // Call import workflow API + importWorkflow(formData) + .then(res => { + const response = res as UploadData; + const { errors, warnings } = response; + setData(response); + + // Navigate to error/warning step if any, otherwise go to confirmation + if (errors.length || warnings.length) { + setCurrent(1); + } else { + setCurrent(2); + // Pre-fill form with file information + form.setFieldsValue({ + name: values.file[0].name.split('.')[0], + platform: values.platform, + fileName: values.file[0].name, + fileSize: values.file[0].size, + }); + } + }); break; - case 1: - setCurrent(2) + case 1: // Step 2: Error/warning display + if (firstFormData) { + const { file, platform } = firstFormData; + // Pre-fill form with file information + form.setFieldsValue({ + name: file[0].name.split('.')[0], + platform: platform, + fileName: file[0].name, + fileSize: file[0].size, + }); + } + setCurrent(2); break; - case 2: - setCurrent(3) - break; - case 3: - setCurrent(4) - break; - case 4: - setCurrent(5) - break; - case 5: + case 2: // Step 3: Confirm information + if (data) { + // Complete import workflow + completeImportWorkflow({ + temp_id: data.temp_id, + name: values.name, + description: values.description, + }) + .then((res) => { + const response = res as { id: string }; + setCurrent(3); + setAppId(response.id); + }); + } break; default: - setCurrent(prev => prev + 1) + setCurrent(prev => prev + 1); break; } - // form - // .validateFields() - // .then(() => { - // }) - // .catch((err) => { - // console.log('err', err) - // }); - } + }; - // 暴露给父组件的方法 + // Expose methods to parent component via ref useImperativeHandle(ref, () => ({ handleOpen, handleClose })); + /** + * Handle navigation to previous step + * Adjusts step based on whether there were errors/warnings + */ const handleLastStep = () => { - setCurrent(prev => prev - 1) - } + let newStep = current - 1; + // If no errors or warnings, skip the error/warning step + if (!data?.warnings?.length && !data?.errors?.length) { + newStep = current - 2; + } + + // Reset form if not going back to error/warning step + if (newStep !== 1) { + form.resetFields(); + } + setCurrent(newStep); + }; + + /** + * Handle navigation after successful import + * @param {string} type - Navigation type ('detail' or 'list') + */ const handleJump = (type: string) => { switch(type) { case 'detail': - break; - default: + // Open application detail page in new tab + window.open(`/#/application/config/${appId}`, '_blank'); break; } - } + refresh(); + handleClose(); + }; + /** + * Generate modal footer based on current step + */ const getFooter = useMemo(() => { switch(current) { - case 0: + case 0: // Step 1: Upload return [ - ] - case 5: - return [ - , - - ] - default: + ]; + case 3: // Step 4: Completed + return null; + default: // Steps 1-2 return [ , , - ] + ]; } - }, [current]) + }, [current]); return ( + {/* Steps indicator */}
({ title: t(`application.${key}`) }))} />
+ + {/* Step 1: File upload */} {current === 0 &&
- + - - - - - } - {current === 3 && - - - - } - {current === 4 && - +
{t('application.baseInfo')}
- - source + + - fileName + - fileSize + - + - -
{t('application.importStatistic')}
- - {['complex', 'nodes', 'task'].map(key => ( - - - - ))} - } - {current === 5 && - -
导入成功
-
您的工作流已成功导入,可以在应用管理中查看和管理
-
+ + {/* Step 4: Success message */} + {current === 3 && + handleJump('list')}> + {t('application.gotoList')} + , + + ]} + /> }
); diff --git a/web/src/views/ApplicationManagement/index.tsx b/web/src/views/ApplicationManagement/index.tsx index 74dcef05..ca9888de 100644 --- a/web/src/views/ApplicationManagement/index.tsx +++ b/web/src/views/ApplicationManagement/index.tsx @@ -83,9 +83,9 @@ const ApplicationManagement: React.FC = () => { setQuery(prev => ({...prev, type: value})) } - // const handleImport = () => { - // uploadWorkflowModalRef.current?.handleOpen() - // } + const handleImport = () => { + uploadWorkflowModalRef.current?.handleOpen() + } return ( <> @@ -111,9 +111,9 @@ const ApplicationManagement: React.FC = () => { - {/* */} + diff --git a/web/src/views/ApplicationManagement/types.ts b/web/src/views/ApplicationManagement/types.ts index ccc4f114..696b828a 100644 --- a/web/src/views/ApplicationManagement/types.ts +++ b/web/src/views/ApplicationManagement/types.ts @@ -2,12 +2,12 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:34:15 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-06 11:08:37 + * @Last Modified time: 2026-02-28 16:16:03 */ /** * Type definitions for Application Management */ - +import type { WorkflowConfig } from '@/views/Workflow/types'; /** * Search query parameters */ @@ -174,9 +174,63 @@ export interface ApiExtensionModalRef { handleOpen: () => void; } - +/** + * Upload workflow modal form data + */ export interface UploadWorkflowModalData { + /** Platform type (e.g., 'dify') */ + platform: string; + /** Array of uploaded files */ + file: any[]; + /** Optional workflow name */ + name?: string; + /** Optional original file name */ + fileName?: string; + /** Optional file size in bytes */ + fileSize?: number; + /** Optional workflow description */ + description?: string; } + +/** + * Complex item for errors and warnings + */ +interface ComplexItem { + /** Error/warning type */ + type: string; + /** Detailed error/warning message */ + detail: string; + /** Node identifier where the error/warning occurred */ + node_id: string; + /** Node name where the error/warning occurred */ + node_name: string; + /** Optional scope of the error/warning */ + scope: string | null; + /** Optional name associated with the error/warning */ + name: string | null; +} + +/** + * Upload data response + * @extends WorkflowConfig + */ +export interface UploadData extends WorkflowConfig { + /** Whether the upload was successful */ + success: boolean; + /** Temporary identifier for the uploaded workflow */ + temp_id: string; + /** Optional workflow identifier if already exists */ + workflow_id?: string; + /** Array of error items */ + errors: ComplexItem[]; + /** Array of warning items */ + warnings: ComplexItem[]; +} + +/** + * Upload workflow modal ref interface + */ export interface UploadWorkflowModalRef { + /** Open the upload workflow modal */ handleOpen: () => void; } \ No newline at end of file diff --git a/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx b/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx index 52394ea1..15933d4a 100644 --- a/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx +++ b/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx @@ -1,3 +1,15 @@ +/* + * @Author: ZhaoYing + * @Date: 2025-12-30 13:59:36 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-28 16:19:26 + */ +/** + * 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 { useTranslation } from 'react-i18next'; @@ -8,54 +20,86 @@ import RbModal from '@/components/RbModal' 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', - 'number', - 'boolean', + '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 ] +/** + * ChatVariableModal component + */ const ChatVariableModal = forwardRef(({ refresh }, ref) => { const { t } = useTranslation(); - const [visible, setVisible] = useState(false); - const [form] = Form.useForm(); - const [loading, setLoading] = useState(false) - const [editIndex, setEditIndex] = useState(undefined) - const type = Form.useWatch('type', form); - // 封装取消方法,添加关闭弹窗逻辑 + // 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 + + /** + * Handle modal close + */ const handleClose = () => { setVisible(false); form.resetFields(); - setLoading(false) - setEditIndex(undefined) + setLoading(false); + setEditIndex(undefined); }; + /** + * Handle modal open + */ const handleOpen = (variable?: ChatVariable, index?: number) => { setVisible(true); if (variable) { - const { default: _, ...rest } = variable - form.setFieldsValue({ ...rest }) - setEditIndex(index) + // Exclude 'default' property and set form values + const { default: _, ...rest } = variable; + form.setFieldsValue({ ...rest }); + setEditIndex(index); } else { + // Reset form for new variable form.resetFields(); - setEditIndex(undefined) + setEditIndex(undefined); } }; - // 封装保存方法,添加提交逻辑 + + /** + * Handle save/submit action + */ const handleSave = () => { form.validateFields().then((values) => { - refresh({ ...values, default: values.defaultValue }, editIndex) - handleClose() - }) - } + // 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 })); @@ -74,6 +118,7 @@ const ChatVariableModal = forwardRef + {/* Variable name field */} + + {/* Variable type field */} + + {/* Default value field - dynamic based on type */} } + + {/* Variable description field */} (({ appId setConversationId(null) setMessage(undefined) setFileList([]) + setLoading(false) + setStreamLoading(false) } /** * Opens the variable configuration modal @@ -179,6 +181,7 @@ const Chat = forwardRef(({ appId cycle_idx: number; node_id: string; node_name?: string; + node_type?: string; input?: any; output?: any; elapsed_time?: string; @@ -188,7 +191,7 @@ const Chat = forwardRef(({ appId }; const node = graphRef.current?.getNodes().find(n => n.id === node_id); - const { name, icon } = node?.getData() || {} + const { name, icon, type } = node?.getData() || {} switch(item.event) { // Append streaming text chunks to assistant message @@ -218,6 +221,7 @@ const Chat = forwardRef(({ appId ...newSubContent[filterIndex], node_id: node_id, node_name: name, + node_type: type, icon, content: {}, } @@ -226,6 +230,7 @@ const Chat = forwardRef(({ appId id: node_id, node_id: node_id, node_name: name, + node_type: type, icon, content: {}, }) @@ -282,6 +287,7 @@ const Chat = forwardRef(({ appId cycle_idx, node_id, node_name: name, + node_type: type, icon, content: { cycle_idx, diff --git a/web/src/views/Workflow/components/Chat/Runtime.tsx b/web/src/views/Workflow/components/Chat/Runtime.tsx index 0f18f4da..e3608e10 100644 --- a/web/src/views/Workflow/components/Chat/Runtime.tsx +++ b/web/src/views/Workflow/components/Chat/Runtime.tsx @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-24 17:57:08 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-24 17:57:08 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-28 16:48:09 */ /* * Runtime Component @@ -105,7 +105,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({ if (Array.isArray(list)) { return {list?.map(vo => { - const isLoop = vo.node_id.startsWith('loop'); + const isLoop = vo.node_type === 'loop'; // Render cycle variables for loop nodes without node_name if (typeof vo.cycle_idx === 'number' && isLoop && !vo.node_name) { return
@@ -165,7 +165,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({ } {/* Display navigation to nested cycles if subContent exists */} {vo.subContent?.length > 0 && ( - handleViewDetail(vo, vo.node_id.startsWith('loop'))}> + handleViewDetail(vo, vo.node_type === 'loop')}> {Math.max(...vo.subContent.map((itemVo: any) => itemVo.cycle_idx + 1))} {t(`workflow.${isLoop ? 'loopNum' : 'iterationNum'}`)} @@ -217,7 +217,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({ children: ( detail ? ( -
+
diff --git a/web/src/views/Workflow/components/Chat/chat.module.css b/web/src/views/Workflow/components/Chat/chat.module.css index 99fe11f7..c005ef2a 100644 --- a/web/src/views/Workflow/components/Chat/chat.module.css +++ b/web/src/views/Workflow/components/Chat/chat.module.css @@ -28,9 +28,6 @@ background-color: transparent; border-top: none; } -:global(.ant-collapse .ant-collapse-content>.ant-collapse-content-box) { - padding-top: 0; -} .collapse-item :global(.ant-collapse) { /* background-color: #F0F3F8; */ background-color: #FBFDFF; @@ -41,5 +38,5 @@ border-radius: 0 0 6px 6px; } .collapse-item :global(.ant-collapse .ant-collapse-content>.ant-collapse-content-box) { - padding: 0 4px 4px 4px; + padding: 4px; } \ No newline at end of file diff --git a/web/src/views/Workflow/components/Properties/CaseList/index.tsx b/web/src/views/Workflow/components/Properties/CaseList/index.tsx index 34708513..70c4c43f 100644 --- a/web/src/views/Workflow/components/Properties/CaseList/index.tsx +++ b/web/src/views/Workflow/components/Properties/CaseList/index.tsx @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-09 18:24:53 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-09 18:24:53 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-28 17:49:28 */ import { type FC } from 'react' import clsx from 'clsx' @@ -292,7 +292,7 @@ const CaseList: FC = ({ const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue); const leftFieldType = leftFieldOption?.dataType; const operatorList = operatorsObj[leftFieldType || 'default'] || operatorsObj.default || []; - const inputType = leftFieldType === 'number' ? currentExpression.input_type : undefined; + const inputType = leftFieldType === 'number' ? currentExpression.input_type?.toLocaleLowerCase() : undefined; return (
@@ -330,7 +330,7 @@ const CaseList: FC = ({ = ({ - {inputType === 'Variable' + {inputType === 'variable' ? } keys - Set of existing keys to check for duplicates + * @param {string} key - Unique key for the variable + * @param {string} label - Human-readable label for the variable + * @param {string} dataType - Data type of the variable + * @param {string} value - Variable value/expression + * @param {any} nodeData - Node data associated with the variable + * @param {Partial} [extra] - Additional suggestion properties + */ const addVariable = ( list: Suggestion[], keys: Set, @@ -39,6 +72,14 @@ const addVariable = ( } }; +/** + * Process node variables based on node type + * + * @param {any} nodeData - Node data object + * @param {string} dataNodeId - Node ID + * @param {Suggestion[]} variableList - List to add variables to + * @param {Set} addedKeys - Set of already added keys + */ const processNodeVariables = ( nodeData: any, dataNodeId: string, @@ -47,29 +88,35 @@ const processNodeVariables = ( ) => { const { type, config } = nodeData; + // 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); }); } + // Process special node types switch (type) { case 'start': + // Add start node variables [...(config?.variables?.defaultValue ?? []), ...(config?.variables?.value ?? [])].forEach((v: any) => { if (v?.name) addVariable(variableList, addedKeys, `${dataNodeId}_${v.name}`, v.name, v.type, `${dataNodeId}.${v.name}`, nodeData); }); + // Add system variables config?.variables?.sys?.forEach((v: any) => { if (v?.name) addVariable(variableList, addedKeys, `${dataNodeId}_sys_${v.name}`, `sys.${v.name}`, v.type, `sys.${v.name}`, nodeData); }); break; case 'parameter-extractor': + // Add extracted parameters (config?.params?.defaultValue || []).forEach((p: any) => { if (p?.name) addVariable(variableList, addedKeys, `${dataNodeId}_${p.name}`, p.name, p.type || 'string', `${dataNodeId}.${p.name}`, nodeData); }); break; case 'var-aggregator': + // Add aggregated variables if (config.group.defaultValue) { (config.group_variables.defaultValue || []).forEach((gv: any) => { if (gv?.key) { @@ -93,6 +140,7 @@ const processNodeVariables = ( break; case 'iteration': + // Add iteration output variable let dt = 'string'; if (nodeData.output) { const sv = variableList.find(v => v.value === nodeData.output); @@ -102,11 +150,14 @@ const processNodeVariables = ( break; case 'loop': + // Add loop cycle variables (config.cycle_vars.defaultValue || []).forEach((cv: any) => { if (cv.name?.trim()) addVariable(variableList, addedKeys, `${dataNodeId}_cycle_${cv.name}`, cv.name, cv.type || 'string', `${dataNodeId}.${cv.name}`, nodeData); }); break; + case 'code': + // Add code node output variables (config.output_variables.defaultValue || []).forEach((cv: any) => { if (cv.name?.trim()) addVariable(variableList, addedKeys, `${dataNodeId}_cycle_${cv.name}`, cv.name, cv.type || 'string', `${dataNodeId}.${cv.name}`, nodeData); }); @@ -114,6 +165,9 @@ const processNodeVariables = ( } }; +/** + * Node types that have output variables + */ const hasOutputNodeTypes = [ 'llm', 'knowledge-retrieval', @@ -123,7 +177,15 @@ const hasOutputNodeTypes = [ 'http-request', 'tool', 'jinja-render' -] +]; + +/** + * Get variables for the current node + * + * @param {any} nodeData - Node data object + * @param {any} values - Additional values to merge with node config + * @returns {Suggestion[]} List of node variables + */ export const getCurrentNodeVariables = (nodeData: any, values: any): Suggestion[] => { if (!nodeData || !hasOutputNodeTypes.includes(nodeData.type)) return []; const list: Suggestion[] = []; @@ -137,9 +199,18 @@ export const getCurrentNodeVariables = (nodeData: any, values: any): Suggestion[ ...values } }, dataNodeId, list, keys); + + // Special case: var-aggregator without group enabled returns no variables return nodeData.type === 'var-aggregator' && !nodeData.config.group.defaultValue ? [] : list; }; +/** + * Get variables from child nodes in a loop/iteration + * + * @param {Node} selectedNode - Selected node + * @param {React.MutableRefObject} graphRef - Graph reference + * @returns {Suggestion[]} List of child node variables + */ export const getChildNodeVariables = ( selectedNode: Node, graphRef: React.MutableRefObject @@ -152,8 +223,15 @@ export const getChildNodeVariables = ( const edges = graph.getEdges(); const keys = new Set(); + // Find child nodes in the same cycle const childNodes = nodes.filter(node => node.getData()?.cycle === selectedNode.id); + /** + * Get all connected nodes recursively + * @param {string} nodeId - Node ID to start from + * @param {Set} visited - Set of visited node IDs + * @returns {string[]} List of connected node IDs + */ const getConnectedNodes = (nodeId: string, visited = new Set()): string[] => { if (visited.has(nodeId)) return []; visited.add(nodeId); @@ -161,12 +239,14 @@ export const getChildNodeVariables = ( return [...prev, ...prev.flatMap(id => getConnectedNodes(id, visited))]; }; + // Collect all relevant node IDs const relevantIds = new Set(); childNodes.forEach(child => { relevantIds.add(child.id); getConnectedNodes(child.id).forEach(id => relevantIds.add(id)); }); + // Process each relevant node relevantIds.forEach(id => { const node = nodes.find(n => n.id === id); if (!node) return; @@ -175,6 +255,7 @@ export const getChildNodeVariables = ( const nodeId = nodeData.id; const { type } = nodeData; + // 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}`; @@ -192,6 +273,7 @@ export const getChildNodeVariables = ( }); } + // Add parameter-extractor variables if (type === 'parameter-extractor') { (nodeData.config?.params?.defaultValue || []).forEach((p: any) => { if (p?.name && !keys.has(`${nodeId}_${p.name}`)) { @@ -207,11 +289,36 @@ export const getChildNodeVariables = ( } }); } + + // 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, + }); + } + }); + } }); return list; }; +/** + * Hook for managing workflow variable list + * + * @param {Node | null | undefined} selectedNode - Currently selected node + * @param {React.MutableRefObject} graphRef - Graph reference + * @param {ChatVariable[]} chatVariables - List of chat variables + * @returns {Suggestion[]} List of available variables + */ export const useVariableList = ( selectedNode: Node | null | undefined, graphRef: React.MutableRefObject, @@ -228,6 +335,12 @@ export const useVariableList = ( const nodes = graph.getNodes(); const keys = new Set(); + /** + * Get all previous connected nodes recursively + * @param {string} nodeId - Node ID to start from + * @param {Set} visited - Set of visited node IDs + * @returns {string[]} List of previous node IDs + */ const getPreviousNodes = (nodeId: string, visited = new Set()): string[] => { if (visited.has(nodeId)) return []; visited.add(nodeId); @@ -235,6 +348,11 @@ export const useVariableList = ( return [...prev, ...prev.flatMap(id => getPreviousNodes(id, visited))]; }; + /** + * Get parent loop/iteration node + * @param {string} nodeId - Node ID to check + * @returns {Node | null} Parent loop/iteration node or null + */ const getParentLoop = (nodeId: string): Node | null => { const node = nodes.find(n => n.id === nodeId); const cycle = node?.getData()?.cycle; @@ -245,17 +363,21 @@ export const useVariableList = ( return null; }; + // Collect relevant node IDs const childIds = nodes.filter(n => n.getData()?.cycle === selectedNode.id).map(n => n.id); const parentLoop = getParentLoop(selectedNode.id); const relevantIds = [...getPreviousNodes(selectedNode.id), ...childIds, ...(parentLoop ? getPreviousNodes(parentLoop.id) : [])]; + // Add chat variables chatVariables?.forEach(v => addVariable(list, keys, `CONVERSATION_${v.name}`, v.name, v.type, `conv.${v.name}`, { type: 'CONVERSATION', name: 'CONVERSATION', icon: '' }, { group: 'CONVERSATION' })); + // Process each relevant node relevantIds.forEach(id => { const node = nodes.find(n => n.id === id); if (node) processNodeVariables(node.getData(), node.getData().id, list, keys); }); + // Add parent loop variables if (parentLoop) { const pd = parentLoop.getData(); const pid = pd.id; @@ -270,7 +392,9 @@ export const useVariableList = ( } else if (pd.type === 'iteration' && !pd.config.input.defaultValue) { let itemType = 'object'; const iv = list.find(v => `{{${v.value}}}` === pd.config.input.defaultValue); - if (iv?.dataType.startsWith('array[')) {itemType = iv.dataType.replace(/^array\[(.+)\]$/, '$1');} + if (iv?.dataType.startsWith('array[')) { + itemType = iv.dataType.replace(/^array\[(.+)\]$/, '$1'); + } addVariable(list, keys, `${pid}_item`, 'item', 'string', `${pid}.item`, pd); addVariable(list, keys, `${pid}_index`, 'index', 'number', `${pid}.index`, pd); } @@ -279,6 +403,7 @@ export const useVariableList = ( return list; }, [selectedNode, graphRef, trigger, chatVariables]); + // Refresh variable list when graph changes useEffect(() => { if (!graphRef?.current) return; const graph = graphRef.current; diff --git a/web/src/views/Workflow/constant.ts b/web/src/views/Workflow/constant.ts index 5ae3e5b0..9c65174c 100644 --- a/web/src/views/Workflow/constant.ts +++ b/web/src/views/Workflow/constant.ts @@ -313,7 +313,7 @@ export const nodeLibrary: NodeLibrary[] = [ config: { input: { type: 'variableList', - filterNodeTypes: ['knowledge-retrieval', 'iteration', 'loop', 'parameter-extractor', 'code'], + filterNodeTypes: ['knowledge-retrieval', 'iteration', 'loop', 'parameter-extractor', 'code', 'CONVERSATION'], filterVariableNames: ['message'] }, parallel: { diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index 35cb5aa3..a50bb416 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:17:48 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-09 18:37:01 + * @Last Modified time: 2026-02-28 17:59:34 */ import { useRef, useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; @@ -135,7 +135,24 @@ export const useWorkflowGraph = ({ if (nodeLibraryConfig?.config) { Object.keys(nodeLibraryConfig.config).forEach(key => { - if (type === 'memory-write' && key === 'message' && nodeLibraryConfig.config) { + if (type === 'loop' && key === 'condition' && nodeLibraryConfig.config) { + const { condition } = config; + console.log('condition', condition) + nodeLibraryConfig.config[key].defaultValue = condition ? { + ...condition, + expressions: (condition as any).expressions.map((expr: any) => { + return expr.input_type ? { ...expr, input_type: expr.input_type.toLocaleLowerCase() } : expr + }) + } : {} + } else if (type === 'if-else' && key === 'cases' && nodeLibraryConfig.config) { + const { cases } = config; + nodeLibraryConfig.config[key].defaultValue = cases && Array.isArray(cases) ? cases.map(item => ({ + ...item, + expressions: item.expressions.map((expr: any) => { + return expr.input_type ? { ...expr, input_type: expr.input_type.toLocaleLowerCase() } : expr + }), + })) : [] + } else if (type === 'memory-write' && key === 'message' && nodeLibraryConfig.config) { nodeLibraryConfig.config['messages'].defaultValue = [{ role: 'USER', content: config[key] }] delete nodeLibraryConfig.config[key] } else if (key === 'memory' && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) { diff --git a/web/src/views/Workflow/index.tsx b/web/src/views/Workflow/index.tsx index 506fd3c4..31e3d4df 100644 --- a/web/src/views/Workflow/index.tsx +++ b/web/src/views/Workflow/index.tsx @@ -58,7 +58,8 @@ const Workflow = forwardRef((_props, ref) => { handleSave, handleRun, graphRef, - addVariable + addVariable, + config })) return (