发布 v1.10 #5
@@ -241,7 +241,9 @@ public class WorkflowExecutionAuditConsumer implements MQConsumerHandler {
|
||||
"iterationCount"))
|
||||
.intValue(),
|
||||
String.valueOf(
|
||||
map.get("outputName")));
|
||||
map.get("outputName")),
|
||||
Boolean.TRUE.equals(
|
||||
map.get("flattenAggregation")));
|
||||
}
|
||||
Map<Object, Object> restored =
|
||||
new LinkedHashMap<>();
|
||||
|
||||
@@ -248,6 +248,7 @@ public class WorkflowCheckService {
|
||||
for (NodeView node : nodes) {
|
||||
checkConfiguredLoopCount(node, issues, issueKeys);
|
||||
checkExplicitLoopInputs(node, issues, issueKeys);
|
||||
checkLoopOutputAggregations(node, issues, issueKeys);
|
||||
if (StringUtils.hasText(node.parentId)) {
|
||||
NodeView parent = nodeMap.get(node.parentId);
|
||||
if (parent != null && !TYPE_LOOP.equals(parent.type)) {
|
||||
@@ -605,6 +606,51 @@ public class WorkflowCheckService {
|
||||
true, 0, "Array<", 0, "Array<".length()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验循环输出的扁平聚合只作用于数组引用。
|
||||
*
|
||||
* @param node 循环节点
|
||||
* @param issues 问题列表
|
||||
* @param issueKeys 问题去重键
|
||||
*/
|
||||
private void checkLoopOutputAggregations(
|
||||
NodeView node,
|
||||
List<WorkflowCheckIssue> issues,
|
||||
Set<String> issueKeys) {
|
||||
if (!TYPE_LOOP.equals(node.type) || node.data == null) {
|
||||
return;
|
||||
}
|
||||
JSONArray outputDefs = node.data.getJSONArray("outputDefs");
|
||||
if (outputDefs == null || outputDefs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (int index = 0; index < outputDefs.size(); index++) {
|
||||
JSONObject outputDef = outputDefs.getJSONObject(index);
|
||||
if (outputDef == null
|
||||
|| !outputDef.getBooleanValue("flattenAggregation")) {
|
||||
continue;
|
||||
}
|
||||
String refType = trimToNull(outputDef.getString("refType"));
|
||||
String ref = trimToNull(outputDef.getString("ref"));
|
||||
String dataType = trimToNull(outputDef.getString("dataType"));
|
||||
if ("ref".equals(refType)
|
||||
&& StringUtils.hasText(ref)
|
||||
&& isArrayDataType(dataType)) {
|
||||
continue;
|
||||
}
|
||||
String outputName = trimToNull(outputDef.getString("name"));
|
||||
addIssue(
|
||||
issues,
|
||||
issueKeys,
|
||||
"LOOP_OUTPUT_FLATTEN_TYPE_INVALID",
|
||||
"循环输出参数[" + safe(outputName)
|
||||
+ "]启用扁平聚合时必须引用数组变量",
|
||||
node.id,
|
||||
null,
|
||||
node.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 index 和 loopItem 仅在所属循环体内引用。
|
||||
*
|
||||
|
||||
@@ -627,11 +627,11 @@ public class WorkflowExecutionAuditConsumerTest {
|
||||
loopRepository.append(
|
||||
resultId,
|
||||
0,
|
||||
Map.of("answer", "first"));
|
||||
Map.of("answer", List.of("first", "second")));
|
||||
loopRepository.append(
|
||||
resultId,
|
||||
1,
|
||||
Map.of("answer", "second"));
|
||||
Map.of("answer", List.of("third")));
|
||||
WorkflowExecutionAuditConsumer consumer =
|
||||
new WorkflowExecutionAuditConsumer(
|
||||
resultService,
|
||||
@@ -650,7 +650,8 @@ public class WorkflowExecutionAuditConsumerTest {
|
||||
new LoopResultReference(
|
||||
resultId,
|
||||
2,
|
||||
"answer"));
|
||||
"answer",
|
||||
true));
|
||||
WorkflowExecStep incomingStep =
|
||||
new WorkflowExecStep();
|
||||
incomingStep.setExecKey("step-loop");
|
||||
@@ -700,10 +701,10 @@ public class WorkflowExecutionAuditConsumerTest {
|
||||
resultCaptor.getValue().getOutput(),
|
||||
Map.class);
|
||||
Assert.assertEquals(
|
||||
List.of("first", "second"),
|
||||
List.of("first", "second", "third"),
|
||||
stepOutput.get("answers"));
|
||||
Assert.assertEquals(
|
||||
List.of("first", "second"),
|
||||
List.of("first", "second", "third"),
|
||||
resultOutput.get("answers"));
|
||||
}
|
||||
|
||||
|
||||
@@ -221,6 +221,55 @@ public class WorkflowCheckServiceTest {
|
||||
assertHasCode(result, "EXPLICIT_LOOP_ITEMS_TYPE_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证循环数组输出允许启用扁平聚合。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldPassArrayLoopOutputFlattenAggregation() throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject loopData = loopData(
|
||||
fixedParameter("count", "2", "Number"), null);
|
||||
JSONObject output = refParameter(
|
||||
"res",
|
||||
"knowledge.documents.content",
|
||||
"Array<String>");
|
||||
output.put("flattenAggregation", true);
|
||||
loopData.put("outputDefs", array(output));
|
||||
String content = workflowJson(
|
||||
array(node("loop-1", "loopNode", null, loopData)),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertTrue(result.isPassed());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证循环标量输出不能启用扁平聚合。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockScalarLoopOutputFlattenAggregation() throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject loopData = loopData(
|
||||
fixedParameter("count", "2", "Number"), null);
|
||||
JSONObject output = refParameter(
|
||||
"res",
|
||||
"child.output",
|
||||
"String");
|
||||
output.put("flattenAggregation", true);
|
||||
loopData.put("outputDefs", array(output));
|
||||
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_OUTPUT_FLATTEN_TYPE_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证新旧循环输入不能同时提交。
|
||||
*/
|
||||
|
||||
@@ -11,6 +11,7 @@ import FloatingTrigger from './floating-trigger.svelte';
|
||||
import Heading from './heading.svelte';
|
||||
import MenuButton from './menu-button.svelte';
|
||||
import MixedInput from './mixed-input.svelte';
|
||||
import InfoTooltip from './info-tooltip.svelte';
|
||||
|
||||
export {
|
||||
Button,
|
||||
@@ -26,4 +27,5 @@ export {
|
||||
Heading,
|
||||
MenuButton,
|
||||
MixedInput,
|
||||
InfoTooltip,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
const {
|
||||
text,
|
||||
label = '查看说明',
|
||||
}: {
|
||||
text: string;
|
||||
label?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="tf-info-tooltip nopan nodrag"
|
||||
aria-label={label}
|
||||
data-help={text}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 10.75V16" />
|
||||
<circle cx="12" cy="7.75" r="0.75" class="tf-info-tooltip-dot" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<style lang="less">
|
||||
.tf-info-tooltip {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: 0 0 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
color: var(--tf-text-secondary);
|
||||
cursor: help;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
overflow: visible;
|
||||
fill: none;
|
||||
stroke: currentcolor;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.tf-info-tooltip-dot {
|
||||
fill: currentcolor;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: var(--tf-primary-color);
|
||||
background: var(--tf-primary-soft-bg);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px var(--tf-primary-soft-border);
|
||||
}
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 0;
|
||||
z-index: 120;
|
||||
width: 280px;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: var(--tf-tip-text);
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
content: attr(data-help);
|
||||
background: var(--tf-tip-bg);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--tf-shadow-medium);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover::after,
|
||||
&:focus::after {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@
|
||||
placeholder,
|
||||
variant = 'default',
|
||||
showSelectedType = true,
|
||||
selectedType,
|
||||
...rest
|
||||
}: {
|
||||
items: SelectItem[],
|
||||
@@ -24,6 +25,7 @@
|
||||
placeholder?: string
|
||||
variant?: 'default' | 'reference' | 'model'
|
||||
showSelectedType?: boolean
|
||||
selectedType?: string
|
||||
[key: string]: any
|
||||
} = $props();
|
||||
|
||||
@@ -184,10 +186,10 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg>
|
||||
</span>
|
||||
{/if}
|
||||
<span class="tf-parameter-name">{item.displayLabel || item.label}</span>
|
||||
<span class="tf-parameter-name" title={String(item.displayLabel || item.label)}>{item.displayLabel || item.label}</span>
|
||||
</div>
|
||||
{#if item.dataType}
|
||||
<span class="tf-parameter-type">{item.dataType}</span>
|
||||
<span class="tf-parameter-type" title={item.dataType}>{item.dataType}</span>
|
||||
{/if}
|
||||
{#if item.itemTypeLabel}
|
||||
<span class="tf-parameter-meta">{item.itemTypeLabel}</span>
|
||||
@@ -230,9 +232,9 @@
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="tf-parameter-name">{item.displayLabel || item.label}</span>
|
||||
{#if variant === 'reference' && showSelectedType && item.dataType}
|
||||
<span class="tf-parameter-type">{item.dataType}</span>
|
||||
<span class="tf-parameter-name" title={String(item.displayLabel || item.label)}>{item.displayLabel || item.label}</span>
|
||||
{#if variant === 'reference' && showSelectedType && (selectedType ?? item.dataType)}
|
||||
<span class="tf-parameter-type" title={selectedType ?? item.dataType}>{selectedType ?? item.dataType}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -251,9 +253,9 @@
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="tf-parameter-name">{item.displayLabel || item.label}</span>
|
||||
{#if variant === 'reference' && showSelectedType && item.dataType}
|
||||
<span class="tf-parameter-type">{item.dataType}</span>
|
||||
<span class="tf-parameter-name" title={String(item.displayLabel || item.label)}>{item.displayLabel || item.label}</span>
|
||||
{#if variant === 'reference' && showSelectedType && (selectedType ?? item.dataType)}
|
||||
<span class="tf-parameter-type" title={selectedType ?? item.dataType}>{selectedType ?? item.dataType}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if index < activeItemsState.length - 1}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {Input, MenuButton, Textarea} from '../base';
|
||||
import {Checkbox, InfoTooltip, Input, MenuButton, Textarea} from '../base';
|
||||
import {Button, FloatingTrigger, Select} from '../base/index.js';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import {useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
||||
@@ -7,6 +7,10 @@
|
||||
import {useRefOptions} from '../utils/useRefOptions.svelte';
|
||||
import {onMount} from 'svelte';
|
||||
import type {Parameter} from '#types';
|
||||
import {
|
||||
isArrayDataType,
|
||||
resolveLoopOutputDataType,
|
||||
} from '../../utils/loopScope';
|
||||
|
||||
onMount(() => {
|
||||
if (!param.refType) {
|
||||
@@ -21,7 +25,8 @@
|
||||
useChildrenOnly,
|
||||
showContentType = false,
|
||||
fixedNumberMin,
|
||||
fixedNumberMax
|
||||
fixedNumberMax,
|
||||
loopOutputAggregation = false
|
||||
}: {
|
||||
parameter: Parameter,
|
||||
index: number,
|
||||
@@ -30,6 +35,7 @@
|
||||
showContentType?: boolean,
|
||||
fixedNumberMin?: number,
|
||||
fixedNumberMax?: number,
|
||||
loopOutputAggregation?: boolean,
|
||||
} = $props();
|
||||
|
||||
|
||||
@@ -44,12 +50,12 @@
|
||||
|
||||
const { updateNodeData } = useSvelteFlow();
|
||||
|
||||
const updateParam = (key: string, value: any) => {
|
||||
const updateParams = (patch: Partial<Parameter>) => {
|
||||
updateNodeData(currentNodeId, (node) => {
|
||||
let parameters = node.data?.[dataKeyName] as Array<Parameter>;
|
||||
parameters[index] = {
|
||||
...parameters[index],
|
||||
[key]: value
|
||||
...patch
|
||||
};
|
||||
return {
|
||||
[dataKeyName]: [...parameters]
|
||||
@@ -57,6 +63,9 @@
|
||||
});
|
||||
};
|
||||
|
||||
const updateParam = (key: string, value: any) => {
|
||||
updateParams({[key]: value});
|
||||
};
|
||||
|
||||
const updateParamByEvent = (name: string, event: Event) => {
|
||||
const newValue = (event.target as any).value;
|
||||
@@ -99,13 +108,35 @@
|
||||
|
||||
const updateRef = (item: any) => {
|
||||
const newValue = item.value;
|
||||
updateParam('ref', newValue);
|
||||
if (!loopOutputAggregation) {
|
||||
updateParam('ref', newValue);
|
||||
return;
|
||||
}
|
||||
const dataType = item.dataType || 'String';
|
||||
updateParams({
|
||||
ref: newValue,
|
||||
dataType,
|
||||
flattenAggregation:
|
||||
isArrayDataType(dataType)
|
||||
? Boolean(param.flattenAggregation)
|
||||
: false
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const updateRefType = (item: any) => {
|
||||
const newValue = item.value;
|
||||
updateParam('refType', newValue);
|
||||
if (!loopOutputAggregation) {
|
||||
updateParam('refType', newValue);
|
||||
return;
|
||||
}
|
||||
updateParams({
|
||||
refType: newValue,
|
||||
flattenAggregation:
|
||||
newValue !== 'ref'
|
||||
? false
|
||||
: Boolean(param.flattenAggregation)
|
||||
});
|
||||
};
|
||||
|
||||
const updateContentType = (item: any) => {
|
||||
@@ -125,7 +156,29 @@
|
||||
});
|
||||
triggerObject?.hide();
|
||||
};
|
||||
let selectItems = useRefOptions(() => useChildrenOnly === true);
|
||||
let selectItems = useRefOptions(
|
||||
() => useChildrenOnly === true,
|
||||
() => param.ref || ''
|
||||
);
|
||||
let sourceDataType = $derived.by(() => {
|
||||
return selectItems.selected?.dataType || param.dataType || 'String';
|
||||
});
|
||||
let canFlattenAggregation = $derived.by(() => {
|
||||
return isArrayDataType(sourceDataType);
|
||||
});
|
||||
let loopOutputDataType = $derived.by(() => {
|
||||
return resolveLoopOutputDataType(
|
||||
sourceDataType,
|
||||
canFlattenAggregation && Boolean(param.flattenAggregation)
|
||||
);
|
||||
});
|
||||
const updateFlattenAggregation = (event: Event) => {
|
||||
const checked = (event.target as HTMLInputElement).checked;
|
||||
updateParams({
|
||||
dataType: sourceDataType,
|
||||
flattenAggregation: canFlattenAggregation && checked
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -153,11 +206,25 @@
|
||||
</div>
|
||||
{:else if (param.refType !== 'input')}
|
||||
<Select items={selectItems.current} style="width: 100%" defaultValue={["ref"]} value={[param.ref]} variant="reference"
|
||||
selectedType={loopOutputAggregation ? loopOutputDataType : undefined}
|
||||
expandAll
|
||||
onSelect={updateRef} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="input-item">
|
||||
<div class="input-item input-actions">
|
||||
{#if loopOutputAggregation && param.refType === 'ref' && param.ref && canFlattenAggregation}
|
||||
<label class="flatten-aggregation-control">
|
||||
<Checkbox
|
||||
checked={Boolean(param.flattenAggregation)}
|
||||
onchange={updateFlattenAggregation}
|
||||
/>
|
||||
<span>扁平聚合</span>
|
||||
</label>
|
||||
<InfoTooltip
|
||||
label="查看扁平聚合说明"
|
||||
text="关闭时按轮次保留数组;开启后按循环顺序合并,只扁平一层。例如 [[A, B], [C]] 会变为 [A, B, C],轮次分组信息将丢失。"
|
||||
/>
|
||||
{/if}
|
||||
<FloatingTrigger placement="bottom" bind:this={triggerObject}>
|
||||
<MenuButton />
|
||||
{#snippet floating()}
|
||||
@@ -209,6 +276,12 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
gap: 5px;
|
||||
justify-content: flex-end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fixed-value {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -243,4 +316,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
.flatten-aggregation-control {
|
||||
display: inline-flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
color: var(--tf-text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
|
||||
:global(.tf-checkbox:focus-visible) {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--tf-primary-soft-border);
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
useChildrenOnly,
|
||||
showContentType = false,
|
||||
fixedNumberMin,
|
||||
fixedNumberMax
|
||||
fixedNumberMax,
|
||||
loopOutputAggregation = false
|
||||
}: {
|
||||
noneParameterText?: string;
|
||||
dataKeyName?: string;
|
||||
@@ -17,6 +18,7 @@
|
||||
showContentType?: boolean,
|
||||
fixedNumberMin?: number,
|
||||
fixedNumberMax?: number,
|
||||
loopOutputAggregation?: boolean,
|
||||
} = $props();
|
||||
|
||||
let currentNodeId = getCurrentNodeId();
|
||||
@@ -28,7 +30,7 @@
|
||||
</script>
|
||||
|
||||
|
||||
<div class="input-container">
|
||||
<div class:loop-output-aggregation={loopOutputAggregation} class="input-container">
|
||||
{#if (parameters.length !== 0)}
|
||||
<div class="input-header">参数名称</div>
|
||||
<div class="input-header">参数值</div>
|
||||
@@ -43,6 +45,7 @@
|
||||
{showContentType}
|
||||
{fixedNumberMin}
|
||||
{fixedNumberMax}
|
||||
{loopOutputAggregation}
|
||||
/>
|
||||
{:else }
|
||||
<div class="none-params">{noneParameterText}</div>
|
||||
@@ -61,6 +64,10 @@
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.loop-output-aggregation {
|
||||
grid-template-columns: 124px minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.none-params {
|
||||
font-size: 12px;
|
||||
background: var(--tf-bg-muted);
|
||||
|
||||
@@ -50,7 +50,12 @@
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<RefParameterList noneParameterText="无输出参数" dataKeyName="outputDefs" useChildrenOnly={true} />
|
||||
<RefParameterList
|
||||
noneParameterText="无输出参数"
|
||||
dataKeyName="outputDefs"
|
||||
useChildrenOnly={true}
|
||||
loopOutputAggregation={true}
|
||||
/>
|
||||
|
||||
</NodeWrapper>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
buildLoopReferenceParameters,
|
||||
buildLoopScopeParameters,
|
||||
isArrayDataType,
|
||||
projectParameterDataType,
|
||||
} from '../../utils/loopScope';
|
||||
|
||||
const fillRefNodeIds = (
|
||||
@@ -25,9 +26,9 @@ const fillRefNodeIds = (
|
||||
const getChildren = (
|
||||
params: any,
|
||||
parentId: string,
|
||||
nodeIsChildren: boolean,
|
||||
nodeType: string,
|
||||
parentPathLabel = '',
|
||||
ancestorCollectionDepth = 0,
|
||||
parentIsCollection = false,
|
||||
) => {
|
||||
if (!params || params.length === 0) return [];
|
||||
@@ -43,25 +44,29 @@ const getChildren = (
|
||||
: parentIsCollection
|
||||
? `${parentPathLabel}.[].${normalizedChildLabel}`
|
||||
: `${parentPathLabel}.${normalizedChildLabel}`;
|
||||
const dataType = nodeIsChildren
|
||||
? `Array<${param.dataType || 'String'}>`
|
||||
: param.dataType || 'String';
|
||||
const dataType = projectParameterDataType(
|
||||
param,
|
||||
ancestorCollectionDepth,
|
||||
);
|
||||
const nextCollectionDepth =
|
||||
ancestorCollectionDepth + (isCollection ? 1 : 0);
|
||||
return {
|
||||
label: pathLabel,
|
||||
dataType: dataType,
|
||||
dataType,
|
||||
value: parentId + '.' + param.name,
|
||||
selectable: true,
|
||||
nodeType: nodeType,
|
||||
displayLabel: pathLabel,
|
||||
pathLabel,
|
||||
itemTypeLabel: parentIsCollection ? '数组项字段' : undefined,
|
||||
itemTypeLabel:
|
||||
ancestorCollectionDepth > 0 ? '数组项字段' : undefined,
|
||||
isCollection,
|
||||
children: getChildren(
|
||||
param.children,
|
||||
parentId + '.' + param.name,
|
||||
nodeIsChildren,
|
||||
nodeType,
|
||||
pathLabel,
|
||||
nextCollectionDepth,
|
||||
isCollection,
|
||||
),
|
||||
};
|
||||
@@ -70,7 +75,6 @@ const getChildren = (
|
||||
|
||||
const nodeToOptions = (
|
||||
node: Node,
|
||||
nodeIsChildren: boolean,
|
||||
currentNode: Node,
|
||||
nodes: Node[],
|
||||
) => {
|
||||
@@ -94,13 +98,10 @@ const nodeToOptions = (
|
||||
const children = [];
|
||||
if (parameters)
|
||||
for (const parameter of parameters) {
|
||||
const dataType = nodeIsChildren
|
||||
? `Array<${parameter.dataType || 'String'}>`
|
||||
: parameter.dataType || 'String';
|
||||
const label = getStartNodeParameterLabel(parameter);
|
||||
children.push({
|
||||
label,
|
||||
dataType: dataType,
|
||||
dataType: projectParameterDataType(parameter),
|
||||
value: node.id + '.' + parameter.name,
|
||||
selectable: true,
|
||||
nodeType: nodeType,
|
||||
@@ -132,7 +133,6 @@ const nodeToOptions = (
|
||||
children: getChildren(
|
||||
referenceParameters,
|
||||
node.id,
|
||||
false,
|
||||
nodeType,
|
||||
),
|
||||
};
|
||||
@@ -145,7 +145,7 @@ const nodeToOptions = (
|
||||
value: node.id,
|
||||
selectable: false,
|
||||
nodeType: nodeType,
|
||||
children: getChildren(outputDefs, node.id, nodeIsChildren, nodeType),
|
||||
children: getChildren(outputDefs, node.id, nodeType),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,7 @@ const nodeToOptions = (
|
||||
|
||||
export const useRefOptions: any = (
|
||||
useChildrenOnly: boolean | (() => boolean) = false,
|
||||
currentRef: string | (() => string) = '',
|
||||
) => {
|
||||
const currentNodeId = getCurrentNodeId();
|
||||
const currentNode = useNodesData(currentNodeId);
|
||||
@@ -161,11 +162,16 @@ export const useRefOptions: any = (
|
||||
typeof useChildrenOnly === 'function'
|
||||
? useChildrenOnly()
|
||||
: useChildrenOnly;
|
||||
const getCurrentRef = () =>
|
||||
typeof currentRef === 'function' ? currentRef() : currentRef;
|
||||
|
||||
let selectItems = $derived.by(() => {
|
||||
const resultOptions = [];
|
||||
if (!currentNode.current) {
|
||||
return [];
|
||||
return {
|
||||
items: [],
|
||||
selected: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
//通过 nodeLookup.get 才会得到有 parentId 的 node
|
||||
@@ -173,11 +179,9 @@ export const useRefOptions: any = (
|
||||
|
||||
if (isChildrenOnly()) {
|
||||
for (const node of nodes) {
|
||||
const nodeIsChildren = node.parentId === currentNode.current.id;
|
||||
if (nodeIsChildren) {
|
||||
if (node.parentId === currentNode.current.id) {
|
||||
const nodeOptions = nodeToOptions(
|
||||
node,
|
||||
nodeIsChildren,
|
||||
cNode,
|
||||
nodes,
|
||||
);
|
||||
@@ -193,10 +197,8 @@ export const useRefOptions: any = (
|
||||
node.type === 'loopNode' &&
|
||||
buildLoopScopeParameters(node, cNode, nodes).length > 0;
|
||||
if (refNodeIds.includes(node.id) || isScopedLoop) {
|
||||
const nodeIsChildren = node.parentId === currentNode.current.id;
|
||||
const nodeOptions = nodeToOptions(
|
||||
node,
|
||||
nodeIsChildren,
|
||||
cNode,
|
||||
nodes,
|
||||
);
|
||||
@@ -205,12 +207,30 @@ export const useRefOptions: any = (
|
||||
}
|
||||
}
|
||||
|
||||
return resultOptions;
|
||||
const items = resultOptions;
|
||||
const stack = [...items];
|
||||
let selected;
|
||||
const currentValue = getCurrentRef();
|
||||
// 单次深度优先扫描定位当前引用,避免为每个参数额外构建全量索引。
|
||||
while (stack.length > 0) {
|
||||
const item = stack.pop();
|
||||
if (item?.value === currentValue) {
|
||||
selected = item;
|
||||
break;
|
||||
}
|
||||
if (item?.children?.length) {
|
||||
stack.push(...item.children);
|
||||
}
|
||||
}
|
||||
return { items, selected };
|
||||
});
|
||||
|
||||
return {
|
||||
get current() {
|
||||
return selectItems;
|
||||
return selectItems.items;
|
||||
},
|
||||
get selected() {
|
||||
return selectItems.selected;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -170,4 +170,5 @@ export type Parameter = {
|
||||
requiredDisabled?: boolean;
|
||||
systemReserved?: boolean;
|
||||
autoManaged?: boolean;
|
||||
flattenAggregation?: boolean;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
buildLoopItemParameter,
|
||||
buildLoopReferenceParameters,
|
||||
buildLoopScopeParameters,
|
||||
findReferenceParameter,
|
||||
resolveLoopOutputDataType,
|
||||
resolveLoopInputs,
|
||||
} from './loopScope';
|
||||
|
||||
@@ -227,7 +229,7 @@ describe('loopScope', () => {
|
||||
'records',
|
||||
]);
|
||||
expect(downstreamParameters[0]?.dataType).toBe('Array<String>');
|
||||
expect(downstreamParameters[1]?.dataType).toBe('Array');
|
||||
expect(downstreamParameters[1]?.dataType).toBe('Array<Object>');
|
||||
expect(downstreamParameters[1]?.children?.[0]?.name).toBe('summary');
|
||||
|
||||
downstreamParameters[1]!.children![0]!.name = 'changed';
|
||||
@@ -235,4 +237,64 @@ describe('loopScope', () => {
|
||||
(childNode.data.outputDefs as Array<any>)[1].children[0].name,
|
||||
).toBe('summary');
|
||||
});
|
||||
|
||||
it('集合子字段按实际层级推导循环输出类型', () => {
|
||||
const loopNode = {
|
||||
id: 'loop',
|
||||
type: 'loopNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
loopInputs: {
|
||||
count: {
|
||||
name: 'count',
|
||||
refType: 'fixed',
|
||||
value: '2',
|
||||
dataType: 'Number',
|
||||
},
|
||||
},
|
||||
outputDefs: [
|
||||
{
|
||||
name: 'grouped',
|
||||
refType: 'ref',
|
||||
ref: 'knowledge.documents.content',
|
||||
dataType: 'Array<String>',
|
||||
},
|
||||
{
|
||||
name: 'flattened',
|
||||
refType: 'ref',
|
||||
ref: 'knowledge.documents.content',
|
||||
dataType: 'Array<String>',
|
||||
flattenAggregation: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies Node;
|
||||
const downstreamNode = {
|
||||
id: 'downstream',
|
||||
type: 'endNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
} satisfies Node;
|
||||
const nodes = [knowledgeNode, loopNode, downstreamNode];
|
||||
|
||||
expect(
|
||||
findReferenceParameter(
|
||||
nodes,
|
||||
'knowledge.documents.content',
|
||||
)?.dataType,
|
||||
).toBe('Array<String>');
|
||||
expect(
|
||||
buildLoopReferenceParameters(loopNode, downstreamNode, nodes)
|
||||
.map((parameter) => parameter.dataType),
|
||||
).toEqual(['Array<Array<String>>', 'Array<String>']);
|
||||
});
|
||||
|
||||
it('扁平聚合只移除循环新增的一层数组', () => {
|
||||
expect(resolveLoopOutputDataType('String', false))
|
||||
.toBe('Array<String>');
|
||||
expect(resolveLoopOutputDataType('Array<String>', false))
|
||||
.toBe('Array<Array<String>>');
|
||||
expect(resolveLoopOutputDataType('Array<String>', true))
|
||||
.toBe('Array<String>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,53 @@ export function isArrayDataType(dataType?: string | null) {
|
||||
return normalized === 'array' || normalized.startsWith('array<');
|
||||
}
|
||||
|
||||
function normalizeDataType(
|
||||
dataType?: string | null,
|
||||
hasChildren = false,
|
||||
) {
|
||||
const normalized = asString(dataType);
|
||||
if (!normalized) {
|
||||
return hasChildren ? 'Object' : 'String';
|
||||
}
|
||||
if (normalized.toLowerCase() === 'array') {
|
||||
return `Array<${hasChildren ? 'Object' : 'Any'}>`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算参数穿过上级集合后的实际引用类型。
|
||||
*/
|
||||
export function projectParameterDataType(
|
||||
parameter: Parameter,
|
||||
ancestorCollectionDepth = 0,
|
||||
) {
|
||||
const dataType = normalizeDataType(
|
||||
parameter.dataType,
|
||||
Boolean(parameter.children?.length),
|
||||
);
|
||||
if (ancestorCollectionDepth <= 0) {
|
||||
return dataType;
|
||||
}
|
||||
const prefix = 'Array<'.repeat(ancestorCollectionDepth);
|
||||
const suffix = '>'.repeat(ancestorCollectionDepth);
|
||||
return `${prefix}${dataType}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算循环完成后对外暴露的输出类型。
|
||||
*/
|
||||
export function resolveLoopOutputDataType(
|
||||
sourceDataType?: string | null,
|
||||
flattenAggregation = false,
|
||||
) {
|
||||
const normalized = normalizeDataType(sourceDataType);
|
||||
if (flattenAggregation && isArrayDataType(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return `Array<${normalized}>`;
|
||||
}
|
||||
|
||||
function getNodeParameters(node: Node): Parameter[] {
|
||||
if (node.type === 'startNode') {
|
||||
return Array.isArray(node.data?.parameters)
|
||||
@@ -52,16 +99,32 @@ export function findReferenceParameter(
|
||||
const path = normalizedReference.slice(node.id.length + 1).split('.');
|
||||
let parameters = getNodeParameters(node);
|
||||
let current: Parameter | undefined;
|
||||
for (const segment of path) {
|
||||
let ancestorCollectionDepth = 0;
|
||||
for (let index = 0; index < path.length; index++) {
|
||||
const segment = path[index];
|
||||
current = parameters.find(
|
||||
(parameter) => asString(parameter.name) === segment,
|
||||
);
|
||||
if (!current) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
index < path.length - 1 &&
|
||||
isArrayDataType(current.dataType)
|
||||
) {
|
||||
ancestorCollectionDepth++;
|
||||
}
|
||||
parameters = current.children || [];
|
||||
}
|
||||
return current ? cloneParameter(current) : undefined;
|
||||
if (!current) {
|
||||
return undefined;
|
||||
}
|
||||
const projected = cloneParameter(current);
|
||||
projected.dataType = projectParameterDataType(
|
||||
current,
|
||||
ancestorCollectionDepth,
|
||||
);
|
||||
return projected;
|
||||
}
|
||||
|
||||
export function resolveLoopInputs(
|
||||
@@ -219,17 +282,19 @@ export function buildLoopReferenceParameters(
|
||||
nodes,
|
||||
outputParameter.ref,
|
||||
);
|
||||
if (!sourceParameter) {
|
||||
return outputParameter;
|
||||
}
|
||||
const sourceDataType = asString(sourceParameter.dataType) || 'String';
|
||||
const sourceChildren = sourceParameter.children?.map(cloneParameter);
|
||||
const sourceDataType =
|
||||
asString(
|
||||
sourceParameter?.dataType || outputParameter.dataType,
|
||||
) || 'String';
|
||||
const sourceChildren = (
|
||||
sourceParameter?.children || outputParameter.children
|
||||
)?.map(cloneParameter);
|
||||
return {
|
||||
...outputParameter,
|
||||
dataType:
|
||||
sourceChildren?.length && !isArrayDataType(sourceDataType)
|
||||
? 'Array'
|
||||
: `Array<${sourceDataType}>`,
|
||||
dataType: resolveLoopOutputDataType(
|
||||
sourceDataType,
|
||||
Boolean(outputParameter.flattenAggregation),
|
||||
),
|
||||
children: sourceChildren,
|
||||
};
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user