feat: 支持工作流文件节点多格式文档导出

- 补齐 md/html/pdf/docx 导出与统一渲染服务

- 收口文件生成节点配置与格式校验

- 修复 PDF 中文字体与 Markdown 渲染链路
This commit is contained in:
2026-04-19 15:23:23 +08:00
parent 51198ff492
commit a5aab86de2
33 changed files with 2144 additions and 102 deletions

View File

@@ -80,6 +80,14 @@
<groupId>org.jsoup</groupId> <groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId> <artifactId>jsoup</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-gfm-tables</artifactId>
</dependency>
<dependency>
<groupId>com.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-pdfbox</artifactId>
</dependency>
<dependency> <dependency>
<groupId>tech.easyflow</groupId> <groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-system</artifactId> <artifactId>easyflow-module-system</artifactId>

View File

@@ -11,6 +11,9 @@ import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckResult;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage; import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.entity.PluginItem; import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.node.filegeneration.FileGenerationRules;
import tech.easyflow.ai.node.filegeneration.SourceFormat;
import tech.easyflow.ai.node.filegeneration.TargetFormat;
import tech.easyflow.ai.plugin.workflow.dependency.WorkflowPluginDependencyService; import tech.easyflow.ai.plugin.workflow.dependency.WorkflowPluginDependencyService;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.PluginItemService; import tech.easyflow.ai.service.PluginItemService;
@@ -45,6 +48,7 @@ public class WorkflowCheckService {
private static final String TYPE_LOOP = "loopNode"; private static final String TYPE_LOOP = "loopNode";
private static final String TYPE_WORKFLOW = "workflow-node"; private static final String TYPE_WORKFLOW = "workflow-node";
private static final String TYPE_PLUGIN = "plugin-node"; private static final String TYPE_PLUGIN = "plugin-node";
private static final String TYPE_MAKE_FILE = "make-file";
private static final String SYSTEM_START_PARAM_NAME = "user_input"; private static final String SYSTEM_START_PARAM_NAME = "user_input";
@Resource @Resource
@@ -222,6 +226,10 @@ public class WorkflowCheckService {
if (node == null) { if (node == null) {
continue; continue;
} }
if (TYPE_MAKE_FILE.equals(node.type)) {
checkMakeFileNode(node, issues, issueKeys);
continue;
}
if (workflowDatacenterContentService.isSearchDatasetNode(node.type)) { if (workflowDatacenterContentService.isSearchDatasetNode(node.type)) {
checkSearchDatasetNode(node, issues, issueKeys); checkSearchDatasetNode(node, issues, issueKeys);
continue; continue;
@@ -236,6 +244,26 @@ public class WorkflowCheckService {
} }
} }
private void checkMakeFileNode(NodeView node,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
try {
String targetFormatValue = node.data == null ? null : trimToNull(node.data.getString("targetFormat"));
if (!StringUtils.hasText(targetFormatValue)) {
targetFormatValue = TargetFormat.DOCX.getValue();
}
String sourceFormatValue = node.data == null ? null : trimToNull(node.data.getString("sourceFormat"));
if (!StringUtils.hasText(sourceFormatValue)) {
sourceFormatValue = SourceFormat.MARKDOWN.getValue();
}
TargetFormat targetFormat = TargetFormat.fromValue(targetFormatValue);
SourceFormat sourceFormat = SourceFormat.fromValue(sourceFormatValue);
FileGenerationRules.requireSupportedCombination(sourceFormat, targetFormat);
} catch (BusinessException e) {
addIssue(issues, issueKeys, "MAKE_FILE_INVALID", e.getMessage(), node.id, null, node.name);
}
}
private void checkSearchDatasetNode(NodeView node, private void checkSearchDatasetNode(NodeView node,
List<WorkflowCheckIssue> issues, List<WorkflowCheckIssue> issues,
Set<String> issueKeys) { Set<String> issueKeys) {

View File

@@ -13,10 +13,16 @@ public class CustomFile implements MultipartFile {
private final String fileName; private final String fileName;
private final byte[] bytes; private final byte[] bytes;
private final String contentType;
public CustomFile(String fileName, byte[] bytes) { public CustomFile(String fileName, byte[] bytes) {
this(fileName, bytes, null);
}
public CustomFile(String fileName, byte[] bytes, String contentType) {
this.fileName = fileName; this.fileName = fileName;
this.bytes = bytes; this.bytes = bytes;
this.contentType = contentType;
} }
@Override @Override
@@ -31,6 +37,9 @@ public class CustomFile implements MultipartFile {
@Override @Override
public String getContentType() { public String getContentType() {
if (contentType != null && !contentType.isBlank()) {
return contentType;
}
Tika tika = new Tika(); Tika tika = new Tika();
InputStream inputStream = new ByteArrayInputStream(bytes); InputStream inputStream = new ByteArrayInputStream(bytes);
String contentType = ""; String contentType = "";

View File

@@ -1,101 +1,100 @@
package tech.easyflow.ai.node; package tech.easyflow.ai.node;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.easyagents.flow.core.chain.Chain; import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.node.BaseNode; import com.easyagents.flow.core.node.BaseNode;
import org.apache.poi.xwpf.usermodel.XWPFDocument; import tech.easyflow.ai.node.filegeneration.FileGenerationRequest;
import org.apache.poi.xwpf.usermodel.XWPFParagraph; import tech.easyflow.ai.node.filegeneration.FileGenerationResult;
import org.apache.poi.xwpf.usermodel.XWPFRun; import tech.easyflow.ai.node.filegeneration.FileGenerationService;
import org.apache.poi.xwpf.usermodel.XWPFStyle;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTInd;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPrGeneral;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTStyle;
import tech.easyflow.common.filestorage.FileStorageManager; import tech.easyflow.common.filestorage.FileStorageManager;
import tech.easyflow.common.util.SpringContextUtil; import tech.easyflow.common.util.SpringContextUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
/**
* 工作流文件导出节点。
*
* <p>节点只负责解析运行时参数、委托导出服务生成文件内容,并通过现有文件存储返回下载地址。</p>
*
* @author Codex
* @since 2026-04-18
*/
public class MakeFileNode extends BaseNode { public class MakeFileNode extends BaseNode {
private String suffix; private String targetFormat;
private String sourceFormat;
private String fileName;
private String templateStyle;
public MakeFileNode() { public MakeFileNode() {
} }
public MakeFileNode(String suffix) { public MakeFileNode(String targetFormat, String sourceFormat, String fileName, String templateStyle) {
this.suffix = suffix; this.targetFormat = targetFormat;
this.sourceFormat = sourceFormat;
this.fileName = fileName;
this.templateStyle = templateStyle;
} }
/**
* 执行文件导出。
*
* @param chain 当前流程链
* @return 仅包含下载地址的输出对象
*/
@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);
Object rawContent = map.get("content");
Map<String, Object> res = new HashMap<>(); if (rawContent == null) {
throw new BusinessException("文件生成节点缺少 content 参数");
String content = map.get("content").toString(); }
ByteArrayOutputStream os = new ByteArrayOutputStream(); FileGenerationRequest request = new FileGenerationRequest(
createFile(suffix, content, os); rawContent.toString(),
sourceFormat,
String fileName = IdUtil.fastSimpleUUID() + "." + suffix; targetFormat,
CustomFile file = new CustomFile(fileName, os.toByteArray()); fileName,
templateStyle
);
FileGenerationService generationService = SpringContextUtil.getBean(FileGenerationService.class);
FileGenerationResult result = generationService.generate(request);
FileStorageManager manager = SpringContextUtil.getBean(FileStorageManager.class); FileStorageManager manager = SpringContextUtil.getBean(FileStorageManager.class);
Map<String, Object> res = new HashMap<>();
String url = manager.save(file); String url = manager.save(new CustomFile(result.getFileName(), result.getBytes(), result.getContentType()));
res.put("url", url); res.put("url", url);
return res; return res;
} }
private void createFile(String suffix, String content, ByteArrayOutputStream os) { public String getTargetFormat() {
if ("docx".equals(suffix)) { return targetFormat;
docx(content, os);
}
} }
private void docx(String content, ByteArrayOutputStream os) { public void setTargetFormat(String targetFormat) {
String separator = "\n"; this.targetFormat = targetFormat;
List<String> split = StrUtil.split(content, separator);
// 创建一个新的Word文档
XWPFDocument doc = new XWPFDocument();
// 创建样式
// CTStyle ctStyle = CTStyle.Factory.newInstance();
// ctStyle.setStyleId("IndentStyle");
// CTPPrGeneral pPr = ctStyle.addNewPPr();
// CTInd ind = pPr.addNewInd();
// ind.setFirstLine(400);
// doc.createStyles().addStyle(new XWPFStyle(ctStyle));
for (String str : split) {
// 创建段落
XWPFParagraph paragraph = doc.createParagraph();
paragraph.setStyle("IndentStyle");
XWPFRun run = paragraph.createRun();
run.setText(str);
}
try {
doc.write(os);
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
try {
os.close();
doc.close();
} catch (IOException e) {
System.out.println("关闭流异常" + e.getMessage());
}
}
} }
public String getSuffix() { public String getSourceFormat() {
return suffix; return sourceFormat;
} }
public void setSuffix(String suffix) { public void setSourceFormat(String sourceFormat) {
this.suffix = suffix; this.sourceFormat = sourceFormat;
}
public String getFileName() {
return fileName;
}
public void setFileName(String fileName) {
this.fileName = fileName;
}
public String getTemplateStyle() {
return templateStyle;
}
public void setTemplateStyle(String templateStyle) {
this.templateStyle = templateStyle;
} }
} }

View File

@@ -4,16 +4,28 @@ import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONObject; import com.alibaba.fastjson.JSONObject;
import com.easyagents.flow.core.node.BaseNode; import com.easyagents.flow.core.node.BaseNode;
import com.easyagents.flow.core.parser.BaseNodeParser; import com.easyagents.flow.core.parser.BaseNodeParser;
import tech.easyflow.ai.node.filegeneration.FileGenerationRules;
import tech.easyflow.ai.node.filegeneration.SourceFormat;
import tech.easyflow.ai.node.filegeneration.TargetFormat;
public class MakeFileNodeParser extends BaseNodeParser { public class MakeFileNodeParser extends BaseNodeParser {
@Override @Override
public BaseNode doParse(JSONObject root, JSONObject data, JSONObject tinyflow) { public BaseNode doParse(JSONObject root, JSONObject data, JSONObject tinyflow) {
String suffix = data.getString("suffix"); String targetFormatValue = data == null ? null : data.getString("targetFormat");
if (StrUtil.isEmpty(suffix)) { if (StrUtil.isBlank(targetFormatValue)) {
suffix = "docx"; targetFormatValue = TargetFormat.DOCX.getValue();
} }
return new MakeFileNode(suffix); String sourceFormatValue = data == null ? null : data.getString("sourceFormat");
if (StrUtil.isBlank(sourceFormatValue)) {
sourceFormatValue = SourceFormat.MARKDOWN.getValue();
}
String fileName = data == null ? null : StrUtil.trimToNull(data.getString("fileName"));
String templateStyle = FileGenerationRules.normalizeTemplateStyle(data == null ? null : data.getString("templateStyle"));
TargetFormat targetFormat = TargetFormat.fromValue(targetFormatValue);
SourceFormat sourceFormat = SourceFormat.fromValue(sourceFormatValue);
FileGenerationRules.requireSupportedCombination(sourceFormat, targetFormat);
return new MakeFileNode(targetFormat.getValue(), sourceFormat.getValue(), fileName, templateStyle);
} }
public String getNodeName() { public String getNodeName() {

View File

@@ -0,0 +1,33 @@
package tech.easyflow.ai.node.filegeneration;
/**
* 文档格式渲染器。
*
* @author Codex
* @since 2026-04-18
*/
public interface DocumentRenderer {
/**
* 当前渲染器对应的目标格式。
*
* @return 目标格式
*/
TargetFormat getTargetFormat();
/**
* 判断当前渲染器是否支持给定输入格式。
*
* @param sourceFormat 输入格式
* @return 是否支持
*/
boolean supports(SourceFormat sourceFormat);
/**
* 执行文档渲染。
*
* @param request 已归一化的导出请求
* @return 导出结果
*/
FileGenerationResult render(FileGenerationRequest request);
}

View File

@@ -0,0 +1,446 @@
package tech.easyflow.ai.node.filegeneration;
import org.apache.poi.xwpf.usermodel.*;
import org.commonmark.ext.gfm.tables.TableBlock;
import org.commonmark.ext.gfm.tables.TableBody;
import org.commonmark.ext.gfm.tables.TableCell;
import org.commonmark.ext.gfm.tables.TableHead;
import org.commonmark.ext.gfm.tables.TableRow;
import org.commonmark.node.*;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* DOCX 文件渲染器。
*
* @author Codex
* @since 2026-04-18
*/
@Component
public class DocxFileRenderer implements DocumentRenderer {
private final MarkdownSupport markdownSupport;
public DocxFileRenderer(MarkdownSupport markdownSupport) {
this.markdownSupport = markdownSupport;
}
@Override
public TargetFormat getTargetFormat() {
return TargetFormat.DOCX;
}
@Override
public boolean supports(SourceFormat sourceFormat) {
return sourceFormat == SourceFormat.PLAIN_TEXT || sourceFormat == SourceFormat.MARKDOWN;
}
@Override
public FileGenerationResult render(FileGenerationRequest request) {
try (XWPFDocument document = new XWPFDocument();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
SourceFormat sourceFormat = SourceFormat.fromValue(request.getSourceFormat());
if (sourceFormat == SourceFormat.PLAIN_TEXT) {
renderPlainText(document, request.getContent());
} else {
renderMarkdown(document, request.getContent());
}
document.write(outputStream);
return new FileGenerationResult(
request.getFileName(),
TargetFormat.DOCX.getValue(),
TargetFormat.DOCX.getContentType(),
outputStream.toByteArray()
);
} catch (IOException e) {
throw new IllegalStateException("DOCX 导出失败: " + e.getMessage(), e);
}
}
private void renderPlainText(XWPFDocument document, String content) {
String normalized = content == null ? "" : content.replace("\r\n", "\n").replace('\r', '\n');
String[] lines = normalized.split("\\n", -1);
if (lines.length == 0) {
lines = new String[]{""};
}
for (String line : lines) {
XWPFParagraph paragraph = document.createParagraph();
paragraph.setSpacingAfter(180);
XWPFRun run = paragraph.createRun();
run.setFontSize(11);
run.setText(line);
}
}
private void renderMarkdown(XWPFDocument document, String markdown) {
Node root = markdownSupport.parse(markdown);
for (Node child = root.getFirstChild(); child != null; child = child.getNext()) {
renderBlock(child, document, 0, 0);
}
}
private void renderBlock(Node node, XWPFDocument document, int listLevel, int quoteLevel) {
if (node instanceof Heading heading) {
renderHeading(heading, document, listLevel, quoteLevel);
return;
}
if (node instanceof Paragraph paragraphNode) {
renderParagraph(paragraphNode, document, listLevel, quoteLevel, null);
return;
}
if (node instanceof BulletList bulletList) {
renderBulletList(bulletList, document, listLevel + 1, quoteLevel);
return;
}
if (node instanceof OrderedList orderedList) {
renderOrderedList(orderedList, document, listLevel + 1, quoteLevel);
return;
}
if (node instanceof BlockQuote blockQuote) {
for (Node child = blockQuote.getFirstChild(); child != null; child = child.getNext()) {
renderBlock(child, document, listLevel, quoteLevel + 1);
}
return;
}
if (node instanceof ThematicBreak) {
XWPFParagraph paragraph = createParagraph(document, listLevel, quoteLevel);
XWPFRun run = paragraph.createRun();
run.setColor("94A3B8");
run.setText("────────────────────────");
return;
}
if (node instanceof TableBlock tableBlock) {
renderTable(tableBlock, document);
return;
}
if (node instanceof FencedCodeBlock fencedCodeBlock) {
renderCodeParagraph(document, fencedCodeBlock.getLiteral(), listLevel, quoteLevel);
return;
}
if (node instanceof IndentedCodeBlock indentedCodeBlock) {
renderCodeParagraph(document, indentedCodeBlock.getLiteral(), listLevel, quoteLevel);
return;
}
for (Node child = node.getFirstChild(); child != null; child = child.getNext()) {
renderBlock(child, document, listLevel, quoteLevel);
}
}
private void renderHeading(Heading heading, XWPFDocument document, int listLevel, int quoteLevel) {
XWPFParagraph paragraph = createParagraph(document, listLevel, quoteLevel);
paragraph.setSpacingBefore(240);
paragraph.setSpacingAfter(180);
XWPFRun run = paragraph.createRun();
run.setBold(true);
run.setFontSize(switch (heading.getLevel()) {
case 1 -> 20;
case 2 -> 18;
case 3 -> 16;
case 4 -> 15;
case 5 -> 14;
default -> 13;
});
renderInlineChildren(heading, paragraph, InlineStyle.defaults().withBold(true).withFontSize(run.getFontSize()));
}
private void renderBulletList(BulletList list, XWPFDocument document, int listLevel, int quoteLevel) {
for (Node child = list.getFirstChild(); child != null; child = child.getNext()) {
if (child instanceof ListItem listItem) {
renderListItem(listItem, document, listLevel, quoteLevel, "- ");
}
}
}
private void renderOrderedList(OrderedList list, XWPFDocument document, int listLevel, int quoteLevel) {
int index = list.getStartNumber();
for (Node child = list.getFirstChild(); child != null; child = child.getNext()) {
if (child instanceof ListItem listItem) {
renderListItem(listItem, document, listLevel, quoteLevel, index + ". ");
index++;
}
}
}
private void renderListItem(ListItem listItem, XWPFDocument document, int listLevel, int quoteLevel, String marker) {
boolean markerRendered = false;
for (Node child = listItem.getFirstChild(); child != null; child = child.getNext()) {
if (child instanceof Paragraph paragraphNode) {
renderParagraph(paragraphNode, document, listLevel, quoteLevel, markerRendered ? null : marker);
markerRendered = true;
continue;
}
if (child instanceof BulletList bulletList) {
renderBulletList(bulletList, document, listLevel + 1, quoteLevel);
continue;
}
if (child instanceof OrderedList orderedList) {
renderOrderedList(orderedList, document, listLevel + 1, quoteLevel);
continue;
}
renderBlock(child, document, listLevel, quoteLevel);
}
if (!markerRendered) {
XWPFParagraph paragraph = createParagraph(document, listLevel, quoteLevel);
appendPrefix(paragraph, quoteLevel, marker);
}
}
private void renderParagraph(Node paragraphNode,
XWPFDocument document,
int listLevel,
int quoteLevel,
String prefix) {
XWPFParagraph paragraph = createParagraph(document, listLevel, quoteLevel);
appendPrefix(paragraph, quoteLevel, prefix);
renderInlineChildren(paragraphNode, paragraph, InlineStyle.defaults());
}
private void renderCodeParagraph(XWPFDocument document, String literal, int listLevel, int quoteLevel) {
XWPFParagraph paragraph = createParagraph(document, listLevel, quoteLevel);
XWPFRun run = paragraph.createRun();
run.setFontFamily("Consolas");
run.setFontSize(10);
run.setText(literal == null ? "" : literal);
}
private void renderInlineChildren(Node parent, XWPFParagraph paragraph, InlineStyle style) {
for (Node child = parent.getFirstChild(); child != null; child = child.getNext()) {
renderInlineNode(child, paragraph, style);
}
}
private void renderInlineNode(Node node, XWPFParagraph paragraph, InlineStyle style) {
if (node instanceof Text text) {
createStyledRun(paragraph, style, text.getLiteral());
return;
}
if (node instanceof SoftLineBreak || node instanceof HardLineBreak) {
XWPFRun run = paragraph.createRun();
applyStyle(run, style);
run.addBreak();
return;
}
if (node instanceof Emphasis emphasis) {
renderInlineChildren(emphasis, paragraph, style.withItalic(true));
return;
}
if (node instanceof StrongEmphasis strongEmphasis) {
renderInlineChildren(strongEmphasis, paragraph, style.withBold(true));
return;
}
if (node instanceof Link link) {
String label = collectInlineText(link);
if (label.isBlank()) {
label = link.getDestination();
}
createHyperlinkRun(paragraph, style, label, link.getDestination());
return;
}
if (node instanceof Code code) {
XWPFRun run = paragraph.createRun();
applyStyle(run, style);
run.setFontFamily("Consolas");
run.setText(code.getLiteral());
return;
}
if (node instanceof HtmlInline htmlInline) {
createStyledRun(paragraph, style, htmlInline.getLiteral());
return;
}
if (node instanceof Image image) {
String altText = collectInlineText(image);
createStyledRun(paragraph, style, altText.isBlank() ? "[图片]" : altText);
return;
}
renderInlineChildren(node, paragraph, style);
}
private void renderTable(TableBlock tableBlock, XWPFDocument document) {
List<TableRowContent> rows = collectTableRows(tableBlock);
if (rows.isEmpty()) {
return;
}
int columnCount = rows.stream().mapToInt(item -> item.cells.size()).max().orElse(1);
XWPFTable table = document.createTable(rows.size(), columnCount);
for (int rowIndex = 0; rowIndex < rows.size(); rowIndex++) {
TableRowContent rowContent = rows.get(rowIndex);
XWPFTableRow tableRow = table.getRow(rowIndex);
for (int columnIndex = 0; columnIndex < columnCount; columnIndex++) {
XWPFTableCell tableCell = tableRow.getCell(columnIndex);
clearCell(tableCell);
if (columnIndex < rowContent.cells.size()) {
XWPFParagraph paragraph = tableCell.addParagraph();
paragraph.setSpacingAfter(80);
renderInlineChildren(
rowContent.cells.get(columnIndex),
paragraph,
rowContent.header ? InlineStyle.defaults().withBold(true) : InlineStyle.defaults()
);
}
}
}
}
private List<TableRowContent> collectTableRows(TableBlock tableBlock) {
List<TableRowContent> rows = new ArrayList<>();
for (Node section = tableBlock.getFirstChild(); section != null; section = section.getNext()) {
boolean header = section instanceof TableHead;
if (!(section instanceof TableHead) && !(section instanceof TableBody)) {
continue;
}
for (Node rowNode = section.getFirstChild(); rowNode != null; rowNode = rowNode.getNext()) {
if (!(rowNode instanceof TableRow)) {
continue;
}
List<TableCell> cells = new ArrayList<>();
for (Node cellNode = rowNode.getFirstChild(); cellNode != null; cellNode = cellNode.getNext()) {
if (cellNode instanceof TableCell tableCell) {
cells.add(tableCell);
}
}
rows.add(new TableRowContent(header, cells));
}
}
return rows;
}
private void clearCell(XWPFTableCell tableCell) {
while (tableCell.getParagraphs().size() > 0) {
tableCell.removeParagraph(0);
}
}
private XWPFParagraph createParagraph(XWPFDocument document, int listLevel, int quoteLevel) {
XWPFParagraph paragraph = document.createParagraph();
paragraph.setSpacingAfter(140);
paragraph.setIndentationLeft((listLevel + quoteLevel) * 320);
return paragraph;
}
private void appendPrefix(XWPFParagraph paragraph, int quoteLevel, String prefix) {
if (quoteLevel > 0) {
createStyledRun(paragraph, InlineStyle.defaults().withColor("64748B"),
"> ".repeat(Math.max(1, quoteLevel)));
}
if (prefix != null && !prefix.isBlank()) {
createStyledRun(paragraph, InlineStyle.defaults().withBold(true), prefix);
}
}
private void createStyledRun(XWPFParagraph paragraph, InlineStyle style, String text) {
XWPFRun run = paragraph.createRun();
applyStyle(run, style);
run.setText(text == null ? "" : text);
}
/**
* 创建外部超链接;内部锚点与空链接首版退化为普通文本。
*
* @param paragraph 目标段落
* @param style 当前内联样式
* @param label 链接文案
* @param destination 链接地址
*/
private void createHyperlinkRun(XWPFParagraph paragraph, InlineStyle style, String label, String destination) {
if (destination == null || destination.isBlank() || destination.startsWith("#")) {
createStyledRun(paragraph, style.withUnderline(true).withColor("2563EB"), label);
return;
}
XWPFHyperlinkRun hyperlinkRun = paragraph.createHyperlinkRun(destination);
applyStyle(hyperlinkRun, style.withUnderline(true).withColor("2563EB"));
hyperlinkRun.setText(label == null ? "" : label);
}
private void applyStyle(XWPFRun run, InlineStyle style) {
run.setBold(style.bold);
run.setItalic(style.italic);
if (style.underline) {
run.setUnderline(UnderlinePatterns.SINGLE);
}
if (style.color != null) {
run.setColor(style.color);
}
if (style.fontSize != null) {
run.setFontSize(style.fontSize);
} else {
run.setFontSize(11);
}
}
private String collectInlineText(Node node) {
StringBuilder builder = new StringBuilder();
appendInlineText(node, builder);
return builder.toString();
}
private void appendInlineText(Node node, StringBuilder builder) {
if (node instanceof Text text) {
builder.append(text.getLiteral());
return;
}
if (node instanceof Code code) {
builder.append(code.getLiteral());
return;
}
if (node instanceof SoftLineBreak || node instanceof HardLineBreak) {
builder.append('\n');
return;
}
for (Node child = node.getFirstChild(); child != null; child = child.getNext()) {
appendInlineText(child, builder);
}
}
private static class TableRowContent {
private final boolean header;
private final List<TableCell> cells;
private TableRowContent(boolean header, List<TableCell> cells) {
this.header = header;
this.cells = cells;
}
}
private static class InlineStyle {
private final boolean bold;
private final boolean italic;
private final boolean underline;
private final String color;
private final Integer fontSize;
private InlineStyle(boolean bold, boolean italic, boolean underline, String color, Integer fontSize) {
this.bold = bold;
this.italic = italic;
this.underline = underline;
this.color = color;
this.fontSize = fontSize;
}
private static InlineStyle defaults() {
return new InlineStyle(false, false, false, null, null);
}
private InlineStyle withBold(boolean value) {
return new InlineStyle(value, italic, underline, color, fontSize);
}
private InlineStyle withItalic(boolean value) {
return new InlineStyle(bold, value, underline, color, fontSize);
}
private InlineStyle withUnderline(boolean value) {
return new InlineStyle(bold, italic, value, color, fontSize);
}
private InlineStyle withColor(String value) {
return new InlineStyle(bold, italic, underline, value, fontSize);
}
private InlineStyle withFontSize(Integer value) {
return new InlineStyle(bold, italic, underline, color, value);
}
}
}

View File

@@ -0,0 +1,47 @@
package tech.easyflow.ai.node.filegeneration;
/**
* 文档导出请求。
*
* @author Codex
* @since 2026-04-18
*/
public class FileGenerationRequest {
private final String content;
private final String sourceFormat;
private final String targetFormat;
private final String fileName;
private final String templateStyle;
public FileGenerationRequest(String content,
String sourceFormat,
String targetFormat,
String fileName,
String templateStyle) {
this.content = content;
this.sourceFormat = sourceFormat;
this.targetFormat = targetFormat;
this.fileName = fileName;
this.templateStyle = templateStyle;
}
public String getContent() {
return content;
}
public String getSourceFormat() {
return sourceFormat;
}
public String getTargetFormat() {
return targetFormat;
}
public String getFileName() {
return fileName;
}
public String getTemplateStyle() {
return templateStyle;
}
}

View File

@@ -0,0 +1,43 @@
package tech.easyflow.ai.node.filegeneration;
/**
* 文档导出结果。
*
* @author Codex
* @since 2026-04-18
*/
public class FileGenerationResult {
private final String fileName;
private final String targetFormat;
private final String contentType;
private final byte[] bytes;
private final long size;
public FileGenerationResult(String fileName, String targetFormat, String contentType, byte[] bytes) {
this.fileName = fileName;
this.targetFormat = targetFormat;
this.contentType = contentType;
this.bytes = bytes;
this.size = bytes == null ? 0L : bytes.length;
}
public String getFileName() {
return fileName;
}
public String getTargetFormat() {
return targetFormat;
}
public String getContentType() {
return contentType;
}
public byte[] getBytes() {
return bytes;
}
public long getSize() {
return size;
}
}

View File

@@ -0,0 +1,60 @@
package tech.easyflow.ai.node.filegeneration;
import tech.easyflow.common.web.exceptions.BusinessException;
/**
* 文件导出节点的固定规则集合。
*
* @author Codex
* @since 2026-04-18
*/
public final class FileGenerationRules {
public static final String DEFAULT_TEMPLATE_STYLE = "default";
private FileGenerationRules() {
}
/**
* 归一模板样式。
*
* @param templateStyle 原始样式值
* @return 可用的模板样式
*/
public static String normalizeTemplateStyle(String templateStyle) {
if (templateStyle == null || templateStyle.isBlank()) {
return DEFAULT_TEMPLATE_STYLE;
}
return DEFAULT_TEMPLATE_STYLE.equalsIgnoreCase(templateStyle.trim())
? DEFAULT_TEMPLATE_STYLE
: DEFAULT_TEMPLATE_STYLE;
}
/**
* 校验输入/输出格式组合是否合法。
*
* @param sourceFormat 输入格式
* @param targetFormat 输出格式
*/
public static void requireSupportedCombination(SourceFormat sourceFormat, TargetFormat targetFormat) {
if (!isSupportedCombination(sourceFormat, targetFormat)) {
throw new BusinessException("文件生成节点暂不支持 " + sourceFormat.getValue() + " -> " + targetFormat.getValue() + " 组合");
}
}
/**
* 判断输入/输出格式组合是否受支持。
*
* @param sourceFormat 输入格式
* @param targetFormat 输出格式
* @return 是否受支持
*/
public static boolean isSupportedCombination(SourceFormat sourceFormat, TargetFormat targetFormat) {
if (sourceFormat == null || targetFormat == null) {
return false;
}
if (sourceFormat == SourceFormat.HTML) {
return targetFormat == TargetFormat.HTML || targetFormat == TargetFormat.PDF;
}
return true;
}
}

View File

@@ -0,0 +1,65 @@
package tech.easyflow.ai.node.filegeneration;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
/**
* 文件导出服务。
*
* @author Codex
* @since 2026-04-18
*/
@Service
public class FileGenerationService {
private final Map<TargetFormat, DocumentRenderer> rendererMap = new EnumMap<>(TargetFormat.class);
public FileGenerationService(List<DocumentRenderer> renderers) {
for (DocumentRenderer renderer : renderers) {
rendererMap.put(renderer.getTargetFormat(), renderer);
}
}
/**
* 生成目标格式文件。
*
* @param request 原始导出请求
* @return 文件导出结果
*/
public FileGenerationResult generate(FileGenerationRequest request) {
if (request == null) {
throw new BusinessException("文件导出请求不能为空");
}
if (request.getContent() == null) {
throw new BusinessException("文件导出内容不能为空");
}
TargetFormat targetFormat = TargetFormat.fromValue(request.getTargetFormat());
SourceFormat sourceFormat = SourceFormat.fromValue(request.getSourceFormat());
FileGenerationRules.requireSupportedCombination(sourceFormat, targetFormat);
DocumentRenderer renderer = rendererMap.get(targetFormat);
if (renderer == null) {
throw new BusinessException("未找到 " + targetFormat.getValue() + " 对应的文件渲染器");
}
if (!renderer.supports(sourceFormat)) {
throw new BusinessException("文件生成节点暂不支持 " + sourceFormat.getValue() + " -> " + targetFormat.getValue() + " 组合");
}
String normalizedTemplateStyle = FileGenerationRules.normalizeTemplateStyle(request.getTemplateStyle());
String normalizedFileName = FileNameSanitizer.sanitize(request.getFileName(), targetFormat);
FileGenerationRequest normalizedRequest = new FileGenerationRequest(
request.getContent(),
sourceFormat.getValue(),
targetFormat.getValue(),
normalizedFileName,
normalizedTemplateStyle
);
FileGenerationResult result = renderer.render(normalizedRequest);
if (result == null || !StringUtils.hasText(result.getFileName()) || result.getBytes() == null) {
throw new IllegalStateException("文件导出失败:渲染结果不完整");
}
return result;
}
}

View File

@@ -0,0 +1,54 @@
package tech.easyflow.ai.node.filegeneration;
import cn.hutool.core.util.IdUtil;
import org.springframework.util.StringUtils;
/**
* 文件名清洗工具。
*
* @author Codex
* @since 2026-04-18
*/
public final class FileNameSanitizer {
private static final String INVALID_CHARS_PATTERN = "[\\\\/:*?\"<>|\\p{Cntrl}]";
private FileNameSanitizer() {
}
/**
* 清洗并补齐目标扩展名。
*
* @param rawFileName 原始文件名
* @param targetFormat 目标格式
* @return 清洗后的最终文件名
*/
public static String sanitize(String rawFileName, TargetFormat targetFormat) {
String baseName = sanitizeBaseName(rawFileName);
if (!StringUtils.hasText(baseName)) {
baseName = "generated-file-" + IdUtil.fastSimpleUUID();
}
return removeTrailingExtension(baseName) + "." + targetFormat.getValue();
}
/**
* 清洗不含扩展名的文件名主体。
*
* @param rawFileName 原始文件名
* @return 清洗后的文件名主体
*/
public static String sanitizeBaseName(String rawFileName) {
if (!StringUtils.hasText(rawFileName)) {
return null;
}
String sanitized = rawFileName.trim().replaceAll(INVALID_CHARS_PATTERN, "");
return sanitized.isBlank() ? null : sanitized;
}
private static String removeTrailingExtension(String fileName) {
int lastDot = fileName.lastIndexOf('.');
if (lastDot <= 0) {
return fileName;
}
return fileName.substring(0, lastDot);
}
}

View File

@@ -0,0 +1,153 @@
package tech.easyflow.ai.node.filegeneration;
import org.jsoup.nodes.Entities;
import org.springframework.stereotype.Component;
import java.util.regex.Pattern;
/**
* HTML 文档构建器。
*
* @author Codex
* @since 2026-04-18
*/
@Component
public class HtmlDocumentBuilder {
private static final Pattern BLANK_LINE_PATTERN = Pattern.compile("\\n\\s*\\n");
private final MarkdownSupport markdownSupport;
private final HtmlSanitizer htmlSanitizer;
public HtmlDocumentBuilder(MarkdownSupport markdownSupport, HtmlSanitizer htmlSanitizer) {
this.markdownSupport = markdownSupport;
this.htmlSanitizer = htmlSanitizer;
}
/**
* 构建完整 HTML 文档。
*
* @param request 已归一化请求
* @return 完整 HTML 文档字符串
*/
public String buildDocument(FileGenerationRequest request) {
String title = Entities.escape(request.getFileName());
String body = buildBody(request);
return """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>__TITLE__</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
background: #f5f7fb;
color: #1f2937;
font-family: "EasyFlowPdfCjk", "Noto Sans CJK SC", "Microsoft YaHei", "PingFang SC", sans-serif;
}
.document-shell {
max-width: 960px;
margin: 0 auto;
padding: 40px 32px 56px;
}
.document-paper {
background: #ffffff;
border: 1px solid #dbe3ef;
border-radius: 20px;
padding: 40px 44px;
}
h1, h2, h3, h4, h5, h6 {
color: #0f172a;
margin-top: 1.4em;
margin-bottom: 0.6em;
line-height: 1.3;
}
h1 { font-size: 30px; }
h2 { font-size: 24px; }
h3 { font-size: 20px; }
p, li, blockquote, td, th {
font-size: 15px;
line-height: 1.75;
}
p { margin: 0 0 1em; white-space: normal; }
ul, ol { padding-left: 1.5em; margin: 0.4em 0 1em; }
blockquote {
margin: 1em 0;
padding: 12px 16px;
border-left: 4px solid #93c5fd;
background: #eff6ff;
color: #334155;
}
hr {
border: none;
border-top: 1px solid #dbe3ef;
margin: 1.5em 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1em 0 1.4em;
}
th, td {
border: 1px solid #dbe3ef;
padding: 10px 12px;
vertical-align: top;
}
th {
background: #eff6ff;
color: #0f172a;
font-weight: 600;
}
a { color: #2563eb; text-decoration: underline; }
code, pre {
font-family: "SFMono-Regular", "Consolas", "EasyFlowPdfCjk", "Noto Sans CJK SC", "Microsoft YaHei", monospace;
}
pre {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 14px 16px;
}
</style>
</head>
<body>
<main class="document-shell">
<article class="document-paper">__BODY__</article>
</main>
</body>
</html>
"""
.replace("__TITLE__", title)
.replace("__BODY__", body);
}
private String buildBody(FileGenerationRequest request) {
SourceFormat sourceFormat = SourceFormat.fromValue(request.getSourceFormat());
return switch (sourceFormat) {
case PLAIN_TEXT -> plainTextToHtml(request.getContent());
case MARKDOWN -> htmlSanitizer.sanitize(markdownSupport.renderHtml(request.getContent()));
case HTML -> htmlSanitizer.sanitize(request.getContent());
};
}
private String plainTextToHtml(String content) {
String normalized = content == null ? "" : content.replace("\r\n", "\n").replace('\r', '\n');
String[] paragraphs = BLANK_LINE_PATTERN.split(normalized, -1);
StringBuilder builder = new StringBuilder();
for (String paragraph : paragraphs) {
if (builder.length() > 0) {
builder.append('\n');
}
String escaped = Entities.escape(paragraph);
builder.append("<p>")
.append(escaped.replace("\n", "<br />"))
.append("</p>");
}
if (builder.length() == 0) {
builder.append("<p></p>");
}
return builder.toString();
}
}

View File

@@ -0,0 +1,43 @@
package tech.easyflow.ai.node.filegeneration;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
/**
* HTML 文件渲染器。
*
* @author Codex
* @since 2026-04-18
*/
@Component
public class HtmlFileRenderer implements DocumentRenderer {
private final HtmlDocumentBuilder htmlDocumentBuilder;
public HtmlFileRenderer(HtmlDocumentBuilder htmlDocumentBuilder) {
this.htmlDocumentBuilder = htmlDocumentBuilder;
}
@Override
public TargetFormat getTargetFormat() {
return TargetFormat.HTML;
}
@Override
public boolean supports(SourceFormat sourceFormat) {
return sourceFormat == SourceFormat.PLAIN_TEXT
|| sourceFormat == SourceFormat.MARKDOWN
|| sourceFormat == SourceFormat.HTML;
}
@Override
public FileGenerationResult render(FileGenerationRequest request) {
byte[] bytes = htmlDocumentBuilder.buildDocument(request).getBytes(StandardCharsets.UTF_8);
return new FileGenerationResult(
request.getFileName(),
TargetFormat.HTML.getValue(),
TargetFormat.HTML.getContentType(),
bytes
);
}
}

View File

@@ -0,0 +1,42 @@
package tech.easyflow.ai.node.filegeneration;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.safety.Safelist;
import org.springframework.stereotype.Component;
/**
* HTML 清洗组件。
*
* @author Codex
* @since 2026-04-18
*/
@Component
public class HtmlSanitizer {
private final Safelist safelist;
public HtmlSanitizer() {
this.safelist = Safelist.relaxed()
.addTags("table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col")
.addAttributes("table", "border", "cellpadding", "cellspacing")
.addAttributes("th", "colspan", "rowspan", "scope", "align")
.addAttributes("td", "colspan", "rowspan", "align")
.addAttributes("col", "span", "width")
.addAttributes("colgroup", "span", "width")
.addProtocols("a", "href", "http", "https", "mailto");
this.safelist.removeTags("img", "style", "script", "iframe", "object", "embed", "form");
this.safelist.preserveRelativeLinks(true);
}
/**
* 清洗 HTML 片段。
*
* @param html 原始 HTML
* @return 清洗后的 HTML 片段
*/
public String sanitize(String html) {
Document.OutputSettings outputSettings = new Document.OutputSettings();
outputSettings.prettyPrint(false);
return Jsoup.clean(html == null ? "" : html, "", safelist, outputSettings);
}
}

View File

@@ -0,0 +1,36 @@
package tech.easyflow.ai.node.filegeneration;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
/**
* Markdown 文件渲染器。
*
* @author Codex
* @since 2026-04-18
*/
@Component
public class MarkdownFileRenderer implements DocumentRenderer {
@Override
public TargetFormat getTargetFormat() {
return TargetFormat.MD;
}
@Override
public boolean supports(SourceFormat sourceFormat) {
return sourceFormat == SourceFormat.PLAIN_TEXT || sourceFormat == SourceFormat.MARKDOWN;
}
@Override
public FileGenerationResult render(FileGenerationRequest request) {
byte[] bytes = (request.getContent() == null ? "" : request.getContent()).getBytes(StandardCharsets.UTF_8);
return new FileGenerationResult(
request.getFileName(),
TargetFormat.MD.getValue(),
TargetFormat.MD.getContentType(),
bytes
);
}
}

View File

@@ -0,0 +1,54 @@
package tech.easyflow.ai.node.filegeneration;
import org.commonmark.Extension;
import org.commonmark.ext.gfm.tables.TablesExtension;
import org.commonmark.node.Node;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.html.HtmlRenderer;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Markdown 解析与 HTML 渲染支持。
*
* @author Codex
* @since 2026-04-18
*/
@Component
public class MarkdownSupport {
private final Parser parser;
private final HtmlRenderer htmlRenderer;
public MarkdownSupport() {
List<Extension> extensions = List.of(TablesExtension.create());
this.parser = Parser.builder()
.extensions(extensions)
.build();
this.htmlRenderer = HtmlRenderer.builder()
.extensions(extensions)
.escapeHtml(true)
.sanitizeUrls(true)
.build();
}
/**
* 解析 Markdown 文本。
*
* @param markdown Markdown 内容
* @return AST 根节点
*/
public Node parse(String markdown) {
return parser.parse(markdown == null ? "" : markdown);
}
/**
* 将 Markdown 渲染为安全 HTML 片段。
*
* @param markdown Markdown 内容
* @return HTML 片段
*/
public String renderHtml(String markdown) {
return htmlRenderer.render(parse(markdown));
}
}

View File

@@ -0,0 +1,109 @@
package tech.easyflow.ai.node.filegeneration;
import com.openhtmltopdf.extend.FSUriResolver;
import com.openhtmltopdf.outputdevice.helper.ExternalResourceControlPriority;
import com.openhtmltopdf.outputdevice.helper.ExternalResourceType;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
/**
* PDF 文件渲染器。
*
* @author Codex
* @since 2026-04-18
*/
@Component
public class PdfFileRenderer implements DocumentRenderer {
static final String FONT_RESOURCE_PATH = "fonts/SourceHanSansSC-VF.ttf";
static final String FONT_FAMILY = "EasyFlowPdfCjk";
private static final FSUriResolver BLOCK_ALL_URI_RESOLVER = PdfFileRenderer::resolveBlockedUri;
private final HtmlDocumentBuilder htmlDocumentBuilder;
private final File fontFile;
@Autowired
public PdfFileRenderer(HtmlDocumentBuilder htmlDocumentBuilder) {
this(htmlDocumentBuilder, FONT_RESOURCE_PATH);
}
PdfFileRenderer(HtmlDocumentBuilder htmlDocumentBuilder, String fontResourcePath) {
this.htmlDocumentBuilder = htmlDocumentBuilder;
this.fontFile = loadFont(fontResourcePath);
}
@Override
public TargetFormat getTargetFormat() {
return TargetFormat.PDF;
}
@Override
public boolean supports(SourceFormat sourceFormat) {
return sourceFormat == SourceFormat.PLAIN_TEXT
|| sourceFormat == SourceFormat.MARKDOWN
|| sourceFormat == SourceFormat.HTML;
}
@Override
public FileGenerationResult render(FileGenerationRequest request) {
String html = htmlDocumentBuilder.buildDocument(request);
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
PdfRendererBuilder builder = new PdfRendererBuilder();
builder.useFastMode();
builder.useFont(fontFile, FONT_FAMILY);
// 首版 PDF 只允许文本与内联样式,不允许解析任何外部或内嵌资源。
builder.useUriResolver(BLOCK_ALL_URI_RESOLVER);
builder.useExternalResourceAccessControl(
PdfFileRenderer::denyExternalResource,
ExternalResourceControlPriority.RUN_BEFORE_RESOLVING_URI
);
builder.useExternalResourceAccessControl(
PdfFileRenderer::denyExternalResource,
ExternalResourceControlPriority.RUN_AFTER_RESOLVING_URI
);
builder.withHtmlContent(html, fontFile.getParentFile().toURI().toString());
builder.toStream(outputStream);
builder.run();
return new FileGenerationResult(
request.getFileName(),
TargetFormat.PDF.getValue(),
TargetFormat.PDF.getContentType(),
outputStream.toByteArray()
);
} catch (Exception e) {
throw new IllegalStateException("PDF 导出失败: " + e.getMessage(), e);
}
}
static String resolveBlockedUri(String baseUri, String uri) {
return null;
}
static boolean denyExternalResource(String uri, ExternalResourceType type) {
return false;
}
private File loadFont(String fontResourcePath) {
ClassPathResource resource = new ClassPathResource(fontResourcePath);
if (!resource.exists()) {
throw new IllegalStateException("PDF 中文字体资源缺失: " + fontResourcePath);
}
try (InputStream inputStream = resource.getInputStream()) {
Path tempFile = Files.createTempFile("easyflow-pdf-font-", ".otf");
Files.copy(inputStream, tempFile, StandardCopyOption.REPLACE_EXISTING);
tempFile.toFile().deleteOnExit();
return tempFile.toFile();
} catch (IOException e) {
throw new IllegalStateException("PDF 中文字体资源加载失败: " + fontResourcePath, e);
}
}
}

View File

@@ -0,0 +1,45 @@
package tech.easyflow.ai.node.filegeneration;
import org.springframework.util.StringUtils;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.Arrays;
/**
* 文件导出的输入内容格式。
*
* @author Codex
* @since 2026-04-18
*/
public enum SourceFormat {
PLAIN_TEXT("plain_text"),
MARKDOWN("markdown"),
HTML("html");
private final String value;
SourceFormat(String value) {
this.value = value;
}
public String getValue() {
return value;
}
/**
* 解析输入格式。
*
* @param value 原始值
* @return 输入格式枚举
*/
public static SourceFormat fromValue(String value) {
if (!StringUtils.hasText(value)) {
throw new BusinessException("文件导出输入格式不能为空");
}
String normalized = value.trim().toLowerCase();
return Arrays.stream(values())
.filter(item -> item.value.equals(normalized))
.findFirst()
.orElseThrow(() -> new BusinessException("暂不支持的文件导出输入格式: " + value));
}
}

View File

@@ -0,0 +1,52 @@
package tech.easyflow.ai.node.filegeneration;
import org.springframework.util.StringUtils;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.Arrays;
/**
* 文件导出的目标格式。
*
* @author Codex
* @since 2026-04-18
*/
public enum TargetFormat {
MD("md", "text/markdown"),
HTML("html", "text/html"),
PDF("pdf", "application/pdf"),
DOCX("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
private final String value;
private final String contentType;
TargetFormat(String value, String contentType) {
this.value = value;
this.contentType = contentType;
}
public String getValue() {
return value;
}
public String getContentType() {
return contentType;
}
/**
* 解析目标格式。
*
* @param value 原始值
* @return 目标格式枚举
*/
public static TargetFormat fromValue(String value) {
if (!StringUtils.hasText(value)) {
throw new BusinessException("文件导出目标格式不能为空");
}
String normalized = value.trim().toLowerCase();
return Arrays.stream(values())
.filter(item -> item.value.equals(normalized))
.findFirst()
.orElseThrow(() -> new BusinessException("暂不支持的文件导出目标格式: " + value));
}
}

View File

@@ -8,6 +8,7 @@ import org.junit.Test;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckResult; import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckResult;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage; import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.node.MakeFileNodeParser;
import tech.easyflow.ai.node.SearchDatasetNodeParser; import tech.easyflow.ai.node.SearchDatasetNodeParser;
import tech.easyflow.ai.node.WorkflowNodeParser; import tech.easyflow.ai.node.WorkflowNodeParser;
import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.ai.service.WorkflowService;
@@ -328,6 +329,37 @@ public class WorkflowCheckServiceTest {
assertHasCode(result, "START_FORM_OPTIONS_EMPTY"); assertHasCode(result, "START_FORM_OPTIONS_EMPTY");
} }
@Test
public void testSaveShouldBlockInvalidMakeFileCombination() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject data = data("文件生成");
data.put("sourceFormat", "html");
data.put("targetFormat", "docx");
String content = workflowJson(
array(node("mf1", "make-file", null, data)),
new JSONArray()
);
WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "MAKE_FILE_INVALID");
}
@Test
public void testSaveShouldPassForValidMakeFileCombination() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject data = data("文件生成");
data.put("sourceFormat", "markdown");
data.put("targetFormat", "pdf");
String content = workflowJson(
array(node("mf1", "make-file", null, data)),
new JSONArray()
);
WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.SAVE, null);
Assert.assertTrue(result.isPassed());
}
private static WorkflowCheckService newService(Map<String, String> workflowStore) throws Exception { private static WorkflowCheckService newService(Map<String, String> workflowStore) throws Exception {
WorkflowCheckService service = new WorkflowCheckService(); WorkflowCheckService service = new WorkflowCheckService();
ChainParser parser = ChainParser.builder() ChainParser parser = ChainParser.builder()
@@ -335,6 +367,7 @@ public class WorkflowCheckServiceTest {
.build(); .build();
parser.addNodeParser("workflow-node", new WorkflowNodeParser()); parser.addNodeParser("workflow-node", new WorkflowNodeParser());
parser.addNodeParser("search-dataset-node", new SearchDatasetNodeParser()); parser.addNodeParser("search-dataset-node", new SearchDatasetNodeParser());
parser.addNodeParser("make-file", new MakeFileNodeParser());
setField(service, "chainParser", parser); setField(service, "chainParser", parser);
setField(service, "workflowService", mockWorkflowService(workflowStore)); setField(service, "workflowService", mockWorkflowService(workflowStore));
setField(service, "workflowDatacenterContentService", new WorkflowDatacenterContentService()); setField(service, "workflowDatacenterContentService", new WorkflowDatacenterContentService());

View File

@@ -0,0 +1,58 @@
package tech.easyflow.ai.node;
import com.alibaba.fastjson.JSONObject;
import com.easyagents.flow.core.node.BaseNode;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.common.web.exceptions.BusinessException;
/**
* {@link MakeFileNodeParser} 单元测试。
*
* @author Codex
* @since 2026-04-18
*/
public class MakeFileNodeParserTest {
@Test
public void testShouldUseDefaults() {
MakeFileNodeParser parser = new MakeFileNodeParser();
MakeFileNode node = (MakeFileNode) parser.doParse(new JSONObject(), new JSONObject(), new JSONObject());
Assert.assertEquals("docx", node.getTargetFormat());
Assert.assertEquals("markdown", node.getSourceFormat());
Assert.assertEquals("default", node.getTemplateStyle());
Assert.assertNull(node.getFileName());
}
@Test
public void testShouldPreferTargetFormatAndTrimFileName() {
MakeFileNodeParser parser = new MakeFileNodeParser();
JSONObject data = new JSONObject();
data.put("targetFormat", "pdf");
data.put("sourceFormat", "markdown");
data.put("fileName", " 导出报告 ");
data.put("templateStyle", "custom");
BaseNode parsed = parser.doParse(new JSONObject(), data, new JSONObject());
MakeFileNode node = (MakeFileNode) parsed;
Assert.assertEquals("pdf", node.getTargetFormat());
Assert.assertEquals("markdown", node.getSourceFormat());
Assert.assertEquals("导出报告", node.getFileName());
Assert.assertEquals("default", node.getTemplateStyle());
}
@Test
public void testShouldRejectIllegalCombination() {
MakeFileNodeParser parser = new MakeFileNodeParser();
JSONObject data = new JSONObject();
data.put("sourceFormat", "html");
data.put("targetFormat", "md");
BusinessException exception = Assert.assertThrows(
BusinessException.class,
() -> parser.doParse(new JSONObject(), data, new JSONObject())
);
Assert.assertTrue(exception.getMessage().contains("html -> md"));
}
}

View File

@@ -0,0 +1,118 @@
package tech.easyflow.ai.node;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.chain.Parameter;
import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.node.filegeneration.FileGenerationService;
import tech.easyflow.ai.node.filegeneration.MarkdownFileRenderer;
import tech.easyflow.common.filestorage.FileStorageManager;
import tech.easyflow.common.util.SpringContextUtil;
import java.lang.reflect.Field;
import java.lang.reflect.Proxy;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* {@link MakeFileNode} 单元测试。
*
* @author Codex
* @since 2026-04-18
*/
public class MakeFileNodeTest {
@Test
public void testExecuteShouldReturnUrl() throws Exception {
RecordingFileStorageManager storageManager = new RecordingFileStorageManager();
FileGenerationService generationService = new FileGenerationService(List.of(new MarkdownFileRenderer()));
ApplicationContext previousContext = getStaticField("applicationContext");
Object previousBeanFactory = getStaticField("beanFactory");
try {
setStaticField("beanFactory", null);
setStaticField("applicationContext", mockApplicationContext(generationService, storageManager));
MakeFileNode node = new MakeFileNode("md", "plain_text", "测试导出", "default");
node.setParameters(Collections.singletonList(new Parameter("content")));
Chain chain = createChain(Map.of("content", "hello"));
Map<String, Object> result = node.execute(chain);
Assert.assertEquals("https://example.com/generated.md", result.get("url"));
Assert.assertNotNull(storageManager.lastFile);
Assert.assertEquals("测试导出.md", storageManager.lastFile.getOriginalFilename());
} finally {
setStaticField("applicationContext", previousContext);
setStaticField("beanFactory", previousBeanFactory);
}
}
private static Chain createChain(Map<String, Object> memory) {
Chain chain = new Chain(new ChainDefinition(), UUID.randomUUID().toString());
chain.setChainStateRepository(new InMemoryChainStateRepository());
chain.getState().getMemory().putAll(memory);
return chain;
}
private static ApplicationContext mockApplicationContext(FileGenerationService service,
FileStorageManager storageManager) {
return (ApplicationContext) Proxy.newProxyInstance(
ApplicationContext.class.getClassLoader(),
new Class[]{ApplicationContext.class},
(proxy, method, args) -> {
if ("getBean".equals(method.getName()) && args != null && args.length == 1 && args[0] instanceof Class<?> clazz) {
if (clazz == FileGenerationService.class) {
return service;
}
if (clazz == FileStorageManager.class) {
return storageManager;
}
}
if ("equals".equals(method.getName())) {
return proxy == args[0];
}
if ("hashCode".equals(method.getName())) {
return System.identityHashCode(proxy);
}
if (method.getReturnType() == boolean.class) {
return false;
}
if (method.getReturnType() == int.class) {
return 0;
}
if (method.getReturnType() == long.class) {
return 0L;
}
return null;
}
);
}
@SuppressWarnings("unchecked")
private static <T> T getStaticField(String fieldName) throws Exception {
Field field = SpringContextUtil.class.getDeclaredField(fieldName);
field.setAccessible(true);
return (T) field.get(null);
}
private static void setStaticField(String fieldName, Object value) throws Exception {
Field field = SpringContextUtil.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(null, value);
}
private static class RecordingFileStorageManager extends FileStorageManager {
private MultipartFile lastFile;
@Override
public String save(MultipartFile file) {
this.lastFile = file;
return "https://example.com/generated.md";
}
}
}

View File

@@ -0,0 +1,60 @@
package tech.easyflow.ai.node.filegeneration;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.List;
/**
* {@link FileGenerationService} 单元测试。
*
* @author Codex
* @since 2026-04-18
*/
public class FileGenerationServiceTest {
@Test
public void testShouldSanitizeFileNameAndOverrideExtension() {
FileGenerationService service = new FileGenerationService(List.of(new MarkdownFileRenderer()));
FileGenerationResult result = service.generate(new FileGenerationRequest(
"demo",
"plain_text",
"md",
" a/b.docx ",
"custom"
));
Assert.assertEquals("ab.md", result.getFileName());
Assert.assertEquals("md", result.getTargetFormat());
Assert.assertEquals("text/markdown", result.getContentType());
}
@Test
public void testShouldFallbackToGeneratedFileName() {
FileGenerationService service = new FileGenerationService(List.of(new MarkdownFileRenderer()));
FileGenerationResult result = service.generate(new FileGenerationRequest(
"",
"markdown",
"md",
" / ",
"default"
));
Assert.assertTrue(result.getFileName().startsWith("generated-file-"));
Assert.assertTrue(result.getFileName().endsWith(".md"));
Assert.assertEquals(0, result.getSize());
}
@Test
public void testShouldRejectHtmlToDocx() {
FileGenerationService service = new FileGenerationService(List.of(new DocxFileRenderer(new MarkdownSupport())));
BusinessException exception = Assert.assertThrows(
BusinessException.class,
() -> service.generate(new FileGenerationRequest("html", "html", "docx", "demo", "default"))
);
Assert.assertTrue(exception.getMessage().contains("html -> docx"));
}
}

View File

@@ -0,0 +1,189 @@
package tech.easyflow.ai.node.filegeneration;
import com.openhtmltopdf.outputdevice.helper.ExternalResourceType;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.junit.Assert;
import org.junit.Test;
import java.io.ByteArrayInputStream;
/**
* 文档渲染器单元测试。
*
* @author Codex
* @since 2026-04-18
*/
public class FileRendererTest {
@Test
public void testMarkdownRendererShouldSupportPlainTextAndMarkdown() {
MarkdownFileRenderer renderer = new MarkdownFileRenderer();
FileGenerationResult plain = renderer.render(new FileGenerationRequest(
"hello",
"plain_text",
"md",
"demo.md",
"default"
));
FileGenerationResult markdown = renderer.render(new FileGenerationRequest(
"# title",
"markdown",
"md",
"demo.md",
"default"
));
Assert.assertEquals("hello", new String(plain.getBytes()));
Assert.assertEquals("# title", new String(markdown.getBytes()));
}
@Test
public void testHtmlRendererShouldSanitizeDangerousHtml() {
HtmlFileRenderer renderer = new HtmlFileRenderer(new HtmlDocumentBuilder(new MarkdownSupport(), new HtmlSanitizer()));
FileGenerationResult result = renderer.render(new FileGenerationRequest(
"<script>alert(1)</script><table><tr><td>ok</td></tr></table>",
"html",
"html",
"demo.html",
"default"
));
String html = new String(result.getBytes());
Assert.assertTrue(html.contains("<table>"));
Assert.assertFalse(html.contains("<script>"));
Assert.assertTrue(html.contains("document-paper"));
}
@Test
public void testHtmlRendererShouldSanitizeMarkdownGeneratedResources() {
HtmlFileRenderer renderer = new HtmlFileRenderer(new HtmlDocumentBuilder(new MarkdownSupport(), new HtmlSanitizer()));
FileGenerationResult result = renderer.render(new FileGenerationRequest(
"![remote](https://example.com/demo.png)\n\n# title",
"markdown",
"html",
"demo.html",
"default"
));
String html = new String(result.getBytes());
Assert.assertFalse(html.contains("<img"));
Assert.assertTrue(html.contains("<h1>title</h1>"));
}
@Test
public void testPdfRendererShouldGeneratePdfWithChineseContent() throws Exception {
PdfFileRenderer renderer = new PdfFileRenderer(new HtmlDocumentBuilder(new MarkdownSupport(), new HtmlSanitizer()));
FileGenerationResult result = renderer.render(new FileGenerationRequest(
"# 中文标题\n\n- 列表项\n\n| 列1 | 列2 |\n| --- | --- |\n| A | B |",
"markdown",
"pdf",
"demo.pdf",
"default"
));
byte[] bytes = result.getBytes();
Assert.assertTrue(bytes.length > 1024);
Assert.assertEquals("%PDF", new String(bytes, 0, 4));
try (PDDocument document = PDDocument.load(bytes)) {
String text = new PDFTextStripper().getText(document);
Assert.assertTrue(text.contains("中文标题"));
Assert.assertTrue(text.contains("列表项"));
Assert.assertTrue(text.contains("列1"));
}
}
@Test
public void testPdfRendererShouldRenderMarkdownInsteadOfKeepingRawSyntax() throws Exception {
PdfFileRenderer renderer = new PdfFileRenderer(new HtmlDocumentBuilder(new MarkdownSupport(), new HtmlSanitizer()));
FileGenerationResult result = renderer.render(new FileGenerationRequest(
"# Java 入门手册\n\n## Hello World\n\n```java\nSystem.out.println(\"你好\");\n```",
"markdown",
"pdf",
"manual.pdf",
"default"
));
try (PDDocument document = PDDocument.load(result.getBytes())) {
String text = new PDFTextStripper().getText(document)
.replace('\u00A0', ' ')
.replace("\r", "");
Assert.assertTrue(text.contains("Java 入门手册"));
Assert.assertTrue(text.contains("Hello World"));
Assert.assertTrue(text.contains("System.out.println"));
Assert.assertTrue(text.contains("你好"));
Assert.assertFalse(text.contains("# Java 入门手册"));
Assert.assertFalse(text.contains("## Hello World"));
Assert.assertFalse(text.contains("```java"));
}
}
@Test
public void testPdfRendererShouldBlockExternalResources() {
PdfFileRenderer renderer = new PdfFileRenderer(new HtmlDocumentBuilder(new MarkdownSupport(), new HtmlSanitizer()) {
@Override
public String buildDocument(FileGenerationRequest request) {
return """
<!DOCTYPE html>
<html>
<body>
<p>blocked resource</p>
<img src="https://example.com/demo.png" alt="remote" />
</body>
</html>
""";
}
});
FileGenerationResult result = renderer.render(new FileGenerationRequest(
"ignored",
"html",
"pdf",
"demo.pdf",
"default"
));
Assert.assertTrue(result.getBytes().length > 512);
Assert.assertEquals("%PDF", new String(result.getBytes(), 0, 4));
Assert.assertNull(PdfFileRenderer.resolveBlockedUri("https://base.example", "https://example.com/demo.png"));
Assert.assertFalse(PdfFileRenderer.denyExternalResource("https://example.com/demo.png", ExternalResourceType.IMAGE_RASTER));
}
@Test
public void testPdfRendererShouldFailFastWhenFontMissing() {
IllegalStateException exception = Assert.assertThrows(
IllegalStateException.class,
() -> new PdfFileRenderer(
new HtmlDocumentBuilder(new MarkdownSupport(), new HtmlSanitizer()),
"fonts/missing-font.otf"
)
);
Assert.assertTrue(exception.getMessage().contains("字体资源缺失"));
}
@Test
public void testDocxRendererShouldRenderMarkdownBlocks() throws Exception {
DocxFileRenderer renderer = new DocxFileRenderer(new MarkdownSupport());
FileGenerationResult result = renderer.render(new FileGenerationRequest(
"# 标题\n\n正文 **加粗** 与 [链接](https://example.com)\n\n- 列表项\n\n> 引用内容\n\n| 列1 | 列2 |\n| --- | --- |\n| A | B |",
"markdown",
"docx",
"demo.docx",
"default"
));
try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(result.getBytes()))) {
Assert.assertTrue(document.getParagraphs().stream().anyMatch(item -> item.getText().contains("标题")));
Assert.assertTrue(document.getParagraphs().stream().anyMatch(item -> item.getText().contains("列表项")));
Assert.assertTrue(document.getParagraphs().stream().anyMatch(item -> item.getText().contains("引用内容")));
Assert.assertTrue(document.getParagraphs().stream().anyMatch(item -> item.getText().contains("链接")));
Assert.assertTrue(document.getHyperlinks().length > 0);
Assert.assertEquals("https://example.com", document.getHyperlinks()[0].getURL());
Assert.assertEquals(1, document.getTables().size());
Assert.assertEquals("列1", document.getTables().get(0).getRow(0).getCell(0).getText());
Assert.assertEquals("A", document.getTables().get(0).getRow(1).getCell(0).getText());
}
}
}

View File

@@ -42,6 +42,16 @@
"fileGeneration": "FileGeneration", "fileGeneration": "FileGeneration",
"fileSettings": "FileSettings", "fileSettings": "FileSettings",
"fileDownloadURL": "FileDownloadURL", "fileDownloadURL": "FileDownloadURL",
"targetFormat": "TargetFormat",
"fileName": "FileName",
"documents": "Documents",
"count": "Count",
"fileNamePlaceholder": "For example: meeting-notes",
"templateStyle": "TemplateStyle",
"defaultTemplateStyle": "Default",
"plain_text": "Plain Text",
"markdown": "Markdown",
"html": "HTML",
"pluginSelect": "PluginSelect", "pluginSelect": "PluginSelect",
"saveData": "SaveData", "saveData": "SaveData",
"saveDataset": "Write Data", "saveDataset": "Write Data",
@@ -105,6 +115,9 @@
"fileGeneration": "Generate Word, PDF, HTML, etc. files for users to download", "fileGeneration": "Generate Word, PDF, HTML, etc. files for users to download",
"fileType": "Please select the type of file to generate", "fileType": "Please select the type of file to generate",
"fileDownloadURL": "Generated file URL", "fileDownloadURL": "Generated file URL",
"targetFormat": "Choose the final document format to export",
"fileName": "Optional. The system will sanitize invalid characters and enforce the target extension",
"templateStyle": "Only the default style is available in the first version. Unknown values fall back automatically",
"plugin": "Select a predefined plugin", "plugin": "Select a predefined plugin",
"saveData": "Save data to data hub", "saveData": "Save data to data hub",
"saveDataset": "Write data into a managed table", "saveDataset": "Write data into a managed table",

View File

@@ -42,6 +42,16 @@
"fileGeneration": "文件生成", "fileGeneration": "文件生成",
"fileSettings": "文件设置", "fileSettings": "文件设置",
"fileDownloadURL": "文件下载地址", "fileDownloadURL": "文件下载地址",
"targetFormat": "输出文档类型",
"fileName": "文件名",
"documents": "文档列表",
"count": "数量",
"fileNamePlaceholder": "例如:会议纪要",
"templateStyle": "模板样式",
"defaultTemplateStyle": "默认样式",
"plain_text": "纯文本",
"markdown": "Markdown",
"html": "HTML",
"pluginSelect": "插件选择", "pluginSelect": "插件选择",
"saveData": "保存数据", "saveData": "保存数据",
"saveDataset": "写入数据", "saveDataset": "写入数据",
@@ -105,6 +115,9 @@
"fileGeneration": "生成 Word、PDF、HTML 等文件供用户下载", "fileGeneration": "生成 Word、PDF、HTML 等文件供用户下载",
"fileType": "请选择生成的文件类型", "fileType": "请选择生成的文件类型",
"fileDownloadURL": "生成后的文件地址", "fileDownloadURL": "生成后的文件地址",
"targetFormat": "选择最终要导出的文档格式",
"fileName": "可选,系统会自动补齐目标扩展名并清洗非法字符",
"templateStyle": "首版仅提供默认样式,未知值会自动回退",
"plugin": "选择定义好的插件", "plugin": "选择定义好的插件",
"saveData": "保存数据到数据中枢", "saveData": "保存数据到数据中枢",
"saveDataset": "将数据写入已接入表", "saveDataset": "将数据写入已接入表",

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import {
getMakeFileNodeFormPatch,
getMakeFileTargetFormatOptions,
isMakeFileFormatCombinationAllowed,
resolveMakeFileNodeTargetFormat,
resolveMakeFileNodeTemplateStyle,
sanitizeMakeFileNodeFileName,
} from '../makeFileNode';
describe('makeFileNode helpers', () => {
it('清洗文件名中的非法字符与首尾空白', () => {
expect(sanitizeMakeFileNodeFileName(' a/b\\\\c:*?<>| ')).toBe('abc');
});
it('识别受支持与不受支持的格式组合', () => {
expect(isMakeFileFormatCombinationAllowed('markdown', 'pdf')).toBe(true);
expect(isMakeFileFormatCombinationAllowed('html', 'html')).toBe(true);
expect(isMakeFileFormatCombinationAllowed('html', 'md')).toBe(false);
expect(isMakeFileFormatCombinationAllowed('html', 'docx')).toBe(false);
});
it('按当前 targetFormat 回显目标格式', () => {
expect(resolveMakeFileNodeTargetFormat({ targetFormat: 'pdf' })).toBe('pdf');
expect(resolveMakeFileNodeTargetFormat({ targetFormat: 'html' })).toBe('html');
});
it('对未知 templateStyle 做默认回退', () => {
expect(resolveMakeFileNodeTemplateStyle({ templateStyle: 'fancy' })).toBe('default');
});
it('在 html 源格式下仅暴露 html 与 pdf 目标格式', () => {
expect(getMakeFileTargetFormatOptions('html').map((item) => item.value)).toEqual([
'html',
'pdf',
]);
});
it('在 sourceFormat 切换为 html 时自动修正非法目标格式', () => {
expect(
getMakeFileNodeFormPatch('sourceFormat', 'html', {
sourceFormat: 'markdown',
targetFormat: 'docx',
}),
).toEqual({
sourceFormat: 'html',
targetFormat: 'html',
});
});
});

View File

@@ -2,6 +2,117 @@ import { $t } from '#/locales';
import nodeNames from './nodeNames'; import nodeNames from './nodeNames';
const INVALID_FILE_NAME_PATTERN = /[\\/:*?"<>|\u0000-\u001f\u007f]/g;
export const makeFileTargetFormatValues = ['md', 'html', 'pdf', 'docx'] as const;
export const makeFileSourceFormatValues = ['plain_text', 'markdown', 'html'] as const;
const DEFAULT_TARGET_FORMAT = 'docx';
const DEFAULT_SOURCE_FORMAT = 'markdown';
const DEFAULT_TEMPLATE_STYLE = 'default';
type MakeFileTargetFormat = (typeof makeFileTargetFormatValues)[number];
type MakeFileSourceFormat = (typeof makeFileSourceFormatValues)[number];
function isMakeFileTargetFormat(value: unknown): value is MakeFileTargetFormat {
return typeof value === 'string' && makeFileTargetFormatValues.includes(value as MakeFileTargetFormat);
}
function isMakeFileSourceFormat(value: unknown): value is MakeFileSourceFormat {
return typeof value === 'string' && makeFileSourceFormatValues.includes(value as MakeFileSourceFormat);
}
function getConfiguredTargetFormat(data?: Record<string, any>) {
const candidate = data?.targetFormat;
return isMakeFileTargetFormat(candidate) ? candidate : DEFAULT_TARGET_FORMAT;
}
export function sanitizeMakeFileNodeFileName(value?: string) {
return (value ?? '').replace(INVALID_FILE_NAME_PATTERN, '').trim();
}
export function isMakeFileFormatCombinationAllowed(
sourceFormat = DEFAULT_SOURCE_FORMAT,
targetFormat = 'docx',
) {
return !(
sourceFormat === 'html' &&
(targetFormat === 'md' || targetFormat === 'docx')
);
}
export function resolveMakeFileNodeSourceFormat(data?: Record<string, any>): MakeFileSourceFormat {
return isMakeFileSourceFormat(data?.sourceFormat) ? data.sourceFormat : DEFAULT_SOURCE_FORMAT;
}
export function resolveMakeFileNodeTargetFormat(data?: Record<string, any>): MakeFileTargetFormat {
const sourceFormat = resolveMakeFileNodeSourceFormat(data);
const targetFormat = getConfiguredTargetFormat(data);
if (isMakeFileFormatCombinationAllowed(sourceFormat, targetFormat)) {
return targetFormat;
}
return sourceFormat === 'html' ? 'html' : DEFAULT_TARGET_FORMAT;
}
export function resolveMakeFileNodeTemplateStyle(data?: Record<string, any>) {
return data?.templateStyle === DEFAULT_TEMPLATE_STYLE ? DEFAULT_TEMPLATE_STYLE : DEFAULT_TEMPLATE_STYLE;
}
export function getMakeFileTargetFormatOptions(sourceFormat = DEFAULT_SOURCE_FORMAT) {
return makeFileTargetFormatValues
.filter((targetFormat) => isMakeFileFormatCombinationAllowed(sourceFormat, targetFormat))
.map((value) => ({
label: value,
value,
}));
}
export function getMakeFileNodeFormPatch(
name: string,
nextValue: string | number | boolean | undefined,
data?: Record<string, any>,
) {
if (name === 'sourceFormat') {
const sourceFormat = isMakeFileSourceFormat(nextValue) ? nextValue : DEFAULT_SOURCE_FORMAT;
const currentTargetFormat = getConfiguredTargetFormat(data);
if (isMakeFileFormatCombinationAllowed(sourceFormat, currentTargetFormat)) {
return { sourceFormat };
}
return {
sourceFormat,
targetFormat: sourceFormat === 'html' ? 'html' : DEFAULT_TARGET_FORMAT,
};
}
if (name === 'targetFormat') {
const sourceFormat = resolveMakeFileNodeSourceFormat(data);
const targetFormat = isMakeFileTargetFormat(nextValue) ? nextValue : DEFAULT_TARGET_FORMAT;
return {
targetFormat: isMakeFileFormatCombinationAllowed(sourceFormat, targetFormat)
? targetFormat
: sourceFormat === 'html'
? 'html'
: DEFAULT_TARGET_FORMAT,
};
}
if (name === 'templateStyle') {
return { templateStyle: resolveMakeFileNodeTemplateStyle({ templateStyle: nextValue }) };
}
return { [name]: nextValue };
}
function handleFileNameInput(event: Event) {
const target = event.target as HTMLInputElement | null;
if (!target) {
return;
}
const sanitized = sanitizeMakeFileNodeFileName(target.value);
if (target.value !== sanitized) {
target.value = sanitized;
}
}
export default { export default {
[nodeNames.makeFileNode]: { [nodeNames.makeFileNode]: {
title: $t('aiWorkflow.fileGeneration'), title: $t('aiWorkflow.fileGeneration'),
@@ -9,8 +120,8 @@ export default {
description: $t('aiWorkflow.descriptions.fileGeneration'), description: $t('aiWorkflow.descriptions.fileGeneration'),
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M14 13.5V8C14 5.79086 12.2091 4 10 4C7.79086 4 6 5.79086 6 8V13.5C6 17.0899 8.91015 20 12.5 20C16.0899 20 19 17.0899 19 13.5V4H21V13.5C21 18.1944 17.1944 22 12.5 22C7.80558 22 4 18.1944 4 13.5V8C4 4.68629 6.68629 2 10 2C13.3137 2 16 4.68629 16 8V13.5C16 15.433 14.433 17 12.5 17C10.567 17 9 15.433 9 13.5V8H11V13.5C11 14.3284 11.6716 15 12.5 15C13.3284 15 14 14.3284 14 13.5Z"></path></svg>', icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M14 13.5V8C14 5.79086 12.2091 4 10 4C7.79086 4 6 5.79086 6 8V13.5C6 17.0899 8.91015 20 12.5 20C16.0899 20 19 17.0899 19 13.5V4H21V13.5C21 18.1944 17.1944 22 12.5 22C7.80558 22 4 18.1944 4 13.5V8C4 4.68629 6.68629 2 10 2C13.3137 2 16 4.68629 16 8V13.5C16 15.433 14.433 17 12.5 17C10.567 17 9 15.433 9 13.5V8H11V13.5C11 14.3284 11.6716 15 12.5 15C13.3284 15 14 14.3284 14 13.5Z"></path></svg>',
sortNo: 802, sortNo: 802,
parametersAddEnable: true, parametersAddEnable: false,
outputDefsAddEnable: true, outputDefsAddEnable: false,
forms: [ forms: [
{ {
type: 'heading', type: 'heading',
@@ -18,16 +129,42 @@ export default {
}, },
{ {
type: 'select', type: 'select',
label: $t('documentCollection.splitterDoc.fileType'), label: $t('aiWorkflow.targetFormat'),
description: $t('aiWorkflow.descriptions.fileType'), description: $t('aiWorkflow.descriptions.targetFormat'),
name: 'suffix', name: 'targetFormat',
defaultValue: 'docx', defaultValue: DEFAULT_TARGET_FORMAT,
resolveValue: resolveMakeFileNodeTargetFormat,
resolveOptions: (data: Record<string, any>) =>
getMakeFileTargetFormatOptions(resolveMakeFileNodeSourceFormat(data)),
onValueChange: (value: string | number | boolean | undefined, data: Record<string, any>) =>
getMakeFileNodeFormPatch('targetFormat', value, data),
},
{
type: 'input',
label: $t('aiWorkflow.fileName'),
description: $t('aiWorkflow.descriptions.fileName'),
name: 'fileName',
placeholder: $t('aiWorkflow.fileNamePlaceholder'),
attrs: {
maxlength: 128,
oninput: handleFileNameInput,
},
},
{
type: 'select',
label: $t('aiWorkflow.templateStyle'),
description: $t('aiWorkflow.descriptions.templateStyle'),
name: 'templateStyle',
defaultValue: DEFAULT_TEMPLATE_STYLE,
resolveValue: resolveMakeFileNodeTemplateStyle,
options: [ options: [
{ {
label: 'docx', label: $t('aiWorkflow.defaultTemplateStyle'),
value: 'docx', value: DEFAULT_TEMPLATE_STYLE,
}, },
], ],
onValueChange: (value: string | number | boolean | undefined, data: Record<string, any>) =>
getMakeFileNodeFormPatch('templateStyle', value, data),
}, },
], ],
parameters: [ parameters: [

View File

@@ -4,10 +4,9 @@
import {Button, Chosen, Heading, Input, Select, Textarea} from '../base'; import {Button, Chosen, Heading, Input, Select, Textarea} from '../base';
import RefParameterList from '../core/RefParameterList.svelte'; import RefParameterList from '../core/RefParameterList.svelte';
import {getCurrentNodeId} from '#components/utils/NodeUtils'; import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {useAddParameter} from '../utils/useAddParameter.svelte'; import {fillParameterId, useAddParameter} from '../utils/useAddParameter.svelte';
import {getOptions} from '../utils/NodeUtils'; import {getOptions} from '../utils/NodeUtils';
import OutputDefList from '../core/OutputDefList.svelte'; import OutputDefList from '../core/OutputDefList.svelte';
import {fillParameterId} from '../utils/useAddParameter.svelte.js';
import ParamTokenEditor from '../core/ParamTokenEditor.svelte'; import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
const { data, ...rest }: { const { data, ...rest }: {
@@ -23,15 +22,31 @@
const editorParameters = $derived.by(() => { const editorParameters = $derived.by(() => {
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || []; return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
}); });
const currentNodeData = $derived.by(() => {
return (currentNode?.current?.data as Record<string, any>) || (data as Record<string, any>) || {};
});
const updateNodeData = (data: Record<string, any>) => { const updateNodeData = (data: Record<string, any>) => {
updateNodeDataInner(currentNodeId, data); updateNodeDataInner(currentNodeId, data);
}; };
const updateNodeDataByEvent = (name: string, event: Event) => { const resolveFormValue = (form: any) => {
updateNodeData({ return form.resolveValue?.(currentNodeData) ?? currentNodeData?.[form.name] ?? form.defaultValue;
[name]: (event.target as any)?.value };
});
const resolveFormOptions = (form: any) => {
return form.resolveOptions?.(currentNodeData) ?? form.options ?? [];
};
const updateFormValue = (form: any, nextValue: any) => {
const patch = form.onValueChange?.(nextValue, currentNodeData) ?? {
[form.name]: nextValue
};
updateNodeData(patch);
};
const updateNodeDataByEvent = (form: any, event: Event) => {
updateFormValue(form, (event.target as any)?.value);
}; };
const node = { const node = {
@@ -118,21 +133,21 @@
mode="input" mode="input"
placeholder={form.placeholder} placeholder={form.placeholder}
style="width: 100%" style="width: 100%"
value={data[form.name] || form.defaultValue} value={resolveFormValue(form)}
parameters={editorParameters} parameters={editorParameters}
{...form.attrs} {...form.attrs}
oninput={(e)=>{ oninput={(e:any)=>{
updateNodeDataByEvent(form.name,e) updateNodeDataByEvent(form,e)
}} }}
/> />
{:else} {:else}
<Input <Input
placeholder={form.placeholder} placeholder={form.placeholder}
style="width: 100%" style="width: 100%"
value={data[form.name] || form.defaultValue} value={resolveFormValue(form)}
{...form.attrs} {...form.attrs}
onchange={(e)=>{ onchange={(e)=>{
updateNodeDataByEvent(form.name,e) updateNodeDataByEvent(form,e)
}} }}
/> />
{/if} {/if}
@@ -146,11 +161,11 @@
rows={3} rows={3}
placeholder={form.placeholder} placeholder={form.placeholder}
style="width: 100%" style="width: 100%"
value={data[form.name] || form.defaultValue} value={resolveFormValue(form)}
parameters={editorParameters} parameters={editorParameters}
{...form.attrs} {...form.attrs}
oninput={(e)=>{ oninput={(e:any)=>{
updateNodeDataByEvent(form.name,e) updateNodeDataByEvent(form,e)
}} }}
/> />
{:else} {:else}
@@ -158,10 +173,10 @@
rows={3} rows={3}
placeholder={form.placeholder} placeholder={form.placeholder}
style="width: 100%" style="width: 100%"
value={data[form.name] || form.defaultValue} value={resolveFormValue(form)}
{...form.attrs} {...form.attrs}
onchange={(e)=>{ onchange={(e)=>{
updateNodeDataByEvent(form.name,e) updateNodeDataByEvent(form,e)
}} }}
/> />
{/if} {/if}
@@ -176,19 +191,16 @@
type="range" type="range"
{...form.attrs} {...form.attrs}
value={data[form.name] ?? form.defaultValue} value={data[form.name] ?? form.defaultValue}
oninput={(e) => updateNodeData({ [form.name]: parseFloat(e.target.value) })} oninput={(e:any) => updateNodeData({ [form.name]: parseFloat(e.target.value) })}
/> />
</div> </div>
</div> </div>
{:else if form.type === 'select'} {:else if form.type === 'select'}
<div class="setting-title">{form.label}</div> <div class="setting-title">{form.label}</div>
<div class="setting-item"> <div class="setting-item">
<Select items={form.options||[]} style="width: 100%" placeholder={form.placeholder} onSelect={(item)=>{ <Select items={resolveFormOptions(form)} style="width: 100%" placeholder={form.placeholder} onSelect={(item)=>{
const newValue = item.value; updateFormValue(form, item.value)
updateNodeData({ }} value={[resolveFormValue(form)]} />
[form.name]: newValue
})
}} value={data[form.name] ? [data[form.name]] : [form.defaultValue]} />
</div> </div>
{:else if form.type === 'chosen'} {:else if form.type === 'chosen'}
<div class="setting-title">{form.label}</div> <div class="setting-title">{form.label}</div>

View File

@@ -46,6 +46,12 @@ export type CustomNodeForm = {
defaultValue?: string | number | boolean; defaultValue?: string | number | boolean;
attrs?: Record<string, any>; attrs?: Record<string, any>;
options?: SelectItem[]; options?: SelectItem[];
resolveValue?: (data: Record<string, any>) => string | number | boolean | undefined;
resolveOptions?: (data: Record<string, any>) => SelectItem[];
onValueChange?: (
value: string | number | boolean | undefined,
data: Record<string, any>,
) => Record<string, any> | void;
templateSupport?: boolean; templateSupport?: boolean;
chosen?: { chosen?: {
labelDataKey: string; labelDataKey: string;
@@ -66,6 +72,7 @@ export type CustomNode = {
icon?: string; icon?: string;
sortNo?: number; sortNo?: number;
group?: 'base' | 'tools'; group?: 'base' | 'tools';
renderFirst?: boolean;
rootClass?: string; rootClass?: string;
rootStyle?: string; rootStyle?: string;
parameters?: Parameter[]; parameters?: Parameter[];

13
pom.xml
View File

@@ -35,6 +35,7 @@
<sa-token.version>1.40.0</sa-token.version> <sa-token.version>1.40.0</sa-token.version>
<commonmark.version>0.18.0</commonmark.version> <commonmark.version>0.18.0</commonmark.version>
<jsoup.version>1.16.1</jsoup.version> <jsoup.version>1.16.1</jsoup.version>
<openhtmltopdf.version>1.0.10</openhtmltopdf.version>
<commons-io.version>2.18.0</commons-io.version> <commons-io.version>2.18.0</commons-io.version>
<commons-compress.version>1.28.0</commons-compress.version> <commons-compress.version>1.28.0</commons-compress.version>
<fastexcel.version>1.2.0</fastexcel.version> <fastexcel.version>1.2.0</fastexcel.version>
@@ -298,12 +299,24 @@
<version>${commonmark.version}</version> <version>${commonmark.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-gfm-tables</artifactId>
<version>${commonmark.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.jsoup</groupId> <groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId> <artifactId>jsoup</artifactId>
<version>${jsoup.version}</version> <version>${jsoup.version}</version>
</dependency> </dependency>
<dependency>
<groupId>com.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-pdfbox</artifactId>
<version>${openhtmltopdf.version}</version>
</dependency>
<dependency> <dependency>
<groupId>commons-io</groupId> <groupId>commons-io</groupId>
<artifactId>commons-io</artifactId> <artifactId>commons-io</artifactId>