feat(web): add loop node; add chat variable;

This commit is contained in:
zhaoying
2026-01-04 20:00:10 +08:00
parent 4e3b8870c5
commit a66fb9eade
29 changed files with 1453 additions and 279 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 B

View File

@@ -1560,6 +1560,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
'question-classifier': 'Question Classifier',
iteration: 'Iteration',
loop: 'Loop',
'cycle-start': '',
break: 'Break Loop',
parallel: 'Parallel Execution',
'var-aggregator': 'Variable Aggregator',
externalInteraction: 'External Interaction',
@@ -1585,9 +1587,11 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
clickToConfigure: 'Click to configure node parameters',
nodeProperties: 'Node Properties',
empty: "Emmm... The box is empty, nothing here~",
empty: "EmmmThe box is empty, there's nothing here",
nodeName: 'Node Name',
addvariable: 'Chat Variables',
addChatVariable: 'Add Chat Variable',
editChatVariable: 'Edit Chat Variable',
config: {
llm: {
@@ -1609,7 +1613,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
editVariable: 'Edit Variable',
variableType: 'Variable Type',
variableName: 'Variable Name',
invalidVariableName: 'Variable name must start with a letter and contain only letters, numbers, and underscores',
invalidVariableName: 'Variable name can only start with English letters and contain English letters, numbers, and underscores',
description: 'Display Name',
default: 'Default Value',
required: 'Required',
@@ -1636,10 +1640,11 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
editParam: 'Edit Extract Parameter',
name: 'Name',
invalidParamName: 'Parameter name must start with a letter and contain only letters, numbers, and underscores',
invalidParamName: 'Extract parameter name can only start with English letters and contain English letters, numbers, and underscores',
type: 'Type',
desc: 'Description',
required: 'Required',
default: 'Default Value',
'string': 'String',
'number': 'Number',
@@ -1651,7 +1656,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
},
'var-aggregator': {
group: 'Aggregation Group',
invalidVariableName: 'Variable name must start with a letter and contain only letters, numbers, and underscores',
invalidVariableName: 'Variable name can only start with English letters and contain English letters, numbers, and underscores',
addGroup: 'Add Group',
variable: 'Variable Assignment'
},
@@ -1670,17 +1675,49 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
"ge": '>=',
else_desc: 'Used to define the logic that should be executed when the if condition is not met.'
},
'http-request': {
auth: 'Authentication',
authType: 'Auth Type',
apiKey: 'API Key',
basic: 'Basic',
bearer: 'Bearer',
custom: 'Custom',
header: 'Header',
api_key: 'API Key',
timeouts: 'Timeout Settings',
"connect_timeout": 'Connection Timeout (seconds)',
"read_timeout": 'Read Timeout (seconds)',
"write_timeout": 'Write Timeout (seconds)',
retry: 'Retry on Failure',
error_handle: 'Error Handling',
verify_ssl: 'Verify SSL Certificate',
none: 'None',
default: 'Default Value',
branch: 'Error Branch',
status_code: 'Status Code',
max_attempts: 'Max Retry Attempts',
retry_interval: 'Retry Interval',
},
'jinja-render': {
template: 'Code',
mapping: 'Input Variables'
},
'question-classifier': {
model_id: 'Model',
input_variable: 'Input Variable',
categories: 'Categories',
user_supplement_prompt: 'Instruction',
class_name: 'Category',
addClassName: 'Add Category'
class_name: 'Classification',
addClassName: 'Add Classification'
},
loop: {
cycle_vars: 'Loop Variables',
condition: 'Loop Termination Condition',
},
name: 'Key',
type: 'Type',
value: 'Value',
addCase: 'Add Condition',
},
clear: 'Clear',

View File

@@ -1662,7 +1662,7 @@ export const zh = {
iteration: '迭代 (Iteration)',
loop: '循环 (Loop)',
'cycle-start': '',
'cycle-end': '退出循环',
break: '退出循环',
parallel: '并行执行',
'var-aggregator': '变量聚合器',
externalInteraction: '外部交互',
@@ -1690,7 +1690,9 @@ export const zh = {
nodeProperties: '节点属性',
empty: "Emmm…盒子是空的这里什么都没有",
nodeName: '节点名称',
addvariable: '会话变量',
addChatVariable: '添加会话变量',
editChatVariable: '编辑会话变量',
config: {
llm: {
@@ -1743,6 +1745,7 @@ export const zh = {
type: '类型',
desc: '描述',
required: '必填',
default: '默认值',
'string': 'String',
'number': 'Number',

View File

@@ -101,6 +101,9 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
const clear = () => {
workflowRef?.current?.graphRef?.current?.clearCells()
}
const addvariable = () => {
workflowRef?.current?.addVariable()
}
return (
<>
<Header className="rb:w-full rb:h-16 rb:grid rb:grid-cols-3 rb:p-[16px_16px_16px_24px]! rb:border-b rb:border-[#EAECEE] rb:leading-8">
@@ -132,6 +135,7 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
{application?.type === 'workflow'
? <div className="rb:h-8 rb:flex rb:items-center rb:justify-end rb:gap-2.5">
<Button onClick={clear}>{t('workflow.clear')}</Button>
<Button onClick={addvariable}>{t('workflow.addvariable')}</Button>
<Button onClick={run}>{t('workflow.run')}</Button>
<Button type="primary" onClick={save}>{t('workflow.save')}</Button>
{/* <Button type="primary">{t('workflow.export')}</Button> */}

View File

@@ -121,7 +121,8 @@ export interface ClusterRef {
export interface WorkflowRef {
handleSave: (flag?: boolean) => Promise<unknown>;
handleRun: () => void;
graphRef: GraphRef
graphRef: GraphRef;
addVariable: () => void;
}
export interface ApplicationModalRef {
handleOpen: (application?: Config) => void;

View File

@@ -0,0 +1,144 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Input, Select, Checkbox, InputNumber } from 'antd';
import { useTranslation } from 'react-i18next';
import type { ChatVariableModalRef } from './types'
import type { ChatVariable } from '../../types';
import RbModal from '@/components/RbModal'
const FormItem = Form.Item;
interface ChatVariableModalProps {
refresh: (value: ChatVariable, editIndex?: number) => void;
}
const types = [
'string',
'number',
'boolean',
]
const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProps>(({
refresh
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<ChatVariable>();
const [loading, setLoading] = useState(false)
const [editIndex, setEditIndex] = useState<number | undefined>(undefined)
const typeValue = Form.useWatch('type', form);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
setLoading(false)
setEditIndex(undefined)
};
const handleOpen = (variable?: ChatVariable, index?: number) => {
setVisible(true);
if (variable) {
form.setFieldsValue(variable)
setEditIndex(index)
} else {
form.resetFields();
setEditIndex(undefined)
}
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form.validateFields().then((values) => {
refresh({ ...values }, editIndex)
handleClose()
})
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen
}));
return (
<RbModal
title={editIndex !== undefined ? t('workflow.editChatVariable') : t('workflow.addChatVariable')}
open={visible}
onCancel={handleClose}
okText={t('common.save')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
scrollToFirstError={{ behavior: 'instant', block: 'end', focus: true }}
>
<FormItem
name="name"
label={t('workflow.config.parameter-extractor.name')}
rules={[
{ required: true, message: t('common.pleaseEnter') },
{ pattern: /^[a-zA-Z_][a-zA-Z0-9_]*$/, message: t('workflow.config.parameter-extractor.invalidParamName') },
]}
>
<Input placeholder={t('common.enter')} />
</FormItem>
<FormItem
name="type"
label={t('workflow.config.parameter-extractor.type')}
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<Select
placeholder={t('common.pleaseSelect')}
onChange={() => form.setFieldValue('default', undefined)}
options={types.map(key => ({
value: key,
label: t(`workflow.config.parameter-extractor.${key}`),
}))}
/>
</FormItem>
<FormItem
name="default"
label={t('workflow.config.parameter-extractor.default')}
>
<Form.Item noStyle shouldUpdate={(prevValues, currentValues) => prevValues.type !== currentValues.type}>
{({ getFieldValue }) => {
const type = getFieldValue('type');
if (type === 'number') {
return <InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} />;
}
if (type === 'boolean') {
return (
<Select
placeholder={t('common.pleaseSelect')}
options={[
{ value: true, label: 'true' },
{ value: false, label: 'false' }
]}
/>
);
}
return <Input placeholder={t('common.enter')} />;
}}
</Form.Item>
</FormItem>
<FormItem
name="description"
label={t('workflow.config.parameter-extractor.desc')}
>
<Input.TextArea placeholder={t('common.enter')} />
</FormItem>
<FormItem
name="required"
valuePropName="checked"
>
<Checkbox>{t('workflow.config.parameter-extractor.required')}</Checkbox>
</FormItem>
</Form>
</RbModal>
);
});
export default ChatVariableModal;

View File

@@ -0,0 +1,113 @@
import React, { useState, useImperativeHandle, forwardRef, useRef } from 'react';
import { Button, Input, Space, Typography, Tooltip, message, List } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import type { ChatVariable, AddChatVariableRef } from '../../types';
import type { ChatVariableModalRef } from './types'
import RbDrawer from '@/components/RbDrawer';
import Empty from '@/components/Empty';
import ChatVariableModal from './ChatVariableModal';
interface AddChatVariableProps {
variables?: ChatVariable[];
onChange?: (variables: ChatVariable[]) => void;
disabled?: boolean;
maxVariables?: number;
}
const AddChatVariable = forwardRef<AddChatVariableRef, AddChatVariableProps>(({
variables = [],
onChange,
}, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const chatVariableRef = useRef<ChatVariableModalRef>(null);
const handleAddVariable = () => {
chatVariableRef.current?.handleOpen()
};
const handleEdit = (index: number) => {
chatVariableRef.current?.handleOpen(variables[index], index)
}
const handleDelete = (index: number) => {
const list = [...variables]
list.splice(index, 1)
onChange && onChange(list)
}
const handleOpen = () => {
setOpen(true)
}
const handleSave = (value: ChatVariable, index?: number) => {
const list = [...variables]
if (index && index > -1) {
list[index] = value
} else {
list.push(value)
}
onChange && onChange(list)
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
}));
return (
<RbDrawer
title={t('workflow.addvariable')}
open={open}
onClose={() => setOpen(false)}
>
<div>
<Button
type="primary"
className="rb:mb-3"
onClick={handleAddVariable}
>
+ {t('workflow.addChatVariable')}
</Button>
{variables.length === 0
? <Empty size={88} />
:
<List
grid={{ gutter: 12, column: 1 }}
dataSource={variables}
renderItem={(item, index) => (
<List.Item>
<div key={index} className="rb:group rb:relative rb:p-[12px_16px] rb:bg-[#FBFDFF] rb:cursor-pointer rb:border rb:border-[#DFE4ED] rb:rounded-lg">
<div className="rb:flex rb:items-center rb:justify-between">
<div className="rb:leading-4">
<span className="rb:font-medium">{item.name}</span>
<span className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular"> ({t(`workflow.config.parameter-extractor.${item.type}`)})</span>
</div>
<span className="rb:block rb:group-hover:hidden rb:text-[12px] rb:text-[#5B6167] rb:font-regular">{item.required ? t('workflow.config.parameter-extractor.required') : ''}</span>
</div>
<div className="rb:mt-1 rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:wrap-break-word rb:line-clamp-1">{item.description}</div>
<Space size={12} className="rb:hidden! rb:group-hover:flex! rb:absolute rb:right-4 rb:top-[50%] rb:transform-[translateY(-50%)] rb:bg-white">
<div
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/editBorder.svg')] rb:hover:bg-[url('@/assets/images/editBg.svg')]"
onClick={() => handleEdit(index)}
></div>
<div
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/deleteBorder.svg')] rb:hover:bg-[url('@/assets/images/deleteBg.svg')]"
onClick={() => handleDelete(index)}
></div>
</Space>
</div>
</List.Item>
)}
/>
}
</div>
<ChatVariableModal
ref={chatVariableRef}
refresh={handleSave}
/>
</RbDrawer>
);
});
export default AddChatVariable;

View File

@@ -0,0 +1,24 @@
import type { ChatVariable } from '../../types'
export interface AddChatVariableProps {
variables?: ChatVariable[];
onChange?: (variables: ChatVariable[]) => void;
disabled?: boolean;
maxVariables?: number;
}
export interface VariableFormData {
name: string;
type: ChatVariable['type'];
description?: string;
required?: boolean;
defaultValue?: any;
}
export interface ChatVariableModalRef {
handleOpen: (value?: ChatVariable, index?: number) => void;
}
export interface ChatVariableModalRef {
handleOpen: (vo?: ChatVariable, index?: number) => void;
}

View File

@@ -44,14 +44,14 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({
>
{data.isContext ? (
<span style={{ fontSize: '12px', marginRight: '4px' }}>📄</span>
) : (
) : data.group !== 'CONVERSATION' ? (
<img
src={data.nodeData?.icon}
style={{ width: '12px', height: '12px', marginRight: '4px' }}
alt=""
/>
)}
{!data.isContext && (
) : null}
{!data.isContext && data.group !== 'CONVERSATION' && (
<>
<span className="rb:wrap-break-word rb:line-clamp-1">{data.nodeData?.name}</span>
<span style={{ color: '#DFE4ED', margin: '0 2px' }}>/</span>

View File

@@ -11,6 +11,7 @@ export interface Suggestion {
type: string;
dataType: string;
value: string;
group?: string
nodeData: NodeProperties;
isContext?: boolean; // 标记是否为context变量
disabled?: boolean; // 标记是否禁用

View File

@@ -23,7 +23,9 @@ const NodeLibrary: FC = () => {
}}
>
<Space size={8} direction="vertical" className="rb:w-full">
{category.nodes.map((node, nodeIndex) => (
{category.nodes
.filter(node => node.type !== 'cycle-start' && node.type !== 'break')
.map((node, nodeIndex) => (
<div
key={nodeIndex}
className="rb:bg-white rb:rounded-lg rb:p-2 rb:border rb:border-[#DFE4ED] rb:cursor-pointer rb:flex rb:items-center rb:gap-2 rb:hover:border-[#155EEF] rb:hover:shadow-[0px_2px_4px_0px_rgba(33,35,50,0.15)]"

View File

@@ -1,18 +1,167 @@
import { useState } from 'react';
import { Popover } from 'antd';
import clsx from 'clsx';
import type { ReactShapeConfig } from '@antv/x6-react-shape';
import { nodeLibrary, graphNodeLibrary } from '../../constant';
import { useTranslation } from 'react-i18next';
const AddNode: ReactShapeConfig['component'] = ({ node }) => {
const data = node?.getData() || {}
const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => {
const data = node?.getData() || {};
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const handleNodeSelect = (selectedNodeType: any) => {
const parentBBox = node.getBBox();
const cycleId = data.cycle;
const newNode = graph.addNode({
...(graphNodeLibrary[selectedNodeType.type] || graphNodeLibrary.default),
x: parentBBox.x,
y: parentBBox.y,
data: {
id: `${selectedNodeType.type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
type: selectedNodeType.type,
icon: selectedNodeType.icon,
name: t(`workflow.${selectedNodeType.type}`),
cycle: cycleId,
parentId: data.parentId,
config: selectedNodeType.config || {}
},
});
// 将新节点添加为父节点的子节点
if (cycleId) {
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
if (parentNode) {
parentNode.addChild(newNode);
}
}
const incomingEdges = graph.getIncomingEdges(node);
const outgoingEdges = graph.getOutgoingEdges(node);
incomingEdges?.forEach(edge => {
graph.addEdge({
source: { cell: edge.getSourceCellId(), port: edge.getSourcePortId() },
target: { cell: newNode.id, port: newNode.getPorts().find((port: any) => port.group === 'left')?.id || 'left' },
attrs: edge.getAttrs()
});
});
outgoingEdges?.forEach(edge => {
const targetCell = graph.getCellById(edge.getTargetCellId()) as any;
const targetPortId = targetCell?.getPorts?.()?.find((port: any) => port.group === 'left')?.id || edge.getTargetPortId();
graph.addEdge({
source: { cell: newNode.id, port: newNode.getPorts().find((port: any) => port.group === 'right')?.id || 'right' },
target: { cell: edge.getTargetCellId(), port: targetPortId },
attrs: edge.getAttrs()
});
});
// 删除所有add-node类型的节点
graph.getNodes().forEach((n: any) => {
if (n.getData()?.type === 'add-node' && n.getData()?.cycle === cycleId) {
n.remove();
}
});
// 自动调整循环节点大小
const loopNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
if (loopNode) {
const adjustLoopSize = () => {
const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
if (childNodes.length > 0) {
const bounds = childNodes.reduce((acc, child) => {
const bbox = child.getBBox();
return {
minX: Math.min(acc.minX, bbox.x),
minY: Math.min(acc.minY, bbox.y),
maxX: Math.max(acc.maxX, bbox.x + bbox.width),
maxY: Math.max(acc.maxY, bbox.y + bbox.height)
};
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
const padding = 20;
const newWidth = Math.max(240, bounds.maxX - bounds.minX + padding * 2);
const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2);
loopNode.prop('size', { width: newWidth, height: newHeight });
}
};
adjustLoopSize();
// 监听子节点移动事件
const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
childNodes.forEach((childNode: any) => {
childNode.on('change:position', adjustLoopSize);
});
}
setOpen(false);
};
const content = (
<div style={{ maxHeight: '300px', overflowY: 'auto', minWidth: '240px' }}>
{nodeLibrary.map((category, categoryIndex) => {
const filteredNodes = category.nodes.filter(nodeType =>
nodeType.type !== 'start' && nodeType.type !== 'end' && nodeType.type !== 'loop' && nodeType.type !== 'cycle-start'
);
if (filteredNodes.length === 0) return null;
return (
<div key={category.category}>
{categoryIndex > 0 && <div style={{ height: '1px', background: '#f0f0f0', margin: '4px 0' }} />}
<div style={{ padding: '4px 12px', fontSize: '12px', color: '#999', fontWeight: 'bold' }}>
{t(`workflow.${category.category}`)}
</div>
{filteredNodes.map((nodeType) => (
<div
key={nodeType.type}
style={{
padding: '8px 12px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
onClick={() => handleNodeSelect(nodeType)}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f0f8ff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'white';
}}
>
<img src={nodeType.icon} className="rb:w-4 rb:h-4" />
<span style={{ fontSize: '14px' }}>{t(`workflow.${nodeType.type}`)}</span>
</div>
))}
</div>
);
})}
</div>
);
return (
<div className={clsx('rb:group rb:relative rb:h-10 rb:w-30 rb:border rb:rounded-xl rb:flex rb:items-center rb:justify-center rb:text-[12px] rb:p-1 rb:box-border', {
'rb:border-orange-500 rb:border-[3px] rb:bg-white rb:text-gray-700': data.isSelected,
'rb:border-[#d1d5db] rb:bg-white rb:text-[#374151]': !data.isSelected
})}>
<span className="rb:overflow-hidden rb:whitespace-nowrap rb:text-ellipsis">
{data.icon} {data.label}
</span>
</div>
<Popover
content={content}
trigger="click"
open={open}
onOpenChange={setOpen}
placement="bottomLeft"
>
<div
className={clsx('rb:group rb:relative rb:h-11 rb:w-22 rb:border rb:rounded-xl rb:flex rb:items-center rb:justify-center rb:text-[12px] rb:p-1 rb:box-border rb:cursor-pointer', {
'rb:border-orange-500 rb:border-[3px] rb:bg-white rb:text-gray-700': data.isSelected,
'rb:border-[#d1d5db] rb:bg-white rb:text-[#374151]': !data.isSelected
})}
>
<span className="rb:overflow-hidden rb:whitespace-nowrap rb:text-ellipsis">
{data.icon} {data.label}
</span>
</div>
</Popover>
);
};

View File

@@ -7,7 +7,7 @@ const ConditionNode: ReactShapeConfig['component'] = ({ node }) => {
const { t } = useTranslation()
return (
<div className={clsx('rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-60 rb:p-2.5 rb:border rb:rounded-xl rb:bg-white rb:hover:shadow-[0px_2px_6px_0px_rgba(33,35,50,0.12)]', {
<div className={clsx('rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:p-2.5 rb:border rb:rounded-xl rb:bg-white rb:hover:shadow-[0px_2px_6px_0px_rgba(33,35,50,0.12)]', {
'rb:border-[#155EEF]': data.isSelected,
'rb:border-[#DFE4ED]': !data.isSelected
})}>

View File

@@ -1,17 +1,11 @@
import clsx from 'clsx';
import type { ReactShapeConfig } from '@antv/x6-react-shape';
import startIcon from '@/assets/images/workflow/start.png';
const GroupStartNode: ReactShapeConfig['component'] = ({ node }) => {
const data = node?.getData() || {}
const GroupStartNode: ReactShapeConfig['component'] = () => {
return (
<div className={clsx('rb:group rb:relative rb:h-10 rb:w-20 rb:border rb:rounded-xl rb:flex rb:items-center rb:justify-center rb:text-[12px] rb:p-1 rb:box-border', {
'rb:border-orange-500 rb:border-[3px] rb:bg-white rb:text-gray-700': data.isSelected,
'rb:border-[#d1d5db] rb:bg-white rb:text-[#374151]': !data.isSelected
})}>
<span className="rb:overflow-hidden rb:whitespace-nowrap rb:text-ellipsis">
{data.icon} {data.label}
</span>
<div className={clsx('rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:p-2.5 rb:border rb:rounded-xl rb:bg-white rb:hover:shadow-[0px_2px_6px_0px_rgba(33,35,50,0.12)] rb:border-[#DFE4ED]')}>
<img src={startIcon} className="rb:w-6 rb:h-6" />
</div>
);
};

View File

@@ -1,98 +0,0 @@
import { useEffect } from 'react';
import clsx from 'clsx';
import { Dropdown } from 'antd';
import { SmallDashOutlined } from '@ant-design/icons';
import type { ReactShapeConfig } from '@antv/x6-react-shape';
import { graphNodeLibrary } from '../../constant';
interface NodeData {
isSelected?: boolean;
type?: string;
label?: string;
icon?: string;
parentId?: string;
isGroup?: boolean;
}
const IterationNode: ReactShapeConfig['component'] = ({ node, graph }) => {
const data = node.getData() as NodeData;
useEffect(() => {
initNodes()
}, [])
const initNodes = () => {
// 添加默认子节点
const parentBBox = node.getBBox();
const centerX = parentBBox.x + 24; // 默认节点宽度的一半
const centerY = parentBBox.y + 50; // 默认节点高度的一半
const childNode1 = graph.addNode({
...graphNodeLibrary.groupStart,
x: centerX,
y: centerY,
data: {
type: 'default',
label: '开始',
// icon: '📌',
parentId: node.id,
isDefault: true // 标记为默认节点,不可删除
},
});
const childNode2 = graph.addNode({
...graphNodeLibrary.addStart,
x: centerX + 150,
y: centerY,
data: {
type: 'default',
label: '添加节点',
icon: '+',
parentId: node.id,
},
});
node.addChild(childNode1)
node.addChild(childNode2)
}
return (
<div className={clsx('rb:group rb:border-2 rb:border-dashed rb:rounded-xl rb:relative rb:min-w-75 rb:min-h-50 rb:p-4', {
'rb:border-orange-500 rb:border-[3px] rb:bg-white rb:text-gray-700': data?.isSelected,
'rb:border-[#d1d5db] rb:bg-white rb:text-[#374151]': !data?.isSelected
})}>
{/* 标题区域 */}
<div className="rb:absolute rb:-top-3 rb:left-4 rb:bg-[#10b981] rb:rounded-[20px] rb:p-[8px_16px] rb:flex rb:items-center rb:gap-2 rb:text-white rb:text-[14px] rb:font-bold rb:z-10">
<div className="rb:w-5 rb:h-5 rb:bg-[#FFFFFF] rb:rounded-sm rb:flex rb:items-center rb:justify-center rb:text-[12px] rb:text-[#10b981]">
🔁
</div>
</div>
<Dropdown
menu={{items: [
{
key: '1',
label: '删除',
},
{
key: '2',
label: '复制',
},
{
key: '3',
label: '删除',
}
]}}
>
<SmallDashOutlined
className={clsx("rb:cursor-pointer rb:right-1 rb:top-1 rb:invisible rb:absolute rb:group-hover:visible", {
'rb:visible': data.isSelected
})}
/>
</Dropdown>
{/* 画布内容区域 */}
<div className="rb:mt-6 rb:min-h-37.5 rb:w-full rb:bg-[radial-gradient(circle,#e5e7eb_1px,transparent_1px)] rb:bg-size-[12px_12px]"></div>
</div>
);
};
export default IterationNode;

View File

@@ -1,19 +1,10 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next'
import clsx from 'clsx';
import { Dropdown } from 'antd';
import { SmallDashOutlined } from '@ant-design/icons';
import type { ReactShapeConfig } from '@antv/x6-react-shape';
import { graphNodeLibrary } from '../../constant';
interface NodeData {
isSelected?: boolean;
type?: string;
label?: string;
icon?: string;
parentId?: string;
isGroup?: boolean;
}
import { edge_color } from '../../hooks/useWorkflowGraph'
const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => {
const data = node.getData() || {};
@@ -21,63 +12,145 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => {
useEffect(() => {
initNodes()
// 检查是否需要添加add-node
checkAndAddAddNode()
}, [])
const checkAndAddAddNode = () => {
if (!graph) return;
const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === data.id);
const cycleStartNodes = childNodes.filter((n: any) => n.getData()?.type === 'cycle-start');
// 如果只有一个cycle-start节点且没有其他类型的子节点则添加add-node
if (cycleStartNodes.length === 1 && childNodes.length === 1) {
const cycleStartNode = cycleStartNodes[0];
const cycleStartBBox = cycleStartNode.getBBox();
const addNode = graph.addNode({
...graphNodeLibrary.addStart,
x: cycleStartBBox.x + 64,
y: cycleStartBBox.y,
data: {
type: 'add-node',
label: '添加节点',
icon: '+',
parentId: node.id,
cycle: data.id,
},
});
node.addChild(addNode);
// 连接cycle-start和add-node
const sourcePorts = cycleStartNode.getPorts();
const targetPorts = addNode.getPorts();
const sourcePort = sourcePorts.find((port: any) => port.group === 'right')?.id || 'right';
const targetPort = targetPorts.find((port: any) => port.group === 'left')?.id || 'left';
graph.addEdge({
source: { cell: cycleStartNode.id, port: sourcePort },
target: { cell: addNode.id, port: targetPort },
attrs: {
line: {
stroke: edge_color,
strokeWidth: 1,
targetMarker: {
name: 'block',
size: 8,
},
},
},
});
}
}
const initNodes = () => {
// 检查是否存在cycle为当前节点ID的子节点若存在则不调用initNodes避免重复创建
const existingCycleNodes = graph.getNodes().filter((n: any) =>
n.getData()?.cycle === data.id
);
if (existingCycleNodes.length > 0) return;
// 添加默认子节点
const parentBBox = node.getBBox();
const centerX = parentBBox.x + 24; // 默认节点宽度的一半
const centerY = parentBBox.y + 50; // 默认节点高度的一半
const childNode1 = graph.addNode({
...graphNodeLibrary.groupStart,
const cycleStartNode = graph.addNode({
...graphNodeLibrary.cycleStart,
x: centerX,
y: centerY,
data: {
type: 'default',
label: '开始',
// icon: '📌',
type: 'cycle-start',
parentId: node.id,
isDefault: true // 标记为默认节点,不可删除
isDefault: true, // 标记为默认节点,不可删除
cycle: data.id,
},
});
const childNode2 = graph.addNode({
const addNode = graph.addNode({
...graphNodeLibrary.addStart,
x: centerX + 150,
x: centerX + 64,
y: centerY,
data: {
type: 'default',
type: 'add-node',
label: '添加节点',
icon: '+',
parentId: node.id,
cycle: data.id,
},
});
node.addChild(childNode1)
node.addChild(childNode2)
node.addChild(cycleStartNode)
node.addChild(addNode)
const sourcePorts = cycleStartNode.getPorts()
const targetPorts = addNode.getPorts()
let sourcePort = sourcePorts.find((port: any) => port.group === 'right')?.id || 'right';
const edgeConfig = {
source: {
cell: cycleStartNode.id,
port: sourcePort
},
target: {
cell: addNode.id,
port: targetPorts.find((port: any) => port.group === 'left')?.id || 'left'
},
attrs: {
line: {
stroke: edge_color,
strokeWidth: 1,
targetMarker: {
name: 'block',
size: 8,
},
},
},
}
graph.addEdge(edgeConfig)
}
return (
<div className={clsx('rb:cursor-pointer rb:group rb:relative rb:h-16 rb:w-60 rb:p-2.5 rb:border rb:rounded-xl rb:bg-white rb:hover:shadow-[0px_2px_6px_0px_rgba(33,35,50,0.12)]', {
'rb:border-[#155EEF]': data.isSelected,
'rb:border-[#DFE4ED]': !data.isSelected
})}>
<div className="rb:flex rb:items-center rb:justify-between">
<div className="rb:flex rb:items-center rb:gap-2 rb:flex-1">
<img src={data.icon} className="rb:w-5 rb:h-5" />
<div className="rb:wrap-break-word rb:line-clamp-1">{data.name ?? t(`workflow.${data.type}`)}</div>
</div>
<div
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/deleteBorder.svg')] rb:hover:bg-[url('@/assets/images/deleteBg.svg')]"
onClick={(e) => {
e.stopPropagation()
node.remove()
}}
></div>
return (
<div className={clsx('rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:p-2.5 rb:border rb:rounded-xl rb:bg-white rb:hover:shadow-[0px_2px_6px_0px_rgba(33,35,50,0.12)]', {
'rb:border-[#155EEF]': data.isSelected,
'rb:border-[#DFE4ED]': !data.isSelected
})}>
<div className="rb:flex rb:items-center rb:justify-between">
<div className="rb:flex rb:items-center rb:gap-2 rb:flex-1">
<img src={data.icon} className="rb:w-5 rb:h-5" />
<div className="rb:wrap-break-word rb:line-clamp-1">{data.name ?? t(`workflow.${data.type}`)}</div>
</div>
<div className="rb:mt-6 rb:min-h-37.5 rb:w-full rb:bg-[radial-gradient(circle,#e5e7eb_1px,transparent_1px)] rb:bg-size-[12px_12px]"></div>
<div
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/deleteBorder.svg')] rb:hover:bg-[url('@/assets/images/deleteBg.svg')]"
onClick={(e) => {
e.stopPropagation()
node.remove()
}}
></div>
</div>
);
<div className="rb:mt-3 rb:min-h-[calc(100%-36px)] rb:w-full rb:bg-[radial-gradient(circle,#e5e7eb_1px,transparent_1px)] rb:bg-size-[12px_12px]"></div>
</div>
);
};
export default LoopNode;

View File

@@ -7,7 +7,7 @@ const NormalNode: ReactShapeConfig['component'] = ({ node }) => {
const { t } = useTranslation()
return (
<div className={clsx('rb:cursor-pointer rb:group rb:relative rb:h-16 rb:w-60 rb:p-2.5 rb:border rb:rounded-xl rb:bg-white rb:hover:shadow-[0px_2px_6px_0px_rgba(33,35,50,0.12)]', {
<div className={clsx('rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:p-2.5 rb:border rb:rounded-xl rb:bg-white rb:hover:shadow-[0px_2px_6px_0px_rgba(33,35,50,0.12)]', {
'rb:border-[#155EEF]': data.isSelected,
'rb:border-[#DFE4ED]': !data.isSelected
})}>

View File

@@ -0,0 +1,220 @@
import { useEffect, useState } from 'react';
import { Popover } from 'antd';
import { useTranslation } from 'react-i18next';
import { nodeLibrary, graphNodeLibrary } from '../constant';
interface PortClickHandlerProps {
graph: any;
}
const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
const { t } = useTranslation();
const [popoverVisible, setPopoverVisible] = useState(false);
const [popoverPosition, setPopoverPosition] = useState({ x: 0, y: 0 });
const [sourceNode, setSourceNode] = useState<any>(null);
const [sourcePort, setSourcePort] = useState<string>('');
const [tempElement, setTempElement] = useState<HTMLElement | null>(null);
useEffect(() => {
const handlePortClick = (event: CustomEvent) => {
const { node, port, element, rect } = event.detail;
setSourceNode(node);
setSourcePort(port);
setTempElement(element);
setPopoverPosition({ x: rect.left, y: rect.top });
setPopoverVisible(true);
};
window.addEventListener('port:click', handlePortClick as EventListener);
return () => {
window.removeEventListener('port:click', handlePortClick as EventListener);
};
}, []);
const handleNodeSelect = (selectedNodeType: any) => {
if (!sourceNode || !graph) return;
const sourceNodeData = sourceNode.getData();
// 计算新节点位置(在源节点右侧)
const sourceBBox = sourceNode.getBBox();
const newX = sourceBBox.x + sourceBBox.width + 50;
const newY = sourceBBox.y;
// 创建新节点
const newNode = graph.addNode({
...(graphNodeLibrary[selectedNodeType.type] || graphNodeLibrary.default),
x: newX,
y: newY,
data: {
id: `${selectedNodeType.type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
type: selectedNodeType.type,
icon: selectedNodeType.icon,
name: t(`workflow.${selectedNodeType.type}`),
cycle: sourceNodeData.cycle, // 继承源节点的cycle
config: selectedNodeType.config || {}
},
});
// 将新节点添加为父节点的子节点
if (sourceNodeData.cycle) {
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle);
if (parentNode) {
parentNode.addChild(newNode);
}
}
// 创建连线
setTimeout(() => {
const targetPorts = newNode.getPorts();
const targetPort = targetPorts.find((port: any) => port.group === 'left')?.id || 'left';
graph.addEdge({
source: { cell: sourceNode.id, port: sourcePort },
target: { cell: newNode.id, port: targetPort },
attrs: {
line: {
stroke: '#155EEF',
strokeWidth: 1,
targetMarker: {
name: 'block',
size: 8,
},
},
},
});
// 循环节点内子节点通过连接桩添加时,调整循环节点大小
const cycleId = sourceNodeData.cycle;
if (cycleId) {
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
if (parentNode) {
const adjustLoopSize = () => {
const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
if (childNodes.length > 0) {
const bounds = childNodes.reduce((acc: any, child: any) => {
const bbox = child.getBBox();
return {
minX: Math.min(acc.minX, bbox.x),
minY: Math.min(acc.minY, bbox.y),
maxX: Math.max(acc.maxX, bbox.x + bbox.width),
maxY: Math.max(acc.maxY, bbox.y + bbox.height)
};
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
const padding = 20;
const newWidth = Math.max(240, bounds.maxX - bounds.minX + padding * 2);
const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2);
parentNode.prop('size', { width: newWidth, height: newHeight });
}
};
adjustLoopSize();
// 监听子节点移动事件
const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
childNodes.forEach((childNode: any) => {
childNode.on('change:position', adjustLoopSize);
});
}
}
}, 50);
// 清理临时元素
if (tempElement) {
document.body.removeChild(tempElement);
setTempElement(null);
}
setPopoverVisible(false);
};
const handlePopoverClose = () => {
setPopoverVisible(false);
if (tempElement) {
document.body.removeChild(tempElement);
setTempElement(null);
}
};
const content = (
<div style={{ maxHeight: '300px', overflowY: 'auto', minWidth: '240px' }}>
{nodeLibrary.map((category, categoryIndex) => {
const sourceNodeData = sourceNode?.getData();
const isChildOfLoop = sourceNodeData?.cycle && graph?.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle && n.getData()?.type === 'loop');
let filteredNodes;
if (isChildOfLoop) {
// Use same filtering as AddNode for child nodes of loop
filteredNodes = category.nodes.filter(nodeType =>
nodeType.type !== 'start' && nodeType.type !== 'end' && nodeType.type !== 'loop' && nodeType.type !== 'cycle-start'
);
} else {
// Original filtering for non-loop child nodes
filteredNodes = category.nodes.filter(nodeType =>
nodeType.type !== 'start' && nodeType.type !== 'end' && nodeType.type !== 'cycle-start' && nodeType.type !== 'break'
);
}
if (filteredNodes.length === 0) return null;
return (
<div key={category.category}>
{categoryIndex > 0 && <div style={{ height: '1px', background: '#f0f0f0', margin: '4px 0' }} />}
<div style={{ padding: '4px 12px', fontSize: '12px', color: '#999', fontWeight: 'bold' }}>
{t(`workflow.${category.category}`)}
</div>
{filteredNodes.map((nodeType) => (
<div
key={nodeType.type}
style={{
padding: '8px 12px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
onClick={() => handleNodeSelect(nodeType)}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f0f8ff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'white';
}}
>
<img src={nodeType.icon} className="rb:w-4 rb:h-4" />
<span style={{ fontSize: '14px' }}>{t(`workflow.${nodeType.type}`)}</span>
</div>
))}
</div>
);
})}
</div>
);
if (!tempElement) return null;
return (
<Popover
content={content}
open={popoverVisible}
onOpenChange={(visible) => {
if (!visible) handlePopoverClose();
}}
placement="right"
overlayStyle={{
position: 'fixed',
left: popoverPosition.x + 10,
top: popoverPosition.y - 10,
}}
>
<div />
</Popover>
);
};
export default PortClickHandler;

View File

@@ -9,8 +9,8 @@ import VariableSelect from '../VariableSelect'
import Editor from '../../Editor'
interface CaseListProps {
value?: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; operator: string; right: string; }[] }>;
onChange?: (value: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; operator: string; right: string; }[] }>) => void;
value?: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; comparison_operator: string; right: string; }[] }>;
onChange?: (value: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; comparison_operator: string; right: string; }[] }>) => void;
options: Suggestion[];
name: string;
selectedNode?: any;
@@ -221,7 +221,7 @@ const CaseList: FC<CaseListProps> = ({
onClick={() => addCondition()}
size="small"
>
+
+ {t('workflow.config.addCase')}
</Button>
{caseFields.length > 1 && <DeleteOutlined
className="rb:text-[12px]"
@@ -229,7 +229,8 @@ const CaseList: FC<CaseListProps> = ({
/>}
</Space>
</div>
{conditionFields?.length > 1 && <>
{conditionFields?.length > 1 &&
<>
<div className="rb:absolute rb:w-3 rb:left-2 rb:top-15 rb:bottom-6 rb:z-10 rb:border rb:border-[#DFE4ED] rb:rounded-l-md rb:border-r-0"></div>
<div className="rb:absolute rb:z-10 rb:left-0 rb:top-[50%] rb:transform-[translateY(-50%)]]">
<Form.Item name={[caseField.name, 'logical_operator']} noStyle >
@@ -238,50 +239,56 @@ const CaseList: FC<CaseListProps> = ({
</div>
</>
}
{conditionFields.map((conditionField, conditionIndex) => (
<div key={conditionField.key} className={clsx({
"rb:mb-3": conditionIndex !== conditionFields.length - 1
})}>
<div className="rb:border rb:border-[#DFE4ED] rb:rounded-md rb:px-2 rb:py-1.5 rb:bg-white">
<Row gutter={12} className="rb:mb-1">
<Col span={14}>
<Form.Item name={[conditionField.name, 'left']} noStyle>
<VariableSelect
placeholder={t('common.pleaseSelect')}
options={options}
size="small"
allowClear={false}
popupMatchSelectWidth={false}
{conditionFields.map((conditionField, conditionIndex) => {
const currentOperator = value?.[caseIndex]?.expressions?.[conditionIndex]?.comparison_operator;
const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty';
return (
<div key={conditionField.key} className={clsx({
"rb:mb-3": conditionIndex !== conditionFields.length - 1
})}>
<div className="rb:border rb:border-[#DFE4ED] rb:rounded-md rb:px-2 rb:py-1.5 rb:bg-white">
<Row gutter={12} className="rb:mb-1">
<Col span={14}>
<Form.Item name={[conditionField.name, 'left']} noStyle>
<VariableSelect
placeholder={t('common.pleaseSelect')}
options={options}
size="small"
allowClear={false}
popupMatchSelectWidth={false}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name={[conditionField.name, 'comparison_operator']} noStyle>
<Select
options={operatorList.map(key => ({
value: key,
label: t(`workflow.config.if-else.${key}`)
}))}
size="small"
popupMatchSelectWidth={false}
/>
</Form.Item>
</Col>
<Col span={2}>
<DeleteOutlined
className="rb:text-[12px]"
onClick={() => removeCondition(conditionField.name)}
/>
</Col>
</Row>
{!hideRightField && (
<Form.Item name={[conditionField.name, 'right']} noStyle>
<Editor options={options} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name={[conditionField.name, 'operator']} noStyle>
<Select
placeholder="包含"
options={operatorList.map(key => ({
value: key,
label: t(`workflow.config.if-else.${key}`)
}))}
size="small"
popupMatchSelectWidth={false}
/>
</Form.Item>
</Col>
<Col span={2}>
<DeleteOutlined
className="rb:text-[12px]"
onClick={() => removeCondition(conditionField.name)}
/>
</Col>
</Row>
<Form.Item name={[conditionField.name, 'right']} noStyle>
<Editor options={options} />
</Form.Item>
)}
</div>
</div>
</div>
))}
)
})}
</div>
)
}}

View File

@@ -24,7 +24,7 @@ const CategoryList: FC<CategoryListProps> = ({ parentName }) => {
return (
<div key={key} className="rb:border rb:border-[#DFE4ED] rb:rounded-md rb:p-3 rb:bg-[#F8F9FB]">
<div className="rb:flex rb:items-center rb:justify-between rb:mb-2">
<div>{t('workflow.config.question-classifier.class_name')} {index + 1}</div>
<div>{t('workflow.config.question-classifier.class_name')} {index + 1}</div>
<div className="rb:flex rb:items-center rb:gap-1">
<span className="rb:text-xs rb:text-gray-500">{contentLength}</span>
<Button

View File

@@ -0,0 +1,140 @@
import { type FC } from 'react'
import clsx from 'clsx'
import { useTranslation } from 'react-i18next';
import { Form, Button, Select, Space, Row, Col, Divider } from 'antd'
import { DeleteOutlined } from '@ant-design/icons';
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin'
import VariableSelect from '../VariableSelect'
import Editor from '../../Editor'
interface Case {
logical_operator: 'and' | 'or';
expressions: Array<{ left: string; comparison_operator: string; right: string; }>
}
interface CaseListProps {
value?: Case;
onChange?: (value: Case) => void;
options: Suggestion[];
parentName: string;
selectedNode?: any;
graphRef?: any;
addBtnText?: string;
}
const operatorList = [
"empty",
"not_empty",
"contains",
"not_contains",
"startwith",
"endwith",
"eq",
"ne",
"lt",
"le",
"gt",
"ge"
]
const ConditionList: FC<CaseListProps> = ({
value,
options,
parentName,
onChange,
}) => {
const { t } = useTranslation();
const handleChangeLogicalOperator = () => {
if (!value) return;
onChange && onChange({
logical_operator: value.logical_operator === 'and' ? 'or' : 'and',
expressions: value.expressions || []
})
}
return (
<>
<Form.List name={[parentName, 'expressions']}>
{(fields, { add, remove }) => (
<div>
<div className="rb:relative">
{fields.map((field, index) => {
const currentOperator = value?.expressions?.[index]?.comparison_operator;
const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty';
return (
<div key={field.key} className="rb:mb-3">
{index > 0 && (<>
<div className="rb:absolute rb:w-3 rb:left-2 rb:top-3.75 rb:bottom-3.75 rb:z-10 rb:border rb:border-[#DFE4ED] rb:rounded-l-md rb:border-r-0"></div>
<div className="rb:absolute rb:z-10 rb:left-0 rb:top-[50%] rb:transform-[translateY(-50%)]]">
<Form.Item name={[parentName, 'logical_operator']} noStyle >
<Button size="small" className="rb:cursor-pointer" onClick={handleChangeLogicalOperator}>{value?.logical_operator}</Button>
</Form.Item>
</div>
</>)}
<div className="rb:border rb:border-[#DFE4ED] rb:rounded-md rb:p-3 rb:bg-white rb:ml-6">
<Row gutter={8} align="middle">
<Col span={14}>
<Form.Item name={[field.name, 'left']} noStyle>
<VariableSelect
placeholder="输入值"
options={options}
size="small"
allowClear={false}
popupMatchSelectWidth={false}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name={[field.name, 'comparison_operator']} noStyle>
<Select
placeholder="包含"
options={operatorList.map(key => ({
value: key,
label: t(`workflow.config.if-else.${key}`)
}))}
size="small"
popupMatchSelectWidth={false}
/>
</Form.Item>
</Col>
<Col span={2}>
<DeleteOutlined
className="rb:text-gray-400 rb:cursor-pointer rb:hover:text-red-500"
onClick={() => remove(field.name)}
/>
</Col>
{!hideRightField && (
<Col span={24}>
<Form.Item name={[field.name, 'right']} noStyle>
<Editor options={options} />
</Form.Item>
</Col>
)}
</Row>
</div>
</div>
)
})}
</div>
<Button
type="dashed"
onClick={() => add({ left: '', comparison_operator: '', right: '' })}
className="rb:w-full rb:ml-6 rb:mt-2"
icon={<span>+</span>}
>
</Button>
</div>
)}
</Form.List>
</>
)
}
export default ConditionList

View File

@@ -0,0 +1,172 @@
import { type FC } from 'react'
import { useTranslation } from 'react-i18next';
import { Form, Button, Select, Row, Col, Input } from 'antd'
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import VariableSelect from '../VariableSelect'
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin'
interface CycleVar {
name: string;
type: string;
value: string;
input_type: string;
}
interface CycleVarsListProps {
value?: CycleVar[];
onChange?: (value: CycleVar[]) => void;
options: Suggestion[];
parentName: string;
selectedNode?: any;
graphRef?: any;
}
const types = [
'string',
'number',
'boolean',
'array[string]',
'array[number]',
'array[boolean]',
'array[object]'
]
const CycleVarsList: FC<CycleVarsListProps> = ({
value = [],
options,
parentName,
onChange,
selectedNode,
graphRef
}) => {
const { t } = useTranslation();
const form = Form.useFormInstance();
// 获取循环节点的子节点变量
const getChildNodeVariables = () => {
if (!selectedNode || !graphRef?.current || selectedNode.getData()?.type !== 'loop') {
return options;
}
const loopNodeId = selectedNode.getData()?.id;
const childNodes = graphRef.current.getNodes().filter((node: any) =>
node.getData()?.cycle === loopNodeId
);
const childVariables: Suggestion[] = [];
childNodes.forEach((childNode: any) => {
const childData = childNode.getData();
if (childData?.config) {
Object.keys(childData.config).forEach(key => {
if (childData.config[key]?.defaultValue) {
childVariables.push({
key: `${childData.id}.${key}`,
label: `${childData.name || childData.type}.${key}`,
type: 'output',
dataType: 'string',
value: `{{${childData.id}.${key}}}`,
nodeData: childData
});
}
});
}
});
return [...options, ...childVariables];
};
const availableOptions = getChildNodeVariables();
return (
<div>
<div className="rb:flex rb:items-center rb:justify-between rb:mb-3">
<span className="rb:text-sm rb:font-medium"></span>
<PlusOutlined className="rb:text-gray-400 rb:cursor-pointer rb:hover:text-blue-500" />
</div>
<Form.List name={parentName}>
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...field }, index) => {
const currentInputType = value?.[index]?.input_type;
return (
<div key={key} className="rb:mb-3 rb:border rb:border-[#DFE4ED] rb:rounded-md rb:p-3 rb:bg-white">
<Row gutter={8} align="middle" className="rb:mb-2">
<Col span={8}>
<Form.Item name={[name, 'name']} noStyle>
<Input placeholder="变量名" size="small" />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name={[name, 'type']} noStyle>
<Select
options={types.map(key => ({
value: key,
label: t(`workflow.config.parameter-extractor.${key}`),
}))}
size="small"
popupMatchSelectWidth={false}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name={[name, 'input_type']} noStyle>
<Select
placeholder="Constant"
options={[
{ label: 'Constant', value: 'constant' },
{ label: 'Variable', value: 'variable' }
]}
size="small"
popupMatchSelectWidth={false}
onChange={() => {
// 重置 value 字段
form.setFieldValue([parentName, index, 'value'], undefined);
}}
/>
</Form.Item>
</Col>
<Col span={2}>
<DeleteOutlined
className="rb:text-gray-400 rb:cursor-pointer rb:hover:text-red-500"
onClick={() => remove(name)}
/>
</Col>
</Row>
<Form.Item name={[name, 'value']} noStyle>
{currentInputType === 'variable' ? (
<VariableSelect
placeholder="选择变量"
options={availableOptions}
/>
) : (
<Input.TextArea
placeholder="输入值"
rows={3}
className="rb:w-full"
/>
)}
</Form.Item>
</div>
)
})}
<Button
type="dashed"
onClick={() => add({ name: '', type: 'string', input_type: 'constant', value: '' })}
className="rb:w-full"
icon={<PlusOutlined />}
>
</Button>
</>
)}
</Form.List>
</div>
)
}
export default CycleVarsList

View File

@@ -26,7 +26,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
}
const labelRender: LabelRender = (props) => {
const { value } = props
const filterOption = options.find(vo => vo.value === value)
const filterOption = options.find(vo => `{{${vo.value}}}` === value)
if (filterOption) {
return (
@@ -62,8 +62,10 @@ const VariableSelect: FC<VariableSelectProps> = ({
const groupedOptions = Object.entries(groupedSuggestions).map(([nodeId, suggestions]) => ({
label: suggestions[0].nodeData.name,
options: suggestions.map(s => ({ label: s.label, value: s.value }))
options: suggestions.map(s => ({ label: s.label, value: `{{${s.value}}}` }))
}));
console.log('groupedOptions', groupedOptions)
return (
<Select

View File

@@ -18,6 +18,8 @@ import CaseList from './CaseList'
import HttpRequest from './HttpRequest';
import MappingList from './MappingList'
import CategoryList from './CategoryList'
import ConditionList from './ConditionList'
import CycleVarsList from './CycleVarsList'
interface PropertiesProps {
selectedNode?: Node | null;
@@ -27,10 +29,12 @@ interface PropertiesProps {
deleteEvent: () => void;
copyEvent: () => void;
parseEvent: () => void;
config?: any;
}
const Properties: FC<PropertiesProps> = ({
selectedNode,
graphRef,
config,
}) => {
const { t } = useTranslation()
const { modal } = App.useApp()
@@ -255,6 +259,25 @@ const Properties: FC<PropertiesProps> = ({
}
});
// Add conversation variables from global config
const conversationVariables = config?.variables || [];
conversationVariables.forEach((variable: any) => {
const key = `CONVERSATION_${variable.name}`;
if (!addedKeys.has(key)) {
addedKeys.add(key);
variableList.push({
key,
label: variable.name,
type: 'variable',
dataType: variable.type,
value: `conversation.${variable.name}`,
nodeData: { type: 'CONVERSATION', name: 'CONVERSATION', icon: '' },
group: 'CONVERSATION'
});
}
});
return variableList;
}, [selectedNode, graphRef]);
@@ -417,7 +440,6 @@ const Properties: FC<PropertiesProps> = ({
)
}
if (config.type === 'caseList') {
console.log('key', key)
return (
<Form.Item key={key} name={key}>
<CaseList
@@ -440,6 +462,16 @@ const Properties: FC<PropertiesProps> = ({
)
}
if (config.type === 'cycleVarsList') {
return (
<Form.Item key={key} name={key}>
<CycleVarsList
parentName={key}
options={variableList}
/>
</Form.Item>
)
}
return (
<Form.Item
@@ -473,15 +505,20 @@ const Properties: FC<PropertiesProps> = ({
: config.type === 'variableList'
? <VariableSelect
placeholder={t('common.pleaseSelect')}
options={variableList.map(vo => ({
...vo,
value: `{{${vo.value}}}`
}))}
options={variableList}
/>
: config.type === 'switch'
? <Switch />
: config.type === 'categoryList'
? <CategoryList parentName={key} />
: config.type === 'conditionList'
? <ConditionList
parentName={key}
options={variableList}
selectedNode={selectedNode}
graphRef={graphRef}
addBtnText={t('workflow.config.addCase')}
/>
: null
}
</Form.Item>

View File

@@ -1,5 +1,4 @@
import LoopNode from './components/Nodes/LoopNode';
import IterationNode from './components/Nodes/IterationNode';
import NormalNode from './components/Nodes/NormalNode';
import ConditionNode from './components/Nodes/ConditionNode';
import GroupStartNode from './components/Nodes/GroupStartNode';
@@ -38,6 +37,7 @@ import outputAuditIcon from '@/assets/images/workflow/output_audit.png';
import selfOptimizationIcon from '@/assets/images/workflow/self_optimization.png';
import processEvolutionIcon from '@/assets/images/workflow/process_evolution.png';
import questionClassifierIcon from '@/assets/images/workflow/question-classifier.png'
import breakIcon from '@/assets/images/workflow/break.png'
import { getModelListUrl } from '@/api/models'
import type { NodeLibrary } from './types'
@@ -225,7 +225,23 @@ export const nodeLibrary: NodeLibrary[] = [
}
},
// { type: "iteration", icon: iterationIcon },
// { type: "loop", icon: loopIcon },
{ type: "loop", icon: loopIcon,
config: {
cycle_vars: {
type: 'cycleVarsList',
},
condition: {
type: 'conditionList',
showLabel: true,
defaultValue: {
logical_operator: 'and',
expressions: []
}
},
}
},
{ type: "cycle-start", icon: loopIcon },
{ type: "break", icon: breakIcon },
// { type: "parallel", icon: parallelIcon },
{ type: "var-aggregator", icon: aggregatorIcon,
config: {
@@ -345,7 +361,7 @@ export const nodeRegisterLibrary: ReactShapeConfig[] = [
shape: 'iteration-node',
width: 200,
height: 200,
component: IterationNode,
component: LoopNode,
},
{
shape: 'normal-node',
@@ -360,15 +376,15 @@ export const nodeRegisterLibrary: ReactShapeConfig[] = [
component: ConditionNode,
},
{
shape: 'group-start-node',
shape: 'cycle-start',
width: 44,
height: 44,
component: GroupStartNode,
},
{
shape: 'add-node',
width: 120,
height: 40,
width: 88,
height: 44,
component: AddNode,
},
];
@@ -452,6 +468,24 @@ export const graphNodeLibrary: Record<string, NodeConfig> = {
items: [{ group: 'left' }],
},
},
'cycle-start': {
width: 44,
height: 44,
shape: 'cycle-start',
ports: {
groups: {right: { position: 'right', attrs: portAttrs }},
items: [{ group: 'right' }],
},
},
'add-node': {
width: 88,
height: 44,
shape: 'add-node',
ports: {
groups: {left: { position: 'left', attrs: portAttrs }},
items: [{ group: 'left' }],
},
},
default: {
width: 240,
height: 64,
@@ -461,18 +495,18 @@ export const graphNodeLibrary: Record<string, NodeConfig> = {
items: defaultPortItems,
},
},
groupStart: {
width: 80,
height: 40,
shape: 'group-start-node',
cycleStart: {
width: 44,
height: 44,
shape: 'cycle-start',
ports: {
groups: {right: { position: 'right', attrs: portAttrs }},
items: [{ group: 'right' }],
},
},
addStart: {
width: 80,
height: 40,
width: 88,
height: 44,
shape: 'add-node',
ports: {
groups: {left: { position: 'left', attrs: portAttrs }},

View File

@@ -17,6 +17,7 @@ export interface UseWorkflowGraphProps {
export interface UseWorkflowGraphReturn {
config: WorkflowConfig | null;
setConfig: React.Dispatch<React.SetStateAction<WorkflowConfig | null>>;
graphRef: React.MutableRefObject<Graph | undefined>;
selectedNode: Node | null;
setSelectedNode: React.Dispatch<React.SetStateAction<Node | null>>;
@@ -157,7 +158,60 @@ export const useWorkflowGraph = ({
return nodeConfig
})
graphRef.current?.addNodes(nodeList)
// 分离父节点和子节点
const parentNodes = nodeList.filter(node => !node.data.cycle)
const childNodes = nodeList.filter(node => node.data.cycle)
// 先添加父节点
graphRef.current?.addNodes(parentNodes)
// 然后处理子节点使用addChild添加到对应的父节点
childNodes.forEach(childNode => {
const cycleId = childNode.data.cycle
if (cycleId) {
const parentNode = graphRef.current?.getCellById(cycleId)
if (parentNode) {
const addedChild = graphRef.current?.addNode(childNode)
if (addedChild) {
parentNode.addChild(addedChild)
}
}
}
})
// 调整父节点大小以适应子节点
setTimeout(() => {
const parentNodesWithChildren = parentNodes.filter(parentNode => {
const parentId = parentNode.data.id
return childNodes.some(child => child.data.cycle === parentId)
})
parentNodesWithChildren.forEach(parentNodeConfig => {
const parentNode = graphRef.current?.getCellById(parentNodeConfig.data.id)
if (parentNode) {
const children = parentNode.getChildren()
if (children && children.length > 0) {
const childBounds = children.map(child => child.getBBox())
const minX = Math.min(...childBounds.map(b => b.x))
const minY = Math.min(...childBounds.map(b => b.y))
const maxX = Math.max(...childBounds.map(b => b.x + b.width))
const maxY = Math.max(...childBounds.map(b => b.y + b.height))
const padding = 24
const headerHeight = 50
const parentBBox = parentNode.getBBox()
const newWidth = Math.max(parentBBox.width, maxX - minX + padding * 2)
const newHeight = Math.max(parentBBox.height, maxY - minY + padding * 2 + headerHeight)
console.log('newWidth', newHeight, newWidth)
parentNode.prop('size', { width: newWidth, height: newHeight })
}
}
})
}, 100)
}
if (edges.length) {
// 去重处理:相同节点之间的连线仅连一次
@@ -304,6 +358,12 @@ export const useWorkflowGraph = ({
};
// 节点选择事件
const nodeClick = ({ node }: { node: Node }) => {
// 忽略 add-node 类型的节点点击
if (node.getData()?.type === 'add-node' || node.getData().type === 'break' || node.getData().type === 'cycle-start') {
setSelectedNode(null)
return;
}
const nodes = graphRef.current?.getNodes();
nodes?.forEach(vo => {
@@ -360,9 +420,9 @@ export const useWorkflowGraph = ({
};
// 节点移动事件
const nodeMoved = ({ node }: { node: Node }) => {
const parentId = node.getData()?.parentId;
if (parentId) {
const parentNode = graphRef.current!.getNodes().find(n => n.id === parentId);
const cycle = node.getData()?.cycle;
if (cycle) {
const parentNode = graphRef.current!.getNodes().find(n => n.id === cycle);
if (parentNode?.getData()?.isGroup) {
// 获取父节点和子节点的边界框
const parentBBox = parentNode.getBBox();
@@ -465,21 +525,23 @@ export const useWorkflowGraph = ({
nodesToDelete.forEach(nodeToDelete => {
// 检查是否为子节点
const nodeData = nodeToDelete.getData();
if (nodeData.parentId) {
if (nodeData.cycle) {
// 找到对应的父节点
const parentNode = nodes?.find(n => n.id === nodeData.parentId);
const parentNode = nodes?.find(n => n.id === nodeData.cycle);
if (parentNode) {
// 使用removeChild方法删除子节点
parentNode.removeChild(nodeToDelete);
parentNodesToUpdate.push(parentNode);
}
// 将子节点添加到删除列表
cells.push(nodeToDelete);
}
// 检查是否为 LoopNode、IterationNode 或 SubGraphNode
else if (nodeToDelete.shape === 'loop-node' || nodeToDelete.shape === 'iteration-node' || nodeToDelete.shape === 'subgraph-node') {
// 查找所有 parentId 为当前节点 id 的子节点
// 查找所有 cycle 为当前节点 id 的子节点
nodes?.forEach(node => {
const data = node.getData();
if (data.parentId === nodeToDelete.id) {
if (data.cycle === nodeToDelete.id || data.cycle === nodeToDelete.getData()?.id) {
cells.push(node);
}
});
@@ -582,13 +644,14 @@ export const useWorkflowGraph = ({
if (sourceType === 'end') return false;
// 获取源节点和目标节点的父节点ID
const sourceParentId = sourceCell?.getData()?.parentId;
const targetParentId = targetCell?.getData()?.parentId;
const sourceParentId = sourceCell?.getData()?.cycle;
const targetParentId = targetCell?.getData()?.cycle;
// 验证父子节点关系:
// 1. 如果两个节点都有父节点ID必须相同才能连线
// 2. 如果一个有父节点ID另一个没有,不能连线
// 3. 如果两个都没有父节点ID可以正常连线
// 2. 如果两个都没有父节点ID可以正常连线
// 3. 如果一个有父节点,一个没有,不能连线
console.log('sourceParentId', sourceParentId, targetParentId)
if (sourceParentId && targetParentId) {
// 同一父节点下的子节点可以互相连线
return sourceParentId === targetParentId;
@@ -635,6 +698,28 @@ export const useWorkflowGraph = ({
graphRef.current.on('node:click', nodeClick);
// 监听连线选择事件
graphRef.current.on('edge:click', edgeClick);
// 监听连接桩点击事件
graphRef.current.on('node:port:click', ({ e, node, port }: { e: MouseEvent, node: Node, port: string }) => {
e.stopPropagation();
const portElement = e.target as HTMLElement;
const rect = portElement.getBoundingClientRect();
// 创建临时的popover触发元素
const tempDiv = document.createElement('div');
tempDiv.style.position = 'fixed';
tempDiv.style.left = rect.left + 'px';
tempDiv.style.top = rect.top + 'px';
tempDiv.style.width = '1px';
tempDiv.style.height = '1px';
tempDiv.style.zIndex = '9999';
document.body.appendChild(tempDiv);
// 触发自定义事件来显示节点选择popover
const customEvent = new CustomEvent('port:click', {
detail: { node, port, element: tempDiv, rect }
});
window.dispatchEvent(customEvent);
});
// 监听画布点击事件,取消选择
graphRef.current.on('blank:click', blankClick);
// 监听缩放事件
@@ -723,36 +808,23 @@ export const useWorkflowGraph = ({
data: { ...cleanNodeData },
});
} else {
// 检查是否放置在群组
const groups = graphRef.current.getNodes().filter(node => {
const shape = node.shape;
return shape === 'loop-node' || shape === 'iteration-node' || shape === 'subgraph-node';
});
let parentGroup = null;
for (const group of groups) {
const bbox = group.getBBox();
if (point.x >= bbox.x && point.x <= bbox.x + bbox.width &&
point.y >= bbox.y && point.y <= bbox.y + bbox.height) {
parentGroup = group;
break;
}
}
const childNode = graphRef.current.addNode({
// 普通节点创建,不支持拖拽到循环节点
graphRef.current.addNode({
...(graphNodeLibrary[dragData.type] || graphNodeLibrary.default),
x: point.x - 60,
y: point.y - 20,
data: { ...cleanNodeData, parentId: parentGroup?.id },
data: { ...cleanNodeData },
});
parentGroup?.addChild(childNode);
}
};
// 保存workflow配置
const handleSave = (flag = true) => {
if (!graphRef.current || !config) return Promise.resolve()
return new Promise((resolve, reject) => {
const nodes = graphRef.current?.getNodes() || [];
const nodes = graphRef.current?.getNodes().filter((node: Node) => {
const nodeData = node.getData();
return nodeData?.type !== 'add-node';
}) || [];
const edges = graphRef.current?.getEdges() || []
const params = {
@@ -781,6 +853,7 @@ export const useWorkflowGraph = ({
id: data.id || node.id,
type: data.type,
name: data.name,
cycle: data.cycle, // 保存cycle参数
position: {
x: position.x,
y: position.y,
@@ -793,8 +866,9 @@ export const useWorkflowGraph = ({
const targetCell = graphRef.current?.getCellById(edge.getTargetCellId());
const sourcePortId = edge.getSourcePortId();
// 过滤无效连线:源节点或目标节点不存在
if (!sourceCell?.getData()?.id || !targetCell?.getData()?.id) {
// 过滤无效连线:源节点或目标节点不存在或者是add-node类型
if (!sourceCell?.getData()?.id || !targetCell?.getData()?.id ||
sourceCell?.getData()?.type === 'add-node' || targetCell?.getData()?.type === 'add-node') {
return null;
}
@@ -832,6 +906,7 @@ export const useWorkflowGraph = ({
return {
config,
setConfig,
graphRef,
selectedNode,
setSelectedNode,
@@ -848,6 +923,6 @@ export const useWorkflowGraph = ({
deleteEvent,
copyEvent,
parseEvent,
handleSave
handleSave,
};
};

View File

@@ -4,20 +4,24 @@ import clsx from 'clsx';
import NodeLibrary from './components/NodeLibrary'
import Properties from './components/Properties';
import CanvasToolbar from './components/CanvasToolbar';
import PortClickHandler from './components/PortClickHandler';
import { useWorkflowGraph } from './hooks/useWorkflowGraph';
import type { WorkflowRef } from '@/views/ApplicationConfig/types'
import Chat from './components/Chat/Chat';
import type { ChatRef } from './types'
import type { ChatRef, AddChatVariableRef, ChatVariable } from './types'
import arrowIcon from '@/assets/images/workflow/arrow.png'
import AddChatVariable from './components/AddChatVariable';
const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const miniMapRef = useRef<HTMLDivElement>(null);
const addChatVariableRef = useRef<AddChatVariableRef>(null)
const chatRef = useRef<ChatRef>(null)
const [collapsed, setCollapsed] = useState(false)
// 使用自定义Hook初始化工作流图
const {
config,
setConfig,
graphRef,
selectedNode,
setSelectedNode,
@@ -33,7 +37,7 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
deleteEvent,
copyEvent,
parseEvent,
handleSave
handleSave,
} = useWorkflowGraph({ containerRef, miniMapRef });
const onDragOver = (event: React.DragEvent) => {
@@ -45,11 +49,24 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
const handleToggle = () => {
setCollapsed(prev => !prev)
}
const addVariable = () => {
addChatVariableRef.current?.handleOpen()
}
const handleUpdateChatVariable = (variables: ChatVariable[]) => {
setConfig(prev => {
if (!prev) return null
return {
...prev,
variables
}
})
}
useImperativeHandle(ref, () => ({
handleSave,
handleRun,
graphRef
graphRef,
addVariable
}))
return (
<div className="rb:h-[calc(100vh-64px)] rb:relative">
@@ -97,12 +114,20 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
deleteEvent={deleteEvent}
copyEvent={copyEvent}
parseEvent={parseEvent}
config={config}
/>
<Chat
ref={chatRef}
graphRef={graphRef}
appId={config?.app_id as string}
/>
<PortClickHandler graph={graphRef.current} />
<AddChatVariable
ref={addChatVariableRef}
variables={config?.variables}
onChange={handleUpdateChatVariable}
/>
</div>
);
});

View File

@@ -24,7 +24,8 @@ export interface NodeConfig {
knowledge_retrieval?: KnowledgeConfig;
group_names?: Array<{key: string, value: string[]}>
group_names?: Array<{ key: string, value: string[] }>
cycle?: string;
[key: string]: unknown;
}
@@ -34,6 +35,7 @@ export interface NodeProperties {
name?: string;
id?: string;
config?: Record<string, NodeConfig>;
hidden?: boolean;
}
export interface NodeLibrary {
@@ -53,6 +55,8 @@ export interface NodeItem {
config: {
[key: string]: unknown;
};
cycle?: string;
}
export interface EdgesItem {
source: string;
@@ -102,4 +106,15 @@ export interface ChatRef {
export type GraphRef = React.MutableRefObject<Graph | undefined>
export interface VariableConfigModalRef {
handleOpen: (values: StartVariableItem[]) => void;
}
export interface ChatVariable {
name: string;
type: string;
required: boolean;
description: string;
default: string;
}
export interface AddChatVariableRef {
handleOpen: (value?: ChatVariable) => void;
}