feat(web): workflow ui upgrade
This commit is contained in:
@@ -1,19 +1,16 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:39:59
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-02 17:06:41
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-05 17:48:25
|
||||
*/
|
||||
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, Divider, Space, Button } from 'antd'
|
||||
import { CaretDownOutlined, CaretRightOutlined } from '@ant-design/icons';
|
||||
import { Form, Input, Select, InputNumber, Switch, Flex, Space, Dropdown, type MenuProps, Button } from 'antd';
|
||||
|
||||
import type { NodeConfig, NodeProperties, ChatVariable } from '../../types'
|
||||
import Empty from '@/components/Empty';
|
||||
import emptyIcon from '@/assets/images/workflow/empty.png'
|
||||
import CustomSelect from "@/components/CustomSelect";
|
||||
import MessageEditor from './MessageEditor'
|
||||
import Knowledge from './Knowledge/Knowledge';
|
||||
@@ -33,19 +30,19 @@ 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 './RbSlider'
|
||||
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'
|
||||
|
||||
/**
|
||||
* Props for Properties component
|
||||
*/
|
||||
interface PropertiesProps {
|
||||
/** Currently selected node */
|
||||
selectedNode?: Node | null;
|
||||
/** Function to update selected node */
|
||||
setSelectedNode: (node: Node | null) => void;
|
||||
selectedNode: Node;
|
||||
/** Reference to graph instance */
|
||||
graphRef: React.MutableRefObject<Graph | undefined>;
|
||||
/** Handler for blank canvas click */
|
||||
@@ -386,15 +383,15 @@ const Properties: FC<PropertiesProps> = ({
|
||||
const handleSureReplace = () => {
|
||||
const { replaceNode } = values;
|
||||
const nodeLibraryConfig = [...nodeLibrary]
|
||||
.flatMap(category => category.nodes)
|
||||
.find(n => n.type === replaceNode)
|
||||
.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<string, any> = {};
|
||||
Object.keys(newConfig).forEach(key => {
|
||||
@@ -418,13 +415,40 @@ const Properties: FC<PropertiesProps> = ({
|
||||
blankClick()
|
||||
}
|
||||
}
|
||||
const handleClick: MenuProps['onClick'] = (e) => {
|
||||
switch(e.key) {
|
||||
case 'delete':
|
||||
selectedNode.remove()
|
||||
break;
|
||||
case 'copy':
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx("rb:w-75 rb:fixed rb:right-0 rb:top-16 rb:bottom-0 rb:p-3 rb:pb-6", styles.properties)}>
|
||||
<div className="rb:font-medium rb:leading-5 rb:pb-3 rb:mb-3 rb:border-b rb:border-b-[#DFE4ED]">{t('workflow.nodeProperties')}</div>
|
||||
{!selectedNode
|
||||
? <Empty url={emptyIcon} size={140} className="rb:h-full rb:mx-15" title={t('workflow.empty')} />
|
||||
: <div className="rb:h-[calc(100%-20px)] rb:overflow-x-hidden rb:overflow-y-auto">
|
||||
<div className={clsx("rb:w-90 rb:fixed rb:right-2.5 rb:top-18.5 rb:bottom-2.5 rb:z-1000", styles.properties)}>
|
||||
<RbCard
|
||||
title={t('workflow.nodeProperties')}
|
||||
extra={<Space>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'delete', icon: <div className="rb:size-4 rb:bg-cover rb:bg-[url('src/assets/images/common/delete_dark.svg')]"></div>, label: <Flex>{t('common.delete')}</Flex>},
|
||||
// { key: 'copy', icon: <div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/common/copy_dark.svg')]"></div>, label: t('common.copy') }
|
||||
],
|
||||
onClick: handleClick
|
||||
}}
|
||||
>
|
||||
<div className="rb:cursor-pointer rb:size-4 rb:hover:bg-[#F6F6F6] rb:rounded-sm rb:bg-cover rb:bg-[url(@/assets/images/common/dash.svg)]">
|
||||
</div>
|
||||
</Dropdown>
|
||||
<div className="rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/close.svg')]" onClick={blankClick}></div>
|
||||
</Space>}
|
||||
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(100vh-131px)]! rb:px-3! rb:pt-0! rb:pb-3!')}
|
||||
>
|
||||
<Form form={form} size="small" layout="vertical">
|
||||
<Form.Item name="name" label={t('workflow.nodeName')}>
|
||||
<Input
|
||||
@@ -458,11 +482,11 @@ const Properties: FC<PropertiesProps> = ({
|
||||
<Button type="primary" size="small" className="rb:text-[12px]!" onClick={handleSureReplace}>{t('workflow.sureReplace')}</Button>
|
||||
</>
|
||||
: selectedNode?.data?.type === 'http-request'
|
||||
? <HttpRequest
|
||||
options={variableList}
|
||||
selectedNode={selectedNode}
|
||||
graphRef={graphRef}
|
||||
/>
|
||||
? <HttpRequest
|
||||
options={variableList}
|
||||
selectedNode={selectedNode}
|
||||
graphRef={graphRef}
|
||||
/>
|
||||
: selectedNode?.data?.type === 'tool'
|
||||
? <ToolConfig options={variableList} />
|
||||
: selectedNode?.data?.type === 'jinja-render'
|
||||
@@ -485,7 +509,7 @@ const Properties: FC<PropertiesProps> = ({
|
||||
|
||||
if (selectedNode?.data?.type === 'start' && key === 'variables' && config.type === 'define') {
|
||||
return (
|
||||
<Form.Item key={key} name={key}>
|
||||
<Form.Item key={key} name={key} className="rb:mb-0!">
|
||||
<VariableList
|
||||
parentName={key}
|
||||
selectedNode={selectedNode}
|
||||
@@ -495,15 +519,18 @@ const Properties: FC<PropertiesProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (key === 'model_id' && selectedNode?.data?.type === 'llm') {
|
||||
return <ModelConfig />
|
||||
}
|
||||
if (selectedNode?.data?.type === 'llm' && key === 'messages' && config.type === 'define') {
|
||||
// 为llm节点且isArray=true时添加context变量支持
|
||||
let contextVariableList = [...getFilteredVariableList('llm')];
|
||||
const isArrayMode = config.isArray !== false; // 默认为true
|
||||
|
||||
|
||||
if (isArrayMode) {
|
||||
const contextKey = `${selectedNode.id}_context`;
|
||||
const hasContextVariable = contextVariableList.some(v => v.key === contextKey);
|
||||
|
||||
|
||||
if (!hasContextVariable) {
|
||||
contextVariableList.unshift({
|
||||
key: contextKey,
|
||||
@@ -520,7 +547,7 @@ const Properties: FC<PropertiesProps> = ({
|
||||
<Form.Item key={key} name={key}>
|
||||
<MessageEditor
|
||||
key={key}
|
||||
options={contextVariableList.filter(variable => variable.nodeData?.type !== 'knowledge-retrieval')}
|
||||
options={contextVariableList.filter(variable => variable.nodeData?.type !== 'knowledge-retrieval')}
|
||||
parentName={key}
|
||||
placeholder={t(config.placeholder || 'common.pleaseSelect')}
|
||||
size="small"
|
||||
@@ -548,10 +575,11 @@ const Properties: FC<PropertiesProps> = ({
|
||||
|
||||
if (config.type === 'messageEditor') {
|
||||
return (
|
||||
<Form.Item key={key} name={key} label={selectedNode?.data?.type === 'memory-write' ? t(`workflow.config.${selectedNode?.data?.type}.${key}`) : undefined }>
|
||||
<MessageEditor
|
||||
<Form.Item key={key} name={key} label={selectedNode?.data?.type === 'memory-write' ? t(`workflow.config.${selectedNode?.data?.type}.${key}`) : undefined}>
|
||||
<MessageEditor
|
||||
title={t(`workflow.config.${selectedNode?.data?.type}.${key}`)}
|
||||
isArray={!!config.isArray}
|
||||
placeholder={t(config.placeholder || 'common.pleaseEnter')}
|
||||
isArray={!!config.isArray}
|
||||
parentName={key}
|
||||
language={config.language as LexicalEditorProps['language']}
|
||||
options={getFilteredVariableList(selectedNode?.data?.type, key)}
|
||||
@@ -569,7 +597,7 @@ const Properties: FC<PropertiesProps> = ({
|
||||
label={t(`workflow.config.${selectedNode?.data?.type}.${key}`)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
|
||||
)
|
||||
}
|
||||
if (config.type === 'groupVariableList') {
|
||||
@@ -586,7 +614,7 @@ const Properties: FC<PropertiesProps> = ({
|
||||
}
|
||||
if (config.type === 'caseList') {
|
||||
return (
|
||||
<Form.Item key={key} name={key}>
|
||||
<Form.Item key={key} name={key} noStyle>
|
||||
<CaseList
|
||||
name={key}
|
||||
options={getFilteredVariableList(selectedNode?.data?.type, key)}
|
||||
@@ -615,13 +643,13 @@ const Properties: FC<PropertiesProps> = ({
|
||||
options={(() => {
|
||||
if (config.filterLoopIterationVars) {
|
||||
const loopIterationVars: Suggestion[] = [];
|
||||
|
||||
|
||||
return [...getFilteredVariableList(selectedNode?.data?.type, key), ...loopIterationVars];
|
||||
}
|
||||
return getFilteredVariableList(selectedNode?.data?.type, key);
|
||||
})()
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
@@ -674,103 +702,117 @@ const Properties: FC<PropertiesProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
key={key}
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={key}
|
||||
label={key === 'vision_input'
|
||||
? undefined : key === 'parallel_count'
|
||||
? <span className="rb:text-[10px] rb:text-[#5B6167] rb:leading-3.5 rb:-mb-1!">{t(`workflow.config.${selectedNode?.data?.type}.${key}`)}</span>
|
||||
: t(`workflow.config.${selectedNode?.data?.type}.${key}`)
|
||||
? <span className="rb:text-[10px] rb:text-[#5B6167] rb:leading-3.5 rb:-mb-1!">{t(`workflow.config.${selectedNode?.data?.type}.${key}`)}</span>
|
||||
: t(`workflow.config.${selectedNode?.data?.type}.${key}`)
|
||||
}
|
||||
layout={config.type === 'switch' ? 'horizontal' : 'vertical'}
|
||||
className={key === 'parallel_count' ? 'rb:-mt-3! rb:leading-3.5!' : ''}
|
||||
className={
|
||||
key === 'parallel' && values?.parallel
|
||||
? 'rb:mb-1!'
|
||||
: key === 'vision' && values?.vision
|
||||
? 'rb:mb-2!'
|
||||
: key === 'group' && values?.group
|
||||
? 'rb:mb-3!'
|
||||
: ''
|
||||
}
|
||||
hidden={Boolean(config.hidden)}
|
||||
>
|
||||
{config.type === 'input'
|
||||
? <Input placeholder={t('common.pleaseEnter')} />
|
||||
: config.type === 'textarea'
|
||||
? <Input.TextArea placeholder={t('common.pleaseEnter')} />
|
||||
: config.type === 'select'
|
||||
? <Select
|
||||
options={config.needTranslation ? (config.options || []).map(vo => ({ ...vo, label: t(vo.label) })) : config.options}
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
/>
|
||||
: config.type === 'inputNumber'
|
||||
? <InputNumber
|
||||
placeholder={t('common.pleaseEnter')}
|
||||
className="rb:w-full!"
|
||||
onChange={(value) => form.setFieldValue(key, value)}
|
||||
/>
|
||||
: config.type === 'slider'
|
||||
? <RbSlider min={config.min} max={config.max} step={config.step} />
|
||||
: config.type === 'customSelect'
|
||||
? <CustomSelect
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
url={config.url as string}
|
||||
params={config.params}
|
||||
hasAll={false}
|
||||
valueKey={config.valueKey}
|
||||
labelKey={config.labelKey}
|
||||
size="small"
|
||||
/>
|
||||
: config.type === 'variableList'
|
||||
? <VariableSelect
|
||||
placeholder={t(config.placeholder || 'common.pleaseSelect')}
|
||||
options={(() => {
|
||||
const baseVariableList = getFilteredVariableList(selectedNode?.data?.type, key);
|
||||
// Apply filtering if specified in config
|
||||
if (config.filterNodeTypes || config.filterVariableNames) {
|
||||
return baseVariableList.filter(variable => {
|
||||
const nodeTypeMatch = !config.filterNodeTypes ||
|
||||
(Array.isArray(config.filterNodeTypes) && config.filterNodeTypes.includes(variable.nodeData?.type));
|
||||
const variableNameMatch = !config.filterVariableNames ||
|
||||
(Array.isArray(config.filterVariableNames) && config.filterVariableNames.includes(variable.label));
|
||||
return nodeTypeMatch || variableNameMatch;
|
||||
});
|
||||
}
|
||||
if (config.onFilterVariableNames) {
|
||||
return baseVariableList.filter(variable => Array.isArray(config.onFilterVariableNames) && config.onFilterVariableNames.includes(variable.label));
|
||||
}
|
||||
// Filter child nodes for iteration output
|
||||
if (config.filterChildNodes && selectedNode) {
|
||||
const graph = graphRef.current;
|
||||
if (!graph) return [];
|
||||
|
||||
const nodes = graph.getNodes();
|
||||
|
||||
// Find child nodes whose cycle field equals parent node's ID
|
||||
const childNodes = nodes.filter(node => {
|
||||
const nodeData = node.getData();
|
||||
return nodeData?.cycle === selectedNode.id;
|
||||
});
|
||||
|
||||
return baseVariableList.filter(variable =>
|
||||
childNodes.some(node => node.id === variable.nodeData?.id) || selectedNode?.data?.type === 'iteration' && key === 'output' && variable.value.includes('sys.')
|
||||
);
|
||||
}
|
||||
return baseVariableList;
|
||||
})()}
|
||||
onChange={(value, option) => handleChangeVariableList(value, option, key)}
|
||||
size="small"
|
||||
/>
|
||||
: config.type === 'switch'
|
||||
? <Switch onChange={
|
||||
key === 'group'
|
||||
? () => { form.setFieldValue('group_variables', []) }
|
||||
: key === 'vision'
|
||||
? () => { form.setFieldValue('vision_input', undefined) }
|
||||
: undefined
|
||||
} />
|
||||
: config.type === 'categoryList'
|
||||
? <CategoryList
|
||||
parentName={key}
|
||||
selectedNode={selectedNode}
|
||||
graphRef={graphRef}
|
||||
options={getFilteredVariableList(selectedNode?.data?.type, key)}
|
||||
/>
|
||||
: config.type === 'editor'
|
||||
? <Editor options={variableList} variant="outlined" size="small" />
|
||||
: null
|
||||
? <Input.TextArea placeholder={t('common.pleaseEnter')} />
|
||||
: config.type === 'select'
|
||||
? <Select
|
||||
options={config.needTranslation ? (config.options || []).map(vo => ({ ...vo, label: t(vo.label) })) : config.options}
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
/>
|
||||
: config.type === 'inputNumber'
|
||||
? <InputNumber
|
||||
placeholder={t('common.pleaseEnter')}
|
||||
className="rb:w-full!"
|
||||
onChange={(value) => form.setFieldValue(key, value)}
|
||||
/>
|
||||
: config.type === 'slider'
|
||||
? <RbSlider
|
||||
min={config.min}
|
||||
max={config.max}
|
||||
step={config.step || 0.01}
|
||||
isInput={true}
|
||||
size="small"
|
||||
/>
|
||||
: config.type === 'customSelect'
|
||||
? <CustomSelect
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
url={config.url as string}
|
||||
params={config.params}
|
||||
hasAll={false}
|
||||
valueKey={config.valueKey}
|
||||
labelKey={config.labelKey}
|
||||
size="small"
|
||||
/>
|
||||
: config.type === 'variableList'
|
||||
? <VariableSelect
|
||||
placeholder={t(config.placeholder || 'common.pleaseSelect')}
|
||||
options={(() => {
|
||||
const baseVariableList = getFilteredVariableList(selectedNode?.data?.type, key);
|
||||
// Apply filtering if specified in config
|
||||
if (config.filterNodeTypes || config.filterVariableNames) {
|
||||
return baseVariableList.filter(variable => {
|
||||
const nodeTypeMatch = !config.filterNodeTypes ||
|
||||
(Array.isArray(config.filterNodeTypes) && config.filterNodeTypes.includes(variable.nodeData?.type));
|
||||
const variableNameMatch = !config.filterVariableNames ||
|
||||
(Array.isArray(config.filterVariableNames) && config.filterVariableNames.includes(variable.label));
|
||||
return nodeTypeMatch || variableNameMatch;
|
||||
});
|
||||
}
|
||||
if (config.onFilterVariableNames) {
|
||||
return baseVariableList.filter(variable => Array.isArray(config.onFilterVariableNames) && config.onFilterVariableNames.includes(variable.label));
|
||||
}
|
||||
// Filter child nodes for iteration output
|
||||
if (config.filterChildNodes && selectedNode) {
|
||||
const graph = graphRef.current;
|
||||
if (!graph) return [];
|
||||
|
||||
const nodes = graph.getNodes();
|
||||
|
||||
// Find child nodes whose cycle field equals parent node's ID
|
||||
const childNodes = nodes.filter(node => {
|
||||
const nodeData = node.getData();
|
||||
return nodeData?.cycle === selectedNode.id;
|
||||
});
|
||||
|
||||
return baseVariableList.filter(variable =>
|
||||
childNodes.some(node => node.id === variable.nodeData?.id) || selectedNode?.data?.type === 'iteration' && key === 'output' && variable.value.includes('sys.')
|
||||
);
|
||||
}
|
||||
return baseVariableList;
|
||||
})()}
|
||||
onChange={(value, option) => handleChangeVariableList(value, option, key)}
|
||||
size="small"
|
||||
/>
|
||||
: config.type === 'switch'
|
||||
? <Switch onChange={
|
||||
key === 'group'
|
||||
? () => { form.setFieldValue('group_variables', []) }
|
||||
: key === 'vision'
|
||||
? () => { form.setFieldValue('vision_input', undefined) }
|
||||
: undefined
|
||||
} />
|
||||
: config.type === 'categoryList'
|
||||
? <CategoryList
|
||||
parentName={key}
|
||||
selectedNode={selectedNode}
|
||||
graphRef={graphRef}
|
||||
options={getFilteredVariableList(selectedNode?.data?.type, key)}
|
||||
/>
|
||||
: config.type === 'editor'
|
||||
? <Editor options={variableList} variant="outlined" size="small" placeholder={config.placeholder || t('common.pleaseEnter')} />
|
||||
: null
|
||||
}
|
||||
</Form.Item>
|
||||
)
|
||||
@@ -779,23 +821,26 @@ const Properties: FC<PropertiesProps> = ({
|
||||
</Form>
|
||||
|
||||
{currentNodeVariables.length > 0 && !(!values?.group && selectedNode.getData().type === 'var-aggregator') &&
|
||||
<div className="rb:pb-3">
|
||||
<Divider />
|
||||
<Space size={8} direction="vertical" className="rb:max-w-full!">
|
||||
<div className="rb:font-medium rb:text-[12px] rb:leading-4.5 rb:cursor-pointer rb:ml-4" onClick={handleToggle}>
|
||||
<div className="rb:text-[12px] rb:leading-4.5">
|
||||
<Flex gap={8} vertical>
|
||||
<Flex align="center" className="rb:font-medium rb:cursor-pointer" onClick={handleToggle}>
|
||||
{t('workflow.config.output')}
|
||||
{outputCollapsed ? <CaretRightOutlined /> : <CaretDownOutlined />}
|
||||
</div>
|
||||
<div
|
||||
className={clsx("rb:size-3 rb:bg-cover rb:bg-[url('src/assets/images/common/caret_right_outlined.svg')]", {
|
||||
'rb:rotate-90': !outputCollapsed
|
||||
})}
|
||||
></div>
|
||||
</Flex>
|
||||
{!outputCollapsed && currentNodeVariables.map(vo => (
|
||||
<div key={vo.value} className="rb:ml-4 rb:text-[12px] rb:flex rb:gap-2">
|
||||
<Flex key={vo.value} gap={4}>
|
||||
<span className="rb:font-medium">{vo.label}</span>
|
||||
<span className="rb:text-[#5B6167]">{vo.dataType}</span>
|
||||
</div>
|
||||
<span className="rb:text-[#212332]">{vo.dataType}</span>
|
||||
</Flex>
|
||||
))}
|
||||
</Space>
|
||||
</Flex>
|
||||
</div>
|
||||
}
|
||||
</div>}
|
||||
</RbCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user