feat: 完成工作流多文件文档解析闭环
- 支持文档解析节点批量解析并收口为 documents 轻量输出 - 收口引用树、节点输出展示与旧工作流固定输出兼容 - 修复共享按钮点击事件,恢复多个节点加号交互
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,27 @@ export default {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
outputDefs: [
|
outputDefs: [
|
||||||
|
{
|
||||||
|
name: 'documents',
|
||||||
|
title: $t('aiWorkflow.documents'),
|
||||||
|
dataType: 'Array',
|
||||||
|
dataTypeDisabled: true,
|
||||||
|
required: true,
|
||||||
|
parametersAddEnable: false,
|
||||||
|
description: $t('aiWorkflow.documents'),
|
||||||
|
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',
|
name: 'content',
|
||||||
title: $t('aiWorkflow.parsedText'),
|
title: $t('aiWorkflow.parsedText'),
|
||||||
@@ -30,8 +51,10 @@ export default {
|
|||||||
dataTypeDisabled: true,
|
dataTypeDisabled: true,
|
||||||
required: true,
|
required: true,
|
||||||
parametersAddEnable: false,
|
parametersAddEnable: false,
|
||||||
description: $t('aiWorkflow.descriptions.parsedText'),
|
|
||||||
deleteDisabled: true,
|
deleteDisabled: true,
|
||||||
|
nameDisabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,12 +145,14 @@
|
|||||||
|
|
||||||
|
|
||||||
<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} {/each}
|
{#if position.length > 1}
|
||||||
|
<span class="output-branch-marker"></span>
|
||||||
{/if}
|
{/if}
|
||||||
<Input style="width: 100%;" value={currentParameter.name} placeholder={placeholder}
|
<Input style="width: 100%;" value={displayParameterName} placeholder={placeholder}
|
||||||
oninput={(e)=>{updateByEvent('name',e)}} disabled={currentParameter.nameDisabled === true} />
|
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"]}
|
||||||
value={currentParameter.dataType ? [currentParameter.dataType]:[]}
|
value={currentParameter.dataType ? [currentParameter.dataType]:[]}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|| '参数'
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user