Merge pull request #156 from SuanmoSuanyangTechnology/feature/ui_zy
refactor: extract useVariableList; properties add output variable
This commit is contained in:
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useState, type FC, type Key } from 'react';
|
import { useEffect, useState, type FC, type Key } from 'react';
|
||||||
import { Select } from 'antd'
|
import { Select } from 'antd';
|
||||||
import type { SelectProps, DefaultOptionType } from 'antd/es/select'
|
import type { SelectProps, DefaultOptionType } from 'antd/es/select';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { request } from '@/utils/request';
|
import { request } from '@/utils/request';
|
||||||
|
|
||||||
// 定义API响应类型
|
interface OptionType {
|
||||||
|
[key: string]: Key | string | number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
items?: T[];
|
items?: T[];
|
||||||
}
|
}
|
||||||
@@ -20,19 +23,16 @@ interface CustomSelectProps extends Omit<SelectProps, 'filterOption'> {
|
|||||||
format?: (items: OptionType[]) => OptionType[];
|
format?: (items: OptionType[]) => OptionType[];
|
||||||
showSearch?: boolean;
|
showSearch?: boolean;
|
||||||
optionFilterProp?: string;
|
optionFilterProp?: string;
|
||||||
// 其他SelectProps属性
|
|
||||||
onChange?: SelectProps<Key, DefaultOptionType>['onChange'];
|
|
||||||
value?: SelectProps<Key, DefaultOptionType>['value'];
|
|
||||||
disabled?: boolean;
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
className?: string;
|
|
||||||
filterOption?: (inputValue: string, option?: DefaultOptionType) => boolean;
|
filterOption?: (inputValue: string, option?: DefaultOptionType) => boolean;
|
||||||
}
|
}
|
||||||
interface OptionType {
|
|
||||||
[key: string]: Key | string | number;
|
const defaultFilterOption = (inputValue: string, option?: DefaultOptionType): boolean => {
|
||||||
}
|
if (!option || !inputValue) return true;
|
||||||
|
const label = String(option.children || option.label || '');
|
||||||
|
return label.toLowerCase().includes(inputValue.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
const CustomSelect: FC<CustomSelectProps> = ({
|
const CustomSelect: FC<CustomSelectProps> = ({
|
||||||
onChange,
|
|
||||||
url,
|
url,
|
||||||
params,
|
params,
|
||||||
valueKey = 'value',
|
valueKey = 'value',
|
||||||
@@ -42,42 +42,37 @@ const CustomSelect: FC<CustomSelectProps> = ({
|
|||||||
allTitle,
|
allTitle,
|
||||||
format,
|
format,
|
||||||
showSearch = false,
|
showSearch = false,
|
||||||
optionFilterProp = 'label',
|
|
||||||
filterOption,
|
filterOption,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [options, setOptions] = useState<OptionType[]>([]);
|
const [options, setOptions] = useState<OptionType[]>([]);
|
||||||
|
|
||||||
// 默认模糊搜索函数
|
|
||||||
const defaultFilterOption = (inputValue: string, option?: DefaultOptionType) => {
|
|
||||||
if (!option || !inputValue) return true;
|
|
||||||
const label = String(option.children || option.label || '');
|
|
||||||
return label.toLowerCase().includes(inputValue.toLowerCase());
|
|
||||||
};
|
|
||||||
// 组件挂载时获取初始数据
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request.get<ApiResponse<OptionType>>(url, params).then((res) => {
|
request.get<ApiResponse<OptionType>>(url, params).then((res) => {
|
||||||
const data = res;
|
const data = Array.isArray(res) ? res : res?.items || [];
|
||||||
setOptions(Array.isArray(data) ? data || [] : Array.isArray(data?.items) ? data.items || [] : []);
|
setOptions(data);
|
||||||
});
|
});
|
||||||
}, []);
|
}, [url, params]);
|
||||||
|
|
||||||
|
const displayOptions = format ? format(options) : options;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
placeholder={placeholder ? placeholder : t('common.select')}
|
placeholder={placeholder || t('common.select')}
|
||||||
onChange={onChange}
|
|
||||||
defaultValue={hasAll ? null : undefined}
|
defaultValue={hasAll ? null : undefined}
|
||||||
showSearch={showSearch}
|
showSearch={showSearch}
|
||||||
filterOption={filterOption || defaultFilterOption}
|
filterOption={filterOption || defaultFilterOption}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{hasAll && (<Select.Option>{allTitle || t('common.all')}</Select.Option>)}
|
{hasAll && <Select.Option value={null}>{allTitle || t('common.all')}</Select.Option>}
|
||||||
{(format ? format(options) : options)?.map(option => (
|
{displayOptions.map((option) => (
|
||||||
<Select.Option key={option[valueKey]} value={option[valueKey]}>
|
<Select.Option key={option[valueKey]} value={option[valueKey]}>
|
||||||
{String(option[labelKey])}
|
{String(option[labelKey])}
|
||||||
</Select.Option>
|
</Select.Option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default CustomSelect;
|
export default CustomSelect;
|
||||||
@@ -1967,6 +1967,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
|||||||
value: 'Value',
|
value: 'Value',
|
||||||
addCase: 'Add Condition',
|
addCase: 'Add Condition',
|
||||||
addVariable: 'Add Variables',
|
addVariable: 'Add Variables',
|
||||||
|
output: 'Output Variable'
|
||||||
},
|
},
|
||||||
|
|
||||||
clear: 'Clear',
|
clear: 'Clear',
|
||||||
|
|||||||
@@ -2061,6 +2061,7 @@ export const zh = {
|
|||||||
value: '值',
|
value: '值',
|
||||||
addCase: '添加条件',
|
addCase: '添加条件',
|
||||||
addVariable: '添加变量',
|
addVariable: '添加变量',
|
||||||
|
output: '输出变量'
|
||||||
},
|
},
|
||||||
|
|
||||||
clear: '清空',
|
clear: '清空',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import InitialValuePlugin from './plugin/InitialValuePlugin';
|
|||||||
import CommandPlugin from './plugin/CommandPlugin';
|
import CommandPlugin from './plugin/CommandPlugin';
|
||||||
import Jinja2HighlightPlugin from './plugin/Jinja2HighlightPlugin';
|
import Jinja2HighlightPlugin from './plugin/Jinja2HighlightPlugin';
|
||||||
import LineNumberPlugin from './plugin/LineNumberPlugin';
|
import LineNumberPlugin from './plugin/LineNumberPlugin';
|
||||||
|
import BlurPlugin from './plugin/BlurPlugin';
|
||||||
import { VariableNode } from './nodes/VariableNode'
|
import { VariableNode } from './nodes/VariableNode'
|
||||||
|
|
||||||
interface LexicalEditorProps {
|
interface LexicalEditorProps {
|
||||||
@@ -113,8 +114,10 @@ const Editor: FC<LexicalEditorProps> =({
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
.editor-content-with-numbers {
|
.editor-content-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
}
|
||||||
|
.editor-content-with-numbers {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
.editor-content-with-numbers p {
|
.editor-content-with-numbers p {
|
||||||
@@ -174,18 +177,20 @@ const Editor: FC<LexicalEditorProps> =({
|
|||||||
<div className="line-numbers">
|
<div className="line-numbers">
|
||||||
<div>1</div>
|
<div>1</div>
|
||||||
</div>
|
</div>
|
||||||
<ContentEditable
|
<div className="editor-content-wrapper">
|
||||||
className="editor-content-with-numbers"
|
<ContentEditable
|
||||||
style={{
|
className="editor-content-with-numbers"
|
||||||
minHeight: minheight,
|
style={{
|
||||||
padding: '4px 0',
|
minHeight: minheight,
|
||||||
outline: 'none',
|
padding: '4px 0',
|
||||||
resize: 'none',
|
outline: 'none',
|
||||||
fontSize: fontSize,
|
resize: 'none',
|
||||||
lineHeight: lineHeight,
|
fontSize: fontSize,
|
||||||
border: 'none',
|
lineHeight: lineHeight,
|
||||||
}}
|
border: 'none',
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
@@ -207,8 +212,8 @@ const Editor: FC<LexicalEditorProps> =({
|
|||||||
style={{
|
style={{
|
||||||
minHeight: placeHolderMinheight,
|
minHeight: placeHolderMinheight,
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: variant === 'borderless' ? '0' : '6px',
|
top: enableJinja2 ? '4px' : variant === 'borderless' ? '0' : '6px',
|
||||||
left: enableJinja2 ? '59px' : (variant === 'borderless' ? '0' : '11px'),
|
left: enableJinja2 ? '16px' : (variant === 'borderless' ? '0' : '11px'),
|
||||||
color: '#A8A9AA',
|
color: '#A8A9AA',
|
||||||
fontSize: fontSize,
|
fontSize: fontSize,
|
||||||
lineHeight: placeHolderMinheight,
|
lineHeight: placeHolderMinheight,
|
||||||
@@ -227,6 +232,7 @@ const Editor: FC<LexicalEditorProps> =({
|
|||||||
<AutocompletePlugin options={options} enableJinja2={enableJinja2} />
|
<AutocompletePlugin options={options} enableJinja2={enableJinja2} />
|
||||||
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} />
|
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} />
|
||||||
<InitialValuePlugin value={value} options={options} enableJinja2={enableJinja2} />
|
<InitialValuePlugin value={value} options={options} enableJinja2={enableJinja2} />
|
||||||
|
{enableJinja2 && <BlurPlugin />}
|
||||||
</div>
|
</div>
|
||||||
</LexicalComposer>
|
</LexicalComposer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={clsx('rb:border rb:rounded-md rb:bg-white rb:text-[12px] rb:inline-flex rb:items-center rb:py-0.5 rb:px-1.5 rb:mx-0.5 rb:cursor-pointer', {
|
className={clsx('rb:border rb:rounded-md rb:bg-white rb:text-[10px] rb:inline-flex rb:items-center rb:py-0 rb:px-1.5 rb:mx-0.5 rb:cursor-pointer', {
|
||||||
'rb:border-[#155EEF]': isSelected,
|
'rb:border-[#155EEF]': isSelected,
|
||||||
'rb:border-[#DFE4ED]': !isSelected
|
'rb:border-[#DFE4ED]': !isSelected
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, type FC } from 'react';
|
import { useEffect, useState, type FC } from 'react';
|
||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
import { $getSelection, $isRangeSelection } from 'lexical';
|
import { $getSelection, $isRangeSelection, $isTextNode } from 'lexical';
|
||||||
|
|
||||||
import { INSERT_VARIABLE_COMMAND } from '../commands';
|
import { INSERT_VARIABLE_COMMAND } from '../commands';
|
||||||
import type { NodeProperties } from '../../../types'
|
import type { NodeProperties } from '../../../types'
|
||||||
@@ -96,7 +96,9 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
|
|||||||
const textAfter = nodeText.substring(anchorOffset);
|
const textAfter = nodeText.substring(anchorOffset);
|
||||||
const newText = textBefore + `{{${suggestion.value}}}` + textAfter;
|
const newText = textBefore + `{{${suggestion.value}}}` + textAfter;
|
||||||
|
|
||||||
anchorNode.setTextContent(newText);
|
if ($isTextNode(anchorNode)) {
|
||||||
|
anchorNode.setTextContent(newText);
|
||||||
|
}
|
||||||
|
|
||||||
// 设置光标位置到插入文本之后
|
// 设置光标位置到插入文本之后
|
||||||
const newOffset = textBefore.length + `{{${suggestion.value}}}`.length;
|
const newOffset = textBefore.length + `{{${suggestion.value}}}`.length;
|
||||||
@@ -129,6 +131,8 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
data-autocomplete-popup="true"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: popupPosition.top,
|
top: popupPosition.top,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { $setSelection } from 'lexical';
|
||||||
|
|
||||||
|
export default function BlurPlugin() {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return editor.registerRootListener((rootElement) => {
|
||||||
|
if (rootElement) {
|
||||||
|
const handleBlur = (e: FocusEvent) => {
|
||||||
|
// 检查是否点击了自动完成弹窗
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
console.log('target', target)
|
||||||
|
if (target?.closest('[data-autocomplete-popup="true"]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.update(() => {
|
||||||
|
$setSelection(null);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
rootElement.addEventListener('blur', handleBlur);
|
||||||
|
return () => {
|
||||||
|
rootElement.removeEventListener('blur', handleBlur);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -33,7 +33,8 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value !== prevValueRef.current && !isUserInputRef.current) {
|
if (value !== prevValueRef.current && !isUserInputRef.current) {
|
||||||
editor.update(() => {
|
queueMicrotask(() => {
|
||||||
|
editor.update(() => {
|
||||||
const root = $getRoot();
|
const root = $getRoot();
|
||||||
root.clear();
|
root.clear();
|
||||||
|
|
||||||
@@ -98,7 +99,8 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
|
|||||||
});
|
});
|
||||||
root.append(paragraph);
|
root.append(paragraph);
|
||||||
}
|
}
|
||||||
}, { discrete: true });
|
}, { discrete: true });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
prevValueRef.current = value;
|
prevValueRef.current = value;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const GroupVariableList: FC<GroupVariableListProps> = ({
|
|||||||
name,
|
name,
|
||||||
options = [],
|
options = [],
|
||||||
isCanAdd = false,
|
isCanAdd = false,
|
||||||
size = "middle"
|
size = "small"
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const form = Form.useFormInstance();
|
const form = Form.useFormInstance();
|
||||||
@@ -37,16 +37,10 @@ const GroupVariableList: FC<GroupVariableListProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rb:mb-4">
|
<div>
|
||||||
<Row gutter={12} className="rb:mb-2!">
|
<div className="rb:font-medium rb:text-[12px] rb:mb-1">
|
||||||
<Col span={12}>
|
{t('workflow.config.var-aggregator.variable')}
|
||||||
<Form.Item
|
</div>
|
||||||
noStyle
|
|
||||||
>
|
|
||||||
{t('workflow.config.var-aggregator.variable')}
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name={name}
|
name={name}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { type FC, useEffect, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Form } from 'antd'
|
||||||
|
import { Node } from '@antv/x6'
|
||||||
|
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin'
|
||||||
|
import MappingList from '../MappingList'
|
||||||
|
import MessageEditor from '../MessageEditor'
|
||||||
|
|
||||||
|
interface MappingItem {
|
||||||
|
name?: string
|
||||||
|
value?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JinjaRenderProps {
|
||||||
|
options: Suggestion[]
|
||||||
|
templateOptions: Suggestion[]
|
||||||
|
selectedNode: Node
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractTemplateVars = (template: string): string[] => {
|
||||||
|
return (template.match(/{{\s*([\w.]+)\s*}}/g) || [])
|
||||||
|
.map(m => m.replace(/{{\s*|\s*}}/g, ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMappingNames = (mapping: MappingItem[]): string[] => {
|
||||||
|
return mapping.filter(item => item?.name).map(item => item.name!)
|
||||||
|
}
|
||||||
|
|
||||||
|
const JinjaRender: FC<JinjaRenderProps> = ({ selectedNode, options, templateOptions }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const form = Form.useFormInstance()
|
||||||
|
const values = Form.useWatch([], form) || {}
|
||||||
|
|
||||||
|
console.log('JinjaRender values', values)
|
||||||
|
|
||||||
|
const prevMappingNamesRef = useRef<string[]>([])
|
||||||
|
const prevTemplateVarsRef = useRef<string[]>([])
|
||||||
|
const syncTimeoutRef = useRef<number | null>(null)
|
||||||
|
const isSyncingRef = useRef(false)
|
||||||
|
const lastSyncSourceRef = useRef<'mapping' | 'template' | null>(null)
|
||||||
|
|
||||||
|
// Reset refs when node changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedNode?.getData()?.id) {
|
||||||
|
prevMappingNamesRef.current = []
|
||||||
|
prevTemplateVarsRef.current = []
|
||||||
|
lastSyncSourceRef.current = null
|
||||||
|
}
|
||||||
|
}, [selectedNode?.getData()?.id])
|
||||||
|
|
||||||
|
// Sync template when mapping names change
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
isSyncingRef.current ||
|
||||||
|
lastSyncSourceRef.current === 'mapping' ||
|
||||||
|
selectedNode?.data?.type !== 'jinja-render' ||
|
||||||
|
!values?.mapping ||
|
||||||
|
!values?.template
|
||||||
|
) return
|
||||||
|
|
||||||
|
const currentMappingNames = Array.isArray(values.mapping) ? getMappingNames(values.mapping) : []
|
||||||
|
const prevNames = prevMappingNamesRef.current
|
||||||
|
|
||||||
|
if (prevNames.length === 0) {
|
||||||
|
prevMappingNamesRef.current = currentMappingNames
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (JSON.stringify(prevNames) === JSON.stringify(currentMappingNames)) return
|
||||||
|
|
||||||
|
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current)
|
||||||
|
const activeElement = document.activeElement as HTMLElement
|
||||||
|
|
||||||
|
syncTimeoutRef.current = setTimeout(() => {
|
||||||
|
let updatedTemplate = String(form.getFieldValue('template') || '')
|
||||||
|
|
||||||
|
prevNames.forEach((oldName, index) => {
|
||||||
|
const newName = currentMappingNames[index]
|
||||||
|
if (newName && oldName !== newName) {
|
||||||
|
updatedTemplate = updatedTemplate.replace(
|
||||||
|
new RegExp(`{{\\s*${oldName}\\s*}}`, 'g'),
|
||||||
|
`{{${newName}}}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (updatedTemplate !== form.getFieldValue('template')) {
|
||||||
|
isSyncingRef.current = true
|
||||||
|
lastSyncSourceRef.current = 'mapping'
|
||||||
|
|
||||||
|
prevTemplateVarsRef.current = extractTemplateVars(updatedTemplate)
|
||||||
|
prevMappingNamesRef.current = currentMappingNames
|
||||||
|
form.setFieldValue('template', updatedTemplate)
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
activeElement?.focus?.()
|
||||||
|
setTimeout(() => {
|
||||||
|
isSyncingRef.current = false
|
||||||
|
lastSyncSourceRef.current = null
|
||||||
|
}, 50)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
prevMappingNamesRef.current = currentMappingNames
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
}, [values?.mapping, selectedNode?.data?.type, form])
|
||||||
|
|
||||||
|
// Sync mapping when template variables change
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('values?.template', values?.template)
|
||||||
|
if (
|
||||||
|
isSyncingRef.current ||
|
||||||
|
lastSyncSourceRef.current === 'template' ||
|
||||||
|
selectedNode?.data?.type !== 'jinja-render' ||
|
||||||
|
!values?.template ||
|
||||||
|
!values?.mapping
|
||||||
|
) return
|
||||||
|
|
||||||
|
const templateVars = extractTemplateVars(String(values.template))
|
||||||
|
if (JSON.stringify(prevTemplateVarsRef.current) === JSON.stringify(templateVars)) return
|
||||||
|
|
||||||
|
const isTemplateEditor = document.activeElement?.closest('[data-editor-type="template"]')
|
||||||
|
if (!isTemplateEditor) {
|
||||||
|
prevTemplateVarsRef.current = templateVars
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedMapping: MappingItem[] = Array.isArray(values.mapping)
|
||||||
|
? [...values.mapping.filter((item: MappingItem) => item)]
|
||||||
|
: []
|
||||||
|
const existingNames = getMappingNames(updatedMapping)
|
||||||
|
let updatedTemplate = String(values.template)
|
||||||
|
|
||||||
|
// Update existing mapping names based on position
|
||||||
|
if (prevTemplateVarsRef.current.length > 0) {
|
||||||
|
prevTemplateVarsRef.current.forEach((oldVar, index) => {
|
||||||
|
const newVar = templateVars[index]
|
||||||
|
if (newVar && oldVar !== newVar && updatedMapping[index]) {
|
||||||
|
updatedMapping[index] = { ...updatedMapping[index], name: newVar }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new mappings and normalize template
|
||||||
|
templateVars.forEach(varName => {
|
||||||
|
const existingMapping = updatedMapping.find(item => item.value === `{{${varName}}}`)
|
||||||
|
const regex = new RegExp(`{{\\s*${varName.replace(/\./g, '\\.')}\\s*}}`, 'g')
|
||||||
|
|
||||||
|
if (existingMapping) {
|
||||||
|
updatedTemplate = updatedTemplate.replace(regex, `{{${existingMapping.name}}}`)
|
||||||
|
} else if (!existingNames.includes(varName)) {
|
||||||
|
const mappingName = varName.includes('.') ? varName.split('.').pop() || varName : varName
|
||||||
|
updatedMapping.push({ name: mappingName, value: `{{${varName}}}` })
|
||||||
|
updatedTemplate = updatedTemplate.replace(regex, `{{${mappingName}}}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove unused mappings and duplicates
|
||||||
|
const seenNames = new Set<string>()
|
||||||
|
const finalMapping = updatedMapping.filter(item => {
|
||||||
|
const isUsed = templateVars.some(v => item.name === v || item.value === `{{${v}}}`)
|
||||||
|
if (!isUsed || !item.name || seenNames.has(item.name)) return false
|
||||||
|
seenNames.add(item.name)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
isSyncingRef.current = true
|
||||||
|
lastSyncSourceRef.current = 'template'
|
||||||
|
prevMappingNamesRef.current = getMappingNames(finalMapping)
|
||||||
|
prevTemplateVarsRef.current = templateVars
|
||||||
|
|
||||||
|
if (JSON.stringify(finalMapping) !== JSON.stringify(values.mapping)) {
|
||||||
|
form.setFieldValue('mapping', finalMapping)
|
||||||
|
}
|
||||||
|
if (updatedTemplate !== String(values.template)) {
|
||||||
|
form.setFieldValue('template', updatedTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
isSyncingRef.current = false
|
||||||
|
lastSyncSourceRef.current = null
|
||||||
|
}, 50)
|
||||||
|
}, [values?.template, selectedNode?.data?.type, form])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item name="mapping" noStyle>
|
||||||
|
<MappingList name="mapping" options={options} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="template">
|
||||||
|
<MessageEditor
|
||||||
|
title={t('workflow.config.jinja-render.template')}
|
||||||
|
isArray={false}
|
||||||
|
parentName="template"
|
||||||
|
enableJinja2={true}
|
||||||
|
options={templateOptions}
|
||||||
|
titleVariant="borderless"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default JinjaRender
|
||||||
@@ -34,9 +34,9 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
|||||||
if (filterOption) {
|
if (filterOption) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={clsx("rb:w-full rb:wrap-break-word rb:line-clamp-1 rb:border rb:border-[#DFE4ED] rb:rounded-md rb:bg-white rb:text-[12px] rb:inline-flex rb:items-center rb:px-1.5 rb:cursor-pointer", {
|
className={clsx("rb:max-w-full rb:wrap-break-word rb:line-clamp-1 rb:border rb:border-[#DFE4ED] rb:rounded-md rb:bg-white rb:text-[12px] rb:inline-flex rb:items-center rb:px-1.5 rb:cursor-pointer", {
|
||||||
'rb:leading-5.5!': size !== 'small',
|
'rb:leading-5.5!': size !== 'small',
|
||||||
'rb:leading-4!': size === 'small'
|
'rb:leading-4! rb:text-[10px]!': size === 'small'
|
||||||
})}
|
})}
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { useMemo, useEffect, useState } from 'react';
|
||||||
|
import { Graph, Node } from '@antv/x6';
|
||||||
|
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin';
|
||||||
|
import type { ChatVariable } from '../../../types';
|
||||||
|
|
||||||
|
const NODE_VARIABLES = {
|
||||||
|
llm: [{ label: 'output', dataType: 'string', field: 'output' }],
|
||||||
|
'jinja-render': [{ label: 'output', dataType: 'string', field: 'output' }],
|
||||||
|
tool: [{ label: 'data', dataType: 'string', field: 'data' }],
|
||||||
|
'knowledge-retrieval': [{ label: 'output', dataType: 'array[object]', field: 'output' }],
|
||||||
|
'parameter-extractor': [
|
||||||
|
{ label: '__is_success', dataType: 'number', field: '__is_success' },
|
||||||
|
{ label: '__reason', dataType: 'string', field: '__reason' }
|
||||||
|
],
|
||||||
|
'http-request': [
|
||||||
|
{ label: 'body', dataType: 'string', field: 'body' },
|
||||||
|
{ label: 'status_code', dataType: 'number', field: 'status_code' }
|
||||||
|
],
|
||||||
|
'question-classifier': [{ label: 'class_name', dataType: 'string', field: 'class_name' }],
|
||||||
|
'memory-read': [
|
||||||
|
{ label: 'answer', dataType: 'string', field: 'answer' },
|
||||||
|
{ label: 'intermediate_outputs', dataType: 'array[object]', field: 'intermediate_outputs' }
|
||||||
|
]
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const addVariable = (
|
||||||
|
list: Suggestion[],
|
||||||
|
keys: Set<string>,
|
||||||
|
key: string,
|
||||||
|
label: string,
|
||||||
|
dataType: string,
|
||||||
|
value: string,
|
||||||
|
nodeData: any,
|
||||||
|
extra?: Partial<Suggestion>
|
||||||
|
) => {
|
||||||
|
if (!keys.has(key)) {
|
||||||
|
keys.add(key);
|
||||||
|
list.push({ key, label, type: 'variable', dataType, value, nodeData, ...extra });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processNodeVariables = (
|
||||||
|
nodeData: any,
|
||||||
|
dataNodeId: string,
|
||||||
|
variableList: Suggestion[],
|
||||||
|
addedKeys: Set<string>
|
||||||
|
) => {
|
||||||
|
const { type, config } = nodeData;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'start':
|
||||||
|
[...(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);
|
||||||
|
});
|
||||||
|
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':
|
||||||
|
(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':
|
||||||
|
if (config.group.defaultValue) {
|
||||||
|
(config.group_variables.defaultValue || []).forEach((gv: any) => {
|
||||||
|
if (gv?.key) {
|
||||||
|
let dt = 'string';
|
||||||
|
if (gv.value?.[0]) {
|
||||||
|
const fv = variableList.find(v => `{{${v.value}}}` === gv.value[0]);
|
||||||
|
if (fv) dt = fv.dataType;
|
||||||
|
}
|
||||||
|
addVariable(variableList, addedKeys, `${dataNodeId}_${gv.key}`, gv.key, dt, `${dataNodeId}.${gv.key}`, nodeData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const fv = (config.group_variables.defaultValue || [])[0];
|
||||||
|
let dt = 'any';
|
||||||
|
if (fv) {
|
||||||
|
const found = variableList.find(v => `{{${v.value}}}` === fv);
|
||||||
|
if (found) dt = found.dataType;
|
||||||
|
}
|
||||||
|
addVariable(variableList, addedKeys, `${dataNodeId}_output`, 'output', dt, `${dataNodeId}.output`, nodeData);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'iteration':
|
||||||
|
let dt = 'string';
|
||||||
|
if (nodeData.output) {
|
||||||
|
const sv = variableList.find(v => v.value === nodeData.output);
|
||||||
|
if (sv) dt = sv.dataType;
|
||||||
|
}
|
||||||
|
addVariable(variableList, addedKeys, `${dataNodeId}_output`, 'output', `array[${dt}]`, `${dataNodeId}.output`, nodeData);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'loop':
|
||||||
|
(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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasOutputNodeTypes = [
|
||||||
|
'llm',
|
||||||
|
'knowledge-retrieval',
|
||||||
|
'memory-read',
|
||||||
|
'question-classifier',
|
||||||
|
'var-aggregator',
|
||||||
|
'http-request',
|
||||||
|
'tool',
|
||||||
|
'jinja-render'
|
||||||
|
]
|
||||||
|
export const getCurrentNodeVariables = (nodeData: any, values: any): Suggestion[] => {
|
||||||
|
if (!nodeData || !hasOutputNodeTypes.includes(nodeData.type)) return [];
|
||||||
|
const list: Suggestion[] = [];
|
||||||
|
const keys = new Set<string>();
|
||||||
|
const dataNodeId = nodeData.id;
|
||||||
|
|
||||||
|
processNodeVariables({
|
||||||
|
...nodeData,
|
||||||
|
config: {
|
||||||
|
...nodeData.config,
|
||||||
|
...values
|
||||||
|
}
|
||||||
|
}, dataNodeId, list, keys);
|
||||||
|
return nodeData.type === 'var-aggregator' && !nodeData.config.group.defaultValue ? [] : list;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useVariableList = (
|
||||||
|
selectedNode: Node | null | undefined,
|
||||||
|
graphRef: React.MutableRefObject<Graph | undefined>,
|
||||||
|
chatVariables: ChatVariable[]
|
||||||
|
) => {
|
||||||
|
const [trigger, setTrigger] = useState(0);
|
||||||
|
|
||||||
|
const variableList = useMemo(() => {
|
||||||
|
if (!selectedNode || !graphRef?.current) return [];
|
||||||
|
|
||||||
|
const list: Suggestion[] = [];
|
||||||
|
const graph = graphRef.current;
|
||||||
|
const edges = graph.getEdges();
|
||||||
|
const nodes = graph.getNodes();
|
||||||
|
const keys = new Set<string>();
|
||||||
|
|
||||||
|
const getPreviousNodes = (nodeId: string, visited = new Set<string>()): string[] => {
|
||||||
|
if (visited.has(nodeId)) return [];
|
||||||
|
visited.add(nodeId);
|
||||||
|
const prev = edges.filter(e => e.getTargetCellId() === nodeId).map(e => e.getSourceCellId());
|
||||||
|
return [...prev, ...prev.flatMap(id => getPreviousNodes(id, visited))];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getParentLoop = (nodeId: string): Node | null => {
|
||||||
|
const node = nodes.find(n => n.id === nodeId);
|
||||||
|
const cycle = node?.getData()?.cycle;
|
||||||
|
if (cycle) {
|
||||||
|
const parent = nodes.find(n => n.getData().id === cycle);
|
||||||
|
if (parent?.getData()?.type === 'loop' || parent?.getData()?.type === 'iteration') return parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
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) : [])];
|
||||||
|
|
||||||
|
chatVariables?.forEach(v => addVariable(list, keys, `CONVERSATION_${v.name}`, v.name, v.type, `conv.${v.name}`, { type: 'CONVERSATION', name: 'CONVERSATION', icon: '' }, { group: 'CONVERSATION' }));
|
||||||
|
|
||||||
|
relevantIds.forEach(id => {
|
||||||
|
const node = nodes.find(n => n.id === id);
|
||||||
|
if (node) processNodeVariables(node.getData(), node.getData().id, list, keys);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parentLoop) {
|
||||||
|
const pd = parentLoop.getData();
|
||||||
|
const pid = pd.id;
|
||||||
|
if (pd.type === 'loop') {
|
||||||
|
(pd.cycle_vars || []).forEach((cv: any) => addVariable(list, keys, `${pid}_cycle_${cv.name}`, cv.name, cv.type || 'String', `${pid}.${cv.name}`, pd));
|
||||||
|
} 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');
|
||||||
|
addVariable(list, keys, `${pid}_item`, 'item', itemType, `${pid}.item`, pd);
|
||||||
|
addVariable(list, keys, `${pid}_index`, 'index', 'number', `${pid}.index`, pd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}, [selectedNode, graphRef, trigger, chatVariables]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!graphRef?.current) return;
|
||||||
|
const graph = graphRef.current;
|
||||||
|
const handler = () => setTrigger(p => p + 1);
|
||||||
|
const events = ['edge:added', 'edge:removed', 'edge:changed', 'edge:connected', 'node:added', 'node:removed', 'node:change:data'];
|
||||||
|
events.forEach(e => graph.on(e, handler));
|
||||||
|
return () => events.forEach(e => graph.off(e, handler));
|
||||||
|
}, [graphRef]);
|
||||||
|
|
||||||
|
return variableList;
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -275,11 +275,6 @@ export const useWorkflowGraph = ({
|
|||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
if (edges.length) {
|
if (edges.length) {
|
||||||
// 计算loop和iteration类型节点的数量
|
|
||||||
const loopIterationCount = nodes.filter(node =>
|
|
||||||
node.type === 'loop' || node.type === 'iteration'
|
|
||||||
).length;
|
|
||||||
|
|
||||||
// 去重处理:对于if-else和question-classifier节点,不同连接桩允许连接到相同节点
|
// 去重处理:对于if-else和question-classifier节点,不同连接桩允许连接到相同节点
|
||||||
const uniqueEdges = edges.filter((edge, index, arr) => {
|
const uniqueEdges = edges.filter((edge, index, arr) => {
|
||||||
return arr.findIndex(e => {
|
return arr.findIndex(e => {
|
||||||
@@ -805,6 +800,9 @@ export const useWorkflowGraph = ({
|
|||||||
validateConnection({ sourceCell, targetCell, targetMagnet }) {
|
validateConnection({ sourceCell, targetCell, targetMagnet }) {
|
||||||
if (!targetMagnet) return false;
|
if (!targetMagnet) return false;
|
||||||
|
|
||||||
|
// 节点不能与自己连线
|
||||||
|
if (sourceCell?.id === targetCell?.id) return false;
|
||||||
|
|
||||||
const sourceType = sourceCell?.getData()?.type;
|
const sourceType = sourceCell?.getData()?.type;
|
||||||
const targetType = targetCell?.getData()?.type;
|
const targetType = targetCell?.getData()?.type;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user