发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
13 changed files with 1737 additions and 62 deletions
Showing only changes of commit 766554bf63 - Show all commits

View File

@@ -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;
}
} }

View File

@@ -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,29 +285,90 @@ 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; 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); 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",
"循环次数必须是 1300 的整数",
node.id,
null,
node.name);
return;
}
addLoopCountIssueIfInvalid( addLoopCountIssueIfInvalid(
value, value,
"EXPLICIT_LOOP_COUNT_INVALID", "EXPLICIT_LOOP_COUNT_INVALID",
@@ -311,6 +376,202 @@ public class WorkflowCheckService {
issues, issues,
issueKeys); 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",
"循环次数必须是 1300 的整数",
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;
} }
/** /**

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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
? ''
: '请输入 1300 的整数';
});
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="输入 1300 或选择变量"
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>

View File

@@ -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',
]);
});
});

View File

@@ -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);
}

View File

@@ -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);
} }
} }

View File

@@ -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');
});
});

View 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,
};
})
: [];
}

View File

@@ -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',

View File

@@ -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()),