feat: 完成工作流多文件文档解析闭环

- 支持文档解析节点批量解析并收口为 documents 轻量输出

- 收口引用树、节点输出展示与旧工作流固定输出兼容

- 修复共享按钮点击事件,恢复多个节点加号交互
This commit is contained in:
2026-04-19 16:05:40 +08:00
parent a5aab86de2
commit 1d8b9d9662
15 changed files with 496 additions and 48 deletions

View File

@@ -6,6 +6,7 @@ import com.easyagents.flow.core.chain.Parameter;
import com.easyagents.flow.core.node.BaseNode; import com.easyagents.flow.core.node.BaseNode;
import tech.easyflow.common.util.SpringContextUtil; import tech.easyflow.common.util.SpringContextUtil;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -30,17 +31,42 @@ public class DocNode extends BaseNode {
@Override @Override
public Map<String, Object> execute(Chain chain) { public Map<String, Object> execute(Chain chain) {
Map<String, Object> map = chain.getState().resolveParameters(this); Map<String, Object> map = chain.getState().resolveParameters(this);
Map<String, Object> res = new HashMap<>();
DocNodeFileContentExtractor extractor = SpringContextUtil.getBean(DocNodeFileContentExtractor.class); DocNodeFileContentExtractor extractor = SpringContextUtil.getBean(DocNodeFileContentExtractor.class);
String docContent = extractor.extract(map.get("file")); List<DocNodeFileContentExtractor.DocExtractedDocument> documents = extractor.extractDocuments(map.get("file"));
String key = "content"; List<Map<String, Object>> documentMaps = new ArrayList<>();
List<Parameter> outputDefs = getOutputDefs(); for (DocNodeFileContentExtractor.DocExtractedDocument document : documents) {
if (outputDefs != null && !outputDefs.isEmpty()) { documentMaps.add(document.toMap());
String defName = outputDefs.get(0).getName();
if (StringUtil.hasText(defName)) key = defName;
} }
res.put(key, docContent);
Map<String, String> outputKeyMapping = resolveOutputKeyMapping();
Map<String, Object> res = new HashMap<>();
res.put(outputKeyMapping.get("documents"), documentMaps);
return res; return res;
} }
/**
* 根据节点输出定义解析运行态输出键名。
*
* @return 逻辑字段到实际输出键名的映射
*/
private Map<String, String> resolveOutputKeyMapping() {
Map<String, String> mapping = new HashMap<>();
mapping.put("documents", "documents");
List<Parameter> outputDefs = getOutputDefs();
if (outputDefs == null || outputDefs.isEmpty()) {
return mapping;
}
String[] logicalKeys = {"documents"};
for (int i = 0; i < outputDefs.size() && i < logicalKeys.length; i++) {
Parameter outputDef = outputDefs.get(i);
String name = outputDef == null ? null : outputDef.getName();
if (StringUtil.hasText(name)) {
mapping.put(logicalKeys[i], name);
}
}
return mapping;
}
} }

View File

@@ -14,7 +14,13 @@ import tech.easyflow.common.web.exceptions.BusinessException;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* {@link DocNode} 文件内容提取器。 * {@link DocNode} 文件内容提取器。
@@ -27,6 +33,9 @@ import java.util.Map;
*/ */
@Component @Component
public class DocNodeFileContentExtractor { public class DocNodeFileContentExtractor {
private static final int FILE_MAX_COUNT = 10;
private static final long FILE_MAX_SINGLE_SIZE = 5L * 1024 * 1024;
private static final long FILE_MAX_TOTAL_SIZE = 50L * 1024 * 1024;
private final DocumentParseBridgeService documentParseBridgeService; private final DocumentParseBridgeService documentParseBridgeService;
private final FileStorageService fileStorageService; private final FileStorageService fileStorageService;
@@ -62,6 +71,33 @@ public class DocNodeFileContentExtractor {
return extractDefaultContent(sourceRef); return extractDefaultContent(sourceRef);
} }
/**
* 批量提取文件文本内容。
*
* @param fileValue 工作流运行态中的单文件或多文件值
* @return 逐文件解析结果
*/
public List<DocExtractedDocument> extractDocuments(Object fileValue) {
List<DocumentSourceRef> sourceRefs = toDocumentSourceRefs(fileValue);
List<DocExtractedDocument> results = new ArrayList<>();
for (int index = 0; index < sourceRefs.size(); index++) {
DocumentSourceRef sourceRef = sourceRefs.get(index);
try {
String content = shouldUseDocumentBridge(sourceRef)
? extractBridgeContent(sourceRef)
: extractDefaultContent(sourceRef);
results.add(new DocExtractedDocument(sourceRef.getFileName(), content));
} catch (Exception e) {
String fileName = StringUtil.hasText(sourceRef.getFileName()) ? sourceRef.getFileName() : ("#" + (index + 1));
if (e instanceof BusinessException businessException) {
throw new BusinessException("文件解析失败(" + fileName + "): " + businessException.getMessage());
}
throw new RuntimeException("文件解析失败(" + fileName + ")", e);
}
}
return results;
}
/** /**
* 将运行时文件值转换为统一文档源。 * 将运行时文件值转换为统一文档源。
* *
@@ -84,6 +120,50 @@ public class DocNodeFileContentExtractor {
return sourceRef; return sourceRef;
} }
/**
* 将单文件或多文件运行值归一化为统一文档源列表。
*
* @param fileValue 运行态文件值
* @return 文档源列表
*/
List<DocumentSourceRef> toDocumentSourceRefs(Object fileValue) {
List<Object> candidates = new ArrayList<>();
collectFileValues(fileValue, candidates);
if (candidates.isEmpty()) {
throw new BusinessException("文件输入不能为空");
}
List<DocumentSourceRef> sourceRefs = new ArrayList<>();
Set<String> seenFilePaths = new LinkedHashSet<>();
long totalSize = 0L;
for (Object candidate : candidates) {
DocumentSourceRef sourceRef = toDocumentSourceRef(candidate);
validateSourceRef(sourceRef);
String filePath = sourceRef.getFilePath().trim();
if (!seenFilePaths.add(filePath)) {
continue;
}
Long size = sourceRef.getSize();
if (size != null && size > FILE_MAX_SINGLE_SIZE) {
throw new BusinessException("单个文件不能超过 5MB: " + sourceRef.getFileName());
}
if (size != null && size > 0) {
totalSize += size;
}
sourceRefs.add(sourceRef);
}
if (sourceRefs.size() > FILE_MAX_COUNT) {
throw new BusinessException("最多上传 10 个文件");
}
if (totalSize > FILE_MAX_TOTAL_SIZE) {
throw new BusinessException("文件总大小不能超过 50MB");
}
if (sourceRefs.isEmpty()) {
throw new BusinessException("文件输入不能为空");
}
return sourceRefs;
}
private void validateSourceRef(DocumentSourceRef sourceRef) { private void validateSourceRef(DocumentSourceRef sourceRef) {
if (sourceRef == null) { if (sourceRef == null) {
throw new BusinessException("文件输入不能为空"); throw new BusinessException("文件输入不能为空");
@@ -96,6 +176,19 @@ public class DocNodeFileContentExtractor {
} }
} }
private void collectFileValues(Object value, List<Object> result) {
if (value == null) {
return;
}
if (value instanceof Collection<?> collection) {
for (Object item : collection) {
collectFileValues(item, result);
}
return;
}
result.add(value);
}
/** /**
* 判断当前文件类型是否应优先走统一文档解析桥接。 * 判断当前文件类型是否应优先走统一文档解析桥接。
* *
@@ -172,4 +265,49 @@ public class DocNodeFileContentExtractor {
} }
return null; return null;
} }
/**
* 逐文件解析结果。
*/
public static final class DocExtractedDocument {
private final String fileName;
private final String content;
/**
* 创建逐文件解析结果。
*
* @param fileName 文件名
* @param content 解析文本
*/
public DocExtractedDocument(String fileName, String content) {
this.fileName = fileName;
this.content = content;
}
/**
* @return 文件名
*/
public String getFileName() {
return fileName;
}
/**
* @return 文本内容
*/
public String getContent() {
return content;
}
/**
* 转为轻量 Map供工作流结果与引用树消费。
*
* @return 轻量结果对象
*/
public Map<String, Object> toMap() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("fileName", fileName);
result.put("content", content);
return result;
}
}
} }

View File

@@ -16,7 +16,9 @@ import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpServer;
@@ -211,6 +213,56 @@ public class DocNodeFileContentExtractorTest {
} }
} }
/**
* 验证多文件输入会按顺序返回逐文件结果。
*/
@Test
public void shouldExtractDocumentsForMultipleFiles() {
RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
bridgeService,
new FakeFileStorageService(),
new FakeReaderManager("plain text")
);
List<DocNodeFileContentExtractor.DocExtractedDocument> documents = extractor.extractDocuments(Arrays.asList(
buildFileValue("demo.pdf", "/files/demo.pdf", "application/pdf"),
buildFileValue("note.txt", "/files/note.txt", "text/plain")
));
Assert.assertEquals(2, documents.size());
Assert.assertEquals("demo.pdf", documents.get(0).getFileName());
Assert.assertEquals("# parsed", documents.get(0).getContent());
Assert.assertEquals("note.txt", documents.get(1).getFileName());
Assert.assertEquals("plain text", documents.get(1).getContent());
}
/**
* 验证多文件中任一文件失败时会暴露文件名。
*/
@Test
public void shouldExposeFileNameWhenMultipleDocumentsFail() {
RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
bridgeService.response.setPreferredText(null);
bridgeService.response.setMarkdown(null);
bridgeService.response.setPlainText(null);
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
bridgeService,
new FakeFileStorageService(),
new FakeReaderManager("plain text")
);
try {
extractor.extractDocuments(Arrays.asList(
buildFileValue("broken.pdf", "/files/broken.pdf", "application/pdf"),
buildFileValue("note.txt", "/files/note.txt", "text/plain")
));
Assert.fail("expected BusinessException");
} catch (BusinessException e) {
Assert.assertEquals("文件解析失败(broken.pdf): 文档解析结果为空", e.getMessage());
}
}
private Map<String, Object> buildFileValue(String fileName, String filePath, String contentType) { private Map<String, Object> buildFileValue(String fileName, String filePath, String contentType) {
Map<String, Object> value = new HashMap<String, Object>(); Map<String, Object> value = new HashMap<String, Object>();
value.put("fileName", fileName); value.put("fileName", fileName);

View File

@@ -0,0 +1,40 @@
package tech.easyflow.ai.node;
import com.easyagents.flow.core.chain.Parameter;
import org.junit.Assert;
import org.junit.Test;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Map;
/**
* {@link DocNode} 单元测试。
*/
public class DocNodeTest {
/**
* 历史工作流若改过输出名,仍应按固定输出槽位顺序映射运行态结果键。
*
* @throws Exception 反射调用失败
*/
@Test
public void shouldResolveOutputKeyMappingByOutputOrder() throws Exception {
DocNode node = new DocNode();
node.setOutputDefs(Arrays.asList(
parameter("documentItems")
));
Method method = DocNode.class.getDeclaredMethod("resolveOutputKeyMapping");
method.setAccessible(true);
Map<String, String> mapping = (Map<String, String>) method.invoke(node);
Assert.assertEquals("documentItems", mapping.get("documents"));
}
private static Parameter parameter(String name) {
Parameter parameter = new Parameter();
parameter.setName(name);
return parameter;
}
}

View File

@@ -24,14 +24,37 @@ export default {
], ],
outputDefs: [ outputDefs: [
{ {
name: 'content', name: 'documents',
title: $t('aiWorkflow.parsedText'), title: $t('aiWorkflow.documents'),
dataType: 'String', dataType: 'Array',
dataTypeDisabled: true, dataTypeDisabled: true,
required: true, required: true,
parametersAddEnable: false, parametersAddEnable: false,
description: $t('aiWorkflow.descriptions.parsedText'), description: $t('aiWorkflow.documents'),
deleteDisabled: true, deleteDisabled: true,
nameDisabled: true,
children: [
{
name: 'fileName',
title: $t('aiWorkflow.fileName'),
dataType: 'String',
dataTypeDisabled: true,
required: true,
parametersAddEnable: false,
deleteDisabled: true,
nameDisabled: true,
},
{
name: 'content',
title: $t('aiWorkflow.parsedText'),
dataType: 'String',
dataTypeDisabled: true,
required: true,
parametersAddEnable: false,
deleteDisabled: true,
nameDisabled: true,
},
],
}, },
], ],
}, },

View File

@@ -0,0 +1,53 @@
import { flushSync, mount, unmount } from 'svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import Button from './button.svelte';
import MenuButton from './menu-button.svelte';
describe('Button', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('转发 onclick 到原生按钮', async () => {
const handleClick = vi.fn();
const host = document.createElement('div');
document.body.appendChild(host);
const app = mount(Button, {
target: host,
props: {
onclick: handleClick,
},
});
flushSync();
(host.querySelector('button') as HTMLButtonElement).click();
expect(handleClick).toHaveBeenCalledTimes(1);
await unmount(app);
host.remove();
});
it('通过 MenuButton 转发 onclick 到原生按钮', async () => {
const handleClick = vi.fn();
const host = document.createElement('div');
document.body.appendChild(host);
const app = mount(MenuButton, {
target: host,
props: {
onclick: handleClick,
},
});
flushSync();
(host.querySelector('button') as HTMLButtonElement).click();
expect(handleClick).toHaveBeenCalledTimes(1);
await unmount(app);
host.remove();
});
});

View File

@@ -3,11 +3,16 @@
import type {MyHTMLButtonAttributes} from './types'; import type {MyHTMLButtonAttributes} from './types';
import type {Snippet} from 'svelte'; import type {Snippet} from 'svelte';
const { children, primary, ...rest }: MyHTMLButtonAttributes & { const { children, primary, onclick, ...rest }: MyHTMLButtonAttributes & {
children?: Snippet; children?: Snippet;
primary?: boolean; primary?: boolean;
} = $props(); } = $props();
</script> </script>
<button type="button" {...rest} class="tf-btn {primary?'tf-btn-primary':''} nopan nodrag {rest.class}"> <button
type="button"
{...rest}
onclick={onclick}
class="tf-btn {primary?'tf-btn-primary':''} nopan nodrag {rest.class}"
>
{@render children?.()} {@render children?.()}
</button> </button>

View File

@@ -182,11 +182,14 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg> <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> </span>
{/if} {/if}
<span class="tf-parameter-name">{item.label}</span> <span class="tf-parameter-name">{item.displayLabel || item.label}</span>
</div> </div>
{#if item.dataType} {#if item.dataType}
<span class="tf-parameter-type">{item.dataType}</span> <span class="tf-parameter-type">{item.dataType}</span>
{/if} {/if}
{#if item.itemTypeLabel}
<span class="tf-parameter-meta">{item.itemTypeLabel}</span>
{/if}
</div> </div>
</button> </button>
</div> </div>
@@ -768,4 +771,11 @@
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
} }
.tf-parameter-meta {
color: var(--tf-text-muted);
font-size: 11px;
white-space: nowrap;
flex-shrink: 0;
}
</style> </style>

View File

@@ -40,6 +40,13 @@
}); });
const { updateNodeData } = useSvelteFlow(); const { updateNodeData } = useSvelteFlow();
const displayParameterName = $derived.by(() => {
const baseName = currentParameter.name || '';
if (!baseName) {
return '';
}
return baseName;
});
const updateAttribute = (key: string, value: any) => { const updateAttribute = (key: string, value: any) => {
updateNodeData(currentNodeId, (node) => { updateNodeData(currentNodeId, (node) => {
@@ -138,11 +145,13 @@
<div class="input-item"> <div class="input-item">
{#if (position.length > 1)} <div class="output-name-shell" class:output-name-shell--child={position.length > 1}>
{#each position as p} &nbsp;{/each} {#if position.length > 1}
{/if} <span class="output-branch-marker"></span>
<Input style="width: 100%;" value={currentParameter.name} placeholder={placeholder} {/if}
oninput={(e)=>{updateByEvent('name',e)}} disabled={currentParameter.nameDisabled === true} /> <Input style="width: 100%;" value={displayParameterName} placeholder={placeholder}
oninput={(e)=>{updateByEvent('name',e)}} disabled={currentParameter.nameDisabled === true} />
</div>
</div> </div>
<div class="input-item"> <div class="input-item">
<Select items={currentParameter.dataTypeItems || parameterDataTypes} style="width: 100%" defaultValue={["String"]} <Select items={currentParameter.dataTypeItems || parameterDataTypes} style="width: 100%" defaultValue={["String"]}
@@ -200,6 +209,27 @@
gap: 2px; gap: 2px;
} }
.output-name-shell {
display: flex;
align-items: center;
width: 100%;
gap: 8px;
&--child {
padding-left: 8px;
}
}
.output-branch-marker {
width: 10px;
height: 10px;
flex-shrink: 0;
border-left: 1px solid var(--tf-border-color);
border-bottom: 1px solid var(--tf-border-color);
border-bottom-left-radius: 6px;
opacity: 0.9;
}
.input-more-setting { .input-more-setting {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -221,4 +251,3 @@
} }
</style> </style>

View File

@@ -0,0 +1,22 @@
import type { Parameter } from '#types';
/**
* 解析开始节点参数在引用选择器中的展示名。
*
* 系统主问题字段始终保持“用户问题”口径;普通自定义字段优先展示参数名,
* 避免文件类型字段退化为默认“文件字段”标签。
*/
export const getStartNodeParameterLabel = (parameter: Parameter) => {
const name = String(parameter?.name || '').trim();
if (name === 'user_input') {
return (
String(parameter?.formLabel || parameter?.displayName || '用户问题').trim()
|| '用户问题'
);
}
return (
name
|| String(parameter?.formLabel || parameter?.displayName || '参数').trim()
|| '参数'
);
};

View File

@@ -1,6 +1,7 @@
import { type Edge, type Node, useNodesData, useStore } from '@xyflow/svelte'; import { type Edge, type Node, useNodesData, useStore } from '@xyflow/svelte';
import type { Parameter } from '#types'; 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 { nodeIcons } from '../../consts'; import { nodeIcons } from '../../consts';
const fillRefNodeIds = ( const fillRefNodeIds = (
@@ -21,24 +22,39 @@ const getChildren = (
parentId: string, parentId: string,
nodeIsChildren: boolean, nodeIsChildren: boolean,
nodeType: string, nodeType: string,
parentPathLabel = '',
parentIsCollection = false,
) => { ) => {
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 childBaseLabel = param.formLabel || param.displayName || param.name;
const normalizedChildLabel = String(childBaseLabel || '').trim();
const pathLabel = !parentPathLabel
? normalizedChildLabel
: parentIsCollection
? `${parentPathLabel}.[].${normalizedChildLabel}`
: `${parentPathLabel}.${normalizedChildLabel}`;
const dataType = nodeIsChildren const dataType = nodeIsChildren
? `Array<${param.dataType || 'String'}>` ? `Array<${param.dataType || 'String'}>`
: param.dataType || 'String'; : param.dataType || 'String';
const label = param.formLabel || param.displayName || param.name;
return { return {
label, label: pathLabel,
dataType: dataType, dataType: dataType,
value: parentId + '.' + param.name, value: parentId + '.' + param.name,
selectable: true, selectable: true,
nodeType: nodeType, nodeType: nodeType,
displayLabel: pathLabel,
pathLabel,
itemTypeLabel: parentIsCollection ? '数组项字段' : undefined,
isCollection,
children: getChildren( children: getChildren(
param.children, param.children,
parentId + '.' + param.name, parentId + '.' + param.name,
nodeIsChildren, nodeIsChildren,
nodeType, nodeType,
pathLabel,
isCollection,
), ),
}; };
}); });
@@ -72,8 +88,7 @@ const nodeToOptions = (
const dataType = nodeIsChildren const dataType = nodeIsChildren
? `Array<${parameter.dataType || 'String'}>` ? `Array<${parameter.dataType || 'String'}>`
: parameter.dataType || 'String'; : parameter.dataType || 'String';
const label = const label = getStartNodeParameterLabel(parameter);
parameter.formLabel || parameter.displayName || parameter.name;
children.push({ children.push({
label, label,
dataType: dataType, dataType: dataType,

View File

@@ -1,5 +1,4 @@
export * from './types'; export * from './types';
export * from './Tinyflow'; export * from './Tinyflow';
export * from './components/TinyflowComponent.svelte'; export * from './components/TinyflowComponent.svelte';
export * from './utils/workflowNodeFields';
export * from './utils/sanitize'; export * from './utils/sanitize';

View File

@@ -15,6 +15,9 @@ export type SelectItem = {
nodeType?: string; nodeType?: string;
dataType?: string; dataType?: string;
displayLabel?: string; displayLabel?: string;
pathLabel?: string;
itemTypeLabel?: string;
isCollection?: boolean;
tags?: string[]; tags?: string[];
children?: SelectItem[]; children?: SelectItem[];
}; };
@@ -115,6 +118,9 @@ export type Parameter = {
name?: string; name?: string;
nameDisabled?: boolean; nameDisabled?: boolean;
displayName?: string; displayName?: string;
pathLabel?: string;
itemTypeLabel?: string;
isCollection?: boolean;
dataType?: string; dataType?: string;
dataTypeItems?: SelectItem[]; dataTypeItems?: SelectItem[];
dataTypeDisabled?: boolean; dataTypeDisabled?: boolean;

View File

@@ -359,8 +359,12 @@ describe('workflow node fields', () => {
expect(documentsParameter?.displayName).toBe('知识库 > documents'); expect(documentsParameter?.displayName).toBe('知识库 > documents');
expect(documentsParameter?.formLabel).toBe('知识库 > documents'); expect(documentsParameter?.formLabel).toBe('知识库 > documents');
expect(contentParameter?.displayName).toBe('知识库 > documents.content'); expect(documentsParameter?.pathLabel).toBe('documents');
expect(contentParameter?.formLabel).toBe('知识库 > documents.content'); expect(documentsParameter?.isCollection).toBe(true);
expect(contentParameter?.displayName).toBe('知识库 > documents.[].content');
expect(contentParameter?.formLabel).toBe('知识库 > documents.[].content');
expect(contentParameter?.pathLabel).toBe('documents.[].content');
expect(contentParameter?.itemTypeLabel).toBe('数组项字段');
}); });
it('uses document node child outputs for reference display', () => { it('uses document node child outputs for reference display', () => {
@@ -385,14 +389,6 @@ describe('workflow node fields', () => {
}, },
], ],
}, },
{
name: 'contents',
formLabel: '逐文件内容',
},
{
name: 'count',
formLabel: '数量',
},
], ],
}, },
}; };
@@ -419,13 +415,9 @@ describe('workflow node fields', () => {
expect(parameters.find((item) => item.name === 'doc_1.documents')?.displayName) expect(parameters.find((item) => item.name === 'doc_1.documents')?.displayName)
.toBe('文档解析 > documents'); .toBe('文档解析 > documents');
expect(parameters.find((item) => item.name === 'doc_1.documents.fileName')?.displayName) expect(parameters.find((item) => item.name === 'doc_1.documents.fileName')?.displayName)
.toBe('文档解析 > documents.fileName'); .toBe('文档解析 > documents.[].fileName');
expect(parameters.find((item) => item.name === 'doc_1.documents.content')?.displayName) expect(parameters.find((item) => item.name === 'doc_1.documents.content')?.displayName)
.toBe('文档解析 > documents.content'); .toBe('文档解析 > documents.[].content');
expect(parameters.find((item) => item.name === 'doc_1.contents')?.displayName)
.toBe('文档解析 > contents');
expect(parameters.find((item) => item.name === 'doc_1.count')?.displayName)
.toBe('文档解析 > count');
}); });
it('applies default binding to llm user prompt after connect', () => { it('applies default binding to llm user prompt after connect', () => {

View File

@@ -160,6 +160,38 @@ function getReferenceParameterLabel(parameter?: Parameter | null) {
return asString(parameter?.name).trim() || getParameterLabel(parameter); return asString(parameter?.name).trim() || getParameterLabel(parameter);
} }
function isCollectionOutputParameter(parameter?: Parameter | null) {
const dataType = asString(parameter?.dataType).trim();
return (
!!parameter?.children?.length &&
(
dataType === 'Array' ||
dataType.length === 0
)
);
}
function getCollectionDisplayName(label: string) {
return label.endsWith('.[]') ? label : `${label}.[]`;
}
function buildCollectionAwareLabel(
referenceLabel: string,
parentLabel: string,
parentIsCollection: boolean,
) {
const normalizedReferenceLabel = asString(referenceLabel).trim();
if (!normalizedReferenceLabel) {
return parentLabel;
}
if (!parentLabel) {
return normalizedReferenceLabel;
}
return parentIsCollection
? `${parentLabel}.[].${normalizedReferenceLabel}`
: `${parentLabel}.${normalizedReferenceLabel}`;
}
function buildDisconnectedDisplayName(parameter: Parameter) { function buildDisconnectedDisplayName(parameter: Parameter) {
const displayName = const displayName =
asString(parameter.displayName).trim() || asString(parameter.displayName).trim() ||
@@ -200,6 +232,7 @@ function flattenOutputDefs(
parameters: Parameter[], parameters: Parameter[],
parentPath = '', parentPath = '',
parentLabel = '', parentLabel = '',
parentIsCollection = false,
): Parameter[] { ): Parameter[] {
if (!parameters.length) { if (!parameters.length) {
return []; return [];
@@ -212,17 +245,21 @@ function flattenOutputDefs(
} }
const path = parentPath ? `${parentPath}.${rawName}` : rawName; const path = parentPath ? `${parentPath}.${rawName}` : rawName;
const label = parentLabel const referenceLabel = getReferenceParameterLabel(parameter);
? `${parentLabel}.${getReferenceParameterLabel(parameter)}` const label = buildCollectionAwareLabel(referenceLabel, parentLabel, parentIsCollection);
: getReferenceParameterLabel(parameter); const displayName = `${getNodeTitle(node)} > ${label}`;
const isCollection = isCollectionOutputParameter(parameter);
const fullRef = `${node.id}.${path}`; const fullRef = `${node.id}.${path}`;
const baseCandidate: Parameter = ensureParameterId({ const baseCandidate: Parameter = ensureParameterId({
name: fullRef, name: fullRef,
ref: fullRef, ref: fullRef,
refType: 'ref', refType: 'ref',
dataType: parameter.dataType || 'String', dataType: parameter.dataType || 'String',
displayName: `${getNodeTitle(node)} > ${label}`, displayName,
formLabel: `${getNodeTitle(node)} > ${label}`, formLabel: displayName,
pathLabel: label,
itemTypeLabel: parentIsCollection ? '数组项字段' : undefined,
isCollection,
nameDisabled: true, nameDisabled: true,
dataTypeDisabled: true, dataTypeDisabled: true,
deleteDisabled: true, deleteDisabled: true,
@@ -234,6 +271,7 @@ function flattenOutputDefs(
parameter.children || [], parameter.children || [],
path, path,
label, label,
isCollection,
); );
return [baseCandidate, ...children]; return [baseCandidate, ...children];
@@ -1227,7 +1265,7 @@ export function renameStartFieldReferencesInNodes(
startNodeId: string, startNodeId: string,
currentKey: string, currentKey: string,
nextKey: string, nextKey: string,
) { ): Node[] {
const normalizedStartNodeId = trimString(startNodeId); const normalizedStartNodeId = trimString(startNodeId);
const normalizedCurrentKey = trimString(currentKey); const normalizedCurrentKey = trimString(currentKey);
const normalizedNextKey = trimString(nextKey); const normalizedNextKey = trimString(nextKey);