feat: 完善循环节点配置与作用域输出

- 支持次数与数组独立或组合配置并补齐检查规则

- 统一循环体临时变量与下游正式输出候选

- 稳定知识库对象数组字段并补充前后端测试
This commit is contained in:
2026-07-29 18:10:23 +08:00
parent 19c7b60a65
commit 766554bf63
13 changed files with 1737 additions and 62 deletions

View File

@@ -8,6 +8,7 @@
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {useAddParameter} from '../utils/useAddParameter.svelte';
import type {TinyflowNodeData} from '#types';
import LoopInputEditor from './loop/LoopInputEditor.svelte';
const { data, ...rest }: {
data: TinyflowNodeData,
@@ -17,17 +18,6 @@
const currentNodeId = getCurrentNodeId();
const { addParameter } = useAddParameter();
$effect(() => {
if (!data.loopVars || data.loopVars.length === 0) {
addParameter(currentNodeId, 'loopVars', {
name: 'loopVar',
nameDisabled: true,
deleteDisabled: true
});
}
});
</script>
@@ -46,17 +36,9 @@
{/snippet}
<div class="heading">
<Heading level={3}>循环变量</Heading>
<!-- <Button class="input-btn-more" style="margin-left: auto" onclick={()=>{-->
<!-- addParameter(currentNodeId)-->
<!-- }}>-->
<!-- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">-->
<!-- <path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>-->
<!-- </svg>-->
<!-- </Button>-->
<Heading level={3}>循环参数</Heading>
</div>
<RefParameterList dataKeyName="loopVars" fixedNumberMin={1} fixedNumberMax={300} />
<div class="loop-limit-hint">单次最多循环 300 次</div>
<LoopInputEditor {data} />
<div class="heading">
<Heading level={3}>输出参数</Heading>
@@ -79,13 +61,6 @@
align-items: center;
}
.loop-limit-hint {
margin-top: 6px;
color: var(--tf-text-secondary);
font-size: 12px;
line-height: 1.4;
}
:global(.loop-handle-wrapper) {
&::after {
//display: none;

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 { getStartNodeParameterLabel } from '#components/utils/startNodeParameterLabel';
import { nodeIcons } from '../../consts';
import {
buildLoopReferenceParameters,
buildLoopScopeParameters,
isArrayDataType,
} from '../../utils/loopScope';
const fillRefNodeIds = (
refNodeIds: string[],
@@ -27,7 +32,10 @@ const getChildren = (
) => {
if (!params || params.length === 0) return [];
return params.map((param: any) => {
const isCollection = param.dataType === 'Array' && param.children && param.children.length > 0;
const isCollection =
isArrayDataType(param.dataType) &&
param.children &&
param.children.length > 0;
const childBaseLabel = param.formLabel || param.displayName || param.name;
const normalizedChildLabel = String(childBaseLabel || '').trim();
const pathLabel = !parentPathLabel
@@ -64,6 +72,7 @@ const nodeToOptions = (
node: Node,
nodeIsChildren: boolean,
currentNode: Node,
nodes: Node[],
) => {
const options = getOptions();
const nodeType = node.type || '';
@@ -105,29 +114,27 @@ const nodeToOptions = (
nodeType: nodeType,
children,
};
} else if (nodeType === 'loopNode' && currentNode.parentId) {
} else if (nodeType === 'loopNode') {
const referenceParameters = buildLoopReferenceParameters(
node,
currentNode,
nodes,
);
if (!referenceParameters.length) {
return undefined;
}
return {
label: title,
icon: icon,
value: node.id,
selectable: false,
nodeType: nodeType,
children: [
{
label: 'loopItem',
dataType: 'Any',
value: node.id + '.loopItem',
selectable: true,
nodeType: nodeType,
},
{
label: 'index',
dataType: 'Number',
value: node.id + '.index',
selectable: true,
nodeType: nodeType,
},
],
children: getChildren(
referenceParameters,
node.id,
false,
nodeType,
),
};
} else {
const outputDefs = node.data.outputDefs;
@@ -168,7 +175,12 @@ export const useRefOptions: any = (
for (const node of nodes) {
const nodeIsChildren = node.parentId === currentNode.current.id;
if (nodeIsChildren) {
const nodeOptions = nodeToOptions(node, nodeIsChildren, cNode);
const nodeOptions = nodeToOptions(
node,
nodeIsChildren,
cNode,
nodes,
);
nodeOptions && resultOptions.push(nodeOptions);
}
}
@@ -177,9 +189,17 @@ export const useRefOptions: any = (
fillRefNodeIds(refNodeIds, currentNodeId, edges);
for (const node of nodes) {
if (refNodeIds.includes(node.id)) {
const isScopedLoop =
node.type === 'loopNode' &&
buildLoopScopeParameters(node, cNode, nodes).length > 0;
if (refNodeIds.includes(node.id) || isScopedLoop) {
const nodeIsChildren = node.parentId === currentNode.current.id;
const nodeOptions = nodeToOptions(node, nodeIsChildren, cNode);
const nodeOptions = nodeToOptions(
node,
nodeIsChildren,
cNode,
nodes,
);
nodeOptions && resultOptions.push(nodeOptions);
}
}

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('数组项字段');
});
it('exposes typed loop variables only to nodes inside the loop body', () => {
const knowledgeNode: Node = {
id: 'knowledge_1',
type: 'knowledgeNode',
position: { x: 0, y: 0 },
data: {
title: '知识库',
outputDefs: [
{
name: 'documents',
dataType: 'Array',
children: [
{ name: 'title', dataType: 'String' },
{ name: 'content', dataType: 'String' },
],
},
],
},
};
const loopNode: Node = {
id: 'loop_1',
type: 'loopNode',
position: { x: 120, y: 0 },
data: {
title: '循环',
loopInputs: {
items: {
name: 'items',
refType: 'ref',
ref: 'knowledge_1.documents',
dataType: 'Array',
},
},
outputDefs: [
{
name: 'res',
dataType: 'Array<String>',
},
],
},
};
const insideNode: Node = {
id: 'llm_inside',
type: 'llmNode',
parentId: 'loop_1',
position: { x: 240, y: 0 },
data: { title: '循环内模型', parameters: [] },
};
const outsideNode: Node = {
id: 'llm_outside',
type: 'llmNode',
position: { x: 240, y: 120 },
data: { title: '循环外模型', parameters: [] },
};
const nodes = [
knowledgeNode,
loopNode,
insideNode,
outsideNode,
];
const insideParameters = buildEditorReferenceParameters(
'llm_inside',
nodes,
[],
[],
);
const outsideParameters = buildEditorReferenceParameters(
'llm_outside',
nodes,
[
{
id: 'loop_to_outside',
source: 'loop_1',
target: 'llm_outside',
},
],
[],
);
expect(insideParameters.map((item) => item.name)).toEqual([
'loop_1.index',
'loop_1.loopItem',
'loop_1.loopItem.title',
'loop_1.loopItem.content',
]);
expect(
insideParameters.find(
(item) => item.name === 'loop_1.loopItem.content',
)?.dataType,
).toBe('String');
expect(
outsideParameters.map((item) => item.name),
).toEqual(['loop_1.res']);
expect(
outsideParameters.some((item) =>
['loop_1.index', 'loop_1.loopItem'].includes(item.name || ''),
),
).toBe(false);
});
it('uses document node child outputs for reference display', () => {
const documentNode: Node = {
id: 'doc_1',

View File

@@ -3,6 +3,10 @@ import type { Edge, Node } from '@xyflow/svelte';
import type { Parameter } from '../types';
import { getTokenRanges } from '../components/utils/paramToken';
import { genShortId } from '../components/utils/IdGen';
import {
buildLoopReferenceParameters,
buildLoopScopeParameters,
} from './loopScope';
export const START_NODE_TYPE = 'startNode';
export const LLM_NODE_TYPE = 'llmNode';
@@ -274,7 +278,11 @@ function flattenOutputDefs(
});
}
function getNodeReferenceParameters(node: Node): Parameter[] {
function getNodeReferenceParameters(
node: Node,
currentNode?: Node,
nodes: Node[] = [],
): Parameter[] {
if (node.type === START_NODE_TYPE) {
const parameters = Array.isArray(node.data?.parameters)
? (node.data.parameters as Parameter[])
@@ -297,6 +305,15 @@ function getNodeReferenceParameters(node: Node): Parameter[] {
);
}
if (node.type === 'loopNode' && currentNode) {
const referenceParameters = buildLoopReferenceParameters(
node,
currentNode,
nodes,
);
return flattenOutputDefs(node, referenceParameters);
}
const outputDefs = Array.isArray(node.data?.outputDefs)
? (node.data.outputDefs as Parameter[])
: [];
@@ -993,10 +1010,22 @@ export function buildEditorReferenceParameters(
) {
const refNodeIds: string[] = [];
flattenNodeRefs(currentNodeId, edges, refNodeIds, new Set<string>());
const currentNode = nodes.find((node) => node.id === currentNodeId);
const upstreamParameters = nodes
.filter((node) => refNodeIds.includes(node.id))
.flatMap((node) => getNodeReferenceParameters(node));
.filter((node) => {
if (refNodeIds.includes(node.id)) {
return true;
}
return Boolean(
currentNode &&
node.type === 'loopNode' &&
buildLoopScopeParameters(node, currentNode, nodes).length,
);
})
.flatMap((node) =>
getNodeReferenceParameters(node, currentNode, nodes),
);
const upstreamNameSet = new Set(
upstreamParameters.map((parameter) => asString(parameter.name).trim()),