Merge pull request #1052 from SuanmoSuanyangTechnology/feature/single_node_run_zy

feat(web): single node run
This commit is contained in:
yingzhao
2026-05-07 18:54:06 +08:00
committed by GitHub
21 changed files with 722 additions and 62 deletions

View File

@@ -19,5 +19,8 @@ export default defineConfig([
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
'@typescript-eslint/no-explicit-any': false
}
},
])

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 13:59:45
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-24 15:48:30
* @Last Modified time: 2026-05-06 15:09:49
*/
import { request } from '@/utils/request'
import type { ApplicationModalData } from '@/views/ApplicationManagement/types'
@@ -178,4 +178,8 @@ export const getAppLogDetail = (app_id: string, conversation_id: string) => {
// Reset agent model config to default
export const resetAppModelConfig = (app_id: string) => {
return request.get(`/apps/${app_id}/model/parameters/default`)
}
// Single node test run
export const nodeRun = (app_id: string, node_id: string, values: Record<string, unknown>) => {
return request.post(`/apps/${app_id}/workflow/nodes/${node_id}/run`, values)
}

View File

@@ -2,7 +2,7 @@
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>编组 31</title>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linejoin="round">
<g id="应用管理-工作流-配置-开始" transform="translate(-1325, -24)" stroke="#171719" stroke-width="1.2">
<g id="应用管理-工作流-配置-开始" transform="translate(-1325, -24)" stroke="#5B6167" stroke-width="1.2">
<g id="运行" transform="translate(1318, 17)">
<g id="编组-31" transform="translate(7, 7)">
<path d="M4.5,3.55424764 L4.5,12.4457524 C4.5,12.9980371 4.94771525,13.4457524 5.5,13.4457524 C5.68741972,13.4457524 5.87106734,13.3930829 6.02999894,13.2937507 L13.1432027,8.8479983 C13.6115392,8.55528797 13.7539124,7.93833759 13.4612021,7.47000106 C13.3807214,7.34123193 13.2719718,7.2324824 13.1432027,7.1520017 L6.02999894,2.70624934 C5.56166241,2.41353901 4.94471203,2.55591217 4.6520017,3.0242487 C4.55266944,3.1831803 4.5,3.36682792 4.5,3.55424764 Z" id="路径-46"></path>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -2536,6 +2536,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
input_result: 'Input',
output_result: 'Output',
process_result: 'Data Processing',
inputs_result: 'Input',
outputs_result: 'Output',
error: 'Error Message',
loopNum: ' loops',
iterationNum: ' iterations',
@@ -2546,6 +2548,13 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
output_cycle_vars: 'Final Loop Variables',
},
sureReplace: 'Confirm Replace',
testRun: 'Test Run',
variables: 'Variables',
startRun: 'Start Run',
reStartRun: 'Restart Run',
status: 'Status',
elapsedTime: 'Elapsed Time',
totalTokens: 'Total Tokens',
checkList: 'Check List',
checkListDesc: 'Ensure all issues are resolved before publishing',
checkListEmpty: 'No issues found',

View File

@@ -2501,6 +2501,8 @@ export const zh = {
input_result: '输入',
output_result: '输出',
process_result: '数据处理',
inputs_result: '输入',
outputs_result: '输出',
error: '错误信息',
loopNum: '个循环',
iterationNum: '个迭代',
@@ -2511,6 +2513,13 @@ export const zh = {
output_cycle_vars: '最终循环变量',
},
sureReplace: '确认替换',
testRun: '测试运行',
variables: '变量',
startRun: '开始运行',
reStartRun: '重新运行',
status: '状态',
elapsedTime: '运行时间',
totalTokens: '总 TOKEN 数',
checkList: '检查清单',
checkListDesc: '发布前确保所有问题均已解决',
checkListEmpty: '没有发现问题',
@@ -2555,6 +2564,7 @@ export const zh = {
variableSelect: {
empty: '暂无变量',
},
singleRun: '运行此节点',
},
emotionEngine: {
emotionEngineConfig: '情感引擎配置',

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-06 21:09:42
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 18:29:48
* @Last Modified time: 2026-04-21 10:22:41
*/
/**
* File Upload Component
@@ -56,7 +56,7 @@ interface UploadFilesProps extends Omit<UploadProps, 'onChange'> {
/** Custom file removal callback */
onRemove?: (file: UploadFile) => boolean | void | Promise<boolean | void>;
featureConfig: FeaturesConfigForm['file_upload'];
featureConfig?: FeaturesConfigForm['file_upload'];
textType?: 'button' | 'text';
block?: boolean;
}
@@ -184,7 +184,7 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
audio: 'audio_max_size_mb',
}
const maxSizeKey = categoryMap[mimePrefix] ?? 'document_max_size_mb'
const maxSize = (featureConfig[maxSizeKey] as number) ?? fileSize
const maxSize = (featureConfig?.[maxSizeKey] as number) ?? fileSize
const fileSizeMB = file.size / 1024 / 1024
const isLtMaxSize = fileSizeMB < maxSize;

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-06 21:09:47
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-23 17:49:42
* @Last Modified time: 2026-04-21 10:22:45
*/
/**
* Upload File List Modal Component
@@ -31,7 +31,7 @@ const FormItem = Form.Item;
interface UploadFileListModalProps {
/** Callback to refresh parent component with new file list */
refresh: (fileList?: any[]) => void;
featureConfig: FeaturesConfigForm['file_upload']
featureConfig?: FeaturesConfigForm['file_upload']
}
/**

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-04-09 18:58:21
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-20 10:39:17
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-05-07 18:35:54
*/
import { useState, useCallback, useEffect, useRef, type FC } from 'react'
import { Popover, Flex } from 'antd'
@@ -60,7 +60,7 @@ const specialValidators: Record<string, (val: any) => boolean> = {
return val.some(c => !c?.expressions?.length || c.expressions.some((expr: any) => !isExprSet(expr)))
},
// question-classifier.categories: every category must have a value
'question-classifier.categories': (val: any[]) => !Array.isArray(val) || !val.some(c => c?.class_name && String(c.class_name).trim()),
'question-classifier.categories': (val: any[]) => !Array.isArray(val) || !val.every(c => c?.class_name && String(c.class_name).trim()),
// var-aggregator.group_variables: must be non-empty array
'var-aggregator.group_variables': (val: any[]) => !Array.isArray(val) || !val.length,
// assigner.assignments: every item needs variable_selector + operation; value required unless operation is 'clear'

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2025-12-23 16:22:51
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-13 14:00:07
* @Last Modified time: 2026-05-06 15:06:03
*/
import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react';
import { createPortal } from 'react-dom';
@@ -27,6 +27,7 @@ export interface Suggestion {
disabled?: boolean; // Flag for disabled state
children?: Suggestion[]; // Sub-variables (e.g. file fields)
parentLabel?: string; // Parent variable label (for child display)
default?: any;
}
// Autocomplete plugin for variable suggestions triggered by '/' character

View File

@@ -20,9 +20,8 @@ const NodeTools: FC<{ node: Node }> = ({
}
}
return (
<div className={clsx("rb:absolute rb:p-1 rb:bg-white rb:-top-7.5 rb:right-0 rb:rounded-lg", {
'rb:block': data.isSelected,
'rb:hidden': !data.isSelected
<Flex align="center" gap={8} className={clsx("rb:absolute rb:p-1! rb:bg-white rb:-top-7.5 rb:right-0 rb:rounded-lg", {
'rb:hidden!': !data.isSelected
})}>
<Dropdown
menu={{
@@ -36,7 +35,7 @@ const NodeTools: FC<{ node: Node }> = ({
<div className="rb:cursor-pointer rb:size-4 rb:hover:bg-[#F6F6F6] rb:rounded-sm rb:bg-cover rb:bg-[url(@/assets/images/common/dash.svg)]">
</div>
</Dropdown>
</div>
</Flex>
)
}

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-09 18:30:28
* @Last Modified by: mikey.zhaopeng
* @Last Modified time: 2026-05-06 11:46:02
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-05-07 18:38:06
*/
import { useEffect, useState } from 'react';
import { Flex, Popover } from 'antd';
@@ -176,17 +176,30 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
if (gap < requiredSpace) {
const shiftX = requiredSpace - gap;
const visited = new Set<string>();
const shiftDownstream = (cell: any) => {
const isCycleContainer = (type: string) => type === 'loop' || type === 'iteration';
const shiftDownstream = (cell: any, shiftChildren: boolean = false) => {
if (visited.has(cell.id)) return;
visited.add(cell.id);
const pos = cell.getPosition();
cell.setPosition(pos.x + shiftX, pos.y);
if (shiftChildren) {
const cellType = cell.getData()?.type;
if (isCycleContainer(cellType)) {
const cycleId = cell.getData()?.id;
const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
childNodes.forEach((child: any) => {
const childPos = child.getPosition();
child.setPosition(childPos.x + shiftX, childPos.y);
});
}
}
graph.getConnectedEdges(cell, { outgoing: true }).forEach((e: any) => {
const tCell = graph.getCellById(e.getTargetCellId());
if (tCell?.isNode()) shiftDownstream(tCell);
if (tCell?.isNode()) shiftDownstream(tCell, shiftChildren);
});
};
shiftDownstream(edgeInsertion.targetCell);
const targetCellType = edgeInsertion.targetCell.getData()?.type;
shiftDownstream(edgeInsertion.targetCell, isCycleContainer(targetCellType));
}
} else if (addNodePosition) {
newX = addNodePosition.x;
@@ -241,7 +254,7 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
if (sourceNodeData.cycle) {
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle);
if (parentNode) parentNode.addChild(newNode, { silent: true });
if (parentNode) parentNode.addChild(newNode);
}
if (edgeInsertion) {
@@ -285,8 +298,8 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
y: parentBBox.y + 70 + 4,
data: { type: 'add-node', label: t('workflow.addNode'), icon: '+', parentId: id, cycle: id },
});
newNode.addChild(cycleStartNode, { silent: true });
newNode.addChild(addNodePlaceholder, { silent: true });
newNode.addChild(cycleStartNode);
newNode.addChild(addNodePlaceholder);
const innerEdge = graph.addEdge({
source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find((p: any) => p.group === 'right')?.id || 'right' },
target: { cell: addNodePlaceholder.id, port: addNodePlaceholder.getPorts().find((p: any) => p.group === 'left')?.id || 'left' },
@@ -295,11 +308,11 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
addedCells.push(cycleStartNode, addNodePlaceholder, innerEdge);
}
// Adjust loop node size when child node is added via port within loop node
const cycleId = sourceNodeData.cycle;
if (cycleId) {
adjustCycleContainerSize(graph, cycleId);
}
// Adjust loop node size when child node is added via port within loop node
const cycleId = sourceNodeData.cycle;
if (cycleId) {
adjustCycleContainerSize(graph, cycleId);
}
// toFront
const bringCycleChildrenToFront = (cycleContainerId: string) => {

View File

@@ -133,7 +133,7 @@ const CycleVarsList: FC<CycleVarsListProps> = ({
return option.dataType === currentType
})}
variant="borderless"
variant="filled"
size="small"
className="select"
/>

View File

@@ -13,8 +13,6 @@ const MemoryConfig: FC<{ options: Suggestion[]; parentName: string; }> = ({
const { t } = useTranslation()
const form = Form.useFormInstance();
const values = Form.useWatch([], form) || {}
console.log('MemoryConfig', values)
const handleChangeEnable = (value: boolean) => {
if (value) {

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-01-19 17:00:26
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-13 10:44:17
* @Last Modified time: 2026-05-07 18:36:58
*/
/**
* useVariableList Hook
@@ -97,14 +97,15 @@ const addVariable = (
dataType: string,
value: string,
nodeData: any,
extra?: Partial<Suggestion>
extra?: Partial<Suggestion>,
defaultValue?: any
) => {
if (!keys.has(key)) {
keys.add(key);
const children = dataType === 'file'
? buildFileChildren(key, value, nodeData, label)
: undefined;
list.push({ key, label, type: 'variable', dataType, value, nodeData, children, ...extra });
list.push({ key, label, type: 'variable', dataType, value, nodeData, children, default: defaultValue, ...extra });
}
};
@@ -153,7 +154,7 @@ const processNodeVariables = (
case 'start':
// Add start node variables
[...(config?.variables?.defaultValue ?? []), ...(config?.variables?.value ?? [])].forEach((v: any) => {
if (v?.name) addVariable(variableList, addedKeys, `${dataNodeId}_${v.name}`, v.name, v.type, `${dataNodeId}.${v.name}`, nodeData);
if (v?.name) addVariable(variableList, addedKeys, `${dataNodeId}_${v.name}`, v.name, v.type, `${dataNodeId}.${v.name}`, nodeData, undefined, v.defaultValue ?? v.default);
});
// Add system variables
config?.variables?.sys?.forEach((v: any) => {
@@ -164,7 +165,7 @@ const processNodeVariables = (
case 'parameter-extractor':
// Add extracted parameters
(config?.params?.defaultValue || []).forEach((p: any) => {
if (p?.name) addVariable(variableList, addedKeys, `${dataNodeId}_${p.name}`, p.name, p.type || 'string', `${dataNodeId}.${p.name}`, nodeData);
if (p?.name) addVariable(variableList, addedKeys, `${dataNodeId}_${p.name}`, p.name, p.type || 'string', `${dataNodeId}.${p.name}`, nodeData, undefined, p.defaultValue ?? p.default);
});
break;
@@ -178,7 +179,7 @@ const processNodeVariables = (
const fv = variableList.find(v => `{{${v.value}}}` === gv.value[0]);
if (fv) dt = fv.dataType;
}
addVariable(variableList, addedKeys, `${dataNodeId}_${gv.key}`, gv.key, dt, `${dataNodeId}.${gv.key}`, nodeData);
addVariable(variableList, addedKeys, `${dataNodeId}_${gv.key}`, gv.key, dt, `${dataNodeId}.${gv.key}`, nodeData, undefined, gv.defaultValue ?? gv.default);
}
});
} else {
@@ -205,14 +206,14 @@ const processNodeVariables = (
case 'loop':
// Add loop cycle variables
(config.cycle_vars.defaultValue || []).forEach((cv: any) => {
if (cv.name?.trim()) addVariable(variableList, addedKeys, `${dataNodeId}_cycle_${cv.name}`, cv.name, cv.type || 'string', `${dataNodeId}.${cv.name}`, nodeData);
if (cv.name?.trim()) addVariable(variableList, addedKeys, `${dataNodeId}_cycle_${cv.name}`, cv.name, cv.type || 'string', `${dataNodeId}.${cv.name}`, nodeData, undefined, cv.defaultValue ?? cv.default);
});
break;
case 'code':
// Add code node output variables
(config.output_variables.defaultValue || []).forEach((cv: any) => {
if (cv.name?.trim()) addVariable(variableList, addedKeys, `${dataNodeId}_cycle_${cv.name}`, cv.name, cv.type || 'string', `${dataNodeId}.${cv.name}`, nodeData);
if (cv.name?.trim()) addVariable(variableList, addedKeys, `${dataNodeId}_cycle_${cv.name}`, cv.name, cv.type || 'string', `${dataNodeId}.${cv.name}`, nodeData, undefined, cv.defaultValue ?? cv.default);
});
break;
}
@@ -321,13 +322,13 @@ export const getChildNodeVariables = (
// Add parameter-extractor variables
if (type === 'parameter-extractor') {
(nodeData.config?.params?.defaultValue || []).forEach((p: any) => {
if (p?.name) addVariable(list, keys, `${nodeId}_${p.name}`, p.name, p.type || 'string', `${nodeId}.${p.name}`, nodeData);
if (p?.name) addVariable(list, keys, `${nodeId}_${p.name}`, p.name, p.type || 'string', `${nodeId}.${p.name}`, nodeData, undefined, p.defaultValue ?? p.default);
});
}
// Add code node variables
if (type === 'code') {
(nodeData.config?.output_variables?.defaultValue || []).forEach((p: any) => {
if (p?.name) addVariable(list, keys, `${nodeId}_${p.name}`, p.name, p.type || 'string', `${nodeId}.${p.name}`, nodeData);
if (p?.name) addVariable(list, keys, `${nodeId}_${p.name}`, p.name, p.type || 'string', `${nodeId}.${p.name}`, nodeData, undefined, p.defaultValue ?? p.default);
});
}
});
@@ -393,7 +394,7 @@ export const useVariableList = (
const relevantIds = [...getPreviousNodes(selectedNode.id), ...childIds, ...(parentLoop ? getPreviousNodes(parentLoop.id) : [])];
// Add chat variables
chatVariables?.forEach(v => addVariable(list, keys, `CONVERSATION_${v.name}`, v.name, v.type, `conv.${v.name}`, { type: 'CONVERSATION', name: 'CONVERSATION', icon: '' }, { group: 'CONVERSATION' }));
chatVariables?.forEach(v => addVariable(list, keys, `CONVERSATION_${v.name}`, v.name, v.type, `conv.${v.name}`, { type: 'CONVERSATION', name: 'CONVERSATION', icon: '' }, { group: 'CONVERSATION' }, v.defaultValue ?? v.default));
// Process each relevant node: deferred types last (they depend on prior variables)
const deferredIds: string[] = [];

View File

@@ -2,13 +2,13 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 15:39:59
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-21 20:27:33
* @Last Modified time: 2026-05-07 18:36:31
*/
import { type FC, useEffect, useState, useMemo } from "react";
import clsx from 'clsx'
import { useTranslation } from 'react-i18next'
import { Graph, Node } from '@antv/x6';
import { Form, Input, Select, InputNumber, Switch, Flex, Space, Dropdown, type MenuProps, Button } from 'antd';
import { Form, Input, Select, InputNumber, Switch, Flex, Space, Dropdown, type MenuProps, Button, App, Popover } from 'antd';
import type { NodeConfig, NodeProperties, ChatVariable } from '../../types'
import CustomSelect from "@/components/CustomSelect";
@@ -28,6 +28,7 @@ import ToolConfig from './ToolConfig'
import MemoryConfig from './MemoryConfig'
import VariableList from './VariableList'
import { useVariableList, getCurrentNodeVariables, getChildNodeVariables } from './hooks/useVariableList'
import { useWorkflowStore } from '@/store/workflow'
import styles from './properties.module.css'
import Editor, { type LexicalEditorProps } from "../Editor";
import RbSlider from '@/components/RbSlider'
@@ -39,6 +40,8 @@ import ModelConfig from './ModelConfig'
import ModelSelect from '@/components/ModelSelect'
import ListOperator from './ListOperator'
import MappingList from "./MappingList";
import SingleNodeRun from '../SingleNodeRun'
import { cannotRunNodes } from '../../constant'
/**
* Props for Properties component
@@ -58,8 +61,12 @@ interface PropertiesProps {
parseEvent: () => void;
/** Workflow configuration */
config?: any;
/** App ID for node run */
appId?: string;
/** Chat variables */
chatVariables: ChatVariable[];
/** Function to save workflow configuration */
handleSave: (flag?: boolean) => Promise<unknown>;
}
/**
@@ -71,9 +78,13 @@ const Properties: FC<PropertiesProps> = ({
selectedNode,
graphRef,
chatVariables,
blankClick
blankClick,
config,
appId,
handleSave,
}) => {
const { t } = useTranslation()
const { message } = App.useApp()
const [form] = Form.useForm<NodeConfig>();
const [configs, setConfigs] = useState<Record<string, NodeConfig>>({} as Record<string, NodeConfig>)
const values = Form.useWatch([], form);
@@ -530,11 +541,35 @@ const Properties: FC<PropertiesProps> = ({
}
}
const [isRun, setIsRun] = useState(false);
const { getCheckResults } = useWorkflowStore()
const handleRun = () => {
handleSave?.(false)
.then(() => {
if (appId) {
const nodeResult = getCheckResults(appId).find(r => r.id === selectedNode.id)
const configErrors = nodeResult?.errors.filter(e => e.key !== 'notConnected') ?? []
if (configErrors.length) {
message.error(configErrors[0].message)
return
}
}
setIsRun(true)
})
}
return (
<>
<div className={clsx("rb:h-[calc(100vh-88px)] rb:w-90 rb:fixed rb:right-2.5 rb:top-18.5 rb:bottom-2.5 rb:z-1000", styles.properties)}>
<RbCard
title={t('workflow.nodeProperties')}
extra={<Space>
{!cannotRunNodes.includes(selectedNode?.data?.type) && <Popover content={t('workflow.singleRun')} classNames={{ body: 'rb:py-0.5! rb:px-1! rb:rounded-[6px]! rb:text-[12px]!' }}>
<div
className="rb:cursor-pointer rb:size-4 rb:hover:bg-[#F6F6F6] rb:rounded-sm rb:bg-cover rb:bg-[url('@/assets/images/workflow/run.svg')]"
onClick={handleRun}
></div>
</Popover>}
<Dropdown
menu={{
items: [
@@ -986,7 +1021,18 @@ const Properties: FC<PropertiesProps> = ({
</div>
}
</RbCard>
{isRun && (
<SingleNodeRun
open={isRun}
onClose={() => setIsRun(false)}
selectedNode={selectedNode}
appId={appId || config?.app_id || ''}
variableList={variableList}
/>
)}
</div>
</>
);
};
export default Properties;

View File

@@ -0,0 +1,74 @@
/*
* @Author: ZhaoYing
* @Date: 2026-05-07 18:37:15
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-05-07 18:37:15
*/
import { type FC } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Flex, Form } from 'antd'
import CodeMirrorEditor from '@/components/CodeMirrorEditor'
const defaultContextItem = {
"content": "",
"title": "",
"url": "",
"icon": "",
"metadata": {
"dataset_id": "",
"dataset_name": "",
"document_id": [],
"document_name": "",
"document_data_source_type": "",
"segment_id": "",
"segment_position": "",
"segment_word_count": "",
"segment_hit_count": "",
"segment_index_node_hash": "",
"score": ""
}
}
const ContextList: FC = () => {
const { t } = useTranslation()
return (
<Form.List name="context" initialValue={[JSON.stringify(defaultContextItem, null, 2)]}>
{(fields, { add, remove }) => (
<Flex vertical gap={8}>
<Flex justify="space-between" align="center">
<div className="rb:text-[12px] rb:font-medium rb:leading-4.5">{t('workflow.config.llm.context')}</div>
<Button
onClick={() => add(JSON.stringify(defaultContextItem, null, 2))}
size="small"
className="rb:text-[12px]! rb:rounded-sm!"
>
+ {t('common.add')}
</Button>
</Flex>
{fields.map(({ key, name }) => (
<Flex vertical gap={4} key={key} className="rb:py-1! rb:bg-[#F6F6F6] rb:rounded-lg rb:text-[12px]">
<Flex justify="space-between" align="center" className="rb:font-medium rb:px-2!">
<span>JSON</span>
<div
className="rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/workflow/deleteBg.svg')] rb:hover:bg-[url('@/assets/images/workflow/deleteBg_hover.svg')]"
onClick={() => remove(name)}
></div>
</Flex>
<Form.Item name={name} noStyle>
<CodeMirrorEditor
language="json"
size="small"
variant="filled"
/>
</Form.Item>
</Flex>
))}
</Flex>
)}
</Form.List>
)
}
export default ContextList

View File

@@ -0,0 +1,134 @@
/*
* @Author: ZhaoYing
* @Date: 2026-05-07 18:37:23
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-05-07 18:37:23
*/
import { type FC, useState, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Form, Row, Col } from 'antd'
import type { FormInstance } from 'antd'
import UploadFiles, { transform_file_type } from '@/views/Conversation/components/FileUpload'
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
import type { UploadFileListModalRef } from '@/views/Conversation/types'
import FileList from '@/components/Chat/FileList'
import { getFileInfoByUrl } from '@/api/fileStorage'
interface FileVarInputProps {
name: string | string[]
dataType: string
form: FormInstance
}
const FileVarInput: FC<FileVarInputProps> = ({ name, dataType, form }) => {
const { t } = useTranslation()
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
const [fileList, setFileList] = useState<any[]>([])
const isSingle = dataType === 'file'
const setFormFileValue = (updated: any[]) => {
form.setFieldValue(name, isSingle ? (updated[0] ?? null) : updated)
}
const fileChange = (file?: any) => {
const fileObj = file ? {
...file,
type: file.type,
transfer_method: 'local_file',
upload_file_id: file.response?.data?.file_id,
} : undefined
if (isSingle) {
const updated = [fileObj]
setFileList(updated)
setTimeout(() => setFormFileValue(updated), 0)
return
}
setFileList(prev => {
const index = prev.findIndex((item: any) => item.uid === fileObj.uid)
const updated = index > -1
? prev.map((item, i) => i === index ? fileObj : item)
: [...prev, fileObj]
setTimeout(() => setFormFileValue(updated), 0)
return updated
})
}
const addFileList = (list?: any[]) => {
if (!list?.length) return
const uploadingList = list.map(f => ({ ...f, status: 'uploading' }))
setFileList(prev => {
const updated = isSingle ? [uploadingList[0]] : [...prev, ...uploadingList]
setTimeout(() => setFormFileValue(updated), 0)
return updated
});
(isSingle ? [uploadingList[0]] : uploadingList).forEach(file => {
getFileInfoByUrl(file.url)
.then((res) => {
const { file_name, file_size, content_type } = res as { file_name: string; file_size: number; content_type: string }
setFileList(prev => {
const updated = prev.map(f =>
f.uid === file.uid
? { ...f, status: 'done', name: file_name, size: file_size, type: transform_file_type[content_type] || content_type }
: f
)
setFormFileValue(updated)
return updated
})
})
.catch(() => {
setFileList(prev => {
const updated = prev.map(f => f.uid === file.uid ? { ...f, status: 'error' } : f)
setFormFileValue(updated)
return updated
})
})
})
}
const previewFileList = useMemo(() => fileList.map(file => ({
...file,
url: file.thumbUrl || file.url || (file.originFileObj ? URL.createObjectURL(file.originFileObj) : undefined)
})), [fileList])
const handleDelete = (file: any) => {
const updated = fileList.filter(item =>
item.thumbUrl && file.thumbUrl ? item.thumbUrl !== file.thumbUrl
: item.url && file.url ? item.url !== file.url
: item.uid !== file.uid
)
setFileList(updated)
setFormFileValue(updated)
}
return (
<>
<UploadFileListModal ref={uploadFileListModalRef} refresh={addFileList} />
<Form.Item name={name} hidden noStyle />
<Form.Item>
<Row gutter={8}>
<Col span={12}>
<UploadFiles
onChange={fileChange}
block={true}
textType="button"
disabled={isSingle && fileList.length > 0}
/>
</Col>
<Col span={12}>
<Button block
disabled={isSingle && fileList.length > 0}
onClick={() => uploadFileListModalRef.current?.handleOpen()}>
{t('memoryConversation.addRemoteFile')}
</Button>
</Col>
</Row>
{previewFileList.length > 0 && (
<FileList wrap="wrap" fileList={previewFileList} onDelete={handleDelete} className="rb:mt-2!" />
)}
</Form.Item>
</>
)
}
export default FileVarInput

View File

@@ -0,0 +1,341 @@
/*
* @Author: ZhaoYing
* @Date: 2026-05-07 18:37:31
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-05-07 18:51:58
*/
import { type FC, useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Flex, Form, Input, InputNumber, Select, App, Checkbox } from 'antd'
import { Node } from '@antv/x6'
import copy from 'copy-to-clipboard'
import clsx from 'clsx'
import { nodeRun } from '@/api/application'
import CodeBlock from '@/components/Markdown/CodeBlock'
import RbCard from '@/components/RbCard/Card'
import styles from '../Properties/properties.module.css'
import ContextList from './ContextList'
import FileVarInput from './FileVarInput'
import type { Suggestion } from '../Editor/plugin/AutocompletePlugin'
import Markdown from '@/components/Markdown'
import RbAlert from '@/components/RbAlert'
interface RunResult {
status: 'completed' | 'failed' | 'running';
node_id?: string;
node_type?: string;
inputs?: Record<string, any>;
outputs?: any;
token_usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
elapsed_time?: number;
error?: string | null;
}
interface SingleNodeRunProps {
open: boolean;
onClose: () => void
selectedNode: Node
appId: string
variableList: Suggestion[]
}
const SingleNodeRun: FC<SingleNodeRunProps> = ({ open, onClose, selectedNode, appId, variableList }) => {
const { t } = useTranslation()
const { message } = App.useApp()
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<RunResult | null>(null)
const [isAutoRun, setIsAutoRun] = useState(false)
const nodeData = selectedNode?.getData() || {}
const nodeName = nodeData.name || t(`workflow.${nodeData.type}`)
const isLlm = nodeData.type === 'llm'
const hasContext = isLlm && nodeData.config.context.defaultValue
// Recursively collect all {{nodeId.var}} references from nodeData, excluding conv. vars
const extractVarRefs = (val: any, refs = new Set<string>()): Set<string> => {
if (typeof val === 'string') {
for (const m of val.matchAll(/\{\{([^}]+)\}\}/g))
if (!m[1].startsWith('conv.') && m[1] !== 'context') {
refs.add(m[1])
}
} else if (Array.isArray(val)) {
val.forEach(v => extractVarRefs(v, refs))
} else if (val && typeof val === 'object') {
Object.values(val).forEach(v => extractVarRefs(v, refs))
}
return refs
}
const varRefs = extractVarRefs(nodeData)
const visionInputRef = isLlm ? nodeData.config.vision_input?.defaultValue?.match(/\{\{([^}]+)\}\}/)?.[1] : undefined
const contextInputRef = isLlm ? nodeData.config.context?.defaultValue?.match(/\{\{([^}]+)\}\}/)?.[1] : undefined
const inputVars = variableList.filter(v => varRefs.has(v.value) && v.value !== visionInputRef && v.value !== contextInputRef)
const handleRun = () => {
form.validateFields()
.then((values) => {
const { inputs = {} } = values
console.log('values', values)
const params: Record<string, any> = {};
Object.keys(inputs).forEach(key => {
const value = inputs[key]
if (typeof value === 'object') {
params[key] = value.map((file: any) => {
if (file.url) {
return file
} else {
return {
type: file.type,
transfer_method: 'local_file',
upload_file_id: file.response.data.file_id
}
}
})
} else {
params[key] = value;
}
})
setLoading(true)
setResult({ status: 'running' })
if (hasContext) {
const contextValues: string[] = form.getFieldValue('context') || []
if (contextValues.length > 0) {
params['context'] = contextValues.map(item => { try { return JSON.parse(item) } catch { return item } })
}
}
nodeRun(appId, nodeData.id, { inputs: params, stream: false })
.then(res => {
setResult(res as RunResult)
})
.catch(err => {
setResult({ status: 'failed', error: err.message })
setLoading(false)
})
.finally(() => setLoading(false))
})
}
const handleCopy = (val: string) => {
copy(val)
message.success(t('common.copySuccess'))
}
const statusColor = result?.status === 'completed' ? '#369F21' : result?.status === 'failed' ? '#FF5D34' : '#5B6167'
useEffect(() => {
if (open) {
if (nodeData?.type === 'iteration' || inputVars.length < 1 && !hasContext && !(isLlm && nodeData?.config?.vision?.defaultValue)) {
setIsAutoRun(true)
}
}
}, [open, inputVars, isLlm, hasContext, nodeData?.type, nodeData?.config?.vision?.defaultValue])
useEffect(() => {
if (isAutoRun) {
handleRun()
}
}, [isAutoRun])
if (!open) return null
return (
// 与 Properties 完全相同的定位容器
<div className={clsx('rb:h-[calc(100vh-88px)] rb:w-90 rb:absolute rb:right-0 rb:top-0 rb:bottom-2.5 rb:z-1002', styles.properties)}>
{/* mask仅覆盖 header 以下的区域header 保持透明露出节点名 */}
<div
className="rb:absolute rb:inset-x-0 rb:bottom-0 rb:top-0 rb:rounded-xl rb:bg-[rgba(0,0,0,0.3)] rb:z-1002"
/>
{/* SingleNodeRun 卡片z-index 高于 mask */}
<div className="rb:absolute rb:inset-x-0 rb:top-25.5 rb:bottom-0 rb:z-1003">
<RbCard
title={`${t('workflow.testRun')} ${nodeName}`}
extra={
<div
className="rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/close.svg')]"
onClick={onClose}
/>
}
headerType="borderless"
headerClassName="rb:font-[MiSans-Bold] rb:font-bold rb:min-h-[48px]!"
className="rb:h-full! rb:hover:shadow-none!"
bodyClassName="rb:overflow-y-auto! rb:h-[calc(100%-48px)]! rb:px-3! rb:pt-0! rb:pb-3!"
>
<Form form={form} layout="vertical" size="small" className="rb:mb-0!">
<Flex vertical gap={12}>
{/* Variables */}
{nodeData?.type !== 'iteration' && inputVars.length > 0 && (
<Flex vertical gap={8}>
<div className="rb:text-[12px] rb:font-medium rb:text-[#5B6167]">{t('workflow.variables')}</div>
{inputVars.map(v => (
<Form.Item
key={v.value}
name={['inputs', v.value.replace('{{', '').replace('}}', '')]}
label={v.dataType.includes('boolean')
? null
: <Flex gap={4} align="center" className="rb:text-[12px]">
{v.nodeData?.icon && <div className={`rb:size-3.5 rb:bg-cover ${v.nodeData.icon}`} />}
<span className="rb:font-medium">{v.nodeData?.name}</span>
<span className="rb:text-[#5B6167]">/</span>
<span className="rb:text-[#1677ff]">{v.label}</span>
</Flex>
}
// rules={[{
// required: ['knowledge-retrieval', 'loop'].includes(nodeData.type) && !v.dataType.includes('boolean'),
// message: ['array[string]', 'array[number]'].includes(v.dataType) && Array.isArray(v.default) && v.default.length > 0 ? t('common.selectPlaceholder', { title: v.label }) : t('common.inputPlaceholder', { title: v.label })
// }]}
className="rb:mb-0!"
>
{['array[string]', 'array[number]'].includes(v.dataType) && Array.isArray(v.default) && v.default.length > 0
? <Select
placeholder={t('common.pleaseSelect')}
options={v.default.map((item: string) => ({ label: item, value: item }))}
/>
: v.dataType.includes('string') && nodeData.type === 'knowledge-retrieval'
? <Input.TextArea
placeholder={t('common.pleaseEnter')}
size="small"
/>
: v.dataType.includes('string')
? <Input
placeholder={t('common.pleaseEnter')}
size="small"
/>
: v.dataType.includes('number')
? <InputNumber
size="small"
placeholder={t('common.pleaseEnter')}
className="rb:w-full!"
onChange={(value) => form.setFieldValue(['retry', 'retry_interval'], value)}
/>
: v.dataType.includes('file')
? <FileVarInput name={['inputs', v.value.replace('{{', '').replace('}}', '')]} dataType={v.dataType} form={form} />
: v.dataType.includes('boolean')
? <Checkbox>
<Flex gap={4} align="center" className="rb:text-[12px]">
{v.nodeData?.icon && <div className={`rb:size-3.5 rb:bg-cover ${v.nodeData.icon}`} />}
<span className="rb:font-medium">{v.nodeData?.name}</span>
<span className="rb:text-[#5B6167]">/</span>
<span className="rb:text-[#1677ff]">{v.label}</span>
</Flex>
</Checkbox>
: null
}
</Form.Item>
))}
</Flex>
)}
{/* Context */}
{hasContext && <ContextList />}
{isLlm && nodeData?.config?.vision?.defaultValue && (() => {
const ref = nodeData.config.vision_input?.defaultValue
const visionVar = ref ? variableList.find(v => v.value === ref) : undefined
const dataType = visionVar?.dataType ?? 'array[file]'
// if (!visionVar) return null
console.log('visionVar', ref)
return (
<Form.Item
name={['inputs', ref.replace('{{', '').replace('}}', '')]}
label={t('workflow.config.llm.vision')}
className="rb:mb-0!"
>
<FileVarInput name={['inputs', ref.replace('{{', '').replace('}}', '')]} dataType={dataType} form={form} />
</Form.Item>
)
})()}
{/* Run button */}
{(!isAutoRun || result?.status) &&
<Button type="primary" block onClick={handleRun} loading={!result?.status && loading} disabled={loading}>
{result?.status ? t('workflow.reStartRun') : t('workflow.startRun')}
</Button>
}
{/* Status row */}
{result && (
<div className="rb:rounded-lg rb:border rb:border-[#E8E8E8] rb:p-3 rb:bg-[#F6FFF4]">
<Flex justify="space-between" align="start">
<Flex vertical align="start" gap={2}>
<span className="rb:text-[11px] rb:text-[#5B6167]">{t('workflow.status')}</span>
<span className="rb:font-medium rb:text-[13px]" style={{ color: statusColor }}>
{result.status?.toUpperCase()}
</span>
</Flex>
<Flex vertical align="start" gap={2}>
<span className="rb:text-[11px] rb:text-[#5B6167]">{t('workflow.elapsedTime')}</span>
{result.elapsed_time != null && <span className="rb:font-medium rb:text-[13px]">{result.elapsed_time?.toFixed(3)}ms</span>}
</Flex>
<Flex vertical gap={2} align="start">
<span className="rb:text-[11px] rb:text-[#5B6167]">{t('workflow.totalTokens')}</span>
{!loading && <span className="rb:font-medium rb:text-[13px]">{ result?.token_usage?.total_tokens || 0} Tokens</span>}
</Flex>
</Flex>
</div>
)}
{/* Input / Output code blocks */}
{result && (['inputs', 'process', 'outputs'] as const).map(key => {
if (nodeData.type !== 'http-request' && key === 'process') return null
const content = typeof result[key as keyof RunResult] === 'object' && result[key as keyof RunResult] ? JSON.stringify(result[key as keyof RunResult], null, 2) : result[key as keyof RunResult] ? result[key as keyof RunResult] : '{}'
return (
<div key={key} className="rb:bg-[#EBEBEB] rb:rounded-lg">
<div className="rb:py-2 rb:px-3 rb:flex rb:justify-between rb:items-center rb:text-[12px]">
{t(`workflow.${key}_result`)}
<Button
className="rb:py-0! rb:px-1! rb:text-[12px]!"
size="small"
onClick={() => handleCopy(content)}
>{t('common.copy')}</Button>
</div>
<div className="rb:max-h-40 rb:overflow-auto">
<CodeBlock
size="small"
value={content}
needCopy={false}
showLineNumbers={true}
background="#EBEBEB"
/>
</div>
</div>
)
})}
{/* Error */}
{result?.error && (
<RbAlert color="orange" className="rb:pb-0!">
<Flex vertical className="rb:w-full!">
<Flex align="center" justify="space-between">
{t(`workflow.error`)}
<Button
className="rb:py-0! rb:px-1! rb:text-[12px]!"
size="small"
onClick={() => handleCopy(result?.error || '')}
>{t('common.copy')}</Button>
</Flex>
<Markdown className="rb:wrap-break-word!" content={result?.error || ''} />
</Flex>
</RbAlert>
)}
</Flex>
</Form>
</RbCard>
</div>
</div>
)
}
export default SingleNodeRun

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 15:06:18
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-05-06 11:53:21
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-05-07 18:17:40
*/
import type { ReactShapeConfig } from '@antv/x6-react-shape';
import type { GroupMetadata, PortMetadata } from '@antv/x6/lib/model/port';
@@ -16,6 +16,12 @@ import NoteNode from './components/Nodes/NoteNode';
import { memoryConfigListUrl } from '@/api/memory';
import type { NodeLibrary } from './types';
export const cannotRunNodes = [
'start',
'end',
'output',
]
/**
* Workflow node library configuration
* Defines all available node types, their icons, and configuration schemas

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 15:17:48
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-05-06 14:30:46
* @Last Modified time: 2026-05-07 18:22:14
*/
import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, History, type Edge } from '@antv/x6';
import { register as registerReactShape } from '@antv/x6-react-shape';
@@ -488,6 +488,36 @@ export const useWorkflowGraph = ({
graphRef.current.addEdges(edgeList.filter(vo => vo !== null))
}
// Check if loop/iteration nodes need add-node added
const parentNodes = graphRef.current.getNodes().filter(node => {
const type = node.getData()?.type;
return type === 'loop' || type === 'iteration';
});
parentNodes.forEach(parentNode => {
const parentData = parentNode.getData();
const allChildren = graphRef.current!.getNodes().filter(n => n.getData()?.cycle === parentData.id);
const cycleStartNodes = allChildren.filter(n => n.getData()?.type === 'cycle-start');
// If only cycle-start exists, add add-node
if (cycleStartNodes.length === 1 && allChildren.length === 1) {
const cycleStartNode = cycleStartNodes[0];
const bbox = cycleStartNode.getBBox();
const addNode = graphRef.current!.addNode({
...graphNodeLibrary.addStart,
x: bbox.x + 84,
y: bbox.y + 4,
data: { type: 'add-node', parentId: parentNode.id, cycle: parentData.id, label: t('workflow.addNode'), icon: '+' },
});
parentNode.addChild(addNode, { silent: true });
graphRef.current!.addEdge({
source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find(p => p.group === 'right')?.id || 'right' },
target: { cell: addNode.id, port: addNode.getPorts().find(p => p.group === 'left')?.id || 'left' },
...edgeAttrs,
});
}
});
graphRef.current.centerContent()
// Initialize after completion, display nodes in visible area
if (nodes.length > 0 || edges.length > 0) {
@@ -761,22 +791,11 @@ export const useWorkflowGraph = ({
setSelectedNode(null)
return;
}
const nodes = graphRef.current?.getNodes();
nodes?.forEach(vo => {
const data = vo.getData();
if (data.isSelected) {
vo.setData({
...data,
isSelected: false,
}, { silent: true });
}
});
clearNodeSelect()
node.setData({
...nodeData,
isSelected: true,
}, { silent: true });
});
clearEdgeSelect()
if (nodeData.type !== 'notes') {
setSelectedNode(node);
@@ -805,7 +824,7 @@ export const useWorkflowGraph = ({
node.setData({
...data,
isSelected: false,
}, { silent: true });
});
}
});
setSelectedNode(null);

View File

@@ -118,6 +118,8 @@ const Workflow = forwardRef<WorkflowRef, { onFeaturesLoad?: (features: FeaturesC
parseEvent={parseEvent}
config={config}
chatVariables={chatVariables}
appId={config?.app_id}
handleSave={handleSave}
/>
}
<Chat