/* * @Author: ZhaoYing * @Date: 2026-02-03 15:39:59 * @Last Modified by: ZhaoYing * @Last Modified time: 2026-04-10 17:24:19 */ import { type FC, useEffect, useState, useMemo } from "react"; import clsx from 'clsx' import { useTranslation } from 'react-i18next' import { Graph, Node } from '@antv/x6'; import { Form, Input, Select, InputNumber, Switch, Flex, Space, Dropdown, type MenuProps, Button } from 'antd'; import type { NodeConfig, NodeProperties, ChatVariable } from '../../types' import CustomSelect from "@/components/CustomSelect"; import MessageEditor from './MessageEditor' import Knowledge from './Knowledge/Knowledge'; import type { Suggestion } from '../Editor/plugin/AutocompletePlugin' import VariableSelect from './VariableSelect'; import ParamsList from './ParamsList'; import GroupVariableList from './GroupVariableList' import CaseList from './CaseList' import HttpRequest from './HttpRequest'; import CategoryList from './CategoryList' import ConditionList from './ConditionList' import CycleVarsList from './CycleVarsList' import AssignmentList from './AssignmentList' import ToolConfig from './ToolConfig' import MemoryConfig from './MemoryConfig' import VariableList from './VariableList' import { useVariableList, getCurrentNodeVariables, getChildNodeVariables } from './hooks/useVariableList' import styles from './properties.module.css' import Editor, { type LexicalEditorProps } from "../Editor"; import RbSlider from '@/components/RbSlider' import JinjaRender from './JinjaRender' import CodeExecution from './CodeExecution' 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 */ interface PropertiesProps { /** Currently selected node */ selectedNode: Node; /** Reference to graph instance */ graphRef: React.MutableRefObject; /** Handler for blank canvas click */ blankClick: () => void; /** Handler for delete event */ deleteEvent: () => void; /** Handler for copy event */ copyEvent: () => void; /** Handler for paste event */ parseEvent: () => void; /** Workflow configuration */ config?: any; /** Chat variables */ chatVariables: ChatVariable[]; } /** * Properties panel component * Displays and manages configuration for selected workflow node * @param props - Component props */ const Properties: FC = ({ selectedNode, graphRef, chatVariables, blankClick }) => { const { t } = useTranslation() const [form] = Form.useForm(); const [configs, setConfigs] = useState>({} as Record) const values = Form.useWatch([], form); const variableList = useVariableList(selectedNode, graphRef, chatVariables) useEffect(() => { if (selectedNode?.getData()?.id) { setOutputCollapsed(true) } form.resetFields() }, [selectedNode?.getData()?.id]) useEffect(() => { if (selectedNode && form) { const { type = 'default', name = '', config, id } = selectedNode.getData() || {} const initialValue: Record = {} Object.keys(config || {}).forEach(key => { if (config && config[key] && 'defaultValue' in config[key]) { initialValue[key] = config[key].defaultValue } }) form.setFieldsValue({ type, id, name, ...initialValue, }) setConfigs(config || {}) } else { form.resetFields() } }, [selectedNode, form]) /** * Update node label in graph * @param newLabel - New label text */ const updateNodeLabel = (newLabel: string) => { if (selectedNode && form) { const nodeData = selectedNode.getData() as NodeProperties; selectedNode.setAttrByPath('text/text', `${nodeData.icon} ${newLabel}`); selectedNode.setData({ ...selectedNode.getData(), name: newLabel }); } }; useEffect(() => { if (values && selectedNode) { const { id, knowledge_retrieval, group, group_variables, ...rest } = values const { knowledge_bases = [], name: _name, description: _description, ...restKnowledgeConfig } = (knowledge_retrieval as any) || {} let allRest = { ...rest, ...restKnowledgeConfig, } if (knowledge_bases?.length) { allRest.knowledge_bases = knowledge_bases?.map((vo: any) => ({ id: vo.id, ...vo.config })) } const nodeData = selectedNode.getData() Object.keys(values).forEach(key => { if (nodeData?.config?.[key]) { // Create a deep copy to avoid reference sharing between nodes if (!nodeData.config[key]) { nodeData.config[key] = {}; } nodeData.config[key] = { ...nodeData.config[key], defaultValue: values[key] }; } }) selectedNode?.setData({ ...nodeData, ...allRest, }) } }, [values, selectedNode, form]) /** * Get filtered variable list based on node type and config key * @param nodeType - Type of the node * @param key - Configuration key * @returns Filtered variable list */ const getFilteredVariableList = (nodeType?: string, key?: string) => { // Check if current node is a child of iteration node const parentIterationNode = selectedNode ? (() => { const nodes = graphRef.current?.getNodes() || []; const nodeData = selectedNode.getData(); const cycle = nodeData?.cycle; if (cycle) { const parentNode = nodes.find(n => n.getData().id === cycle); if (parentNode) { const parentData = parentNode.getData(); if (parentData?.type === 'iteration') { return parentNode; } } } return null; })() : null; // Helper function to add parent iteration variables const addParentIterationVars = (filteredList: any[]) => { if (parentIterationNode) { const parentData = parentIterationNode.getData(); const parentNodeId = parentData.id; if (parentData.config?.input?.defaultValue) { const itemKey = `${parentNodeId}_item`; const indexKey = `${parentNodeId}_index`; const existingItemVar = filteredList.find(v => v.key === itemKey); const existingIndexVar = filteredList.find(v => v.key === indexKey); if (!existingItemVar) { // Determine item dataType from input variable let itemDataType = 'object'; const inputVariable = variableList.find(v => `{{${v.value}}}` === parentData.config.input.defaultValue); if (inputVariable && inputVariable.dataType.startsWith('array[')) { itemDataType = inputVariable.dataType.replace(/^array\[(.+)\]$/, '$1'); } filteredList.push({ key: itemKey, label: 'item', type: 'variable', dataType: itemDataType, value: `${parentNodeId}.item`, nodeData: parentData, }); } if (!existingIndexVar) { filteredList.push({ key: indexKey, label: 'index', type: 'variable', dataType: 'number', value: `${parentNodeId}.index`, nodeData: parentData, }); } } } return filteredList; }; if (nodeType === 'llm') { // For LLM nodes that are children of iteration or loop nodes, include parent variables const parentLoopNode = selectedNode ? (() => { const nodes = graphRef.current?.getNodes() || []; const nodeData = selectedNode.getData(); const cycle = nodeData?.cycle; if (cycle) { const parentNode = nodes.find(n => n.getData().id === cycle); if (parentNode) { const parentData = parentNode.getData(); if (parentData?.type === 'loop' || parentData?.type === 'iteration') { return parentNode; } } } return null; })() : null; let filteredList = variableList.filter(variable => !['boolean', 'object', 'array[boolean]'].includes(variable.dataType)); // If this LLM node is a child of iteration/loop, ensure parent variables are included if (parentLoopNode) { const parentData = parentLoopNode.getData(); const parentNodeId = parentData.id; // Ensure parent loop/iteration variables are included if (parentData.type === 'loop') { const cycleVars = parentData.cycle_vars || []; cycleVars.forEach((cycleVar: any) => { const key = `${parentNodeId}_cycle_${cycleVar.name}`; const existingVar = filteredList.find(v => v.key === key); if (!existingVar && cycleVar.name && cycleVar.type !== 'boolean') { filteredList.push({ key, label: cycleVar.name, type: 'variable', dataType: cycleVar.type || 'String', value: `${parentNodeId}.${cycleVar.name}`, nodeData: parentData, }); } }); } else if (parentData.type === 'iteration') { // Add item and index variables for iteration parent if (parentData.config?.input?.defaultValue) { const itemKey = `${parentNodeId}_item`; const indexKey = `${parentNodeId}_index`; const existingItemVar = filteredList.find(v => v.key === itemKey); const existingIndexVar = filteredList.find(v => v.key === indexKey); if (!existingItemVar) { // Determine item dataType from input variable let itemDataType = 'object'; const inputVariable = variableList.find(v => `{{${v.value}}}` === parentData.config.input.defaultValue); if (inputVariable && inputVariable.dataType.startsWith('array[')) { itemDataType = inputVariable.dataType.replace(/^array\[(.+)\]$/, '$1'); } filteredList.push({ key: itemKey, label: 'item', type: 'variable', dataType: itemDataType, value: `${parentNodeId}.item`, nodeData: parentData, }); } if (!existingIndexVar) { filteredList.push({ key: indexKey, label: 'index', type: 'variable', dataType: 'Number', value: `${parentNodeId}.index`, nodeData: parentData, }); } } } } return filteredList; } if (nodeType === 'knowledge-retrieval') { const allList = addParentIterationVars(variableList); let filteredList: Suggestion[] = [] allList.forEach(variable => { if (variable.dataType === 'string') { filteredList.push(variable) } else if (variable.dataType === 'file') { filteredList.push({ ...variable, disabled: true, children: variable.children.filter((child: Suggestion) => child.dataType === 'string') }) } }) return filteredList } if ((nodeType === 'parameter-extractor' && key === 'text') || (nodeType === 'question-classifier' && ['input_variable', 'categories'].includes(key as string)) ) { const allList = addParentIterationVars(variableList); let filteredList: Suggestion[] = [] allList.forEach(variable => { if (variable.dataType === 'string') { filteredList.push(variable) } else if (variable.dataType === 'file') { filteredList.push({ ...variable, children: variable.children.filter((child: Suggestion) => child.dataType === 'string') }) } }) return filteredList } if ((nodeType === 'parameter-extractor' && key === 'prompt') || (nodeType === 'question-classifier' && key === 'user_supplement_prompt') ) { const allList = addParentIterationVars(variableList); let filteredList: Suggestion[] = [] allList.forEach(variable => { if (['string', 'number'].includes(variable.dataType)) { filteredList.push(variable) } else if (variable.dataType === 'file') { filteredList.push({ ...variable, disabled: true, children: variable.children.filter((child: Suggestion) => ['string', 'number'].includes(child.dataType)) }) } }) return filteredList } if (nodeType === 'memory-read') { let filteredList = addParentIterationVars(variableList).filter(variable => variable.dataType === 'string'); return filteredList; } if (nodeType === 'memory-write') { const allList = addParentIterationVars(variableList); let filteredList: Suggestion[] = [] allList.forEach(variable => { if (['string', 'array[file]'].includes(variable.dataType)) { filteredList.push(variable) } else if (variable.dataType === 'file') { filteredList.push({ ...variable, children: variable.children.filter((child: Suggestion) => child.dataType === 'string') }) } }) return filteredList } if (nodeType === 'parameter-extractor' && key === 'prompt') { let filteredList = addParentIterationVars(variableList).filter(variable => variable.dataType === 'string' || variable.dataType === 'number'); return filteredList; } if ((nodeType === 'iteration' && key === 'output')) { if (!selectedNode) return []; let filteredList = variableList.filter(variable => variable.value.includes('sys.')) const childVariables = getChildNodeVariables(selectedNode, graphRef); const existingKeys = new Set(filteredList.map(v => v.key)); childVariables.forEach(v => { if (!existingKeys.has(v.key)) { filteredList.push(v); existingKeys.add(v.key); } }); return filteredList.filter(variable => variable.dataType !== 'array[file]'); } if (nodeType === 'loop' && key === 'condition') { if (!selectedNode) return []; let filteredList = addParentIterationVars(variableList).filter(variable => variable.nodeData.type !== 'loop'); const childVariables = getChildNodeVariables(selectedNode, graphRef); const existingKeys = new Set(filteredList.map(v => v.key)); childVariables.forEach(v => { if (!existingKeys.has(v.key)) { filteredList.push(v); existingKeys.add(v.key); } }); return filteredList; } if (nodeType === 'iteration') { return variableList.filter(variable => variable.dataType.includes('array')); } if ((nodeType === 'if-else' && key === 'cases')) { const allList = addParentIterationVars(variableList); let filteredList: Suggestion[] = [] allList.forEach(variable => { if (variable.dataType === 'file') { filteredList.push({ ...variable, disabled: true, }) } else { filteredList.push(variable) } }) return filteredList } // For all other node types, add parent iteration variables if applicable let baseList = variableList; return addParentIterationVars(baseList); }; // const defaultVariableList = calculateVariableList(selectedNode as Node, graphRef, workflowConfig ) console.log('values', values) /** * Get current node output variables */ const currentNodeVariables = useMemo(() => { if (!selectedNode) return [] return getCurrentNodeVariables(selectedNode?.getData(), values, variableList) }, [selectedNode?.getData(), values]) const [outputCollapsed, setOutputCollapsed] = useState(true) /** * Toggle output section collapsed state */ const handleToggle = () => { setOutputCollapsed((prev: boolean) => !prev) } /** * Handle variable list change and update output type for iteration nodes * @param _value - Selected value * @param option - Selected option * @param key - Configuration key */ const handleChangeVariableList = (_value: string, option: any, key: string) => { if (selectedNode?.data?.type === 'iteration' && key === 'output') { form.setFieldValue('output_type', option?.dataType) } } console.log('variableList', variableList, currentNodeVariables) const handleSureReplace = () => { const { replaceNode } = values; const nodeLibraryConfig = [...nodeLibrary] .flatMap(category => category.nodes) .find(n => n.type === replaceNode) if (replaceNode && nodeLibraryConfig) { // Preserve existing config values when switching node types const currentData = selectedNode?.data || {}; const currentConfig = currentData.config || {}; const newConfig = nodeLibraryConfig.config || {}; // Merge configs: keep existing values for matching keys, add new keys from template const mergedConfig: Record = {}; Object.keys(newConfig).forEach(key => { if (currentConfig[key] && currentConfig[key].defaultValue !== undefined) { // Preserve existing value if it exists mergedConfig[key] = { ...newConfig[key], defaultValue: currentConfig[key].defaultValue }; } else { // Use new config template mergedConfig[key] = { ...newConfig[key] }; } }); selectedNode?.setData({ ...currentData, ...nodeLibraryConfig, config: mergedConfig }) blankClick() } } const handleClick: MenuProps['onClick'] = (e) => { switch (e.key) { case 'delete': selectedNode.remove() break; case 'copy': break; } } return (
, label: {t('common.delete')} }, // { key: 'copy', icon:
, label: t('common.copy') } ], onClick: handleClick }} >
} headerType="borderless" headerClassName={clsx("rb:font-[MiSans-Bold] rb:font-bold rb:min-h-[48px]!")} className="rb:h-full! rb:hover:shadow-none!" bodyClassName={clsx('rb:overflow-y-auto! rb:h-[calc(100%-48px)]! rb:px-3! rb:pt-0! rb:pb-3!')} >
{ updateNodeLabel(e.target.value); }} /> {selectedNode?.data?.type === 'list-operator' ? : selectedNode?.data?.type === 'unknown' ? <> : config.type === 'textarea' ? : config.type === 'select' ?