diff --git a/web/package.json b/web/package.json index e6c7483d..9d157982 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "memory-bear-font-end", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 1a824585..b2ed5707 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1552,17 +1552,17 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re answer: 'Answer', aiAndCognitiveProcessing: 'AI & Cognitive Processing', llm: 'Large Language Model (LLM)', - model_selection: 'Model Selection', - model_voting: 'Model Voting', - rag: 'Knowledge Retrieval (RAG)', - classification: 'Smart Classification', + model_selection: 'Multi-Model Selection', + model_voting: 'Multi-Model Voting', + 'knowledge-retrieval': 'Knowledge Retrieval (RAG)', + classification: 'Intelligent Classification', 'parameter-extractor': 'Parameter Extraction', flowControl: 'Flow Control', - condition: 'Conditional Branch', + 'if-else': 'Conditional Branch', iteration: 'Iteration', loop: 'Loop', parallel: 'Parallel Execution', - aggregator: 'Aggregator', + 'var-aggregator': 'Variable Aggregator', externalInteraction: 'External Interaction', http_request: 'HTTP Request', tools: 'Tools', @@ -1586,7 +1586,7 @@ 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, there's nothing here~", + empty: "Emmm... The box is empty, nothing here~", nodeName: 'Node Name', @@ -1609,16 +1609,66 @@ 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', description: 'Display Name', default: 'Default Value', required: 'Required', max_length: 'Max Length', defaultChecked: 'Checked', - notDefaultChecked: 'Not Checked', + notDefaultChecked: 'Unchecked', options: 'Options', }, end: { output: 'Reply' + }, + 'knowledge-retrieval': { + query: 'Query Variable', + knowledge_retrieval: 'Knowledge Base', + recallConfig: 'Recall Test', + }, + 'parameter-extractor': { + model_id: 'Model', + text: 'Input Variable', + params: 'Extract Parameters', + prompt: 'Instruction', + + addParam: 'Add Extract Parameter', + editParam: 'Edit Extract Parameter', + + name: 'Name', + invalidParamName: 'Parameter name must start with a letter and contain only letters, numbers, and underscores', + type: 'Type', + desc: 'Description', + required: 'Required', + + 'string': 'String', + 'number': 'Number', + 'boolean': 'Boolean', + 'array[string]': 'Array[String]', + 'array[number]': 'Array[Number]', + 'array[boolean]': 'Array[Boolean]', + 'array[object]': 'Array[Object]', + }, + 'var-aggregator': { + group: 'Aggregation Group', + invalidVariableName: 'Variable name must start with a letter and contain only letters, numbers, and underscores', + addGroup: 'Add Group', + variable: 'Variable Assignment' + }, + 'if-else': { + "empty": 'Is Empty', + "not_empty": 'Is Not Empty', + "contains": 'Contains', + "not_contains": 'Does Not Contain', + "startwith": 'Starts With', + "endwith": 'Ends With', + "eq": '==', + "ne": '!=', + "lt": '<', + "le": '<=', + "gt": '>', + "ge": '>=', + else_desc: 'Used to define the logic that should be executed when the if condition is not met.' } }, @@ -1627,7 +1677,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re save: 'Save', export: 'Export', variableConfig: 'Variable Configuration', - variableRequired: 'required', + variableRequired: 'Required', addMessage: 'Add Message', answerDesc: 'Reply' }, diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index f3b1134a..e1490bda 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1188,7 +1188,9 @@ export const zh = { memoryContent: '记忆内容', created_at: '创建时间', updated_at: '最后更新时间', - fullScreen: '全屏' + fullScreen: '全屏', + + memoryWindow: "{{name}}的记忆之窗" }, space: { createSpace: '创建空间', @@ -1778,7 +1780,8 @@ export const zh = { "lt": '<', "le": '<=', "gt": '>', - "ge": '>=' + "ge": '>=', + else_desc: '用于定义当 if 条件不满足时应执行的逻辑。' } }, diff --git a/web/src/views/UserMemoryDetail/Neo4j.tsx b/web/src/views/UserMemoryDetail/Neo4j.tsx index 75cde3f9..f638b21f 100644 --- a/web/src/views/UserMemoryDetail/Neo4j.tsx +++ b/web/src/views/UserMemoryDetail/Neo4j.tsx @@ -37,7 +37,7 @@ const Neo4j: FC = () => { memoryInsightRef.current?.getData() } if (response.summary_success) { - memoryInsightRef.current?.getData() + aboutMeRef.current?.getData() } }) .finally(() => { diff --git a/web/src/views/Workflow/components/CanvasToolbar.tsx b/web/src/views/Workflow/components/CanvasToolbar.tsx index ac1b1130..f5bf7e9c 100644 --- a/web/src/views/Workflow/components/CanvasToolbar.tsx +++ b/web/src/views/Workflow/components/CanvasToolbar.tsx @@ -36,7 +36,7 @@ const CanvasToolbar: FC = ({ if (edges.length === 0) { nodes.forEach((node, index) => { const nodeData = node.getData(); - const isSpecialNode = nodeData?.isGroup || nodeData?.type === 'condition'; + const isSpecialNode = nodeData?.isGroup || nodeData?.type === 'if-else'; const nodeHeight = isSpecialNode ? 220 : 50; const xPosition = 100; const yPosition = index * (nodeHeight + 100) + 100; @@ -89,7 +89,7 @@ const CanvasToolbar: FC = ({ if (!node) return; const nodeData = node.getData(); - const isSpecialNode = nodeData?.isGroup || nodeData?.type === 'condition'; + const isSpecialNode = nodeData?.isGroup || nodeData?.type === 'if-else'; const nodeWidth = isSpecialNode ? 400 : 160; const gap = isSpecialNode ? 150 : 100; @@ -107,7 +107,7 @@ const CanvasToolbar: FC = ({ if (!node) return parentY; const nodeData = node.getData(); - const isSpecialNode = nodeData?.isGroup || nodeData?.type === 'condition'; + const isSpecialNode = nodeData?.isGroup || nodeData?.type === 'if-else'; const nodeHeight = isSpecialNode ? 220 : 50; const verticalGap = isSpecialNode ? 80 : 40; const spacing = baseNodeSpacing + nodeHeight + verticalGap; diff --git a/web/src/views/Workflow/components/Editor/index.tsx b/web/src/views/Workflow/components/Editor/index.tsx index 9575b8fa..a882207a 100644 --- a/web/src/views/Workflow/components/Editor/index.tsx +++ b/web/src/views/Workflow/components/Editor/index.tsx @@ -20,7 +20,7 @@ interface LexicalEditorProps { placeholder?: string; value?: string; onChange?: (value: string) => void; - suggestions: Suggestion[]; + options: Suggestion[]; } const theme = { @@ -35,7 +35,7 @@ const Editor: FC =({ placeholder = "请输入内容...", value = "", onChange, - suggestions, + options, }) => { const [_count, setCount] = useState(0); const initialConfig = { @@ -91,9 +91,9 @@ const Editor: FC =({ /> - + { setCount(count) }} onChange={onChange} /> - + ); diff --git a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx index 4a86332f..152083f4 100644 --- a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx @@ -14,7 +14,7 @@ export interface Suggestion { nodeData: NodeProperties } -const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions }) => { +const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { const [editor] = useLexicalComposerContext(); const [showSuggestions, setShowSuggestions] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); @@ -73,8 +73,8 @@ const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions }) if (!showSuggestions) return null; - // Group suggestions by node id - const groupedSuggestions = suggestions.reduce((groups: Record, suggestion) => { + // Group options by node id + const groupedSuggestions = options.reduce((groups: Record, suggestion) => { const { nodeData } = suggestion const nodeId = nodeData.id as string; if (!groups[nodeId]) { @@ -84,6 +84,9 @@ const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions }) return groups; }, {}); + if (Object.entries(groupedSuggestions).length === 0) { + return null + } return (
= ({ value, suggestions = [] }) => { +const InitialValuePlugin: React.FC = ({ value, options = [] }) => { const [editor] = useLexicalComposerContext(); const initializedRef = useRef(false); @@ -29,7 +29,7 @@ const InitialValuePlugin: React.FC = ({ value, suggesti if (match) { const [_, nodeId, label] = match; - const suggestion = suggestions.find(s => { + const suggestion = options.find(s => { if (nodeId === 'sys') { return s.nodeData.type === 'start' && s.label === `sys.${label}` } @@ -51,7 +51,7 @@ const InitialValuePlugin: React.FC = ({ value, suggesti initializedRef.current = true; } - }, [suggestions]); + }, [options]); return null; }; diff --git a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx index f1a48f91..de6fcb1c 100644 --- a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx +++ b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx @@ -1,153 +1,32 @@ -import React from 'react'; +import { useTranslation } from 'react-i18next' import clsx from 'clsx'; -import { Button } from 'antd' import type { ReactShapeConfig } from '@antv/x6-react-shape'; const ConditionNode: ReactShapeConfig['component'] = ({ node }) => { const data = node?.getData() || {}; - - const addPort = (e: React.MouseEvent) => { - if (!node || !node.addPort) return; - e.stopPropagation(); - - const currentPorts = node.getPorts(); - const totalPorts = currentPorts.length; - - // 如果没有端口,添加第一个端口和ELSE端口 - if (totalPorts === 0) { - // 添加第一个ELIF端口 - node.addPort({ - id: 'elif_1', - group: 'right', - attrs: { - text: { - text: 'ELIF 1', - }, - }, - }); - // 添加ELSE端口 - node.addPort({ - id: 'else', - group: 'right', - attrs: { - text: { - text: 'ELSE', - }, - }, - }); - return; - } - - // 如果只有一个端口,确保它是ELSE,然后在之前添加ELIF - if (totalPorts === 1) { - const existingPort = currentPorts[0]; - - // 如果现有端口不是ELSE,先移除它 - if (node.removePort && existingPort.id !== 'else') { - node.removePort(existingPort.id as string); - - // 添加ELIF端口 - node.addPort({ - id: 'elif_1', - group: 'right', - attrs: { - text: { - text: 'ELIF 1', - }, - }, - }); - } - - // 添加或确保存在ELSE端口 - if (existingPort.id !== 'else') { - node.addPort({ - id: 'else', - group: 'right', - attrs: { - text: { - text: 'ELSE', - }, - }, - }); - } - return; - } - - // 获取最后一个端口,确保它是ELSE - let lastPort = currentPorts[totalPorts - 1]; - - // 如果最后一个端口不是ELSE,先移除它 - if (node.removePort && lastPort.id !== 'else') { - node.removePort(lastPort.id as string); - - // 添加ELSE端口作为最后一个 - node.addPort({ - id: 'else', - group: 'right', - attrs: { - text: { - text: 'ELSE', - }, - }, - }); - - // 更新currentPorts和totalPorts - const updatedPorts = node.getPorts(); - const updatedTotal = updatedPorts.length; - lastPort = updatedPorts[updatedTotal - 1]; - } - - // 计算新的ELIF端口数量(最后一个是ELSE,不算在内) - const elifCount = totalPorts - 1; - const newElifCount = elifCount + 1; - - // 如果有removePort方法,先移除最后一个端口(ELSE),添加新的ELIF端口,再添加回ELSE端口 - if (node.removePort) { - // 移除最后一个端口(ELSE) - node.removePort(lastPort.id as string); - - // 添加新的ELIF端口在倒数第二个位置 - node.addPort({ - id: `elif_${newElifCount}`, - group: 'right', - attrs: { - text: { - text: `ELIF ${newElifCount}`, - }, - }, - }); - - // 添加回ELSE端口 - node.addPort({ - id: 'else', - group: 'right', - attrs: { - text: { - text: 'ELSE', - }, - }, - }); - } - }; - - // const removeElif = (e: React.MouseEvent) => { - // e.stopPropagation(); - // }; + const { t } = useTranslation() return ( -
- - - {/* 标题区域 */} -
-
- 🔀 +
+
+ +
{data.name ?? t(`workflow.${data.type}`)}
- 条件分支 + +
{ + e.stopPropagation() + node.remove() + }} + >
+ +
{t('workflow.clickToConfigure')}
); }; diff --git a/web/src/views/Workflow/components/Properties/CaseList/index.tsx b/web/src/views/Workflow/components/Properties/CaseList/index.tsx new file mode 100644 index 00000000..9e273115 --- /dev/null +++ b/web/src/views/Workflow/components/Properties/CaseList/index.tsx @@ -0,0 +1,309 @@ +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 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; + options: Suggestion[]; + name: string; + selectedNode?: any; + graphRef?: any; +} +const operatorList = [ + "empty", + "not_empty", + "contains", + "not_contains", + "startwith", + "endwith", + "eq", + "ne", + "lt", + "le", + "gt", + "ge" +] + +const CaseList: FC = ({ + value = [], + options, + name, + onChange, + selectedNode, + graphRef +}) => { + const { t } = useTranslation(); + + const updateNodePorts = (caseCount: number, removedCaseIndex?: number) => { + if (!selectedNode || !graphRef?.current) return; + + // 保存现有连线信息(包括左侧端口连线) + const existingEdges = graphRef.current.getEdges().filter((edge: any) => + edge.getSourceCellId() === selectedNode.id || edge.getTargetCellId() === selectedNode.id + ); + const edgeConnections = existingEdges.map((edge: any) => ({ + edge, + sourcePortId: edge.getSourcePortId(), + targetCellId: edge.getTargetCellId(), + targetPortId: edge.getTargetPortId(), + sourceCellId: edge.getSourceCellId(), + isIncoming: edge.getTargetCellId() === selectedNode.id + })); + + // 移除所有现有的右侧端口 + const existingPorts = selectedNode.getPorts(); + existingPorts.forEach((port: any) => { + if (port.group === 'right') { + selectedNode.removePort(port.id); + } + }); + + // 计算新的节点高度:基础高度88px + 每个额外port增加30px + const baseHeight = 88; + const totalPorts = caseCount + 1; // IF/ELIF + ELSE + const newHeight = baseHeight + (totalPorts - 2) * 30; + + selectedNode.prop('size', { width: 240, height: newHeight }) + + // 添加 IF 端口 + selectedNode.addPort({ + id: 'CASE1', + group: 'right', + args: { dy: 24 }, + attrs: { text: { text: 'IF', fontSize: 12, fill: '#5B6167' }} + }); + + // 添加 ELIF 端口 + for (let i = 1; i < caseCount; i++) { + selectedNode.addPort({ + id: `CASE${i + 1}`, + group: 'right', + attrs: { text: { text: 'ELIF', fontSize: 12, fill: '#5B6167' }} + }); + } + + // 添加 ELSE 端口 + selectedNode.addPort({ + id: `CASE${caseCount + 1}`, + group: 'right', + attrs: { text: { text: 'ELSE', fontSize: 12, fill: '#5B6167' }} + }); + + // 恢复仍然存在的端口连线 + setTimeout(() => { + edgeConnections.forEach(({ edge, sourcePortId, targetCellId, targetPortId, sourceCellId, isIncoming }: any) => { + // 如果是进入连线(左侧端口),直接恢复 + if (isIncoming) { + const sourceCell = graphRef.current?.getCellById(sourceCellId); + if (sourceCell) { + graphRef.current?.addEdge({ + source: { cell: sourceCellId, port: sourcePortId }, + target: { cell: selectedNode.id, port: targetPortId }, + attrs: { + line: { + stroke: '#155EEF', + strokeWidth: 1, + targetMarker: { + name: 'block', + size: 8, + }, + }, + }, + }); + } + graphRef.current?.removeCell(edge); + return; + } + + // 处理右侧端口连线 + const originalCaseNumber = parseInt(sourcePortId.match(/CASE(\d+)/)?.[1] || '0'); + + // 如果是被删除的端口,不重新创建连线 + if (removedCaseIndex !== undefined && originalCaseNumber === removedCaseIndex + 1) { + graphRef.current?.removeCell(edge); + return; + } + + let newPortId = sourcePortId; + + // 如果是原来的ELSE端口,重新映射到新的ELSE端口 + const maxOriginalCaseNumber = Math.max(...edgeConnections + .filter(({ isIncoming }: any) => !isIncoming) + .map(({ sourcePortId }: any) => { + const match = sourcePortId.match(/CASE(\d+)/); + return match ? parseInt(match[1]) : 0; + })); + + if (originalCaseNumber === maxOriginalCaseNumber) { + newPortId = `CASE${caseCount + 1}`; // 新的ELSE端口 + } else if (removedCaseIndex !== undefined && originalCaseNumber > removedCaseIndex + 1) { + // 如果是被删除端口之后的端口,编号向前移动 + newPortId = `CASE${originalCaseNumber - 1}`; + } + + const newPorts = selectedNode.getPorts(); + const matchingPort = newPorts.find((port: any) => port.id === newPortId); + + if (matchingPort) { + const targetCell = graphRef.current?.getCellById(targetCellId); + if (targetCell) { + graphRef.current?.addEdge({ + source: { cell: selectedNode.id, port: newPortId }, + target: { cell: targetCellId, port: targetPortId }, + attrs: { + line: { + stroke: '#155EEF', + strokeWidth: 1, + targetMarker: { + name: 'block', + size: 8, + }, + }, + }, + }); + } + } + + graphRef.current?.removeCell(edge); + }); + }, 50); + }; + const handleChangeLogicalOperator = (index: number) => { + const newValue = [...value] + newValue[index] = { + ...newValue[index], + logical_operator: newValue[index].logical_operator === 'and' ? 'or' : 'and' + } + onChange && onChange(newValue) + } + + const handleAddCase = (addCaseFunc: Function) => { + addCaseFunc({ logical_operator: 'and', expressions: [] }); + setTimeout(() => { + updateNodePorts((value?.length || 0) + 1); + }, 100); + }; + + const handleRemoveCase = (removeCaseFunc: Function, fieldName: number, caseIndex: number) => { + removeCaseFunc(fieldName); + setTimeout(() => { + updateNodePorts((value?.length || 1) - 1, caseIndex); + }, 100); + }; + + return ( + <> + + {(caseFields, { add: addCase, remove: removeCase }) => ( + <> + {caseFields.map((caseField, caseIndex) => ( +
+ + {(conditionFields, { add: addCondition, remove: removeCondition }) => { + return ( +
+
+ + {caseIndex === 0 ? 'IF' : 'ELIF'}
+ {caseFields.length > 1 && {`CASE ${caseIndex + 1}`}} +
+ + + + {caseFields.length > 1 && handleRemoveCase(removeCase, caseField.name, caseIndex)} + />} + +
+ {conditionFields?.length > 1 && <> +
+
+ + + +
+ + } + {conditionFields.map((conditionField, conditionIndex) => ( +
+
+ + + + + + + + + : t('workflow.config.var-aggregator.variable')} + + + {isCanAdd && + remove(name)} /> + } + + + + + +
+ ) + })} + {isCanAdd && + + } + + )} + + ) +} + +export default GroupVariableList \ No newline at end of file diff --git a/web/src/views/Workflow/components/Properties/MessageEditor.tsx b/web/src/views/Workflow/components/Properties/MessageEditor.tsx index 879f5072..54086c90 100644 --- a/web/src/views/Workflow/components/Properties/MessageEditor.tsx +++ b/web/src/views/Workflow/components/Properties/MessageEditor.tsx @@ -1,20 +1,19 @@ -import { type FC, useMemo } from 'react'; +import { type FC } from 'react'; import { useTranslation } from 'react-i18next' import { Input, Form, Space, Button, Row, Col, Select, type FormListOperation } from 'antd'; import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; -import { Graph, Node } from '@antv/x6'; import Editor from '../Editor' import type { Suggestion } from '../Editor/plugin/AutocompletePlugin' interface TextareaProps { + options: Suggestion[]; + title?: string isArray?: boolean; parentName?: string; label?: string; placeholder?: string; value?: string; onChange?: (value?: string) => void; - selectedNode?: Node | null; - graphRef?: React.MutableRefObject; } const roleOptions = [ // { label: 'SYSTEM', value: 'SYSTEM' }, @@ -22,92 +21,15 @@ const roleOptions = [ { label: 'ASSISTANT', value: 'ASSISTANT' }, ] const MessageEditor: FC = ({ + title, isArray = true, parentName = 'messages', placeholder, - selectedNode, - graphRef, + options, }) => { const { t } = useTranslation() const form = Form.useFormInstance(); const values = form.getFieldsValue() - - const suggestions = useMemo(() => { - if (!selectedNode || !graphRef?.current) return []; - - const suggestions: Suggestion[] = []; - const graph = graphRef.current; - const edges = graph.getEdges(); - const nodes = graph.getNodes(); - - // Find all connected previous nodes (recursive) - const getAllPreviousNodes = (nodeId: string, visited = new Set()): string[] => { - if (visited.has(nodeId)) return []; - visited.add(nodeId); - - const directPrevious = edges - .filter(edge => edge.getTargetCellId() === nodeId) - .map(edge => edge.getSourceCellId()); - - const allPrevious = [...directPrevious]; - directPrevious.forEach(prevNodeId => { - allPrevious.push(...getAllPreviousNodes(prevNodeId, visited)); - }); - - return allPrevious; - }; - - const allPreviousNodeIds = getAllPreviousNodes(selectedNode.id); - console.log('allPreviousNodeIds', allPreviousNodeIds) - - allPreviousNodeIds.forEach(nodeId => { - const node = nodes.find(n => n.id === nodeId); - if (!node) return; - - const nodeData = node.getData(); - - switch(nodeData.type) { - case 'start': - const list = [ - ...(nodeData.config?.variables?.defaultValue ?? []), - ...(nodeData.config?.variables?.value ?? []) - ] - list.forEach((variable: any) => { - suggestions.push({ - key: `${nodeId}_${variable.name}`, - label: variable.name, - type: 'variable', - dataType: variable.type, - value: `${nodeId}.${variable.name}`, - nodeData: nodeData, - }); - }); - nodeData.config?.variables?.sys.forEach((variable: any) => { - suggestions.push({ - key: `${nodeId}_${variable.name}`, - label: `sys.${variable.name}`, - type: 'variable', - dataType: variable.type, - value: `sys.${variable.name}`, - nodeData: nodeData, - }); - }); - break - case 'llm': - suggestions.push({ - key: `${nodeId}_output`, - label: 'output', - type: 'variable', - dataType: 'String', - value: `${nodeId}.output`, - nodeData: nodeData, - }); - break - } - }); - - return suggestions; - }, [selectedNode, graphRef]); const handleAdd = (add: FormListOperation['add']) => { const list = values[parentName]; @@ -158,7 +80,7 @@ const MessageEditor: FC = ({ name={[name, 'content']} noStyle > - + ) @@ -175,14 +97,14 @@ const MessageEditor: FC = ({ - {t('workflow.answerDesc')} + {title ?? t('workflow.answerDesc')} - + } diff --git a/web/src/views/Workflow/components/Properties/ParamsList/ParamEditModal.tsx b/web/src/views/Workflow/components/Properties/ParamsList/ParamEditModal.tsx new file mode 100644 index 00000000..0e920f9c --- /dev/null +++ b/web/src/views/Workflow/components/Properties/ParamsList/ParamEditModal.tsx @@ -0,0 +1,121 @@ +import { forwardRef, useImperativeHandle, useState } from 'react'; +import { Form, Input, Select, Checkbox } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import type { ParamItem, ParamEditModalRef } from './types' +import RbModal from '@/components/RbModal' + +const FormItem = Form.Item; + +interface ParamEditModalProps { + refresh: (values: ParamItem, editIndex?: number) => void; +} + +const types = [ + 'string', + 'number', + 'boolean', + 'array[string]', + 'array[number]', + 'array[boolean]', + 'array[object]' +] + +const ParamEditModal = forwardRef(({ + refresh +}, ref) => { + const { t } = useTranslation(); + const [visible, setVisible] = useState(false); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false) + const [editIndex, setEditIndex] = useState(undefined) + + // 封装取消方法,添加关闭弹窗逻辑 + const handleClose = () => { + setVisible(false); + form.resetFields(); + setLoading(false) + setEditIndex(undefined) + }; + + const handleOpen = (variable?: ParamItem, 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, + handleClose + })); + + return ( + +
+ + + + + = ({ labelRender={labelRender} onChange={handleChange} showSearch + allowClear={allowClear} filterOption={(input, option) => { if (option?.options) { return option.label?.toLowerCase().includes(input.toLowerCase()) || diff --git a/web/src/views/Workflow/components/Properties/index.tsx b/web/src/views/Workflow/components/Properties/index.tsx index 3594fc75..f97f4532 100644 --- a/web/src/views/Workflow/components/Properties/index.tsx +++ b/web/src/views/Workflow/components/Properties/index.tsx @@ -1,7 +1,7 @@ import { type FC, useEffect, useState, useRef, useMemo } from "react"; import { useTranslation } from 'react-i18next' import { Graph, Node } from '@antv/x6'; -import { Form, Input, Button, Select, InputNumber, Slider, Space, Divider, App } from 'antd' +import { Form, Input, Button, Select, InputNumber, Slider, Space, Divider, App, Switch } from 'antd' import type { NodeConfig, NodeProperties, StartVariableItem, VariableEditModalRef } from '../../types' import Empty from '@/components/Empty'; @@ -12,6 +12,9 @@ import MessageEditor from './MessageEditor' import Knowledge from './Knowledge/Knowledge'; import type { Suggestion } from '../Editor/plugin/AutocompletePlugin' import VariableSelect from './VariableSelect'; +import ParamsList from './ParamsList'; +import GroupVariableList from './GroupVariableList' +import CaseList from './CaseList' interface PropertiesProps { selectedNode?: Node | null; @@ -70,12 +73,24 @@ const Properties: FC = ({ useEffect(() => { if (values && selectedNode) { - const { id, knowledge_retrieval, ...rest } = values - const { knowledge_bases, ...restKnowledgeConfig } = knowledge_retrieval || {} + const { id, knowledge_retrieval, group, group_names, ...rest } = values + const { knowledge_bases = [], ...restKnowledgeConfig } = (knowledge_retrieval as any) || {} + + let groupNames: Record | string[] = {} + + if (group && group_names?.length) { + group_names.forEach(vo => { + (groupNames as Record)[vo.key] = vo.value + }) + } else if (!group) { + groupNames = group_names?.[0]?.value || [] + } let allRest = { ...rest, ...restKnowledgeConfig, - knowledge_bases: knowledge_bases?.map(vo => ({ + } + if (knowledge_bases?.length) { + allRest.knowledge_bases = knowledge_bases?.map((vo: any) => ({ id: vo.id, ...vo.config })) @@ -134,7 +149,6 @@ const Properties: FC = ({ }) } - const variableList = useMemo(() => { if (!selectedNode || !graphRef?.current) return []; @@ -142,6 +156,7 @@ const Properties: FC = ({ const graph = graphRef.current; const edges = graph.getEdges(); const nodes = graph.getNodes(); + const addedKeys = new Set(); // Find all connected previous nodes (recursive) const getAllPreviousNodes = (nodeId: string, visited = new Set()): string[] => { @@ -175,35 +190,47 @@ const Properties: FC = ({ ...(nodeData.config?.variables?.value ?? []) ] list.forEach((variable: any) => { - variableList.push({ - key: `${nodeId}_${variable.name}`, - label: variable.name, - type: 'variable', - dataType: variable.type, - value: `{{${nodeId}.${variable.name}}}`, - nodeData: nodeData, - }); + const key = `${nodeId}_${variable.name}`; + if (!addedKeys.has(key)) { + addedKeys.add(key); + variableList.push({ + key, + label: variable.name, + type: 'variable', + dataType: variable.type, + value: `{{${nodeId}.${variable.name}}}`, + nodeData: nodeData, + }); + } }); nodeData.config?.variables?.sys.forEach((variable: any) => { - variableList.push({ - key: `${nodeId}_${variable.name}`, - label: `sys.${variable.name}`, - type: 'variable', - dataType: variable.type, - value: `{{sys.${variable.name}}}`, - nodeData: nodeData, - }); + const key = `${nodeId}_sys_${variable.name}`; + if (!addedKeys.has(key)) { + addedKeys.add(key); + variableList.push({ + key, + label: `sys.${variable.name}`, + type: 'variable', + dataType: variable.type, + value: `sys.${variable.name}`, + nodeData: nodeData, + }); + } }); break case 'llm': - variableList.push({ - key: `${nodeId}_output`, - label: 'output', - type: 'variable', - dataType: 'String', - value: `${nodeId}.output`, - nodeData: nodeData, - }); + const llmKey = `${nodeId}_output`; + if (!addedKeys.has(llmKey)) { + addedKeys.add(llmKey); + variableList.push({ + key: llmKey, + label: 'output', + type: 'variable', + dataType: 'String', + value: `${nodeId}.output`, + nodeData: nodeData, + }); + } break } }); @@ -279,14 +306,14 @@ const Properties: FC = ({ if (selectedNode.data?.type === 'llm' && key === 'messages' && config.type === 'define') { return ( - + ) } if (selectedNode.data?.type === 'end' && key === 'output') { return ( - + ) } @@ -306,11 +333,61 @@ const Properties: FC = ({ ) } + if (config.type === 'messageEditor') { + return ( + + + + ) + } + + if (config.type === 'paramList') { + return ( + + + + + ) + } + if (config.type === 'groupVariableList') { + return ( + + + + + ) + } + if (config.type === 'caseList') { + console.log('key', key) + return ( + + + + ) + } + return ( {config.type === 'input' ? @@ -339,6 +416,8 @@ const Properties: FC = ({ placeholder={t('common.pleaseSelect')} options={variableList} /> + : config.type === 'switch' + ? : null } diff --git a/web/src/views/Workflow/constant.ts b/web/src/views/Workflow/constant.ts index cc1b363a..c1171101 100644 --- a/web/src/views/Workflow/constant.ts +++ b/web/src/views/Workflow/constant.ts @@ -142,61 +142,102 @@ export const nodeLibrary: NodeLibrary[] = [ } }, // { type: "classification", icon: classificationIcon }, - // { type: "parameter_extraction", icon: parameterExtractionIcon } - ] - }, - /* - { - category: "cognitiveUpgrading", - nodes: [ - { type: "task_planning", icon: taskPlanningIcon }, - { type: "reasoning_control", icon: reasoningControlIcon }, - { type: "self_reflection", icon: selfReflectionIcon }, - { type: "memory_enhancement", icon: memoryEnhancementIcon } - ] - }, - { - category: "agentCollaborationNode", - nodes: [ - { type: "agent_scheduling", icon: agentSchedulingIcon }, - { type: "agent_collaboration", icon: agentCollaborationIcon }, - { type: "agent_arbitration", icon: agentArbitrationIcon } + { type: "parameter-extractor", icon: parameterExtractionIcon, + config: { + model_id: { + type: 'customSelect', + url: getModelListUrl, + params: { type: 'llm,chat' }, // llm/chat + valueKey: 'id', + labelKey: 'name', + }, + text: { + type: 'variableList', + }, + params: { + type: 'paramList', + }, + prompt: { + type: 'messageEditor', + isArray: false, + }, + } + } ] }, + // { + // category: "cognitiveUpgrading", + // nodes: [ + // { type: "task_planning", icon: taskPlanningIcon }, + // { type: "reasoning_control", icon: reasoningControlIcon }, + // { type: "self_reflection", icon: selfReflectionIcon }, + // { type: "memory_enhancement", icon: memoryEnhancementIcon } + // ] + // }, + // { + // category: "agentCollaborationNode", + // nodes: [ + // { type: "agent_scheduling", icon: agentSchedulingIcon }, + // { type: "agent_collaboration", icon: agentCollaborationIcon }, + // { type: "agent_arbitration", icon: agentArbitrationIcon } + // ] + // }, { category: "flowControl", nodes: [ - { type: "condition", icon: conditionIcon }, - { type: "iteration", icon: iterationIcon }, - { type: "loop", icon: loopIcon }, - { type: "parallel", icon: parallelIcon }, - { type: "aggregator", icon: aggregatorIcon } + { type: "if-else", icon: conditionIcon, + config: { + cases: { + type: 'caseList', + defaultValue: [ + { + logical_operator: 'and', + expressions: [] + } + ] + } + } + }, + // { type: "iteration", icon: iterationIcon }, + // { type: "loop", icon: loopIcon }, + // { type: "parallel", icon: parallelIcon }, + { type: "var-aggregator", icon: aggregatorIcon, + config: { + group: { + type: 'switch', + defaultValue: false + }, + group_names: { + type: 'groupVariableList', + defaultValue: [{ key: 'Group1', value: []}] + } + } + } ] }, - { - category: "externalInteraction", - nodes: [ - { type: "http_request", icon: httpRequestIcon }, - { type: "tools", icon: toolsIcon }, - { type: "code_execution", icon: codeExecutionIcon }, - { type: "template_rendering", icon: templateRenderingIcon } - ] - }, - { - category: "safetyAndCompliance", - nodes: [ - { type: "sensitive_detection", icon: sensitiveDetectionIcon }, - { type: "output_audit", icon: outputAuditIcon } - ] - }, - { - category: "evolutionAndGovernance", - nodes: [ - { type: "self_optimization", icon: selfOptimizationIcon }, - { type: "process_evolution", icon: processEvolutionIcon } - ] - }, - */ + // { + // category: "externalInteraction", + // nodes: [ + // { type: "http_request", icon: httpRequestIcon }, + // { type: "tools", icon: toolsIcon }, + // { type: "code_execution", icon: codeExecutionIcon }, + // { type: "template_rendering", icon: templateRenderingIcon } + // ] + // }, + // { + // category: "safetyAndCompliance", + // nodes: [ + // { type: "sensitive_detection", icon: sensitiveDetectionIcon }, + // { type: "output_audit", icon: outputAuditIcon } + // ] + // }, + // { + // category: "evolutionAndGovernance", + // nodes: [ + // { type: "self_optimization", icon: selfOptimizationIcon }, + // { type: "process_evolution", icon: processEvolutionIcon } + // ] + // }, ]; // 节点注册库 @@ -221,8 +262,8 @@ export const nodeRegisterLibrary: ReactShapeConfig[] = [ }, { shape: 'condition-node', - width: 200, - height: 100, + width: 240, + height: 88, component: ConditionNode, }, { @@ -253,7 +294,7 @@ interface NodeConfig { const portAttrs = { circle: { - r: 4, magnet: true, stroke: '#155EEF', strokeWidth: 2, fill: '#155EEF', + r: 4, magnet: true, stroke: '#155EEF', strokeWidth: 2, fill: '#155EEF', position: { top: 22 } }, } const defaultPortGroups = { @@ -287,16 +328,16 @@ export const graphNodeLibrary: Record = { items: defaultPortItems, }, }, - condition: { + 'if-else': { width: 240, - height: 200, + height: 88, shape: 'condition-node', ports: { groups: defaultPortGroups, items: [ { group: 'left' }, - { group: 'right', id: 'if_1', attrs: {text: { text: 'IF' }} }, - { group: 'right', id: 'else_2', attrs: {text: { text: 'ELSE' }} } + { group: 'right', id: 'CASE1', args: { dy: 24 }, attrs: { text: { text: 'IF', fontSize: 12, color: '#5B6167' }} }, + { group: 'right', id: 'CASE2', attrs: { text: { text: 'ELSE', fontSize: 12, color: '#5B6167' }} } ], }, }, diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index 65dcead7..d25d5491 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -8,6 +8,7 @@ import { register } from '@antv/x6-react-shape'; import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary } from '../constant'; import type { WorkflowConfig, NodeProperties } from '../types'; import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application' +import type { PortMetadata } from '@antv/x6/lib/model/port'; export interface UseWorkflowGraphProps { containerRef: React.RefObject; @@ -35,7 +36,7 @@ export interface UseWorkflowGraphReturn { handleSave: (flag?: boolean) => Promise; } -const edge_color = '#155EEF'; +export const edge_color = '#155EEF'; const edge_selected_color = '#4DA8FF' export const useWorkflowGraph = ({ @@ -88,7 +89,13 @@ export const useWorkflowGraph = ({ nodeLibraryConfig.config[key].defaultValue = { ...rest } - console.log(type, config, nodeLibraryConfig) + } else if (key === 'group_names' && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) { + const { group_names, group } = config + nodeLibraryConfig.config[key].defaultValue = group + ? Object.entries(group_names as Record).map(([key, value]) => ({ key, value })) + : [{ key: 'Group1', value: group_names }] + + console.log('group_names', nodeLibraryConfig.config) } else if (nodeLibraryConfig.config && nodeLibraryConfig.config[key] && config[key]) { nodeLibraryConfig.config[key].defaultValue = config[key] } @@ -102,13 +109,59 @@ export const useWorkflowGraph = ({ data: { ...node, ...nodeLibraryConfig}, ...position, } + + // 如果是if-else节点,根据cases动态生成端口 + if (type === 'if-else' && config.cases && Array.isArray(config.cases)) { + const caseCount = config.cases.length; + const totalPorts = caseCount + 1; // IF/ELIF + ELSE + const baseHeight = 88; + const newHeight = baseHeight + (totalPorts - 2) * 30; + + const portAttrs = { + circle: { + r: 4, magnet: true, stroke: '#155EEF', strokeWidth: 2, fill: '#155EEF', position: { top: 22 } + }, + }; + + const portItems: PortMetadata[] = [ + { group: 'left' }, + { group: 'right', id: 'CASE1', args: { dy: 24 }, attrs: { text: { text: 'IF', fontSize: 12, fill: '#5B6167' }} } + ]; + + // 添加 ELIF 端口 + for (let i = 1; i < caseCount; i++) { + portItems.push({ + group: 'right', + id: `CASE${i + 1}`, + attrs: { text: { text: 'ELIF', fontSize: 12, fill: '#5B6167' }} + }); + } + + // 添加 ELSE 端口 + portItems.push({ + group: 'right', + id: `CASE${caseCount + 1}`, + attrs: { text: { text: 'ELSE', fontSize: 12, fill: '#5B6167' }} + }); + + nodeConfig.ports = { + groups: { + right: { position: 'right', attrs: portAttrs }, + left: { position: 'left', attrs: portAttrs }, + }, + items: portItems + }; + + nodeConfig.height = newHeight; + } + return nodeConfig }) graphRef.current?.addNodes(nodeList) } if (edges.length) { const edgeList = edges.map(edge => { - const { source, target } = edge + const { source, target, label } = edge const sourceCell = graphRef.current?.getCellById(source) const targetCell = graphRef.current?.getCellById(target) @@ -116,16 +169,22 @@ export const useWorkflowGraph = ({ const sourcePorts = (sourceCell as Node).getPorts() const targetPorts = (targetCell as Node).getPorts() + let sourcePort = sourcePorts.find((port: any) => port.group === 'right')?.id || 'right'; + + // 如果是if-else节点且有label,使用label作为源端口 + if (sourceCell.getData()?.type === 'if-else' && label) { + sourcePort = label; + } + const edgeConfig = { source: { cell: sourceCell.id, - port: sourcePorts.find((port: any) => port.group === 'right')?.id || 'right' + port: sourcePort }, target: { cell: targetCell.id, port: targetPorts.find((port: any) => port.group === 'left')?.id || 'left' }, - // label, attrs: { line: { stroke: edge_color, @@ -646,13 +705,13 @@ export const useWorkflowGraph = ({ y: point.y - 100, data: { ...cleanNodeData, isGroup: true }, }); - } else if (dragData.type === 'condition') { + } else if (dragData.type === 'if-else') { // 创建条件节点 graphRef.current.addNode({ ...graphNodeLibrary[dragData.type], x: point.x - 100, y: point.y - 60, - data: { ...cleanNodeData, elifCount: 0 }, + data: { ...cleanNodeData }, }); } else { // 检查是否放置在群组内 @@ -719,10 +778,21 @@ export const useWorkflowGraph = ({ }; }), edges: edges.map((edge: Edge) => { + const sourceCell = graphRef.current?.getCellById(edge.getSourceCellId()); + const sourcePortId = edge.getSourcePortId(); + + // 如果是if-else节点的右侧端口连线,添加label + if (sourceCell?.getData()?.type === 'if-else' && sourcePortId?.startsWith('CASE')) { + return { + source: edge.getSourceCellId(), + target: edge.getTargetCellId(), + label: sourcePortId, + }; + } + return { source: edge.getSourceCellId(), target: edge.getTargetCellId(), - // label: edge.getAttrs()?.label?.text, }; }), } diff --git a/web/src/views/Workflow/types.ts b/web/src/views/Workflow/types.ts index 5ee4e4ac..c3dd4532 100644 --- a/web/src/views/Workflow/types.ts +++ b/web/src/views/Workflow/types.ts @@ -1,7 +1,8 @@ import { Graph } from '@antv/x6'; +import type { KnowledgeConfig } from './components/Properties/Knowledge/types' export interface NodeConfig { - type: 'input' | 'textarea' | 'select' | 'inputNumber' | 'slider' | 'customSelect' | 'define' | 'knowledge' | 'variableList'; + type: 'input' | 'textarea' | 'select' | 'inputNumber' | 'slider' | 'customSelect' | 'define' | 'knowledge' | 'variableList' | string; options?: { label: string; value: string }[]; max?: number; @@ -20,6 +21,10 @@ export interface NodeConfig { type: string; readonly: boolean; }> + + knowledge_retrieval?: KnowledgeConfig; + + group_names?: Array<{key: string, value: string[]}> [key: string]: unknown; }