feat: 完善循环节点配置与作用域输出
- 支持次数与数组独立或组合配置并补齐检查规则 - 统一循环体临时变量与下游正式输出候选 - 稳定知识库对象数组字段并补充前后端测试
This commit is contained in:
@@ -17,6 +17,9 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为工作流知识库节点提供检索能力。
|
||||||
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class KnowledgeProviderImpl implements KnowledgeProvider {
|
public class KnowledgeProviderImpl implements KnowledgeProvider {
|
||||||
|
|
||||||
@@ -24,14 +27,23 @@ public class KnowledgeProviderImpl implements KnowledgeProvider {
|
|||||||
private DocumentCollectionService documentCollectionService;
|
private DocumentCollectionService documentCollectionService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取知识库
|
* 获取知识库检索器。
|
||||||
* @param id 知识库id
|
*
|
||||||
|
* @param id 知识库 ID
|
||||||
|
* @return 知识库检索器
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Knowledge getKnowledge(Object id) {
|
public Knowledge getKnowledge(Object id) {
|
||||||
return new Knowledge() {
|
return new Knowledge() {
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
@Override
|
@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();
|
KnowledgeRetrievalRequest request = new KnowledgeRetrievalRequest();
|
||||||
request.setKnowledgeId(new BigInteger(id.toString()));
|
request.setKnowledgeId(new BigInteger(id.toString()));
|
||||||
request.setQuery(keyword);
|
request.setQuery(keyword);
|
||||||
@@ -45,10 +57,29 @@ public class KnowledgeProviderImpl implements KnowledgeProvider {
|
|||||||
}
|
}
|
||||||
List<Map<String, Object>> res = new ArrayList<>();
|
List<Map<String, Object>> res = new ArrayList<>();
|
||||||
for (Document document : documents) {
|
for (Document document : documents) {
|
||||||
res.add(JSONObject.from(document));
|
res.add(toWorkflowDocument(document, id));
|
||||||
}
|
}
|
||||||
return res;
|
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.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验工作流结构、节点配置和预执行约束。
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class WorkflowCheckService {
|
public class WorkflowCheckService {
|
||||||
private static final String LEVEL_ERROR = "ERROR";
|
private static final String LEVEL_ERROR = "ERROR";
|
||||||
@@ -240,7 +243,7 @@ public class WorkflowCheckService {
|
|||||||
Set<String> issueKeys) {
|
Set<String> issueKeys) {
|
||||||
for (NodeView node : nodes) {
|
for (NodeView node : nodes) {
|
||||||
checkConfiguredLoopCount(node, issues, issueKeys);
|
checkConfiguredLoopCount(node, issues, issueKeys);
|
||||||
checkFixedExplicitLoopCount(node, issues, issueKeys);
|
checkExplicitLoopInputs(node, issues, issueKeys);
|
||||||
if (StringUtils.hasText(node.parentId)) {
|
if (StringUtils.hasText(node.parentId)) {
|
||||||
NodeView parent = nodeMap.get(node.parentId);
|
NodeView parent = nodeMap.get(node.parentId);
|
||||||
if (parent != null && !TYPE_LOOP.equals(parent.type)) {
|
if (parent != null && !TYPE_LOOP.equals(parent.type)) {
|
||||||
@@ -255,6 +258,7 @@ public class WorkflowCheckService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
checkLoopParentCycle(node, nodeMap, issues, issueKeys);
|
checkLoopParentCycle(node, nodeMap, issues, issueKeys);
|
||||||
|
checkLoopVariableScope(node, nodeMap, issues, issueKeys);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,36 +285,293 @@ public class WorkflowCheckService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验显式循环节点使用固定数值时的次数范围。
|
* 校验显式循环节点的新旧输入结构。
|
||||||
*
|
*
|
||||||
* @param node 节点
|
* @param node 节点
|
||||||
* @param issues 问题列表
|
* @param issues 问题列表
|
||||||
* @param issueKeys 问题去重键
|
* @param issueKeys 问题去重键
|
||||||
*/
|
*/
|
||||||
private void checkFixedExplicitLoopCount(
|
private void checkExplicitLoopInputs(
|
||||||
NodeView node,
|
NodeView node,
|
||||||
List<WorkflowCheckIssue> issues,
|
List<WorkflowCheckIssue> issues,
|
||||||
Set<String> issueKeys) {
|
Set<String> issueKeys) {
|
||||||
if (!TYPE_LOOP.equals(node.type) || node.data == null) {
|
if (!TYPE_LOOP.equals(node.type) || node.data == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JSONObject loopInputs = node.data.getJSONObject("loopInputs");
|
||||||
JSONArray loopVars = node.data.getJSONArray("loopVars");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loopVars == null || loopVars.isEmpty()) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
issueKeys,
|
||||||
|
"LOOP_INPUT_REQUIRED",
|
||||||
|
"请至少配置循环次数或输入数组",
|
||||||
|
node.id,
|
||||||
|
null,
|
||||||
|
node.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
JSONObject loopVar = loopVars.getJSONObject(0);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
Object value = loopVar.get("value");
|
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",
|
||||||
|
node,
|
||||||
|
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(
|
addLoopCountIssueIfInvalid(
|
||||||
value,
|
value,
|
||||||
"EXPLICIT_LOOP_COUNT_INVALID",
|
"EXPLICIT_LOOP_COUNT_INVALID",
|
||||||
node,
|
node,
|
||||||
issues,
|
issues,
|
||||||
issueKeys);
|
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");
|
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);
|
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) {
|
private static JSONObject data(String title) {
|
||||||
JSONObject data = new JSONObject();
|
JSONObject data = new JSONObject();
|
||||||
data.put("title", title);
|
data.put("title", title);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||||
import type {TinyflowNodeData} from '#types';
|
import type {TinyflowNodeData} from '#types';
|
||||||
|
import LoopInputEditor from './loop/LoopInputEditor.svelte';
|
||||||
|
|
||||||
const { data, ...rest }: {
|
const { data, ...rest }: {
|
||||||
data: TinyflowNodeData,
|
data: TinyflowNodeData,
|
||||||
@@ -17,17 +18,6 @@
|
|||||||
const currentNodeId = getCurrentNodeId();
|
const currentNodeId = getCurrentNodeId();
|
||||||
const { addParameter } = useAddParameter();
|
const { addParameter } = useAddParameter();
|
||||||
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!data.loopVars || data.loopVars.length === 0) {
|
|
||||||
addParameter(currentNodeId, 'loopVars', {
|
|
||||||
name: 'loopVar',
|
|
||||||
nameDisabled: true,
|
|
||||||
deleteDisabled: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
@@ -46,17 +36,9 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<div class="heading">
|
<div class="heading">
|
||||||
<Heading level={3}>循环变量</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>-->
|
|
||||||
</div>
|
</div>
|
||||||
<RefParameterList dataKeyName="loopVars" fixedNumberMin={1} fixedNumberMax={300} />
|
<LoopInputEditor {data} />
|
||||||
<div class="loop-limit-hint">单次最多循环 300 次</div>
|
|
||||||
|
|
||||||
<div class="heading">
|
<div class="heading">
|
||||||
<Heading level={3}>输出参数</Heading>
|
<Heading level={3}>输出参数</Heading>
|
||||||
@@ -79,13 +61,6 @@
|
|||||||
align-items: center;
|
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) {
|
:global(.loop-handle-wrapper) {
|
||||||
&::after {
|
&::after {
|
||||||
//display: none;
|
//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 { getCurrentNodeId, getOptions } from '#components/utils/NodeUtils';
|
||||||
import { getStartNodeParameterLabel } from '#components/utils/startNodeParameterLabel';
|
import { getStartNodeParameterLabel } from '#components/utils/startNodeParameterLabel';
|
||||||
import { nodeIcons } from '../../consts';
|
import { nodeIcons } from '../../consts';
|
||||||
|
import {
|
||||||
|
buildLoopReferenceParameters,
|
||||||
|
buildLoopScopeParameters,
|
||||||
|
isArrayDataType,
|
||||||
|
} from '../../utils/loopScope';
|
||||||
|
|
||||||
const fillRefNodeIds = (
|
const fillRefNodeIds = (
|
||||||
refNodeIds: string[],
|
refNodeIds: string[],
|
||||||
@@ -27,7 +32,10 @@ const getChildren = (
|
|||||||
) => {
|
) => {
|
||||||
if (!params || params.length === 0) return [];
|
if (!params || params.length === 0) return [];
|
||||||
return params.map((param: any) => {
|
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 childBaseLabel = param.formLabel || param.displayName || param.name;
|
||||||
const normalizedChildLabel = String(childBaseLabel || '').trim();
|
const normalizedChildLabel = String(childBaseLabel || '').trim();
|
||||||
const pathLabel = !parentPathLabel
|
const pathLabel = !parentPathLabel
|
||||||
@@ -64,6 +72,7 @@ const nodeToOptions = (
|
|||||||
node: Node,
|
node: Node,
|
||||||
nodeIsChildren: boolean,
|
nodeIsChildren: boolean,
|
||||||
currentNode: Node,
|
currentNode: Node,
|
||||||
|
nodes: Node[],
|
||||||
) => {
|
) => {
|
||||||
const options = getOptions();
|
const options = getOptions();
|
||||||
const nodeType = node.type || '';
|
const nodeType = node.type || '';
|
||||||
@@ -105,29 +114,27 @@ const nodeToOptions = (
|
|||||||
nodeType: nodeType,
|
nodeType: nodeType,
|
||||||
children,
|
children,
|
||||||
};
|
};
|
||||||
} else if (nodeType === 'loopNode' && currentNode.parentId) {
|
} else if (nodeType === 'loopNode') {
|
||||||
|
const referenceParameters = buildLoopReferenceParameters(
|
||||||
|
node,
|
||||||
|
currentNode,
|
||||||
|
nodes,
|
||||||
|
);
|
||||||
|
if (!referenceParameters.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
label: title,
|
label: title,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
value: node.id,
|
value: node.id,
|
||||||
selectable: false,
|
selectable: false,
|
||||||
nodeType: nodeType,
|
nodeType: nodeType,
|
||||||
children: [
|
children: getChildren(
|
||||||
{
|
referenceParameters,
|
||||||
label: 'loopItem',
|
node.id,
|
||||||
dataType: 'Any',
|
false,
|
||||||
value: node.id + '.loopItem',
|
nodeType,
|
||||||
selectable: true,
|
),
|
||||||
nodeType: nodeType,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'index',
|
|
||||||
dataType: 'Number',
|
|
||||||
value: node.id + '.index',
|
|
||||||
selectable: true,
|
|
||||||
nodeType: nodeType,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const outputDefs = node.data.outputDefs;
|
const outputDefs = node.data.outputDefs;
|
||||||
@@ -168,7 +175,12 @@ export const useRefOptions: any = (
|
|||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
const nodeIsChildren = node.parentId === currentNode.current.id;
|
const nodeIsChildren = node.parentId === currentNode.current.id;
|
||||||
if (nodeIsChildren) {
|
if (nodeIsChildren) {
|
||||||
const nodeOptions = nodeToOptions(node, nodeIsChildren, cNode);
|
const nodeOptions = nodeToOptions(
|
||||||
|
node,
|
||||||
|
nodeIsChildren,
|
||||||
|
cNode,
|
||||||
|
nodes,
|
||||||
|
);
|
||||||
nodeOptions && resultOptions.push(nodeOptions);
|
nodeOptions && resultOptions.push(nodeOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,9 +189,17 @@ export const useRefOptions: any = (
|
|||||||
fillRefNodeIds(refNodeIds, currentNodeId, edges);
|
fillRefNodeIds(refNodeIds, currentNodeId, edges);
|
||||||
|
|
||||||
for (const node of nodes) {
|
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 nodeIsChildren = node.parentId === currentNode.current.id;
|
||||||
const nodeOptions = nodeToOptions(node, nodeIsChildren, cNode);
|
const nodeOptions = nodeToOptions(
|
||||||
|
node,
|
||||||
|
nodeIsChildren,
|
||||||
|
cNode,
|
||||||
|
nodes,
|
||||||
|
);
|
||||||
nodeOptions && resultOptions.push(nodeOptions);
|
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('数组项字段');
|
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', () => {
|
it('uses document node child outputs for reference display', () => {
|
||||||
const documentNode: Node = {
|
const documentNode: Node = {
|
||||||
id: 'doc_1',
|
id: 'doc_1',
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { Edge, Node } from '@xyflow/svelte';
|
|||||||
import type { Parameter } from '../types';
|
import type { Parameter } from '../types';
|
||||||
import { getTokenRanges } from '../components/utils/paramToken';
|
import { getTokenRanges } from '../components/utils/paramToken';
|
||||||
import { genShortId } from '../components/utils/IdGen';
|
import { genShortId } from '../components/utils/IdGen';
|
||||||
|
import {
|
||||||
|
buildLoopReferenceParameters,
|
||||||
|
buildLoopScopeParameters,
|
||||||
|
} from './loopScope';
|
||||||
|
|
||||||
export const START_NODE_TYPE = 'startNode';
|
export const START_NODE_TYPE = 'startNode';
|
||||||
export const LLM_NODE_TYPE = 'llmNode';
|
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) {
|
if (node.type === START_NODE_TYPE) {
|
||||||
const parameters = Array.isArray(node.data?.parameters)
|
const parameters = Array.isArray(node.data?.parameters)
|
||||||
? (node.data.parameters as Parameter[])
|
? (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)
|
const outputDefs = Array.isArray(node.data?.outputDefs)
|
||||||
? (node.data.outputDefs as Parameter[])
|
? (node.data.outputDefs as Parameter[])
|
||||||
: [];
|
: [];
|
||||||
@@ -993,10 +1010,22 @@ export function buildEditorReferenceParameters(
|
|||||||
) {
|
) {
|
||||||
const refNodeIds: string[] = [];
|
const refNodeIds: string[] = [];
|
||||||
flattenNodeRefs(currentNodeId, edges, refNodeIds, new Set<string>());
|
flattenNodeRefs(currentNodeId, edges, refNodeIds, new Set<string>());
|
||||||
|
const currentNode = nodes.find((node) => node.id === currentNodeId);
|
||||||
|
|
||||||
const upstreamParameters = nodes
|
const upstreamParameters = nodes
|
||||||
.filter((node) => refNodeIds.includes(node.id))
|
.filter((node) => {
|
||||||
.flatMap((node) => getNodeReferenceParameters(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(
|
const upstreamNameSet = new Set(
|
||||||
upstreamParameters.map((parameter) => asString(parameter.name).trim()),
|
upstreamParameters.map((parameter) => asString(parameter.name).trim()),
|
||||||
|
|||||||
Reference in New Issue
Block a user