发布 v1.10 #5
@@ -17,6 +17,9 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 为工作流知识库节点提供检索能力。
|
||||
*/
|
||||
@Component
|
||||
public class KnowledgeProviderImpl implements KnowledgeProvider {
|
||||
|
||||
@@ -24,14 +27,23 @@ public class KnowledgeProviderImpl implements KnowledgeProvider {
|
||||
private DocumentCollectionService documentCollectionService;
|
||||
|
||||
/**
|
||||
* 获取知识库
|
||||
* @param id 知识库id
|
||||
* 获取知识库检索器。
|
||||
*
|
||||
* @param id 知识库 ID
|
||||
* @return 知识库检索器
|
||||
*/
|
||||
@Override
|
||||
public Knowledge getKnowledge(Object id) {
|
||||
return new Knowledge() {
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public List<Map<String, Object>> search(String keyword, int limit, KnowledgeNode knowledgeNode, Chain chain) {
|
||||
public List<Map<String, Object>> search(
|
||||
String keyword,
|
||||
int limit,
|
||||
KnowledgeNode knowledgeNode,
|
||||
Chain chain) {
|
||||
KnowledgeRetrievalRequest request = new KnowledgeRetrievalRequest();
|
||||
request.setKnowledgeId(new BigInteger(id.toString()));
|
||||
request.setQuery(keyword);
|
||||
@@ -45,10 +57,29 @@ public class KnowledgeProviderImpl implements KnowledgeProvider {
|
||||
}
|
||||
List<Map<String, Object>> res = new ArrayList<>();
|
||||
for (Document document : documents) {
|
||||
res.add(JSONObject.from(document));
|
||||
res.add(toWorkflowDocument(document, id));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将检索文档转换为工作流稳定对象,并保留旧序列化字段。
|
||||
*
|
||||
* @param document 检索文档
|
||||
* @param knowledgeId 知识库 ID
|
||||
* @return 工作流文档对象
|
||||
*/
|
||||
private Map<String, Object> toWorkflowDocument(
|
||||
Document document, Object knowledgeId) {
|
||||
JSONObject result = JSONObject.from(document);
|
||||
result.put("title", document.getTitle());
|
||||
result.put("content", document.getContent());
|
||||
result.put(
|
||||
"documentId",
|
||||
document.getMetadata("documentId", document.getId()));
|
||||
result.put("knowledgeId", knowledgeId);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 校验工作流结构、节点配置和预执行约束。
|
||||
*/
|
||||
@Service
|
||||
public class WorkflowCheckService {
|
||||
private static final String LEVEL_ERROR = "ERROR";
|
||||
@@ -240,7 +243,7 @@ public class WorkflowCheckService {
|
||||
Set<String> issueKeys) {
|
||||
for (NodeView node : nodes) {
|
||||
checkConfiguredLoopCount(node, issues, issueKeys);
|
||||
checkFixedExplicitLoopCount(node, issues, issueKeys);
|
||||
checkExplicitLoopInputs(node, issues, issueKeys);
|
||||
if (StringUtils.hasText(node.parentId)) {
|
||||
NodeView parent = nodeMap.get(node.parentId);
|
||||
if (parent != null && !TYPE_LOOP.equals(parent.type)) {
|
||||
@@ -255,6 +258,7 @@ public class WorkflowCheckService {
|
||||
}
|
||||
}
|
||||
checkLoopParentCycle(node, nodeMap, issues, issueKeys);
|
||||
checkLoopVariableScope(node, nodeMap, issues, issueKeys);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,29 +285,90 @@ public class WorkflowCheckService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验显式循环节点使用固定数值时的次数范围。
|
||||
* 校验显式循环节点的新旧输入结构。
|
||||
*
|
||||
* @param node 节点
|
||||
* @param issues 问题列表
|
||||
* @param issueKeys 问题去重键
|
||||
*/
|
||||
private void checkFixedExplicitLoopCount(
|
||||
private void checkExplicitLoopInputs(
|
||||
NodeView node,
|
||||
List<WorkflowCheckIssue> issues,
|
||||
Set<String> issueKeys) {
|
||||
if (!TYPE_LOOP.equals(node.type) || node.data == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
JSONObject loopInputs = node.data.getJSONObject("loopInputs");
|
||||
JSONArray loopVars = node.data.getJSONArray("loopVars");
|
||||
if (loopVars == null || loopVars.isEmpty()) {
|
||||
if (loopInputs != null) {
|
||||
if (loopVars != null && !loopVars.isEmpty()) {
|
||||
addIssue(
|
||||
issues,
|
||||
issueKeys,
|
||||
"LOOP_INPUT_SCHEMA_CONFLICT",
|
||||
"循环输入配置存在冲突,请重新保存循环节点",
|
||||
node.id,
|
||||
null,
|
||||
node.name);
|
||||
}
|
||||
JSONObject count = loopInputs.getJSONObject("count");
|
||||
JSONObject items = loopInputs.getJSONObject("items");
|
||||
if (count == null && items == null) {
|
||||
addIssue(
|
||||
issues,
|
||||
issueKeys,
|
||||
"LOOP_INPUT_REQUIRED",
|
||||
"请至少配置循环次数或输入数组",
|
||||
node.id,
|
||||
null,
|
||||
node.name);
|
||||
return;
|
||||
}
|
||||
checkExplicitCountParameter(count, node, issues, issueKeys);
|
||||
checkExplicitItemsParameter(items, node, issues, issueKeys);
|
||||
return;
|
||||
}
|
||||
|
||||
if (loopVars == null || loopVars.isEmpty()) {
|
||||
addIssue(
|
||||
issues,
|
||||
issueKeys,
|
||||
"LOOP_INPUT_REQUIRED",
|
||||
"请至少配置循环次数或输入数组",
|
||||
node.id,
|
||||
null,
|
||||
node.name);
|
||||
return;
|
||||
}
|
||||
|
||||
JSONObject loopVar = loopVars.getJSONObject(0);
|
||||
if (loopVar == null || !"fixed".equals(loopVar.getString("refType"))) {
|
||||
if (loopVar == null) {
|
||||
addIssue(
|
||||
issues,
|
||||
issueKeys,
|
||||
"LOOP_INPUT_REQUIRED",
|
||||
"请至少配置循环次数或输入数组",
|
||||
node.id,
|
||||
null,
|
||||
node.name);
|
||||
return;
|
||||
}
|
||||
if (!"fixed".equals(loopVar.getString("refType"))) {
|
||||
return;
|
||||
}
|
||||
Object value = loopVar.get("value");
|
||||
if (value != null && StringUtils.hasText(String.valueOf(value))) {
|
||||
if (value == null || !StringUtils.hasText(String.valueOf(value))) {
|
||||
addIssue(
|
||||
issues,
|
||||
issueKeys,
|
||||
"EXPLICIT_LOOP_COUNT_INVALID",
|
||||
"循环次数必须是 1~300 的整数",
|
||||
node.id,
|
||||
null,
|
||||
node.name);
|
||||
return;
|
||||
}
|
||||
addLoopCountIssueIfInvalid(
|
||||
value,
|
||||
"EXPLICIT_LOOP_COUNT_INVALID",
|
||||
@@ -311,6 +376,202 @@ public class WorkflowCheckService {
|
||||
issues,
|
||||
issueKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验显式循环次数参数。
|
||||
*
|
||||
* @param count 次数参数
|
||||
* @param node 节点
|
||||
* @param issues 问题列表
|
||||
* @param issueKeys 问题去重键
|
||||
*/
|
||||
private void checkExplicitCountParameter(
|
||||
JSONObject count,
|
||||
NodeView node,
|
||||
List<WorkflowCheckIssue> issues,
|
||||
Set<String> issueKeys) {
|
||||
if (count == null) {
|
||||
return;
|
||||
}
|
||||
String refType = trimToNull(count.getString("refType"));
|
||||
if ("fixed".equals(refType)) {
|
||||
Object value = count.get("value");
|
||||
if (value == null || !StringUtils.hasText(String.valueOf(value))) {
|
||||
addIssue(
|
||||
issues,
|
||||
issueKeys,
|
||||
"EXPLICIT_LOOP_COUNT_INVALID",
|
||||
"循环次数必须是 1~300 的整数",
|
||||
node.id,
|
||||
null,
|
||||
node.name);
|
||||
return;
|
||||
}
|
||||
addLoopCountIssueIfInvalid(
|
||||
value,
|
||||
"EXPLICIT_LOOP_COUNT_INVALID",
|
||||
node,
|
||||
issues,
|
||||
issueKeys);
|
||||
return;
|
||||
}
|
||||
if (!"ref".equals(refType)
|
||||
|| !StringUtils.hasText(trimToNull(count.getString("ref")))
|
||||
|| !"Number".equalsIgnoreCase(
|
||||
safe(count.getString("dataType")))) {
|
||||
addIssue(
|
||||
issues,
|
||||
issueKeys,
|
||||
"EXPLICIT_LOOP_COUNT_INVALID",
|
||||
"循环次数必须引用数值变量",
|
||||
node.id,
|
||||
null,
|
||||
node.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验显式循环数组参数。
|
||||
*
|
||||
* @param items 数组参数
|
||||
* @param node 节点
|
||||
* @param issues 问题列表
|
||||
* @param issueKeys 问题去重键
|
||||
*/
|
||||
private void checkExplicitItemsParameter(
|
||||
JSONObject items,
|
||||
NodeView node,
|
||||
List<WorkflowCheckIssue> issues,
|
||||
Set<String> issueKeys) {
|
||||
if (items == null) {
|
||||
return;
|
||||
}
|
||||
String refType = trimToNull(items.getString("refType"));
|
||||
String ref = trimToNull(items.getString("ref"));
|
||||
String dataType = trimToNull(items.getString("dataType"));
|
||||
if (!"ref".equals(refType)
|
||||
|| !StringUtils.hasText(ref)
|
||||
|| !isArrayDataType(dataType)) {
|
||||
addIssue(
|
||||
issues,
|
||||
issueKeys,
|
||||
"EXPLICIT_LOOP_ITEMS_TYPE_INVALID",
|
||||
"输入数组必须引用数组变量",
|
||||
node.id,
|
||||
null,
|
||||
node.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断参数类型是否为数组。
|
||||
*
|
||||
* @param dataType 参数类型
|
||||
* @return 数组类型返回 {@code true}
|
||||
*/
|
||||
private boolean isArrayDataType(String dataType) {
|
||||
return StringUtils.hasText(dataType)
|
||||
&& ("Array".equalsIgnoreCase(dataType)
|
||||
|| dataType.regionMatches(
|
||||
true, 0, "Array<", 0, "Array<".length()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 index 和 loopItem 仅在所属循环体内引用。
|
||||
*
|
||||
* @param node 当前节点
|
||||
* @param nodeMap 节点索引
|
||||
* @param issues 问题列表
|
||||
* @param issueKeys 问题去重键
|
||||
*/
|
||||
private void checkLoopVariableScope(
|
||||
NodeView node,
|
||||
Map<String, NodeView> nodeMap,
|
||||
List<WorkflowCheckIssue> issues,
|
||||
Set<String> issueKeys) {
|
||||
if (node.data == null) {
|
||||
return;
|
||||
}
|
||||
Set<String> references = new LinkedHashSet<>();
|
||||
collectParameterReferences(node.data, references);
|
||||
for (String reference : references) {
|
||||
int separator = reference.indexOf('.');
|
||||
if (separator <= 0) {
|
||||
continue;
|
||||
}
|
||||
String loopNodeId = reference.substring(0, separator);
|
||||
String variablePath = reference.substring(separator + 1);
|
||||
if (!("index".equals(variablePath)
|
||||
|| variablePath.startsWith("index.")
|
||||
|| "loopItem".equals(variablePath)
|
||||
|| variablePath.startsWith("loopItem."))) {
|
||||
continue;
|
||||
}
|
||||
NodeView loopNode = nodeMap.get(loopNodeId);
|
||||
if (loopNode == null || !TYPE_LOOP.equals(loopNode.type)) {
|
||||
continue;
|
||||
}
|
||||
if (!isDescendantOfLoop(node, loopNodeId, nodeMap)) {
|
||||
addIssue(
|
||||
issues,
|
||||
issueKeys,
|
||||
"LOOP_SCOPE_REFERENCE_INVALID",
|
||||
"循环变量只能在对应循环体内使用",
|
||||
node.id,
|
||||
null,
|
||||
node.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归收集节点数据中的参数引用。
|
||||
*
|
||||
* @param value 待遍历值
|
||||
* @param references 引用结果
|
||||
*/
|
||||
private void collectParameterReferences(
|
||||
Object value, Set<String> references) {
|
||||
if (value instanceof JSONObject object) {
|
||||
String reference = trimToNull(object.getString("ref"));
|
||||
if (StringUtils.hasText(reference)) {
|
||||
references.add(reference);
|
||||
}
|
||||
for (Object child : object.values()) {
|
||||
collectParameterReferences(child, references);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value instanceof JSONArray array) {
|
||||
for (Object child : array) {
|
||||
collectParameterReferences(child, references);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断节点是否位于指定循环节点内部。
|
||||
*
|
||||
* @param node 当前节点
|
||||
* @param loopNodeId 循环节点 ID
|
||||
* @param nodeMap 节点索引
|
||||
* @return 位于循环体内返回 {@code true}
|
||||
*/
|
||||
private boolean isDescendantOfLoop(
|
||||
NodeView node,
|
||||
String loopNodeId,
|
||||
Map<String, NodeView> nodeMap) {
|
||||
Set<String> visited = new HashSet<>();
|
||||
NodeView current = node;
|
||||
while (current != null
|
||||
&& StringUtils.hasText(current.parentId)
|
||||
&& visited.add(current.id)) {
|
||||
if (loopNodeId.equals(current.parentId)) {
|
||||
return true;
|
||||
}
|
||||
current = nodeMap.get(current.parentId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package tech.easyflow.ai.easyagentsflow.knowledge;
|
||||
|
||||
import com.easyagents.core.document.Document;
|
||||
import com.easyagents.flow.core.knowledge.Knowledge;
|
||||
import com.easyagents.flow.core.node.KnowledgeNode;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link KnowledgeProviderImpl} 的工作流文档契约测试。
|
||||
*/
|
||||
public class KnowledgeProviderImplTest {
|
||||
|
||||
/**
|
||||
* 验证对象数组同时提供稳定顶层字段和旧版字段。
|
||||
*
|
||||
* @throws Exception 注入测试依赖失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldExposeStableDocumentFieldsAndKeepLegacyFields()
|
||||
throws Exception {
|
||||
Document document = new Document();
|
||||
document.setId(BigInteger.valueOf(42));
|
||||
document.setTitle("文档标题");
|
||||
document.setContent("文档内容");
|
||||
document.addMetadata("documentId", BigInteger.valueOf(420));
|
||||
document.addMetadata("legacyKey", "legacy-value");
|
||||
|
||||
DocumentCollectionService service =
|
||||
mock(DocumentCollectionService.class);
|
||||
when(service.search(any(KnowledgeRetrievalRequest.class)))
|
||||
.thenReturn(Collections.singletonList(document));
|
||||
KnowledgeProviderImpl provider = new KnowledgeProviderImpl();
|
||||
setField(provider, "documentCollectionService", service);
|
||||
|
||||
KnowledgeNode knowledgeNode = new KnowledgeNode();
|
||||
knowledgeNode.setId("knowledge-node");
|
||||
Knowledge knowledge =
|
||||
provider.getKnowledge(BigInteger.valueOf(88));
|
||||
List<Map<String, Object>> result =
|
||||
knowledge.search("问题", 10, knowledgeNode, null);
|
||||
|
||||
Assert.assertEquals(1, result.size());
|
||||
Map<String, Object> item = result.get(0);
|
||||
Assert.assertEquals("文档标题", item.get("title"));
|
||||
Assert.assertEquals("文档内容", item.get("content"));
|
||||
Assert.assertEquals(BigInteger.valueOf(420), item.get("documentId"));
|
||||
Assert.assertEquals(BigInteger.valueOf(88), item.get("knowledgeId"));
|
||||
Assert.assertEquals(42L, ((Number) item.get("id")).longValue());
|
||||
Assert.assertTrue(item.containsKey("metadataMap"));
|
||||
Assert.assertEquals(
|
||||
"legacy-value",
|
||||
((Map<?, ?>) item.get("metadataMap")).get("legacyKey"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入测试依赖。
|
||||
*
|
||||
* @param target 目标对象
|
||||
* @param fieldName 字段名称
|
||||
* @param value 字段值
|
||||
* @throws Exception 字段不存在或不可访问时抛出
|
||||
*/
|
||||
private static void setField(
|
||||
Object target, String fieldName, Object value) throws Exception {
|
||||
Field field = KnowledgeProviderImpl.class
|
||||
.getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,134 @@ public class WorkflowCheckServiceTest {
|
||||
assertHasCode(result, "EXPLICIT_LOOP_COUNT_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证循环次数和输入数组可分别存在,也可同时存在。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldPassForSupportedExplicitLoopInputs() throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject countOnly = loopData(
|
||||
fixedParameter("count", "3", "Number"), null);
|
||||
JSONObject itemsOnly = loopData(
|
||||
null, refParameter("items", "start.items", "Array<Object>"));
|
||||
JSONObject both = loopData(
|
||||
refParameter("count", "start.count", "Number"),
|
||||
refParameter("items", "knowledge.documents", "Array"));
|
||||
String content = workflowJson(
|
||||
array(
|
||||
node("loop-count", "loopNode", null, countOnly),
|
||||
node("loop-items", "loopNode", null, itemsOnly),
|
||||
node("loop-both", "loopNode", null, both)
|
||||
),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertTrue(result.isPassed());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证显式循环至少需要一个输入。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockEmptyExplicitLoopInputs() throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject loopData = data("循环");
|
||||
loopData.put("loopInputs", new JSONObject());
|
||||
String content = workflowJson(
|
||||
array(node("loop-1", "loopNode", null, loopData)),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "LOOP_INPUT_REQUIRED");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证输入数组只接受数组变量引用。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockFixedOrNonArrayLoopItems() throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject fixedItems = loopData(
|
||||
null, fixedParameter("items", "[]", "Array"));
|
||||
JSONObject stringItems = loopData(
|
||||
null, refParameter("items", "start.value", "String"));
|
||||
String content = workflowJson(
|
||||
array(
|
||||
node("loop-fixed", "loopNode", null, fixedItems),
|
||||
node("loop-string", "loopNode", null, stringItems)
|
||||
),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "EXPLICIT_LOOP_ITEMS_TYPE_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证新旧循环输入不能同时提交。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockConflictingLoopInputSchemas() throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject loopData = loopData(
|
||||
fixedParameter("count", "2", "Number"), null);
|
||||
loopData.put("loopVars", array(
|
||||
fixedParameter("loopVar", "2", "Number")));
|
||||
String content = workflowJson(
|
||||
array(node("loop-1", "loopNode", null, loopData)),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "LOOP_INPUT_SCHEMA_CONFLICT");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证循环变量只能由对应循环体中的节点引用。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockLoopVariableReferenceOutsideScope() throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject childData = data("循环内节点");
|
||||
childData.put("inputDefs", array(
|
||||
refParameter("item", "loop-1.loopItem.content", "String")));
|
||||
JSONObject outsideData = data("循环外节点");
|
||||
outsideData.put("inputDefs", array(
|
||||
refParameter("index", "loop-1.index", "Number")));
|
||||
String content = workflowJson(
|
||||
array(
|
||||
node(
|
||||
"loop-1",
|
||||
"loopNode",
|
||||
null,
|
||||
loopData(fixedParameter(
|
||||
"count", "2", "Number"), null)),
|
||||
node("inside", "codeNode", "loop-1", childData),
|
||||
node("outside", "codeNode", null, outsideData)
|
||||
),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
Assert.assertEquals(
|
||||
1,
|
||||
result.getIssues().stream()
|
||||
.filter(issue -> "LOOP_SCOPE_REFERENCE_INVALID"
|
||||
.equals(issue.getCode()))
|
||||
.count());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证嵌套节点只能挂在显式循环节点下。
|
||||
*/
|
||||
@@ -552,6 +680,62 @@ public class WorkflowCheckServiceTest {
|
||||
return node(id, "search-dataset-node", parentId, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建显式循环节点数据。
|
||||
*
|
||||
* @param count 次数参数
|
||||
* @param items 数组参数
|
||||
* @return 循环节点数据
|
||||
*/
|
||||
private static JSONObject loopData(JSONObject count, JSONObject items) {
|
||||
JSONObject data = data("循环");
|
||||
JSONObject inputs = new JSONObject();
|
||||
if (count != null) {
|
||||
inputs.put("count", count);
|
||||
}
|
||||
if (items != null) {
|
||||
inputs.put("items", items);
|
||||
}
|
||||
data.put("loopInputs", inputs);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建固定值参数。
|
||||
*
|
||||
* @param name 参数名称
|
||||
* @param value 参数值
|
||||
* @param dataType 参数类型
|
||||
* @return 参数对象
|
||||
*/
|
||||
private static JSONObject fixedParameter(
|
||||
String name, String value, String dataType) {
|
||||
JSONObject parameter = new JSONObject();
|
||||
parameter.put("name", name);
|
||||
parameter.put("refType", "fixed");
|
||||
parameter.put("value", value);
|
||||
parameter.put("dataType", dataType);
|
||||
return parameter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建变量引用参数。
|
||||
*
|
||||
* @param name 参数名称
|
||||
* @param ref 引用路径
|
||||
* @param dataType 参数类型
|
||||
* @return 参数对象
|
||||
*/
|
||||
private static JSONObject refParameter(
|
||||
String name, String ref, String dataType) {
|
||||
JSONObject parameter = new JSONObject();
|
||||
parameter.put("name", name);
|
||||
parameter.put("refType", "ref");
|
||||
parameter.put("ref", ref);
|
||||
parameter.put("dataType", dataType);
|
||||
return parameter;
|
||||
}
|
||||
|
||||
private static JSONObject data(String title) {
|
||||
JSONObject data = new JSONObject();
|
||||
data.put("title", title);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||
import type {TinyflowNodeData} from '#types';
|
||||
import LoopInputEditor from './loop/LoopInputEditor.svelte';
|
||||
|
||||
const { data, ...rest }: {
|
||||
data: TinyflowNodeData,
|
||||
@@ -17,17 +18,6 @@
|
||||
const currentNodeId = getCurrentNodeId();
|
||||
const { addParameter } = useAddParameter();
|
||||
|
||||
|
||||
$effect(() => {
|
||||
if (!data.loopVars || data.loopVars.length === 0) {
|
||||
addParameter(currentNodeId, 'loopVars', {
|
||||
name: 'loopVar',
|
||||
nameDisabled: true,
|
||||
deleteDisabled: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
@@ -46,17 +36,9 @@
|
||||
{/snippet}
|
||||
|
||||
<div class="heading">
|
||||
<Heading level={3}>循环变量</Heading>
|
||||
<!-- <Button class="input-btn-more" style="margin-left: auto" onclick={()=>{-->
|
||||
<!-- addParameter(currentNodeId)-->
|
||||
<!-- }}>-->
|
||||
<!-- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">-->
|
||||
<!-- <path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>-->
|
||||
<!-- </svg>-->
|
||||
<!-- </Button>-->
|
||||
<Heading level={3}>循环参数</Heading>
|
||||
</div>
|
||||
<RefParameterList dataKeyName="loopVars" fixedNumberMin={1} fixedNumberMax={300} />
|
||||
<div class="loop-limit-hint">单次最多循环 300 次</div>
|
||||
<LoopInputEditor {data} />
|
||||
|
||||
<div class="heading">
|
||||
<Heading level={3}>输出参数</Heading>
|
||||
@@ -79,13 +61,6 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loop-limit-hint {
|
||||
margin-top: 6px;
|
||||
color: var(--tf-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
:global(.loop-handle-wrapper) {
|
||||
&::after {
|
||||
//display: none;
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type Node,
|
||||
useNodesData,
|
||||
useStore,
|
||||
useSvelteFlow,
|
||||
} from '@xyflow/svelte';
|
||||
|
||||
import type {
|
||||
Parameter,
|
||||
SelectItem,
|
||||
TinyflowNodeData,
|
||||
} from '#types';
|
||||
import { getCurrentNodeId } from '#components/utils/NodeUtils';
|
||||
import { MixedInput, Select } from '../../base';
|
||||
import {
|
||||
buildLoopItemParameter,
|
||||
resolveLoopInputs,
|
||||
} from '../../../utils/loopScope';
|
||||
import { useRefOptions } from '../../utils/useRefOptions.svelte';
|
||||
import { filterRefOptionsByDataType } from '../../utils/refOptionFilter';
|
||||
|
||||
const { data }: { data: TinyflowNodeData } = $props();
|
||||
const currentNodeId = getCurrentNodeId();
|
||||
const currentNode = useNodesData(currentNodeId);
|
||||
const { nodes } = $derived(useStore());
|
||||
const { updateNodeData } = useSvelteFlow();
|
||||
const referenceOptions = useRefOptions();
|
||||
|
||||
const loopNode = $derived.by(() => {
|
||||
return (
|
||||
currentNode.current || {
|
||||
id: currentNodeId,
|
||||
type: 'loopNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data,
|
||||
}
|
||||
) as Node;
|
||||
});
|
||||
const loopInputs = $derived.by(() =>
|
||||
resolveLoopInputs(loopNode, nodes || []),
|
||||
);
|
||||
const loopItem = $derived.by(() =>
|
||||
buildLoopItemParameter(loopNode, nodes || []),
|
||||
);
|
||||
const countOptions = $derived.by(() =>
|
||||
filterRefOptionsByDataType(
|
||||
referenceOptions.current,
|
||||
['Number'],
|
||||
loopInputs.count?.ref || '',
|
||||
),
|
||||
);
|
||||
const itemsOptions = $derived.by(() =>
|
||||
filterRefOptionsByDataType(
|
||||
referenceOptions.current,
|
||||
['Array'],
|
||||
loopInputs.items?.ref || '',
|
||||
),
|
||||
);
|
||||
|
||||
const countType = $derived<'fixed' | 'ref'>(
|
||||
loopInputs.count?.refType === 'ref' ? 'ref' : 'fixed',
|
||||
);
|
||||
const countTextValue = $derived(
|
||||
loopInputs.count?.refType === 'fixed'
|
||||
? String(loopInputs.count.value || '')
|
||||
: '',
|
||||
);
|
||||
const countRefValue = $derived(
|
||||
loopInputs.count?.refType === 'ref'
|
||||
? String(loopInputs.count.ref || '')
|
||||
: '',
|
||||
);
|
||||
const countHint = $derived.by(() => {
|
||||
const count = loopInputs.count;
|
||||
if (!count || count.refType !== 'fixed') {
|
||||
return '';
|
||||
}
|
||||
const value = Number(count.value);
|
||||
return Number.isInteger(value) && value >= 1 && value <= 300
|
||||
? ''
|
||||
: '请输入 1~300 的整数';
|
||||
});
|
||||
const summary = $derived.by(() => {
|
||||
const count = loopInputs.count;
|
||||
const items = loopInputs.items;
|
||||
if (count && items) {
|
||||
return count.refType === 'fixed' && count.value
|
||||
? `最多处理数组前 ${count.value} 项`
|
||||
: '按循环次数限制数组遍历';
|
||||
}
|
||||
if (items) {
|
||||
return '遍历数组全部元素';
|
||||
}
|
||||
if (count) {
|
||||
return count.refType === 'fixed' && count.value
|
||||
? `循环 ${count.value} 次`
|
||||
: '按引用次数循环';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
function updateInputs(
|
||||
updater: (inputs: {
|
||||
count?: Parameter;
|
||||
items?: Parameter;
|
||||
}) => {
|
||||
count?: Parameter;
|
||||
items?: Parameter;
|
||||
},
|
||||
) {
|
||||
updateNodeData(currentNodeId, (node) => {
|
||||
const current = resolveLoopInputs(node, nodes || []);
|
||||
const next = updater({
|
||||
count: current.count,
|
||||
items: current.items,
|
||||
});
|
||||
const loopInputs: Record<string, Parameter> = {};
|
||||
if (next.count) {
|
||||
loopInputs.count = next.count;
|
||||
}
|
||||
if (next.items) {
|
||||
loopInputs.items = next.items;
|
||||
}
|
||||
return {
|
||||
loopInputs,
|
||||
loopVars: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function updateCountType(type: 'fixed' | 'ref') {
|
||||
updateInputs((current) => ({
|
||||
...current,
|
||||
count:
|
||||
type === 'ref'
|
||||
? {
|
||||
name: 'count',
|
||||
refType: 'ref',
|
||||
ref:
|
||||
current.count?.refType === 'ref'
|
||||
? current.count.ref || ''
|
||||
: '',
|
||||
dataType: 'Number',
|
||||
}
|
||||
: {
|
||||
name: 'count',
|
||||
refType: 'fixed',
|
||||
value:
|
||||
current.count?.refType === 'fixed'
|
||||
? current.count.value || ''
|
||||
: '',
|
||||
dataType: 'Number',
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function updateCountText(value: string) {
|
||||
const normalized = value.trim();
|
||||
updateInputs((current) => ({
|
||||
...current,
|
||||
count: normalized
|
||||
? {
|
||||
name: 'count',
|
||||
refType: 'fixed',
|
||||
value:
|
||||
/^\d+$/.test(normalized) &&
|
||||
Number(normalized) > 300
|
||||
? '300'
|
||||
: normalized,
|
||||
dataType: 'Number',
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function updateCountRef(value: string) {
|
||||
updateInputs((current) => ({
|
||||
...current,
|
||||
count: value
|
||||
? {
|
||||
name: 'count',
|
||||
refType: 'ref',
|
||||
ref: value,
|
||||
dataType: 'Number',
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function updateItems(item: SelectItem) {
|
||||
updateInputs((current) => ({
|
||||
...current,
|
||||
items: {
|
||||
name: 'items',
|
||||
refType: 'ref',
|
||||
ref: String(item.value),
|
||||
dataType: item.dataType || 'Array',
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function clearItems(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
updateInputs((current) => ({
|
||||
...current,
|
||||
items: undefined,
|
||||
}));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="loop-inputs">
|
||||
<div class="loop-field">
|
||||
<div class="loop-label">
|
||||
<span>循环次数</span>
|
||||
<span class="loop-optional">可选</span>
|
||||
</div>
|
||||
<MixedInput
|
||||
type={countType}
|
||||
textValue={countTextValue}
|
||||
refValue={countRefValue}
|
||||
refOptions={countOptions}
|
||||
placeholder="输入 1~300 或选择变量"
|
||||
onTypeChange={updateCountType}
|
||||
onTextChange={updateCountText}
|
||||
onRefChange={updateCountRef}
|
||||
/>
|
||||
{#if countHint}
|
||||
<div class="loop-error" role="status">{countHint}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="loop-field">
|
||||
<div class="loop-label">
|
||||
<span>输入数组</span>
|
||||
<span class="loop-optional">可选</span>
|
||||
</div>
|
||||
<div class="loop-array-input">
|
||||
<Select
|
||||
items={itemsOptions}
|
||||
value={loopInputs.items?.ref ? [loopInputs.items.ref] : []}
|
||||
placeholder="选择要遍历的数组"
|
||||
variant="reference"
|
||||
style="width: 100%"
|
||||
onSelect={updateItems}
|
||||
/>
|
||||
{#if loopInputs.items}
|
||||
<button
|
||||
type="button"
|
||||
class="loop-clear nopan nodrag"
|
||||
aria-label="清空输入数组"
|
||||
title="清空输入数组"
|
||||
onclick={clearItems}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if summary}
|
||||
<div class="loop-summary">{summary}</div>
|
||||
{:else}
|
||||
<div class="loop-error" role="status">
|
||||
请至少配置循环次数或输入数组
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="loop-variables">
|
||||
<div class="loop-variables-title">循环体变量</div>
|
||||
<div class="loop-variable-list">
|
||||
<span class="loop-variable">
|
||||
<strong>index</strong>
|
||||
<small>Number</small>
|
||||
</span>
|
||||
{#if loopItem}
|
||||
<span class="loop-variable">
|
||||
<strong>loopItem</strong>
|
||||
<small>{loopItem.dataType}</small>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="less">
|
||||
.loop-inputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loop-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.loop-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--tf-text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loop-optional {
|
||||
color: var(--tf-text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.loop-array-input {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.loop-array-input:has(.loop-clear) :global(.tf-select-input) {
|
||||
padding-right: 48px;
|
||||
}
|
||||
|
||||
.loop-clear {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 28px;
|
||||
z-index: 1;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 5px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--tf-text-muted);
|
||||
cursor: pointer;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.loop-clear:hover {
|
||||
background: var(--tf-bg-hover);
|
||||
color: var(--tf-text-primary);
|
||||
}
|
||||
|
||||
.loop-clear:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--tf-focus-shadow);
|
||||
}
|
||||
|
||||
.loop-clear svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.loop-summary {
|
||||
margin-top: -8px;
|
||||
color: var(--tf-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.loop-error {
|
||||
color: var(--tf-danger-soft-text);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.loop-variables {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--tf-border-color-soft);
|
||||
}
|
||||
|
||||
.loop-variables-title {
|
||||
margin-bottom: 8px;
|
||||
color: var(--tf-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.loop-variable-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.loop-variable {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--tf-border-color-soft);
|
||||
border-radius: 6px;
|
||||
background: var(--tf-bg-surface-alt);
|
||||
color: var(--tf-text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.loop-variable strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loop-variable small {
|
||||
color: var(--tf-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { filterRefOptionsByDataType } from './refOptionFilter';
|
||||
|
||||
describe('filterRefOptionsByDataType', () => {
|
||||
const options = [
|
||||
{
|
||||
label: '知识库',
|
||||
selectable: false,
|
||||
value: 'knowledge',
|
||||
children: [
|
||||
{
|
||||
label: '文档',
|
||||
selectable: true,
|
||||
value: 'knowledge.documents',
|
||||
dataType: 'Array<Object>',
|
||||
},
|
||||
{
|
||||
label: '数量',
|
||||
selectable: true,
|
||||
value: 'knowledge.count',
|
||||
dataType: 'Number',
|
||||
},
|
||||
{
|
||||
label: '标题',
|
||||
selectable: true,
|
||||
value: 'knowledge.title',
|
||||
dataType: 'String',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it('数组输入接受 Array 及 Array 泛型', () => {
|
||||
const filtered = filterRefOptionsByDataType(options, ['Array']);
|
||||
|
||||
expect(filtered[0]?.children.map((item: any) => item.value)).toEqual([
|
||||
'knowledge.documents',
|
||||
]);
|
||||
});
|
||||
|
||||
it('次数输入只展示 Number,并保留当前旧引用', () => {
|
||||
const filtered = filterRefOptionsByDataType(
|
||||
options,
|
||||
['Number'],
|
||||
'knowledge.title',
|
||||
);
|
||||
|
||||
expect(filtered[0]?.children.map((item: any) => item.value)).toEqual([
|
||||
'knowledge.count',
|
||||
'knowledge.title',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
function isAcceptedDataType(dataType: string, acceptedDataTypes: string[]) {
|
||||
const normalized = dataType.trim().toLowerCase();
|
||||
return acceptedDataTypes.some((acceptedDataType) => {
|
||||
const accepted = acceptedDataType.trim().toLowerCase();
|
||||
return accepted === 'array'
|
||||
? normalized === 'array' || normalized.startsWith('array<')
|
||||
: normalized === accepted;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按数据类型过滤引用选项,并保留当前已选择的旧引用。
|
||||
*/
|
||||
export function filterRefOptionsByDataType(
|
||||
options: any[],
|
||||
acceptedDataTypes: string[],
|
||||
currentRef = '',
|
||||
): any[] {
|
||||
if (!acceptedDataTypes.length) {
|
||||
return options;
|
||||
}
|
||||
return options
|
||||
.map((option) => {
|
||||
const children = Array.isArray(option.children)
|
||||
? filterRefOptionsByDataType(
|
||||
option.children,
|
||||
acceptedDataTypes,
|
||||
currentRef,
|
||||
)
|
||||
: [];
|
||||
const selectable =
|
||||
option.selectable === true &&
|
||||
(isAcceptedDataType(
|
||||
String(option.dataType || ''),
|
||||
acceptedDataTypes,
|
||||
) ||
|
||||
String(option.value || '') === currentRef);
|
||||
if (!selectable && children.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...option,
|
||||
selectable,
|
||||
children,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import type { Parameter } from '#types';
|
||||
import { getCurrentNodeId, getOptions } from '#components/utils/NodeUtils';
|
||||
import { getStartNodeParameterLabel } from '#components/utils/startNodeParameterLabel';
|
||||
import { nodeIcons } from '../../consts';
|
||||
import {
|
||||
buildLoopReferenceParameters,
|
||||
buildLoopScopeParameters,
|
||||
isArrayDataType,
|
||||
} from '../../utils/loopScope';
|
||||
|
||||
const fillRefNodeIds = (
|
||||
refNodeIds: string[],
|
||||
@@ -27,7 +32,10 @@ const getChildren = (
|
||||
) => {
|
||||
if (!params || params.length === 0) return [];
|
||||
return params.map((param: any) => {
|
||||
const isCollection = param.dataType === 'Array' && param.children && param.children.length > 0;
|
||||
const isCollection =
|
||||
isArrayDataType(param.dataType) &&
|
||||
param.children &&
|
||||
param.children.length > 0;
|
||||
const childBaseLabel = param.formLabel || param.displayName || param.name;
|
||||
const normalizedChildLabel = String(childBaseLabel || '').trim();
|
||||
const pathLabel = !parentPathLabel
|
||||
@@ -64,6 +72,7 @@ const nodeToOptions = (
|
||||
node: Node,
|
||||
nodeIsChildren: boolean,
|
||||
currentNode: Node,
|
||||
nodes: Node[],
|
||||
) => {
|
||||
const options = getOptions();
|
||||
const nodeType = node.type || '';
|
||||
@@ -105,29 +114,27 @@ const nodeToOptions = (
|
||||
nodeType: nodeType,
|
||||
children,
|
||||
};
|
||||
} else if (nodeType === 'loopNode' && currentNode.parentId) {
|
||||
} else if (nodeType === 'loopNode') {
|
||||
const referenceParameters = buildLoopReferenceParameters(
|
||||
node,
|
||||
currentNode,
|
||||
nodes,
|
||||
);
|
||||
if (!referenceParameters.length) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
label: title,
|
||||
icon: icon,
|
||||
value: node.id,
|
||||
selectable: false,
|
||||
nodeType: nodeType,
|
||||
children: [
|
||||
{
|
||||
label: 'loopItem',
|
||||
dataType: 'Any',
|
||||
value: node.id + '.loopItem',
|
||||
selectable: true,
|
||||
nodeType: nodeType,
|
||||
},
|
||||
{
|
||||
label: 'index',
|
||||
dataType: 'Number',
|
||||
value: node.id + '.index',
|
||||
selectable: true,
|
||||
nodeType: nodeType,
|
||||
},
|
||||
],
|
||||
children: getChildren(
|
||||
referenceParameters,
|
||||
node.id,
|
||||
false,
|
||||
nodeType,
|
||||
),
|
||||
};
|
||||
} else {
|
||||
const outputDefs = node.data.outputDefs;
|
||||
@@ -168,7 +175,12 @@ export const useRefOptions: any = (
|
||||
for (const node of nodes) {
|
||||
const nodeIsChildren = node.parentId === currentNode.current.id;
|
||||
if (nodeIsChildren) {
|
||||
const nodeOptions = nodeToOptions(node, nodeIsChildren, cNode);
|
||||
const nodeOptions = nodeToOptions(
|
||||
node,
|
||||
nodeIsChildren,
|
||||
cNode,
|
||||
nodes,
|
||||
);
|
||||
nodeOptions && resultOptions.push(nodeOptions);
|
||||
}
|
||||
}
|
||||
@@ -177,9 +189,17 @@ export const useRefOptions: any = (
|
||||
fillRefNodeIds(refNodeIds, currentNodeId, edges);
|
||||
|
||||
for (const node of nodes) {
|
||||
if (refNodeIds.includes(node.id)) {
|
||||
const isScopedLoop =
|
||||
node.type === 'loopNode' &&
|
||||
buildLoopScopeParameters(node, cNode, nodes).length > 0;
|
||||
if (refNodeIds.includes(node.id) || isScopedLoop) {
|
||||
const nodeIsChildren = node.parentId === currentNode.current.id;
|
||||
const nodeOptions = nodeToOptions(node, nodeIsChildren, cNode);
|
||||
const nodeOptions = nodeToOptions(
|
||||
node,
|
||||
nodeIsChildren,
|
||||
cNode,
|
||||
nodes,
|
||||
);
|
||||
nodeOptions && resultOptions.push(nodeOptions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import type { Node } from '@xyflow/svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildLoopItemParameter,
|
||||
buildLoopReferenceParameters,
|
||||
buildLoopScopeParameters,
|
||||
resolveLoopInputs,
|
||||
} from './loopScope';
|
||||
|
||||
const knowledgeNode = {
|
||||
id: 'knowledge',
|
||||
type: 'knowledgeNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
outputDefs: [
|
||||
{
|
||||
name: 'documents',
|
||||
dataType: 'Array',
|
||||
children: [
|
||||
{ name: 'title', dataType: 'String' },
|
||||
{ name: 'content', dataType: 'String' },
|
||||
{ name: 'documentId', dataType: 'Number' },
|
||||
{ name: 'knowledgeId', dataType: 'Number' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies Node;
|
||||
|
||||
describe('loopScope', () => {
|
||||
it('同时解析次数和对象数组,并生成对象 item 字段', () => {
|
||||
const loopNode = {
|
||||
id: 'loop',
|
||||
type: 'loopNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
loopInputs: {
|
||||
count: {
|
||||
name: 'count',
|
||||
refType: 'fixed',
|
||||
value: '2',
|
||||
dataType: 'Number',
|
||||
},
|
||||
items: {
|
||||
name: 'items',
|
||||
refType: 'ref',
|
||||
ref: 'knowledge.documents',
|
||||
dataType: 'Array',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Node;
|
||||
|
||||
const inputs = resolveLoopInputs(
|
||||
loopNode,
|
||||
[knowledgeNode, loopNode],
|
||||
);
|
||||
const loopItem = buildLoopItemParameter(
|
||||
loopNode,
|
||||
[knowledgeNode, loopNode],
|
||||
);
|
||||
|
||||
expect(inputs.count?.value).toBe('2');
|
||||
expect(inputs.items?.ref).toBe('knowledge.documents');
|
||||
expect(loopItem?.dataType).toBe('Object');
|
||||
expect(loopItem?.children?.map((item) => item.name)).toEqual([
|
||||
'title',
|
||||
'content',
|
||||
'documentId',
|
||||
'knowledgeId',
|
||||
]);
|
||||
});
|
||||
|
||||
it('仅在对应循环体中暴露 index 和 loopItem', () => {
|
||||
const loopNode = {
|
||||
id: 'loop',
|
||||
type: 'loopNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
loopInputs: {
|
||||
items: {
|
||||
name: 'items',
|
||||
refType: 'ref',
|
||||
ref: 'knowledge.documents',
|
||||
dataType: 'Array',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Node;
|
||||
const childNode = {
|
||||
id: 'child',
|
||||
type: 'llmNode',
|
||||
parentId: 'loop',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
} satisfies Node;
|
||||
const outsideNode = {
|
||||
id: 'outside',
|
||||
type: 'llmNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
} satisfies Node;
|
||||
const nodes = [knowledgeNode, loopNode, childNode, outsideNode];
|
||||
|
||||
expect(
|
||||
buildLoopScopeParameters(loopNode, childNode, nodes)
|
||||
.map((item) => item.name),
|
||||
).toEqual(['index', 'loopItem']);
|
||||
expect(
|
||||
buildLoopScopeParameters(loopNode, outsideNode, nodes),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('兼容旧版数组 loopVars', () => {
|
||||
const loopNode = {
|
||||
id: 'loop',
|
||||
type: 'loopNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
loopVars: [
|
||||
{
|
||||
name: 'loopVar',
|
||||
refType: 'ref',
|
||||
ref: 'knowledge.documents',
|
||||
dataType: 'Array',
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies Node;
|
||||
|
||||
const inputs = resolveLoopInputs(
|
||||
loopNode,
|
||||
[knowledgeNode, loopNode],
|
||||
);
|
||||
|
||||
expect(inputs.legacy).toBe(true);
|
||||
expect(inputs.items?.name).toBe('items');
|
||||
expect(inputs.count).toBeUndefined();
|
||||
});
|
||||
|
||||
it('纯次数循环把 loopItem 声明为当前数值序号', () => {
|
||||
const loopNode = {
|
||||
id: 'loop',
|
||||
type: 'loopNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
loopInputs: {
|
||||
count: {
|
||||
name: 'count',
|
||||
refType: 'fixed',
|
||||
value: '3',
|
||||
dataType: 'Number',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Node;
|
||||
|
||||
expect(
|
||||
buildLoopItemParameter(loopNode, [loopNode])?.dataType,
|
||||
).toBe('Number');
|
||||
});
|
||||
|
||||
it('循环体内只暴露临时变量,下游只暴露正式输出', () => {
|
||||
const loopNode = {
|
||||
id: 'loop',
|
||||
type: 'loopNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
loopInputs: {
|
||||
items: {
|
||||
name: 'items',
|
||||
refType: 'ref',
|
||||
ref: 'knowledge.documents',
|
||||
dataType: 'Array',
|
||||
},
|
||||
},
|
||||
outputDefs: [
|
||||
{
|
||||
name: 'res',
|
||||
ref: 'child.output',
|
||||
dataType: 'String',
|
||||
},
|
||||
{
|
||||
name: 'records',
|
||||
ref: 'child.record',
|
||||
dataType: 'String',
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies Node;
|
||||
const childNode = {
|
||||
id: 'child',
|
||||
type: 'llmNode',
|
||||
parentId: 'loop',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
outputDefs: [
|
||||
{ name: 'output', dataType: 'String' },
|
||||
{
|
||||
name: 'record',
|
||||
dataType: 'Object',
|
||||
children: [{ name: 'summary', dataType: 'String' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies Node;
|
||||
const downstreamNode = {
|
||||
id: 'downstream',
|
||||
type: 'endNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
} satisfies Node;
|
||||
const nodes = [knowledgeNode, loopNode, childNode, downstreamNode];
|
||||
|
||||
expect(
|
||||
buildLoopReferenceParameters(loopNode, childNode, nodes)
|
||||
.map((item) => item.name),
|
||||
).toEqual(['index', 'loopItem']);
|
||||
const downstreamParameters = buildLoopReferenceParameters(
|
||||
loopNode,
|
||||
downstreamNode,
|
||||
nodes,
|
||||
);
|
||||
expect(downstreamParameters.map((item) => item.name)).toEqual([
|
||||
'res',
|
||||
'records',
|
||||
]);
|
||||
expect(downstreamParameters[0]?.dataType).toBe('Array<String>');
|
||||
expect(downstreamParameters[1]?.dataType).toBe('Array');
|
||||
expect(downstreamParameters[1]?.children?.[0]?.name).toBe('summary');
|
||||
|
||||
downstreamParameters[1]!.children![0]!.name = 'changed';
|
||||
expect(
|
||||
(childNode.data.outputDefs as Array<any>)[1].children[0].name,
|
||||
).toBe('summary');
|
||||
});
|
||||
});
|
||||
237
easyflow-ui-admin/packages/tinyflow-ui/src/utils/loopScope.ts
Normal file
237
easyflow-ui-admin/packages/tinyflow-ui/src/utils/loopScope.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import type { Node } from '@xyflow/svelte';
|
||||
|
||||
import type { Parameter } from '../types';
|
||||
|
||||
export type LoopInputs = {
|
||||
count?: Parameter;
|
||||
items?: Parameter;
|
||||
legacy: boolean;
|
||||
};
|
||||
|
||||
function asString(value: unknown) {
|
||||
return value == null ? '' : String(value).trim();
|
||||
}
|
||||
|
||||
function cloneParameter(parameter: Parameter): Parameter {
|
||||
return {
|
||||
...parameter,
|
||||
children: parameter.children?.map(cloneParameter),
|
||||
};
|
||||
}
|
||||
|
||||
export function isArrayDataType(dataType?: string | null) {
|
||||
const normalized = asString(dataType).toLowerCase();
|
||||
return normalized === 'array' || normalized.startsWith('array<');
|
||||
}
|
||||
|
||||
function getNodeParameters(node: Node): Parameter[] {
|
||||
if (node.type === 'startNode') {
|
||||
return Array.isArray(node.data?.parameters)
|
||||
? (node.data.parameters as Parameter[])
|
||||
: [];
|
||||
}
|
||||
return Array.isArray(node.data?.outputDefs)
|
||||
? (node.data.outputDefs as Parameter[])
|
||||
: [];
|
||||
}
|
||||
|
||||
export function findReferenceParameter(
|
||||
nodes: Node[],
|
||||
reference?: string | null,
|
||||
): Parameter | undefined {
|
||||
const normalizedReference = asString(reference);
|
||||
if (!normalizedReference) {
|
||||
return undefined;
|
||||
}
|
||||
const node = nodes.find((candidate) =>
|
||||
normalizedReference.startsWith(`${candidate.id}.`),
|
||||
);
|
||||
if (!node) {
|
||||
return undefined;
|
||||
}
|
||||
const path = normalizedReference.slice(node.id.length + 1).split('.');
|
||||
let parameters = getNodeParameters(node);
|
||||
let current: Parameter | undefined;
|
||||
for (const segment of path) {
|
||||
current = parameters.find(
|
||||
(parameter) => asString(parameter.name) === segment,
|
||||
);
|
||||
if (!current) {
|
||||
return undefined;
|
||||
}
|
||||
parameters = current.children || [];
|
||||
}
|
||||
return current ? cloneParameter(current) : undefined;
|
||||
}
|
||||
|
||||
export function resolveLoopInputs(
|
||||
loopNode: Node,
|
||||
nodes: Node[],
|
||||
): LoopInputs {
|
||||
const data = (loopNode.data || {}) as Record<string, any>;
|
||||
if (data.loopInputs && typeof data.loopInputs === 'object') {
|
||||
return {
|
||||
count: data.loopInputs.count
|
||||
? cloneParameter(data.loopInputs.count as Parameter)
|
||||
: undefined,
|
||||
items: data.loopInputs.items
|
||||
? cloneParameter(data.loopInputs.items as Parameter)
|
||||
: undefined,
|
||||
legacy: false,
|
||||
};
|
||||
}
|
||||
|
||||
const legacyParameter = Array.isArray(data.loopVars)
|
||||
? (data.loopVars[0] as Parameter | undefined)
|
||||
: undefined;
|
||||
if (!legacyParameter) {
|
||||
return { legacy: false };
|
||||
}
|
||||
const referencedParameter = findReferenceParameter(
|
||||
nodes,
|
||||
legacyParameter.ref,
|
||||
);
|
||||
const legacyIsArray =
|
||||
isArrayDataType(legacyParameter.dataType) ||
|
||||
isArrayDataType(referencedParameter?.dataType);
|
||||
return legacyIsArray
|
||||
? {
|
||||
items: {
|
||||
...cloneParameter(legacyParameter),
|
||||
name: 'items',
|
||||
dataType:
|
||||
referencedParameter?.dataType ||
|
||||
legacyParameter.dataType ||
|
||||
'Array',
|
||||
},
|
||||
legacy: true,
|
||||
}
|
||||
: {
|
||||
count: {
|
||||
...cloneParameter(legacyParameter),
|
||||
name: 'count',
|
||||
dataType: 'Number',
|
||||
},
|
||||
legacy: true,
|
||||
};
|
||||
}
|
||||
|
||||
function getArrayItemDataType(dataType?: string | null) {
|
||||
const normalized = asString(dataType);
|
||||
const genericMatch = normalized.match(/^Array<(.+)>$/i);
|
||||
return genericMatch?.[1]?.trim() || '';
|
||||
}
|
||||
|
||||
export function buildLoopItemParameter(
|
||||
loopNode: Node,
|
||||
nodes: Node[],
|
||||
): Parameter | undefined {
|
||||
const inputs = resolveLoopInputs(loopNode, nodes);
|
||||
const items = inputs.items;
|
||||
if (!items) {
|
||||
return inputs.count
|
||||
? {
|
||||
name: 'loopItem',
|
||||
displayName: 'loopItem',
|
||||
dataType: 'Number',
|
||||
nameDisabled: true,
|
||||
dataTypeDisabled: true,
|
||||
deleteDisabled: true,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
const referencedParameter =
|
||||
findReferenceParameter(nodes, items.ref) || items;
|
||||
const children = referencedParameter.children?.map(cloneParameter);
|
||||
return {
|
||||
name: 'loopItem',
|
||||
displayName: 'loopItem',
|
||||
dataType:
|
||||
getArrayItemDataType(referencedParameter.dataType) ||
|
||||
(children?.length ? 'Object' : 'Any'),
|
||||
children,
|
||||
nameDisabled: true,
|
||||
dataTypeDisabled: true,
|
||||
deleteDisabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function isNodeWithinLoopScope(
|
||||
currentNode: Node,
|
||||
loopNodeId: string,
|
||||
nodes: Node[],
|
||||
) {
|
||||
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
|
||||
const visited = new Set<string>();
|
||||
let current: Node | undefined = currentNode;
|
||||
while (current?.parentId && !visited.has(current.id)) {
|
||||
visited.add(current.id);
|
||||
if (current.parentId === loopNodeId) {
|
||||
return true;
|
||||
}
|
||||
current = nodeMap.get(current.parentId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildLoopScopeParameters(
|
||||
loopNode: Node,
|
||||
currentNode: Node,
|
||||
nodes: Node[],
|
||||
): Parameter[] {
|
||||
if (!isNodeWithinLoopScope(currentNode, loopNode.id, nodes)) {
|
||||
return [];
|
||||
}
|
||||
const parameters: Parameter[] = [
|
||||
{
|
||||
name: 'index',
|
||||
displayName: 'index',
|
||||
dataType: 'Number',
|
||||
nameDisabled: true,
|
||||
dataTypeDisabled: true,
|
||||
deleteDisabled: true,
|
||||
},
|
||||
];
|
||||
const loopItem = buildLoopItemParameter(loopNode, nodes);
|
||||
if (loopItem) {
|
||||
parameters.push(loopItem);
|
||||
}
|
||||
return parameters;
|
||||
}
|
||||
|
||||
export function buildLoopReferenceParameters(
|
||||
loopNode: Node,
|
||||
currentNode: Node,
|
||||
nodes: Node[],
|
||||
): Parameter[] {
|
||||
const scopeParameters = buildLoopScopeParameters(
|
||||
loopNode,
|
||||
currentNode,
|
||||
nodes,
|
||||
);
|
||||
if (scopeParameters.length) {
|
||||
return scopeParameters;
|
||||
}
|
||||
return Array.isArray(loopNode.data?.outputDefs)
|
||||
? (loopNode.data.outputDefs as Parameter[]).map((parameter) => {
|
||||
const outputParameter = cloneParameter(parameter);
|
||||
const sourceParameter = findReferenceParameter(
|
||||
nodes,
|
||||
outputParameter.ref,
|
||||
);
|
||||
if (!sourceParameter) {
|
||||
return outputParameter;
|
||||
}
|
||||
const sourceDataType = asString(sourceParameter.dataType) || 'String';
|
||||
const sourceChildren = sourceParameter.children?.map(cloneParameter);
|
||||
return {
|
||||
...outputParameter,
|
||||
dataType:
|
||||
sourceChildren?.length && !isArrayDataType(sourceDataType)
|
||||
? 'Array'
|
||||
: `Array<${sourceDataType}>`,
|
||||
children: sourceChildren,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
}
|
||||
@@ -367,6 +367,107 @@ describe('workflow node fields', () => {
|
||||
expect(contentParameter?.itemTypeLabel).toBe('数组项字段');
|
||||
});
|
||||
|
||||
it('exposes typed loop variables only to nodes inside the loop body', () => {
|
||||
const knowledgeNode: Node = {
|
||||
id: 'knowledge_1',
|
||||
type: 'knowledgeNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: '知识库',
|
||||
outputDefs: [
|
||||
{
|
||||
name: 'documents',
|
||||
dataType: 'Array',
|
||||
children: [
|
||||
{ name: 'title', dataType: 'String' },
|
||||
{ name: 'content', dataType: 'String' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const loopNode: Node = {
|
||||
id: 'loop_1',
|
||||
type: 'loopNode',
|
||||
position: { x: 120, y: 0 },
|
||||
data: {
|
||||
title: '循环',
|
||||
loopInputs: {
|
||||
items: {
|
||||
name: 'items',
|
||||
refType: 'ref',
|
||||
ref: 'knowledge_1.documents',
|
||||
dataType: 'Array',
|
||||
},
|
||||
},
|
||||
outputDefs: [
|
||||
{
|
||||
name: 'res',
|
||||
dataType: 'Array<String>',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const insideNode: Node = {
|
||||
id: 'llm_inside',
|
||||
type: 'llmNode',
|
||||
parentId: 'loop_1',
|
||||
position: { x: 240, y: 0 },
|
||||
data: { title: '循环内模型', parameters: [] },
|
||||
};
|
||||
const outsideNode: Node = {
|
||||
id: 'llm_outside',
|
||||
type: 'llmNode',
|
||||
position: { x: 240, y: 120 },
|
||||
data: { title: '循环外模型', parameters: [] },
|
||||
};
|
||||
const nodes = [
|
||||
knowledgeNode,
|
||||
loopNode,
|
||||
insideNode,
|
||||
outsideNode,
|
||||
];
|
||||
|
||||
const insideParameters = buildEditorReferenceParameters(
|
||||
'llm_inside',
|
||||
nodes,
|
||||
[],
|
||||
[],
|
||||
);
|
||||
const outsideParameters = buildEditorReferenceParameters(
|
||||
'llm_outside',
|
||||
nodes,
|
||||
[
|
||||
{
|
||||
id: 'loop_to_outside',
|
||||
source: 'loop_1',
|
||||
target: 'llm_outside',
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(insideParameters.map((item) => item.name)).toEqual([
|
||||
'loop_1.index',
|
||||
'loop_1.loopItem',
|
||||
'loop_1.loopItem.title',
|
||||
'loop_1.loopItem.content',
|
||||
]);
|
||||
expect(
|
||||
insideParameters.find(
|
||||
(item) => item.name === 'loop_1.loopItem.content',
|
||||
)?.dataType,
|
||||
).toBe('String');
|
||||
expect(
|
||||
outsideParameters.map((item) => item.name),
|
||||
).toEqual(['loop_1.res']);
|
||||
expect(
|
||||
outsideParameters.some((item) =>
|
||||
['loop_1.index', 'loop_1.loopItem'].includes(item.name || ''),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('uses document node child outputs for reference display', () => {
|
||||
const documentNode: Node = {
|
||||
id: 'doc_1',
|
||||
|
||||
@@ -3,6 +3,10 @@ import type { Edge, Node } from '@xyflow/svelte';
|
||||
import type { Parameter } from '../types';
|
||||
import { getTokenRanges } from '../components/utils/paramToken';
|
||||
import { genShortId } from '../components/utils/IdGen';
|
||||
import {
|
||||
buildLoopReferenceParameters,
|
||||
buildLoopScopeParameters,
|
||||
} from './loopScope';
|
||||
|
||||
export const START_NODE_TYPE = 'startNode';
|
||||
export const LLM_NODE_TYPE = 'llmNode';
|
||||
@@ -274,7 +278,11 @@ function flattenOutputDefs(
|
||||
});
|
||||
}
|
||||
|
||||
function getNodeReferenceParameters(node: Node): Parameter[] {
|
||||
function getNodeReferenceParameters(
|
||||
node: Node,
|
||||
currentNode?: Node,
|
||||
nodes: Node[] = [],
|
||||
): Parameter[] {
|
||||
if (node.type === START_NODE_TYPE) {
|
||||
const parameters = Array.isArray(node.data?.parameters)
|
||||
? (node.data.parameters as Parameter[])
|
||||
@@ -297,6 +305,15 @@ function getNodeReferenceParameters(node: Node): Parameter[] {
|
||||
);
|
||||
}
|
||||
|
||||
if (node.type === 'loopNode' && currentNode) {
|
||||
const referenceParameters = buildLoopReferenceParameters(
|
||||
node,
|
||||
currentNode,
|
||||
nodes,
|
||||
);
|
||||
return flattenOutputDefs(node, referenceParameters);
|
||||
}
|
||||
|
||||
const outputDefs = Array.isArray(node.data?.outputDefs)
|
||||
? (node.data.outputDefs as Parameter[])
|
||||
: [];
|
||||
@@ -993,10 +1010,22 @@ export function buildEditorReferenceParameters(
|
||||
) {
|
||||
const refNodeIds: string[] = [];
|
||||
flattenNodeRefs(currentNodeId, edges, refNodeIds, new Set<string>());
|
||||
const currentNode = nodes.find((node) => node.id === currentNodeId);
|
||||
|
||||
const upstreamParameters = nodes
|
||||
.filter((node) => refNodeIds.includes(node.id))
|
||||
.flatMap((node) => getNodeReferenceParameters(node));
|
||||
.filter((node) => {
|
||||
if (refNodeIds.includes(node.id)) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(
|
||||
currentNode &&
|
||||
node.type === 'loopNode' &&
|
||||
buildLoopScopeParameters(node, currentNode, nodes).length,
|
||||
);
|
||||
})
|
||||
.flatMap((node) =>
|
||||
getNodeReferenceParameters(node, currentNode, nodes),
|
||||
);
|
||||
|
||||
const upstreamNameSet = new Set(
|
||||
upstreamParameters.map((parameter) => asString(parameter.name).trim()),
|
||||
|
||||
Reference in New Issue
Block a user