Compare commits
5 Commits
8546d927bc
...
5827ecde42
| Author | SHA1 | Date | |
|---|---|---|---|
| 5827ecde42 | |||
| 1d8b9d9662 | |||
| a5aab86de2 | |||
| 51198ff492 | |||
| 9feb889637 |
@@ -5,10 +5,7 @@ import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.hutool.core.io.IoUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import com.mybatisflex.core.paginate.Page;
|
||||
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||
import com.easyagents.flow.core.chain.Parameter;
|
||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||
import com.easyagents.flow.core.parser.ChainParser;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
@@ -25,6 +22,7 @@ import tech.easyflow.ai.easyagentsflow.service.CodeEngineCapabilityService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.publish.WorkflowPublishAppService;
|
||||
@@ -77,8 +75,6 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
||||
@Resource
|
||||
private ChainExecutor chainExecutor;
|
||||
@Resource
|
||||
private ChainParser chainParser;
|
||||
@Resource
|
||||
private TinyFlowService tinyFlowService;
|
||||
@Resource
|
||||
private CodeEngineCapabilityService codeEngineCapabilityService;
|
||||
@@ -87,6 +83,8 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
||||
@Resource
|
||||
private WorkflowDatacenterContentService workflowDatacenterContentService;
|
||||
@Resource
|
||||
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
||||
@Resource
|
||||
private ResourceAccessService resourceAccessService;
|
||||
@Resource
|
||||
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
|
||||
@@ -126,6 +124,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
||||
if (variables == null) {
|
||||
variables = new HashMap<>();
|
||||
}
|
||||
variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables);
|
||||
if (StpUtil.isLogin()) {
|
||||
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
||||
}
|
||||
@@ -155,6 +154,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
||||
throw new RuntimeException("工作流不存在");
|
||||
}
|
||||
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||
variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables);
|
||||
if (StpUtil.isLogin()) {
|
||||
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
||||
}
|
||||
@@ -245,17 +245,10 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
||||
return Result.fail(1, "can not find the workflow by id: " + id);
|
||||
}
|
||||
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||
|
||||
ChainDefinition definition = chainParser.parse(workflowDatacenterContentService.prepareContent(workflow.getContent()));
|
||||
if (definition == null) {
|
||||
Map<String, Object> res = workflowRunningParameterResolver.buildRunningParametersView(workflow);
|
||||
if (res == null) {
|
||||
return Result.fail(2, "节点配置错误,请检查! ");
|
||||
}
|
||||
List<Parameter> chainParameters = definition.getStartParameters();
|
||||
Map<String, Object> res = new HashMap<>();
|
||||
res.put("parameters", chainParameters);
|
||||
res.put("title", workflow.getTitle());
|
||||
res.put("description", workflow.getDescription());
|
||||
res.put("icon", workflow.getIcon());
|
||||
return Result.ok(res);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package tech.easyflow.admin.model.dashboard;
|
||||
|
||||
/**
|
||||
* 聊天统计可用状态。
|
||||
*/
|
||||
public class DashboardChatStatusVo {
|
||||
|
||||
private Boolean available;
|
||||
|
||||
private String message;
|
||||
|
||||
public Boolean getAvailable() {
|
||||
return available;
|
||||
}
|
||||
|
||||
public void setAvailable(Boolean available) {
|
||||
this.available = available;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package tech.easyflow.admin.model.dashboard;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* 工作台分布/排行项。
|
||||
*/
|
||||
@@ -21,6 +23,14 @@ public class DashboardDistributionItemVo {
|
||||
|
||||
private Long knowledgeBaseTotal;
|
||||
|
||||
private BigInteger assistantId;
|
||||
|
||||
private Long messageTotal;
|
||||
|
||||
private Long sessionTotal;
|
||||
|
||||
private Double avgMessagePerSession;
|
||||
|
||||
public String getKey() {
|
||||
return key;
|
||||
}
|
||||
@@ -84,4 +94,36 @@ public class DashboardDistributionItemVo {
|
||||
public void setKnowledgeBaseTotal(Long knowledgeBaseTotal) {
|
||||
this.knowledgeBaseTotal = knowledgeBaseTotal;
|
||||
}
|
||||
|
||||
public BigInteger getAssistantId() {
|
||||
return assistantId;
|
||||
}
|
||||
|
||||
public void setAssistantId(BigInteger assistantId) {
|
||||
this.assistantId = assistantId;
|
||||
}
|
||||
|
||||
public Long getMessageTotal() {
|
||||
return messageTotal;
|
||||
}
|
||||
|
||||
public void setMessageTotal(Long messageTotal) {
|
||||
this.messageTotal = messageTotal;
|
||||
}
|
||||
|
||||
public Long getSessionTotal() {
|
||||
return sessionTotal;
|
||||
}
|
||||
|
||||
public void setSessionTotal(Long sessionTotal) {
|
||||
this.sessionTotal = sessionTotal;
|
||||
}
|
||||
|
||||
public Double getAvgMessagePerSession() {
|
||||
return avgMessagePerSession;
|
||||
}
|
||||
|
||||
public void setAvgMessagePerSession(Double avgMessagePerSession) {
|
||||
this.avgMessagePerSession = avgMessagePerSession;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ public class DashboardOverviewVo {
|
||||
|
||||
private DashboardSummaryVo summary;
|
||||
|
||||
private DashboardChatStatusVo chatStatus;
|
||||
|
||||
private List<DashboardTrendItemVo> trends;
|
||||
|
||||
private List<DashboardDistributionItemVo> distribution;
|
||||
@@ -34,6 +36,14 @@ public class DashboardOverviewVo {
|
||||
this.trends = trends;
|
||||
}
|
||||
|
||||
public DashboardChatStatusVo getChatStatus() {
|
||||
return chatStatus;
|
||||
}
|
||||
|
||||
public void setChatStatus(DashboardChatStatusVo chatStatus) {
|
||||
this.chatStatus = chatStatus;
|
||||
}
|
||||
|
||||
public List<DashboardDistributionItemVo> getDistribution() {
|
||||
return distribution;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ public class DashboardSummaryVo {
|
||||
|
||||
private Long knowledgeBaseTotal;
|
||||
|
||||
private Long chatMessageTotal;
|
||||
|
||||
private Long chatSessionTotal;
|
||||
|
||||
private Long activeAssistantTotal;
|
||||
|
||||
public Long getUserTotal() {
|
||||
return userTotal;
|
||||
}
|
||||
@@ -54,4 +60,28 @@ public class DashboardSummaryVo {
|
||||
public void setKnowledgeBaseTotal(Long knowledgeBaseTotal) {
|
||||
this.knowledgeBaseTotal = knowledgeBaseTotal;
|
||||
}
|
||||
|
||||
public Long getChatMessageTotal() {
|
||||
return chatMessageTotal;
|
||||
}
|
||||
|
||||
public void setChatMessageTotal(Long chatMessageTotal) {
|
||||
this.chatMessageTotal = chatMessageTotal;
|
||||
}
|
||||
|
||||
public Long getChatSessionTotal() {
|
||||
return chatSessionTotal;
|
||||
}
|
||||
|
||||
public void setChatSessionTotal(Long chatSessionTotal) {
|
||||
this.chatSessionTotal = chatSessionTotal;
|
||||
}
|
||||
|
||||
public Long getActiveAssistantTotal() {
|
||||
return activeAssistantTotal;
|
||||
}
|
||||
|
||||
public void setActiveAssistantTotal(Long activeAssistantTotal) {
|
||||
this.activeAssistantTotal = activeAssistantTotal;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ public class DashboardTrendItemVo {
|
||||
|
||||
private Long activeUserTotal;
|
||||
|
||||
private Long chatMessageTotal;
|
||||
|
||||
private Long chatSessionTotal;
|
||||
|
||||
public String getKey() {
|
||||
return key;
|
||||
}
|
||||
@@ -34,4 +38,20 @@ public class DashboardTrendItemVo {
|
||||
public void setActiveUserTotal(Long activeUserTotal) {
|
||||
this.activeUserTotal = activeUserTotal;
|
||||
}
|
||||
|
||||
public Long getChatMessageTotal() {
|
||||
return chatMessageTotal;
|
||||
}
|
||||
|
||||
public void setChatMessageTotal(Long chatMessageTotal) {
|
||||
this.chatMessageTotal = chatMessageTotal;
|
||||
}
|
||||
|
||||
public Long getChatSessionTotal() {
|
||||
return chatSessionTotal;
|
||||
}
|
||||
|
||||
public void setChatSessionTotal(Long chatSessionTotal) {
|
||||
this.chatSessionTotal = chatSessionTotal;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
package tech.easyflow.admin.service.dashboard.impl;
|
||||
|
||||
import com.easyagents.flow.core.chain.ChainStatus;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import com.mybatisflex.core.row.Db;
|
||||
import com.mybatisflex.core.row.Row;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import tech.easyflow.admin.model.dashboard.DashboardChatStatusVo;
|
||||
import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo;
|
||||
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
|
||||
import tech.easyflow.admin.model.dashboard.DashboardOverviewVo;
|
||||
import tech.easyflow.admin.model.dashboard.DashboardSummaryVo;
|
||||
import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo;
|
||||
import tech.easyflow.admin.service.dashboard.DashboardService;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
||||
import tech.easyflow.chatlog.service.ChatDashboardQueryService;
|
||||
import tech.easyflow.common.constant.Constants;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
@@ -40,24 +45,38 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
public class DashboardServiceImpl implements DashboardService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DashboardServiceImpl.class);
|
||||
private static final ZoneId DEFAULT_ZONE_ID = ZoneId.systemDefault();
|
||||
private static final String CHAT_UNAVAILABLE_MESSAGE = "聊天数据不可用";
|
||||
private static final int DEFAULT_ASSISTANT_RANK_LIMIT = 5;
|
||||
|
||||
@Resource
|
||||
private SysAccountRoleService sysAccountRoleService;
|
||||
|
||||
@Resource
|
||||
private SysRoleService sysRoleService;
|
||||
|
||||
@Resource
|
||||
private ChatDashboardQueryService chatDashboardQueryService;
|
||||
|
||||
/**
|
||||
* 获取工作台总览信息。
|
||||
*
|
||||
* @param loginAccount 当前登录账号
|
||||
* @param query 查询条件
|
||||
* @return 工作台总览
|
||||
*/
|
||||
@Override
|
||||
public DashboardOverviewVo getOverview(LoginAccount loginAccount, DashboardOverviewQuery query) {
|
||||
DashboardQueryContext context = buildContext(loginAccount, query);
|
||||
|
||||
DashboardSummaryVo summary = buildSummary(context);
|
||||
List<DashboardTrendItemVo> trends = buildTrends(context);
|
||||
List<DashboardDistributionItemVo> distribution = buildDistribution(context, summary);
|
||||
ChatDashboardPayload chatPayload = buildChatPayload(context, summary);
|
||||
|
||||
DashboardOverviewVo result = new DashboardOverviewVo();
|
||||
result.setSummary(summary);
|
||||
result.setTrends(trends);
|
||||
result.setDistribution(distribution);
|
||||
result.setChatStatus(chatPayload.chatStatus);
|
||||
result.setTrends(chatPayload.trends);
|
||||
result.setDistribution(chatPayload.distribution);
|
||||
|
||||
DashboardOverviewQuery normalizedQuery = new DashboardOverviewQuery();
|
||||
normalizedQuery.setRange(context.range);
|
||||
@@ -66,6 +85,12 @@ public class DashboardServiceImpl implements DashboardService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建顶部汇总卡片。
|
||||
*
|
||||
* @param context 查询上下文
|
||||
* @return 汇总结果
|
||||
*/
|
||||
private DashboardSummaryVo buildSummary(DashboardQueryContext context) {
|
||||
DashboardSummaryVo summary = new DashboardSummaryVo();
|
||||
summary.setUserTotal(countScopedTable("tb_sys_account", "a", true, context));
|
||||
@@ -73,72 +98,127 @@ public class DashboardServiceImpl implements DashboardService {
|
||||
summary.setBotTotal(countScopedTable("tb_bot", "b", false, context));
|
||||
summary.setWorkflowTotal(countScopedTable("tb_workflow", "w", false, context));
|
||||
summary.setKnowledgeBaseTotal(countScopedTable("tb_document_collection", "d", false, context));
|
||||
summary.setChatMessageTotal(0L);
|
||||
summary.setChatSessionTotal(0L);
|
||||
summary.setActiveAssistantTotal(0L);
|
||||
return summary;
|
||||
}
|
||||
|
||||
private List<DashboardTrendItemVo> buildTrends(DashboardQueryContext context) {
|
||||
List<TimeBucket> buckets = buildBuckets(context.range);
|
||||
String bucketFormat = "today".equals(context.range) ? "%Y-%m-%d %H:00:00" : "%Y-%m-%d";
|
||||
/**
|
||||
* 构建聊天统计载荷。
|
||||
*
|
||||
* @param context 查询上下文
|
||||
* @param summary 汇总结果
|
||||
* @return 聊天统计载荷
|
||||
*/
|
||||
private ChatDashboardPayload buildChatPayload(DashboardQueryContext context, DashboardSummaryVo summary) {
|
||||
DashboardChatStatusVo chatStatus = new DashboardChatStatusVo();
|
||||
chatStatus.setAvailable(Boolean.TRUE);
|
||||
chatStatus.setMessage("");
|
||||
|
||||
Map<String, Long> activeUserMap = queryActiveUserTrend(context, bucketFormat);
|
||||
if (!chatDashboardQueryService.available()) {
|
||||
chatStatus.setAvailable(Boolean.FALSE);
|
||||
chatStatus.setMessage(CHAT_UNAVAILABLE_MESSAGE);
|
||||
summary.setChatMessageTotal(0L);
|
||||
summary.setChatSessionTotal(0L);
|
||||
summary.setActiveAssistantTotal(0L);
|
||||
return new ChatDashboardPayload(chatStatus, new ArrayList<>(), new ArrayList<>());
|
||||
}
|
||||
|
||||
LocalDate startDate = context.startTime.toLocalDate();
|
||||
LocalDate endDate = context.endTime.toLocalDate();
|
||||
try {
|
||||
ChatDashboardSummary chatSummary = chatDashboardQueryService.querySummary(startDate, endDate, context.tenantFilterId);
|
||||
summary.setChatMessageTotal(chatSummary.messageTotal());
|
||||
summary.setChatSessionTotal(chatSummary.sessionTotal());
|
||||
summary.setActiveAssistantTotal(chatSummary.activeAssistantTotal());
|
||||
|
||||
List<ChatDashboardTrend> rawTrends = "today".equals(context.range)
|
||||
? chatDashboardQueryService.queryHourlyTrends(context.startTime, context.endTime, context.tenantFilterId)
|
||||
: chatDashboardQueryService.queryTrends(startDate, endDate, context.tenantFilterId);
|
||||
List<DashboardTrendItemVo> trends = buildTrendItems(context.range, rawTrends);
|
||||
|
||||
List<ChatAssistantUsageRank> rawRanks = chatDashboardQueryService.queryAssistantUsageRanks(
|
||||
startDate,
|
||||
endDate,
|
||||
context.tenantFilterId,
|
||||
DEFAULT_ASSISTANT_RANK_LIMIT
|
||||
);
|
||||
List<DashboardDistributionItemVo> distribution = buildAssistantDistribution(rawRanks);
|
||||
return new ChatDashboardPayload(chatStatus, trends, distribution);
|
||||
} catch (Exception ex) {
|
||||
log.warn("加载工作台聊天统计失败,已降级为不可用状态,range={}, tenantId={}",
|
||||
context.range,
|
||||
context.tenantFilterId,
|
||||
ex);
|
||||
chatStatus.setAvailable(Boolean.FALSE);
|
||||
chatStatus.setMessage(CHAT_UNAVAILABLE_MESSAGE);
|
||||
summary.setChatMessageTotal(0L);
|
||||
summary.setChatSessionTotal(0L);
|
||||
summary.setActiveAssistantTotal(0L);
|
||||
return new ChatDashboardPayload(chatStatus, new ArrayList<>(), new ArrayList<>());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建聊天趋势项,缺失日期补 0。
|
||||
*
|
||||
* @param range 时间范围
|
||||
* @param rawTrends 原始趋势
|
||||
* @return 趋势项
|
||||
*/
|
||||
private List<DashboardTrendItemVo> buildTrendItems(String range, List<ChatDashboardTrend> rawTrends) {
|
||||
List<TimeBucket> buckets = buildBuckets(range);
|
||||
Map<String, ChatDashboardTrend> trendMap = new HashMap<>();
|
||||
for (ChatDashboardTrend rawTrend : rawTrends) {
|
||||
trendMap.put(rawTrend.bucketKey(), rawTrend);
|
||||
}
|
||||
|
||||
List<DashboardTrendItemVo> items = new ArrayList<>(buckets.size());
|
||||
for (TimeBucket bucket : buckets) {
|
||||
long activeUserTotal = activeUserMap.getOrDefault(bucket.key, 0L);
|
||||
|
||||
ChatDashboardTrend trend = trendMap.get(bucket.key);
|
||||
DashboardTrendItemVo item = new DashboardTrendItemVo();
|
||||
item.setKey(bucket.key);
|
||||
item.setLabel(bucket.label);
|
||||
item.setActiveUserTotal(activeUserTotal);
|
||||
item.setActiveUserTotal(0L);
|
||||
item.setChatMessageTotal(trend == null ? 0L : trend.messageTotal());
|
||||
item.setChatSessionTotal(trend == null ? 0L : trend.sessionTotal());
|
||||
items.add(item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private List<DashboardDistributionItemVo> buildDistribution(DashboardQueryContext context, DashboardSummaryVo summary) {
|
||||
return buildResourceDistribution(summary);
|
||||
}
|
||||
|
||||
private List<DashboardDistributionItemVo> buildResourceDistribution(DashboardSummaryVo summary) {
|
||||
List<DashboardDistributionItemVo> items = new ArrayList<>();
|
||||
items.add(buildPlatformItem("userTotal", "用户总量", summary.getUserTotal()));
|
||||
items.add(buildPlatformItem("activeUserTotal", "活跃用户", summary.getActiveUserTotal()));
|
||||
items.add(buildPlatformItem("botTotal", "助手数量", summary.getBotTotal()));
|
||||
items.add(buildPlatformItem("workflowTotal", "工作流数量", summary.getWorkflowTotal()));
|
||||
items.add(buildPlatformItem("knowledgeBaseTotal", "知识库数量", summary.getKnowledgeBaseTotal()));
|
||||
/**
|
||||
* 构建智能体使用排行。
|
||||
*
|
||||
* @param ranks 原始排行数据
|
||||
* @return 页面排行项
|
||||
*/
|
||||
private List<DashboardDistributionItemVo> buildAssistantDistribution(List<ChatAssistantUsageRank> ranks) {
|
||||
List<DashboardDistributionItemVo> items = new ArrayList<>(ranks.size());
|
||||
for (ChatAssistantUsageRank rank : ranks) {
|
||||
DashboardDistributionItemVo item = new DashboardDistributionItemVo();
|
||||
item.setKey(rank.assistantId() == null ? "" : rank.assistantId().toString());
|
||||
item.setAssistantId(rank.assistantId());
|
||||
item.setLabel(resolveAssistantLabel(rank.assistantId(), rank.assistantName()));
|
||||
item.setMessageTotal(rank.messageTotal());
|
||||
item.setSessionTotal(rank.sessionTotal());
|
||||
item.setAvgMessagePerSession(calculateAvg(rank.messageTotal(), rank.sessionTotal()));
|
||||
item.setValue(rank.messageTotal());
|
||||
items.add(item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private DashboardDistributionItemVo buildPlatformItem(String key, String label, Long value) {
|
||||
DashboardDistributionItemVo item = new DashboardDistributionItemVo();
|
||||
item.setKey(key);
|
||||
item.setLabel(label);
|
||||
item.setValue(defaultLong(value));
|
||||
return item;
|
||||
}
|
||||
|
||||
private Map<String, Long> queryActiveUserTrend(DashboardQueryContext context, String bucketFormat) {
|
||||
StringBuilder sql = new StringBuilder();
|
||||
List<Object> params = new ArrayList<>();
|
||||
|
||||
sql.append("SELECT DATE_FORMAT(l.created, '").append(bucketFormat).append("') AS bucket_key, ")
|
||||
.append("COUNT(DISTINCT l.account_id) AS total ")
|
||||
.append("FROM tb_sys_log l ")
|
||||
.append("INNER JOIN tb_sys_account a ON a.id = l.account_id AND a.is_deleted IS NULL ")
|
||||
.append("WHERE l.created >= ? AND l.created < ? ");
|
||||
params.add(toDate(context.startTime));
|
||||
params.add(toDate(context.endTime));
|
||||
appendOptionalTenantFilter(sql, params, context.tenantFilterId, "a.tenant_id");
|
||||
appendOptionalDeptFilter(sql, params, context.deptFilterId, "a.dept_id");
|
||||
sql.append("GROUP BY bucket_key ORDER BY bucket_key ASC");
|
||||
|
||||
Map<String, Long> data = new HashMap<>();
|
||||
for (Row row : Db.selectListBySql(sql.toString(), params.toArray())) {
|
||||
data.put(asString(row.get("bucket_key")), asLong(row.get("total")));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按租户统计平台资源数量。
|
||||
*
|
||||
* @param tableName 表名
|
||||
* @param alias 别名
|
||||
* @param containsLogicDelete 是否包含逻辑删除条件
|
||||
* @param context 查询上下文
|
||||
* @return 统计值
|
||||
*/
|
||||
private long countScopedTable(String tableName, String alias, boolean containsLogicDelete, DashboardQueryContext context) {
|
||||
StringBuilder sql = new StringBuilder();
|
||||
List<Object> params = new ArrayList<>();
|
||||
@@ -152,6 +232,12 @@ public class DashboardServiceImpl implements DashboardService {
|
||||
return queryForLong(sql.toString(), params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计当前时间范围内活跃用户数。
|
||||
*
|
||||
* @param context 查询上下文
|
||||
* @return 活跃用户数
|
||||
*/
|
||||
private long countActiveUsers(DashboardQueryContext context) {
|
||||
StringBuilder sql = new StringBuilder();
|
||||
List<Object> params = new ArrayList<>();
|
||||
@@ -167,11 +253,26 @@ public class DashboardServiceImpl implements DashboardService {
|
||||
return queryForLong(sql.toString(), params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 count SQL 并返回 long 值。
|
||||
*
|
||||
* @param sql SQL
|
||||
* @param params 参数
|
||||
* @return long 值
|
||||
*/
|
||||
private long queryForLong(String sql, List<Object> params) {
|
||||
Object result = Db.selectObject(sql, params.toArray());
|
||||
return asLong(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加租户过滤。
|
||||
*
|
||||
* @param sql SQL 构造器
|
||||
* @param params 参数列表
|
||||
* @param tenantId 租户 ID
|
||||
* @param columnName 列名
|
||||
*/
|
||||
private void appendOptionalTenantFilter(StringBuilder sql, List<Object> params, BigInteger tenantId, String columnName) {
|
||||
if (tenantId != null) {
|
||||
sql.append(" AND ").append(columnName).append(" = ? ");
|
||||
@@ -179,6 +280,14 @@ public class DashboardServiceImpl implements DashboardService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加部门过滤。
|
||||
*
|
||||
* @param sql SQL 构造器
|
||||
* @param params 参数列表
|
||||
* @param deptId 部门 ID
|
||||
* @param columnName 列名
|
||||
*/
|
||||
private void appendOptionalDeptFilter(StringBuilder sql, List<Object> params, BigInteger deptId, String columnName) {
|
||||
if (deptId != null) {
|
||||
sql.append(" AND ").append(columnName).append(" = ? ");
|
||||
@@ -186,6 +295,13 @@ public class DashboardServiceImpl implements DashboardService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建查询上下文。
|
||||
*
|
||||
* @param loginAccount 当前登录账号
|
||||
* @param query 查询条件
|
||||
* @return 查询上下文
|
||||
*/
|
||||
private DashboardQueryContext buildContext(LoginAccount loginAccount, DashboardOverviewQuery query) {
|
||||
DashboardQueryContext context = new DashboardQueryContext();
|
||||
context.range = normalizeRange(query == null ? null : query.getRange());
|
||||
@@ -203,11 +319,16 @@ public class DashboardServiceImpl implements DashboardService {
|
||||
context.endTime = LocalDateTime.of(today.plusDays(1), LocalTime.MIN);
|
||||
}
|
||||
|
||||
context.tenantFilterId = context.superAdmin ? null : loginAccount.getTenantId();
|
||||
|
||||
context.tenantFilterId = context.superAdmin || loginAccount == null ? null : loginAccount.getTenantId();
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前登录账号是否为超管。
|
||||
*
|
||||
* @param loginAccount 当前登录账号
|
||||
* @return true 表示超管
|
||||
*/
|
||||
private boolean isSuperAdmin(LoginAccount loginAccount) {
|
||||
if (loginAccount == null || loginAccount.getId() == null) {
|
||||
return false;
|
||||
@@ -228,6 +349,12 @@ public class DashboardServiceImpl implements DashboardService {
|
||||
return sysRoleService.count(roleWrapper) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化时间范围参数。
|
||||
*
|
||||
* @param range 原始时间范围
|
||||
* @return 规范化后的时间范围
|
||||
*/
|
||||
private String normalizeRange(String range) {
|
||||
if (!StringUtils.hasText(range)) {
|
||||
return "7d";
|
||||
@@ -238,10 +365,15 @@ public class DashboardServiceImpl implements DashboardService {
|
||||
throw new BusinessException("不支持的时间范围: " + range);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建时间桶。
|
||||
*
|
||||
* @param range 时间范围
|
||||
* @return 时间桶列表
|
||||
*/
|
||||
private List<TimeBucket> buildBuckets(String range) {
|
||||
List<TimeBucket> buckets = new ArrayList<>();
|
||||
LocalDate today = LocalDate.now(DEFAULT_ZONE_ID);
|
||||
|
||||
if ("today".equals(range)) {
|
||||
DateTimeFormatter keyFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00:00");
|
||||
DateTimeFormatter labelFormatter = DateTimeFormatter.ofPattern("HH:00");
|
||||
@@ -264,18 +396,22 @@ public class DashboardServiceImpl implements DashboardService {
|
||||
return buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 LocalDateTime 转换为 Date。
|
||||
*
|
||||
* @param dateTime 时间
|
||||
* @return Date
|
||||
*/
|
||||
private Date toDate(LocalDateTime dateTime) {
|
||||
return Date.from(dateTime.atZone(DEFAULT_ZONE_ID).toInstant());
|
||||
}
|
||||
|
||||
private long defaultLong(Long value) {
|
||||
return value == null ? 0L : value;
|
||||
}
|
||||
|
||||
private String asString(Object value) {
|
||||
return value == null ? "" : String.valueOf(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析对象为 long 值。
|
||||
*
|
||||
* @param value 原始对象
|
||||
* @return long 值
|
||||
*/
|
||||
private long asLong(Object value) {
|
||||
if (value == null) {
|
||||
return 0L;
|
||||
@@ -286,6 +422,37 @@ public class DashboardServiceImpl implements DashboardService {
|
||||
return Long.parseLong(String.valueOf(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算平均每会话消息数。
|
||||
*
|
||||
* @param messageTotal 消息总数
|
||||
* @param sessionTotal 会话总数
|
||||
* @return 平均值
|
||||
*/
|
||||
private double calculateAvg(long messageTotal, long sessionTotal) {
|
||||
if (sessionTotal <= 0) {
|
||||
return 0D;
|
||||
}
|
||||
return (double) messageTotal / (double) sessionTotal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析智能体展示名称。
|
||||
*
|
||||
* @param assistantId 智能体 ID
|
||||
* @param assistantName 智能体名称
|
||||
* @return 展示名称
|
||||
*/
|
||||
private String resolveAssistantLabel(BigInteger assistantId, String assistantName) {
|
||||
if (StringUtils.hasText(assistantName)) {
|
||||
return assistantName.trim();
|
||||
}
|
||||
return assistantId == null ? "智能体-未知" : "智能体-" + assistantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作台查询上下文。
|
||||
*/
|
||||
private static class DashboardQueryContext {
|
||||
private String range;
|
||||
private BigInteger tenantFilterId;
|
||||
@@ -295,6 +462,9 @@ public class DashboardServiceImpl implements DashboardService {
|
||||
private LocalDateTime endTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间桶。
|
||||
*/
|
||||
private static class TimeBucket {
|
||||
private final String key;
|
||||
private final String label;
|
||||
@@ -304,4 +474,14 @@ public class DashboardServiceImpl implements DashboardService {
|
||||
this.label = label;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天统计页面载荷。
|
||||
*/
|
||||
private record ChatDashboardPayload(
|
||||
DashboardChatStatusVo chatStatus,
|
||||
List<DashboardTrendItemVo> trends,
|
||||
List<DashboardDistributionItemVo> distribution
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package tech.easyflow.admin.service.dashboard.impl;
|
||||
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo;
|
||||
import tech.easyflow.admin.model.dashboard.DashboardSummaryVo;
|
||||
import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
||||
import tech.easyflow.chatlog.service.ChatDashboardQueryService;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.math.BigInteger;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link DashboardServiceImpl} 测试。
|
||||
*/
|
||||
public class DashboardServiceImplTest {
|
||||
|
||||
/**
|
||||
* 验证分析库不可用时返回明确不可用状态,且趋势与排行为空。
|
||||
*
|
||||
* @throws Exception 反射调用失败
|
||||
*/
|
||||
@Test
|
||||
public void shouldReturnUnavailableChatPayloadWhenAnalyticalDbIsDisabled() throws Exception {
|
||||
DashboardServiceImpl service = new DashboardServiceImpl();
|
||||
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
|
||||
when(chatDashboardQueryService.available()).thenReturn(false);
|
||||
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
|
||||
|
||||
Object context = newContext("7d", null);
|
||||
DashboardSummaryVo summary = new DashboardSummaryVo();
|
||||
Object payload = invokeBuildChatPayload(service, context, summary);
|
||||
|
||||
Object chatStatus = readField(payload, "chatStatus");
|
||||
List<?> trends = (List<?>) readField(payload, "trends");
|
||||
List<?> distribution = (List<?>) readField(payload, "distribution");
|
||||
|
||||
Assert.assertFalse(Boolean.TRUE.equals(invokeGetter(chatStatus, "getAvailable")));
|
||||
Assert.assertEquals(invokeGetter(chatStatus, "getMessage"), "聊天数据不可用");
|
||||
Assert.assertTrue(trends.isEmpty());
|
||||
Assert.assertTrue(distribution.isEmpty());
|
||||
Assert.assertEquals(summary.getChatMessageTotal(), Long.valueOf(0L));
|
||||
Assert.assertEquals(summary.getChatSessionTotal(), Long.valueOf(0L));
|
||||
Assert.assertEquals(summary.getActiveAssistantTotal(), Long.valueOf(0L));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 today 返回 24 个小时点位,且排行名称与均值回退正确。
|
||||
*
|
||||
* @throws Exception 反射调用失败
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void shouldBuildHourlyTrendForToday() throws Exception {
|
||||
DashboardServiceImpl service = new DashboardServiceImpl();
|
||||
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
|
||||
String currentHourKey = LocalDateTime.of(LocalDate.now(), LocalTime.of(10, 0))
|
||||
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00:00"));
|
||||
when(chatDashboardQueryService.available()).thenReturn(true);
|
||||
when(chatDashboardQueryService.querySummary(any(), any(), any()))
|
||||
.thenReturn(new ChatDashboardSummary(3L, 9L, 1L));
|
||||
when(chatDashboardQueryService.queryHourlyTrends(any(), any(), any()))
|
||||
.thenReturn(List.of(new ChatDashboardTrend(currentHourKey, 3L, 9L)));
|
||||
when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class)))
|
||||
.thenReturn(List.of(new ChatAssistantUsageRank(BigInteger.ONE, "", 3L, 9L)));
|
||||
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
|
||||
|
||||
Object context = newContext("today", BigInteger.valueOf(9));
|
||||
DashboardSummaryVo summary = new DashboardSummaryVo();
|
||||
Object payload = invokeBuildChatPayload(service, context, summary);
|
||||
|
||||
Object chatStatus = readField(payload, "chatStatus");
|
||||
List<DashboardTrendItemVo> trends = (List<DashboardTrendItemVo>) readField(payload, "trends");
|
||||
List<DashboardDistributionItemVo> distribution = (List<DashboardDistributionItemVo>) readField(payload, "distribution");
|
||||
|
||||
Assert.assertTrue(Boolean.TRUE.equals(invokeGetter(chatStatus, "getAvailable")));
|
||||
Assert.assertEquals(trends.size(), 24);
|
||||
Assert.assertEquals(trends.get(0).getLabel(), "00:00");
|
||||
Assert.assertEquals(trends.get(10).getKey(), currentHourKey);
|
||||
Assert.assertEquals(trends.get(10).getLabel(), "10:00");
|
||||
Assert.assertEquals(trends.get(10).getChatMessageTotal(), Long.valueOf(9L));
|
||||
Assert.assertEquals(trends.get(10).getChatSessionTotal(), Long.valueOf(3L));
|
||||
Assert.assertEquals(trends.get(11).getChatMessageTotal(), Long.valueOf(0L));
|
||||
Assert.assertEquals(trends.get(11).getChatSessionTotal(), Long.valueOf(0L));
|
||||
Assert.assertEquals(summary.getChatMessageTotal(), Long.valueOf(9L));
|
||||
Assert.assertEquals(summary.getChatSessionTotal(), Long.valueOf(3L));
|
||||
Assert.assertEquals(summary.getActiveAssistantTotal(), Long.valueOf(1L));
|
||||
Assert.assertEquals(distribution.get(0).getLabel(), "智能体-1");
|
||||
Assert.assertEquals(distribution.get(0).getAvgMessagePerSession(), Double.valueOf(3D));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造查询上下文。
|
||||
*
|
||||
* @param range 时间范围
|
||||
* @param tenantId 租户 ID
|
||||
* @return 查询上下文实例
|
||||
* @throws Exception 反射失败
|
||||
*/
|
||||
private Object newContext(String range, BigInteger tenantId) throws Exception {
|
||||
Class<?> contextClass = Class.forName(
|
||||
"tech.easyflow.admin.service.dashboard.impl.DashboardServiceImpl$DashboardQueryContext"
|
||||
);
|
||||
Constructor<?> constructor = contextClass.getDeclaredConstructor();
|
||||
constructor.setAccessible(true);
|
||||
Object context = constructor.newInstance();
|
||||
setField(context, "range", range);
|
||||
setField(context, "tenantFilterId", tenantId);
|
||||
setField(context, "startTime", LocalDateTime.of(LocalDate.now(), java.time.LocalTime.MIN));
|
||||
setField(context, "endTime", LocalDateTime.of(LocalDate.now().plusDays(1), java.time.LocalTime.MIN));
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用私有聊天载荷组装方法。
|
||||
*
|
||||
* @param service service
|
||||
* @param context 上下文
|
||||
* @param summary 汇总对象
|
||||
* @return 载荷
|
||||
* @throws Exception 反射失败
|
||||
*/
|
||||
private Object invokeBuildChatPayload(DashboardServiceImpl service, Object context, DashboardSummaryVo summary)
|
||||
throws Exception {
|
||||
Method method = DashboardServiceImpl.class.getDeclaredMethod(
|
||||
"buildChatPayload",
|
||||
context.getClass(),
|
||||
DashboardSummaryVo.class
|
||||
);
|
||||
method.setAccessible(true);
|
||||
return method.invoke(service, context, summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取对象字段。
|
||||
*
|
||||
* @param target 目标对象
|
||||
* @param fieldName 字段名
|
||||
* @return 字段值
|
||||
* @throws Exception 反射失败
|
||||
*/
|
||||
private Object readField(Object target, String fieldName) throws Exception {
|
||||
Field field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
return field.get(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 getter。
|
||||
*
|
||||
* @param target 目标对象
|
||||
* @param methodName 方法名
|
||||
* @return 返回值
|
||||
* @throws Exception 反射失败
|
||||
*/
|
||||
private Object invokeGetter(Object target, String methodName) throws Exception {
|
||||
Method method = target.getClass().getMethod(methodName);
|
||||
return method.invoke(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过反射设置字段值。
|
||||
*
|
||||
* @param target 目标对象
|
||||
* @param fieldName 字段名
|
||||
* @param value 字段值
|
||||
* @throws Exception 反射失败
|
||||
*/
|
||||
private void setField(Object target, String fieldName, Object value) throws Exception {
|
||||
Class<?> current = target.getClass();
|
||||
while (current != null) {
|
||||
try {
|
||||
Field field = current.getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
return;
|
||||
} catch (NoSuchFieldException ignored) {
|
||||
current = current.getSuperclass();
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("未找到字段: " + fieldName);
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,7 @@ package tech.easyflow.publicapi.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||
import com.easyagents.flow.core.chain.Parameter;
|
||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||
import com.easyagents.flow.core.parser.ChainParser;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -16,7 +13,7 @@ import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
||||
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.common.constant.Constants;
|
||||
@@ -41,13 +38,11 @@ public class PublicWorkflowController {
|
||||
@Resource
|
||||
private ChainExecutor chainExecutor;
|
||||
@Resource
|
||||
private ChainParser chainParser;
|
||||
@Resource
|
||||
private TinyFlowService tinyFlowService;
|
||||
@Resource
|
||||
private WorkflowCheckService workflowCheckService;
|
||||
@Resource
|
||||
private WorkflowDatacenterContentService workflowDatacenterContentService;
|
||||
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
||||
|
||||
/**
|
||||
* 通过id或别名获取工作流详情
|
||||
@@ -81,6 +76,7 @@ public class PublicWorkflowController {
|
||||
if (variables == null) {
|
||||
variables = new HashMap<>();
|
||||
}
|
||||
variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables);
|
||||
if (StpUtil.isLogin()) {
|
||||
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
||||
}
|
||||
@@ -104,6 +100,7 @@ public class PublicWorkflowController {
|
||||
throw new RuntimeException("工作流不存在");
|
||||
}
|
||||
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||
variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables);
|
||||
String executeId = chainExecutor.executeAsync(PublishedWorkflowDefinitionIds.published(id.toString()), variables);
|
||||
return Result.ok(executeId);
|
||||
}
|
||||
@@ -139,17 +136,10 @@ public class PublicWorkflowController {
|
||||
return Result.fail(1, "can not find the workflow by id: " + id);
|
||||
}
|
||||
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||
|
||||
ChainDefinition definition = chainParser.parse(workflowDatacenterContentService.prepareContent(workflow.getContent()));
|
||||
if (definition == null) {
|
||||
Map<String, Object> res = workflowRunningParameterResolver.buildRunningParametersView(workflow);
|
||||
if (res == null) {
|
||||
return Result.fail(2, "节点配置错误,请检查! ");
|
||||
}
|
||||
List<Parameter> chainParameters = definition.getStartParameters();
|
||||
Map<String, Object> res = new HashMap<>();
|
||||
res.put("parameters", chainParameters);
|
||||
res.put("title", workflow.getTitle());
|
||||
res.put("description", workflow.getDescription());
|
||||
res.put("icon", workflow.getIcon());
|
||||
return Result.ok(res);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,18 +4,15 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import com.mybatisflex.core.paginate.Page;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||
import com.easyagents.flow.core.chain.Parameter;
|
||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||
import com.easyagents.flow.core.parser.ChainParser;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.common.annotation.UsePermission;
|
||||
@@ -48,13 +45,11 @@ public class UcWorkflowController extends BaseCurdController<WorkflowService, Wo
|
||||
@Resource
|
||||
private ChainExecutor chainExecutor;
|
||||
@Resource
|
||||
private ChainParser chainParser;
|
||||
@Resource
|
||||
private TinyFlowService tinyFlowService;
|
||||
@Resource
|
||||
private WorkflowCheckService workflowCheckService;
|
||||
@Resource
|
||||
private WorkflowDatacenterContentService workflowDatacenterContentService;
|
||||
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
||||
@Resource
|
||||
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
|
||||
|
||||
@@ -86,6 +81,7 @@ public class UcWorkflowController extends BaseCurdController<WorkflowService, Wo
|
||||
if (variables == null) {
|
||||
variables = new HashMap<>();
|
||||
}
|
||||
variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables);
|
||||
if (StpUtil.isLogin()) {
|
||||
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
||||
}
|
||||
@@ -115,6 +111,7 @@ public class UcWorkflowController extends BaseCurdController<WorkflowService, Wo
|
||||
throw new RuntimeException("工作流不存在");
|
||||
}
|
||||
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||
variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables);
|
||||
if (StpUtil.isLogin()) {
|
||||
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
||||
}
|
||||
@@ -176,17 +173,10 @@ public class UcWorkflowController extends BaseCurdController<WorkflowService, Wo
|
||||
return Result.fail(1, "can not find the workflow by id: " + id);
|
||||
}
|
||||
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||
|
||||
ChainDefinition definition = chainParser.parse(workflowDatacenterContentService.prepareContent(workflow.getContent()));
|
||||
if (definition == null) {
|
||||
Map<String, Object> res = workflowRunningParameterResolver.buildRunningParametersView(workflow);
|
||||
if (res == null) {
|
||||
return Result.fail(2, "节点配置错误,请检查! ");
|
||||
}
|
||||
List<Parameter> chainParameters = definition.getStartParameters();
|
||||
Map<String, Object> res = new HashMap<>();
|
||||
res.put("parameters", chainParameters);
|
||||
res.put("title", workflow.getTitle());
|
||||
res.put("description", workflow.getDescription());
|
||||
res.put("icon", workflow.getIcon());
|
||||
return Result.ok(res);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,14 @@
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.commonmark</groupId>
|
||||
<artifactId>commonmark-ext-gfm-tables</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.openhtmltopdf</groupId>
|
||||
<artifactId>openhtmltopdf-pdfbox</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-module-system</artifactId>
|
||||
|
||||
@@ -11,6 +11,9 @@ import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckResult;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
||||
import tech.easyflow.ai.entity.PluginItem;
|
||||
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.snapshot.WorkflowPluginSnapshotResolver;
|
||||
import tech.easyflow.ai.service.PluginItemService;
|
||||
@@ -45,6 +48,8 @@ public class WorkflowCheckService {
|
||||
private static final String TYPE_LOOP = "loopNode";
|
||||
private static final String TYPE_WORKFLOW = "workflow-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";
|
||||
|
||||
@Resource
|
||||
private WorkflowService workflowService;
|
||||
@@ -78,6 +83,10 @@ public class WorkflowCheckService {
|
||||
Set<String> issueKeys = new LinkedHashSet<>();
|
||||
ParsedWorkflow parsedWorkflow = parseAndCheckBase(content, issues, issueKeys);
|
||||
if (parsedWorkflow != null) {
|
||||
List<NodeView> startNodes = parsedWorkflow.nodes.stream()
|
||||
.filter(node -> TYPE_START.equals(node.type))
|
||||
.collect(Collectors.toList());
|
||||
checkStartFormSchema(startNodes, issues, issueKeys);
|
||||
checkPluginSchemaHashes(parsedWorkflow, issues, issueKeys);
|
||||
}
|
||||
|
||||
@@ -217,6 +226,10 @@ public class WorkflowCheckService {
|
||||
if (node == null) {
|
||||
continue;
|
||||
}
|
||||
if (TYPE_MAKE_FILE.equals(node.type)) {
|
||||
checkMakeFileNode(node, issues, issueKeys);
|
||||
continue;
|
||||
}
|
||||
if (workflowDatacenterContentService.isSearchDatasetNode(node.type)) {
|
||||
checkSearchDatasetNode(node, issues, issueKeys);
|
||||
continue;
|
||||
@@ -231,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,
|
||||
List<WorkflowCheckIssue> issues,
|
||||
Set<String> issueKeys) {
|
||||
@@ -436,6 +469,69 @@ public class WorkflowCheckService {
|
||||
detectWorkflowReferenceCycle(currentWorkflowIdString, currentWorkflowIdString, currentContent, contentCache, issues, issueKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验开始节点开场表单 Schema 的最小约束。
|
||||
*
|
||||
* @param startNodes 开始节点列表
|
||||
* @param issues 问题收集
|
||||
* @param issueKeys 去重键集合
|
||||
*/
|
||||
private void checkStartFormSchema(List<NodeView> startNodes,
|
||||
List<WorkflowCheckIssue> issues,
|
||||
Set<String> issueKeys) {
|
||||
for (NodeView startNode : startNodes) {
|
||||
if (startNode == null || startNode.data == null) {
|
||||
continue;
|
||||
}
|
||||
JSONArray schema = startNode.data.getJSONArray("startFormSchema");
|
||||
if (schema == null || schema.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
JSONObject userInputField = null;
|
||||
for (int i = 0; i < schema.size(); i++) {
|
||||
JSONObject field = schema.getJSONObject(i);
|
||||
if (field == null) {
|
||||
continue;
|
||||
}
|
||||
String key = trimToNull(field.getString("key"));
|
||||
if (!StringUtils.hasText(key)) {
|
||||
addIssue(issues, issueKeys, "START_FORM_FIELD_KEY_EMPTY",
|
||||
"开始表单字段缺少 key", startNode.id, null, startNode.name);
|
||||
continue;
|
||||
}
|
||||
String type = trimToNull(field.getString("type"));
|
||||
if (Arrays.asList("radio", "checkbox", "select").contains(type)) {
|
||||
JSONArray options = field.getJSONArray("options");
|
||||
if (options == null || options.isEmpty()) {
|
||||
addIssue(issues, issueKeys, "START_FORM_OPTIONS_EMPTY",
|
||||
"选择类开始表单字段必须配置至少一个选项", startNode.id, null, startNode.name);
|
||||
}
|
||||
}
|
||||
if (SYSTEM_START_PARAM_NAME.equals(key)) {
|
||||
userInputField = field;
|
||||
}
|
||||
}
|
||||
|
||||
if (userInputField == null) {
|
||||
addIssue(issues, issueKeys, "START_FORM_USER_INPUT_MISSING",
|
||||
"开始表单必须包含 user_input 主问题字段", startNode.id, null, startNode.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Boolean.TRUE.equals(userInputField.getBoolean("required"))) {
|
||||
addIssue(issues, issueKeys, "START_FORM_USER_INPUT_REQUIRED",
|
||||
"开始表单的 user_input 字段必须为必填", startNode.id, null, startNode.name);
|
||||
}
|
||||
|
||||
String userInputType = trimToNull(userInputField.getString("type"));
|
||||
if (!Arrays.asList("text", "textarea").contains(userInputType)) {
|
||||
addIssue(issues, issueKeys, "START_FORM_USER_INPUT_TYPE_INVALID",
|
||||
"开始表单的 user_input 字段只能使用 text 或 textarea", startNode.id, null, startNode.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void detectWorkflowReferenceCycle(String rootWorkflowId, String currentWorkflowId, String currentContent,
|
||||
Map<String, String> contentCache,
|
||||
List<WorkflowCheckIssue> issues, Set<String> issueKeys) {
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
package tech.easyflow.ai.easyagentsflow.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||
import com.easyagents.flow.core.chain.Parameter;
|
||||
import com.easyagents.flow.core.parser.ChainParser;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
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.Set;
|
||||
|
||||
/**
|
||||
* 工作流运行参数解析器。
|
||||
*
|
||||
* <p>负责统一构建运行页所需的开始节点参数、开场表单元信息和字段 Schema,
|
||||
* 避免 Admin / Public / UserCenter 三端各自维护一套解析逻辑。</p>
|
||||
*/
|
||||
@Service
|
||||
public class WorkflowRunningParameterResolver {
|
||||
|
||||
private static final String START_NODE_TYPE = "startNode";
|
||||
private static final String SYSTEM_START_PARAM_NAME = "user_input";
|
||||
private static final String DEFAULT_START_FORM_TITLE = "开始问答";
|
||||
private static final String DEFAULT_START_FORM_DESCRIPTION = "请先补充必要信息,再开始执行工作流。";
|
||||
private static final String DEFAULT_START_FORM_SUBMIT_TEXT = "开始";
|
||||
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;
|
||||
|
||||
@Resource
|
||||
private ChainParser chainParser;
|
||||
@Resource
|
||||
private WorkflowDatacenterContentService workflowDatacenterContentService;
|
||||
|
||||
/**
|
||||
* 构建工作流运行参数视图。
|
||||
*
|
||||
* @param workflow 工作流实体
|
||||
* @return 运行参数视图;当流程内容无法解析时返回 {@code null}
|
||||
*/
|
||||
public Map<String, Object> buildRunningParametersView(Workflow workflow) {
|
||||
if (workflow == null) {
|
||||
return null;
|
||||
}
|
||||
List<Parameter> startParameters = resolveStartParameters(workflow.getContent());
|
||||
if (startParameters == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
JSONObject startNodeData = findStartNodeData(workflow.getContent());
|
||||
List<Map<String, Object>> startFormSchema = resolveStartFormSchema(startNodeData, startParameters);
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("parameters", startParameters);
|
||||
result.put("title", workflow.getTitle());
|
||||
result.put("description", workflow.getDescription());
|
||||
result.put("icon", workflow.getIcon());
|
||||
result.put("startFormMeta", resolveStartFormMeta(startNodeData));
|
||||
result.put("startFormSchema", startFormSchema);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析开始节点运行参数。
|
||||
*
|
||||
* @param content 工作流内容
|
||||
* @return 开始节点参数;解析失败时返回 {@code null}
|
||||
*/
|
||||
public List<Parameter> resolveStartParameters(String content) {
|
||||
try {
|
||||
String preparedContent = workflowDatacenterContentService.prepareContent(content);
|
||||
ChainDefinition definition = chainParser.parse(preparedContent);
|
||||
return definition == null ? null : definition.getStartParameters();
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化工作流运行时变量,确保文件参数统一为文件对象数组。
|
||||
*
|
||||
* @param content 工作流内容
|
||||
* @param variables 原始运行变量
|
||||
* @return 归一化后的变量副本
|
||||
*/
|
||||
public Map<String, Object> normalizeRuntimeVariables(String content, Map<String, Object> variables) {
|
||||
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||
if (variables != null) {
|
||||
normalized.putAll(variables);
|
||||
}
|
||||
List<Parameter> startParameters = resolveStartParameters(content);
|
||||
if (startParameters == null || startParameters.isEmpty()) {
|
||||
return normalized;
|
||||
}
|
||||
for (Parameter parameter : startParameters) {
|
||||
if (!isFileParameter(parameter)) {
|
||||
continue;
|
||||
}
|
||||
String name = trimToNull(parameter.getName());
|
||||
if (!StringUtils.hasText(name) || !normalized.containsKey(name)) {
|
||||
continue;
|
||||
}
|
||||
normalized.put(name, normalizeFileVariableValue(normalized.get(name), name));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private Map<String, Object> resolveStartFormMeta(JSONObject startNodeData) {
|
||||
JSONObject rawMeta = startNodeData == null ? null : startNodeData.getJSONObject("startFormMeta");
|
||||
Map<String, Object> meta = new LinkedHashMap<>();
|
||||
meta.put("title", trimToDefault(rawMeta == null ? null : rawMeta.getString("title"), DEFAULT_START_FORM_TITLE));
|
||||
meta.put("description", trimToDefault(rawMeta == null ? null : rawMeta.getString("description"), DEFAULT_START_FORM_DESCRIPTION));
|
||||
meta.put("submitText", trimToDefault(rawMeta == null ? null : rawMeta.getString("submitText"), DEFAULT_START_FORM_SUBMIT_TEXT));
|
||||
return meta;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> resolveStartFormSchema(JSONObject startNodeData, List<Parameter> parameters) {
|
||||
JSONArray rawSchema = startNodeData == null ? null : startNodeData.getJSONArray("startFormSchema");
|
||||
List<Map<String, Object>> schema = new ArrayList<>();
|
||||
Set<String> seenKeys = new LinkedHashSet<>();
|
||||
boolean hasExplicitSchema = rawSchema != null;
|
||||
boolean hasSystemParameter = parameters.stream().anyMatch(parameter ->
|
||||
SYSTEM_START_PARAM_NAME.equals(trimToNull(parameter == null ? null : parameter.getName()))
|
||||
);
|
||||
if (rawSchema != null && !rawSchema.isEmpty()) {
|
||||
for (int i = 0; i < rawSchema.size(); i++) {
|
||||
JSONObject field = rawSchema.getJSONObject(i);
|
||||
Map<String, Object> normalized = normalizeStartFormField(field, null);
|
||||
if (normalized == null) {
|
||||
continue;
|
||||
}
|
||||
String key = String.valueOf(normalized.get("key"));
|
||||
if (!seenKeys.add(key)) {
|
||||
continue;
|
||||
}
|
||||
if (SYSTEM_START_PARAM_NAME.equals(key)) {
|
||||
schema.add(0, normalized);
|
||||
} else {
|
||||
schema.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (schema.isEmpty()) {
|
||||
for (Parameter parameter : parameters) {
|
||||
Map<String, Object> normalized = normalizeStartFormField(null, parameter);
|
||||
if (normalized == null) {
|
||||
continue;
|
||||
}
|
||||
String key = String.valueOf(normalized.get("key"));
|
||||
if (!seenKeys.add(key)) {
|
||||
continue;
|
||||
}
|
||||
if (SYSTEM_START_PARAM_NAME.equals(key)) {
|
||||
schema.add(0, normalized);
|
||||
} else {
|
||||
schema.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!seenKeys.contains(SYSTEM_START_PARAM_NAME) && (hasExplicitSchema || hasSystemParameter)) {
|
||||
schema.add(0, normalizeStartFormField(new JSONObject(), null));
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
private Map<String, Object> normalizeStartFormField(JSONObject field, Parameter parameter) {
|
||||
String parameterName = parameter == null ? null : trimToNull(parameter.getName());
|
||||
String key = trimToNull(field == null ? null : field.getString("key"));
|
||||
if (!StringUtils.hasText(key)) {
|
||||
key = parameterName;
|
||||
}
|
||||
if (!StringUtils.hasText(key)) {
|
||||
key = SYSTEM_START_PARAM_NAME;
|
||||
}
|
||||
boolean systemReserved = SYSTEM_START_PARAM_NAME.equals(key)
|
||||
|| (field != null && Boolean.TRUE.equals(field.getBoolean("systemReserved")));
|
||||
String type = resolveStartFormFieldType(field == null ? null : field.getString("type"), parameter, systemReserved);
|
||||
List<String> options = resolveFieldOptions(field, parameter, type);
|
||||
|
||||
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||
normalized.put("key", key);
|
||||
normalized.put("label", trimToDefault(
|
||||
field == null ? null : field.getString("label"),
|
||||
parameter == null ? null : parameter.getFormLabel(),
|
||||
SYSTEM_START_PARAM_NAME.equals(key) ? "用户问题" : key
|
||||
));
|
||||
normalized.put("type", type);
|
||||
normalized.put("required", systemReserved || (field != null && Boolean.TRUE.equals(field.getBoolean("required")))
|
||||
|| (parameter != null && parameter.isRequired()));
|
||||
normalized.put("placeholder", trimToDefault(
|
||||
field == null ? null : field.getString("placeholder"),
|
||||
parameter == null ? null : parameter.getFormPlaceholder(),
|
||||
SYSTEM_START_PARAM_NAME.equals(key) ? "请输入用户问题" : ""
|
||||
));
|
||||
normalized.put("description", trimToDefault(
|
||||
field == null ? null : field.getString("description"),
|
||||
parameter == null ? null : parameter.getFormDescription(),
|
||||
""
|
||||
));
|
||||
normalized.put("defaultValue", resolveDefaultValue(field, parameter, type));
|
||||
normalized.put("options", options);
|
||||
normalized.put("systemReserved", systemReserved);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private Object resolveDefaultValue(JSONObject field, Parameter parameter, String type) {
|
||||
Object rawDefaultValue = field == null ? null : field.get("defaultValue");
|
||||
if (rawDefaultValue != null) {
|
||||
if ("checkbox".equals(type) && rawDefaultValue instanceof List<?>) {
|
||||
return rawDefaultValue;
|
||||
}
|
||||
return rawDefaultValue;
|
||||
}
|
||||
return parameter == null ? "" : parameter.getDefaultValue();
|
||||
}
|
||||
|
||||
private List<String> resolveFieldOptions(JSONObject field, Parameter parameter, String type) {
|
||||
if (!"radio".equals(type) && !"checkbox".equals(type) && !"select".equals(type)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<String> options = new ArrayList<>();
|
||||
if (field != null) {
|
||||
JSONArray rawOptions = field.getJSONArray("options");
|
||||
if (rawOptions != null) {
|
||||
for (int i = 0; i < rawOptions.size(); i++) {
|
||||
String option = trimToNull(rawOptions.getString(i));
|
||||
if (StringUtils.hasText(option)) {
|
||||
options.add(option);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!options.isEmpty()) {
|
||||
return options;
|
||||
}
|
||||
if (parameter != null && parameter.getEnums() != null) {
|
||||
for (Object option : parameter.getEnums()) {
|
||||
String normalized = trimToNull(option == null ? null : String.valueOf(option));
|
||||
if (StringUtils.hasText(normalized)) {
|
||||
options.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private String resolveStartFormFieldType(String rawType, Parameter parameter, boolean systemReserved) {
|
||||
String parameterType = parameterType(parameter);
|
||||
String requested = trimToNull(rawType);
|
||||
String normalized;
|
||||
if (StringUtils.hasText(requested)) {
|
||||
normalized = requested;
|
||||
} else if (StringUtils.hasText(parameterType)) {
|
||||
normalized = parameterType;
|
||||
} else {
|
||||
normalized = systemReserved ? "textarea" : "text";
|
||||
}
|
||||
if (systemReserved) {
|
||||
return "text".equals(normalized) ? "text" : "textarea";
|
||||
}
|
||||
return switch (normalized) {
|
||||
case "textarea", "radio", "checkbox", "select", "file" -> normalized;
|
||||
default -> "text";
|
||||
};
|
||||
}
|
||||
|
||||
private String parameterType(Parameter parameter) {
|
||||
if (parameter == null) {
|
||||
return null;
|
||||
}
|
||||
if ("file".equals(trimToNull(parameter.getContentType()))
|
||||
|| "file".equalsIgnoreCase(trimToNull(String.valueOf(parameter.getDataType())))) {
|
||||
return "file";
|
||||
}
|
||||
String formType = trimToNull(parameter.getFormType());
|
||||
if ("textarea".equals(formType)) {
|
||||
return "textarea";
|
||||
}
|
||||
if ("radio".equals(formType)) {
|
||||
return "radio";
|
||||
}
|
||||
if ("checkbox".equals(formType)) {
|
||||
return "checkbox";
|
||||
}
|
||||
if ("select".equals(formType)) {
|
||||
return "select";
|
||||
}
|
||||
return SYSTEM_START_PARAM_NAME.equals(trimToNull(parameter.getName())) ? "textarea" : "text";
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断参数是否为文件输入参数。
|
||||
*
|
||||
* @param parameter 参数定义
|
||||
* @return 是否文件参数
|
||||
*/
|
||||
private boolean isFileParameter(Parameter parameter) {
|
||||
if (parameter == null) {
|
||||
return false;
|
||||
}
|
||||
return "file".equals(trimToNull(parameter.getContentType()))
|
||||
|| "file".equalsIgnoreCase(trimToNull(String.valueOf(parameter.getDataType())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单文件或多文件运行值归一化为文件对象数组。
|
||||
*
|
||||
* @param value 原始变量值
|
||||
* @param parameterName 参数名
|
||||
* @return 归一化后的文件对象数组
|
||||
*/
|
||||
private List<Object> normalizeFileVariableValue(Object value, String parameterName) {
|
||||
List<Object> candidates = new ArrayList<>();
|
||||
collectFileValues(value, candidates);
|
||||
if (candidates.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
List<Object> normalized = new ArrayList<>();
|
||||
Set<String> seenFilePaths = new LinkedHashSet<>();
|
||||
long totalSize = 0L;
|
||||
for (Object candidate : candidates) {
|
||||
if (!(candidate instanceof Map<?, ?> fileMap)) {
|
||||
throw new BusinessException("文件参数 " + parameterName + " 的输入格式不正确,必须为文件对象或文件对象数组");
|
||||
}
|
||||
String fileName = trimObjectToNull(fileMap.get("fileName"));
|
||||
String filePath = trimObjectToNull(fileMap.get("filePath"));
|
||||
if (!StringUtils.hasText(fileName)) {
|
||||
throw new BusinessException("文件参数 " + parameterName + " 缺少 fileName");
|
||||
}
|
||||
if (!StringUtils.hasText(filePath)) {
|
||||
throw new BusinessException("文件参数 " + parameterName + " 缺少 filePath");
|
||||
}
|
||||
if (!seenFilePaths.add(filePath)) {
|
||||
continue;
|
||||
}
|
||||
Long size = parseLong(fileMap.get("size"));
|
||||
if (size != null && size > FILE_MAX_SINGLE_SIZE) {
|
||||
throw new BusinessException("文件参数 " + parameterName + " 中单个文件不能超过 5MB");
|
||||
}
|
||||
if (size != null && size > 0) {
|
||||
totalSize += size;
|
||||
}
|
||||
normalized.add(new LinkedHashMap<>(fileMap));
|
||||
}
|
||||
|
||||
if (normalized.size() > FILE_MAX_COUNT) {
|
||||
throw new BusinessException("文件参数 " + parameterName + " 最多上传 10 个文件");
|
||||
}
|
||||
if (totalSize > FILE_MAX_TOTAL_SIZE) {
|
||||
throw new BusinessException("文件参数 " + parameterName + " 的文件总大小不能超过 50MB");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private String trimObjectToNull(Object value) {
|
||||
return trimToNull(value == null ? null : String.valueOf(value));
|
||||
}
|
||||
|
||||
private Long parseLong(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
String text = trimObjectToNull(value);
|
||||
if (!StringUtils.hasText(text)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(text);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new BusinessException("文件大小格式不正确: " + text);
|
||||
}
|
||||
}
|
||||
|
||||
private JSONObject findStartNodeData(String content) {
|
||||
if (!StringUtils.hasText(content)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Object parsed = JSON.parse(content);
|
||||
if (!(parsed instanceof JSONObject root)) {
|
||||
return null;
|
||||
}
|
||||
JSONArray nodes = root.getJSONArray("nodes");
|
||||
if (nodes == null) {
|
||||
return null;
|
||||
}
|
||||
for (int i = 0; i < nodes.size(); i++) {
|
||||
JSONObject node = nodes.getJSONObject(i);
|
||||
if (node == null) {
|
||||
continue;
|
||||
}
|
||||
if (!START_NODE_TYPE.equals(trimToNull(node.getString("type")))) {
|
||||
continue;
|
||||
}
|
||||
return node.getJSONObject("data");
|
||||
}
|
||||
return null;
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String trimToDefault(String value, String fallback) {
|
||||
String normalized = trimToNull(value);
|
||||
return StringUtils.hasText(normalized) ? normalized : fallback;
|
||||
}
|
||||
|
||||
private String trimToDefault(String primary, String secondary, String fallback) {
|
||||
String normalizedPrimary = trimToNull(primary);
|
||||
if (StringUtils.hasText(normalizedPrimary)) {
|
||||
return normalizedPrimary;
|
||||
}
|
||||
String normalizedSecondary = trimToNull(secondary);
|
||||
return StringUtils.hasText(normalizedSecondary) ? normalizedSecondary : fallback;
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,16 @@ public class CustomFile implements MultipartFile {
|
||||
|
||||
private final String fileName;
|
||||
private final byte[] bytes;
|
||||
private final String contentType;
|
||||
|
||||
public CustomFile(String fileName, byte[] bytes) {
|
||||
this(fileName, bytes, null);
|
||||
}
|
||||
|
||||
public CustomFile(String fileName, byte[] bytes, String contentType) {
|
||||
this.fileName = fileName;
|
||||
this.bytes = bytes;
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -31,6 +37,9 @@ public class CustomFile implements MultipartFile {
|
||||
|
||||
@Override
|
||||
public String getContentType() {
|
||||
if (contentType != null && !contentType.isBlank()) {
|
||||
return contentType;
|
||||
}
|
||||
Tika tika = new Tika();
|
||||
InputStream inputStream = new ByteArrayInputStream(bytes);
|
||||
String contentType = "";
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.easyagents.flow.core.chain.Parameter;
|
||||
import com.easyagents.flow.core.node.BaseNode;
|
||||
import tech.easyflow.common.util.SpringContextUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -30,17 +31,42 @@ public class DocNode extends BaseNode {
|
||||
@Override
|
||||
public Map<String, Object> execute(Chain chain) {
|
||||
Map<String, Object> map = chain.getState().resolveParameters(this);
|
||||
Map<String, Object> res = new HashMap<>();
|
||||
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<Parameter> outputDefs = getOutputDefs();
|
||||
if (outputDefs != null && !outputDefs.isEmpty()) {
|
||||
String defName = outputDefs.get(0).getName();
|
||||
if (StringUtil.hasText(defName)) key = defName;
|
||||
List<Map<String, Object>> documentMaps = new ArrayList<>();
|
||||
for (DocNodeFileContentExtractor.DocExtractedDocument document : documents) {
|
||||
documentMaps.add(document.toMap());
|
||||
}
|
||||
res.put(key, docContent);
|
||||
|
||||
Map<String, String> outputKeyMapping = resolveOutputKeyMapping();
|
||||
Map<String, Object> res = new HashMap<>();
|
||||
res.put(outputKeyMapping.get("documents"), documentMaps);
|
||||
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.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.Set;
|
||||
|
||||
/**
|
||||
* {@link DocNode} 文件内容提取器。
|
||||
@@ -27,6 +33,9 @@ import java.util.Map;
|
||||
*/
|
||||
@Component
|
||||
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 FileStorageService fileStorageService;
|
||||
@@ -62,6 +71,33 @@ public class DocNodeFileContentExtractor {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单文件或多文件运行值归一化为统一文档源列表。
|
||||
*
|
||||
* @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) {
|
||||
if (sourceRef == null) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐文件解析结果。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +1,100 @@
|
||||
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.node.BaseNode;
|
||||
import org.apache.poi.xwpf.usermodel.XWPFDocument;
|
||||
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
|
||||
import org.apache.poi.xwpf.usermodel.XWPFRun;
|
||||
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.ai.node.filegeneration.FileGenerationRequest;
|
||||
import tech.easyflow.ai.node.filegeneration.FileGenerationResult;
|
||||
import tech.easyflow.ai.node.filegeneration.FileGenerationService;
|
||||
import tech.easyflow.common.filestorage.FileStorageManager;
|
||||
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.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 工作流文件导出节点。
|
||||
*
|
||||
* <p>节点只负责解析运行时参数、委托导出服务生成文件内容,并通过现有文件存储返回下载地址。</p>
|
||||
*
|
||||
* @author Codex
|
||||
* @since 2026-04-18
|
||||
*/
|
||||
public class MakeFileNode extends BaseNode {
|
||||
|
||||
private String suffix;
|
||||
private String targetFormat;
|
||||
private String sourceFormat;
|
||||
private String fileName;
|
||||
private String templateStyle;
|
||||
|
||||
public MakeFileNode() {
|
||||
}
|
||||
|
||||
public MakeFileNode(String suffix) {
|
||||
this.suffix = suffix;
|
||||
public MakeFileNode(String targetFormat, String sourceFormat, String fileName, String templateStyle) {
|
||||
this.targetFormat = targetFormat;
|
||||
this.sourceFormat = sourceFormat;
|
||||
this.fileName = fileName;
|
||||
this.templateStyle = templateStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行文件导出。
|
||||
*
|
||||
* @param chain 当前流程链
|
||||
* @return 仅包含下载地址的输出对象
|
||||
*/
|
||||
@Override
|
||||
public Map<String, Object> execute(Chain chain) {
|
||||
Map<String, Object> map = chain.getState().resolveParameters(this);
|
||||
|
||||
Map<String, Object> res = new HashMap<>();
|
||||
|
||||
String content = map.get("content").toString();
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
createFile(suffix, content, os);
|
||||
|
||||
String fileName = IdUtil.fastSimpleUUID() + "." + suffix;
|
||||
CustomFile file = new CustomFile(fileName, os.toByteArray());
|
||||
|
||||
Object rawContent = map.get("content");
|
||||
if (rawContent == null) {
|
||||
throw new BusinessException("文件生成节点缺少 content 参数");
|
||||
}
|
||||
FileGenerationRequest request = new FileGenerationRequest(
|
||||
rawContent.toString(),
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
fileName,
|
||||
templateStyle
|
||||
);
|
||||
FileGenerationService generationService = SpringContextUtil.getBean(FileGenerationService.class);
|
||||
FileGenerationResult result = generationService.generate(request);
|
||||
FileStorageManager manager = SpringContextUtil.getBean(FileStorageManager.class);
|
||||
|
||||
String url = manager.save(file);
|
||||
Map<String, Object> res = new HashMap<>();
|
||||
String url = manager.save(new CustomFile(result.getFileName(), result.getBytes(), result.getContentType()));
|
||||
res.put("url", url);
|
||||
return res;
|
||||
}
|
||||
|
||||
private void createFile(String suffix, String content, ByteArrayOutputStream os) {
|
||||
if ("docx".equals(suffix)) {
|
||||
docx(content, os);
|
||||
}
|
||||
public String getTargetFormat() {
|
||||
return targetFormat;
|
||||
}
|
||||
|
||||
private void docx(String content, ByteArrayOutputStream os) {
|
||||
String separator = "\n";
|
||||
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 void setTargetFormat(String targetFormat) {
|
||||
this.targetFormat = targetFormat;
|
||||
}
|
||||
|
||||
public String getSuffix() {
|
||||
return suffix;
|
||||
public String getSourceFormat() {
|
||||
return sourceFormat;
|
||||
}
|
||||
|
||||
public void setSuffix(String suffix) {
|
||||
this.suffix = suffix;
|
||||
public void setSourceFormat(String sourceFormat) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,28 @@ import cn.hutool.core.util.StrUtil;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.easyagents.flow.core.node.BaseNode;
|
||||
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 {
|
||||
|
||||
@Override
|
||||
public BaseNode doParse(JSONObject root, JSONObject data, JSONObject tinyflow) {
|
||||
String suffix = data.getString("suffix");
|
||||
if (StrUtil.isEmpty(suffix)) {
|
||||
suffix = "docx";
|
||||
String targetFormatValue = data == null ? null : data.getString("targetFormat");
|
||||
if (StrUtil.isBlank(targetFormatValue)) {
|
||||
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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -8,6 +8,7 @@ import org.junit.Test;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckResult;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.node.MakeFileNodeParser;
|
||||
import tech.easyflow.ai.node.SearchDatasetNodeParser;
|
||||
import tech.easyflow.ai.node.WorkflowNodeParser;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
@@ -236,6 +237,129 @@ public class WorkflowCheckServiceTest {
|
||||
Assert.assertEquals(0, result.getIssueCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPreExecuteShouldBlockStartFormWithoutUserInput() throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject startData = data("开始");
|
||||
JSONArray schema = new JSONArray();
|
||||
JSONObject field = new JSONObject();
|
||||
field.put("key", "scene");
|
||||
field.put("type", "select");
|
||||
field.put("required", true);
|
||||
JSONArray options = new JSONArray();
|
||||
options.add("售前");
|
||||
field.put("options", options);
|
||||
schema.add(field);
|
||||
startData.put("startFormSchema", schema);
|
||||
String content = workflowJson(
|
||||
array(
|
||||
node("s1", "startNode", null, startData),
|
||||
node("e1", "endNode", null, data("结束"))
|
||||
),
|
||||
array(edge("e1", "s1", "e1"))
|
||||
);
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.PRE_EXECUTE, BigInteger.ONE);
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "START_FORM_USER_INPUT_MISSING");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPreExecuteShouldBlockInvalidStartFormUserInputTypeAndOptions() throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject startData = data("开始");
|
||||
JSONArray schema = new JSONArray();
|
||||
|
||||
JSONObject userInputField = new JSONObject();
|
||||
userInputField.put("key", "user_input");
|
||||
userInputField.put("type", "radio");
|
||||
userInputField.put("required", false);
|
||||
schema.add(userInputField);
|
||||
|
||||
JSONObject selectField = new JSONObject();
|
||||
selectField.put("key", "scene");
|
||||
selectField.put("type", "select");
|
||||
selectField.put("required", true);
|
||||
schema.add(selectField);
|
||||
|
||||
startData.put("startFormSchema", schema);
|
||||
String content = workflowJson(
|
||||
array(
|
||||
node("s1", "startNode", null, startData),
|
||||
node("e1", "endNode", null, data("结束"))
|
||||
),
|
||||
array(edge("e1", "s1", "e1"))
|
||||
);
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.PRE_EXECUTE, BigInteger.ONE);
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "START_FORM_USER_INPUT_REQUIRED");
|
||||
assertHasCode(result, "START_FORM_USER_INPUT_TYPE_INVALID");
|
||||
assertHasCode(result, "START_FORM_OPTIONS_EMPTY");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSaveShouldBlockInvalidStartFormSchema() throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject startData = data("开始");
|
||||
JSONArray schema = new JSONArray();
|
||||
|
||||
JSONObject userInputField = new JSONObject();
|
||||
userInputField.put("key", "user_input");
|
||||
userInputField.put("type", "radio");
|
||||
userInputField.put("required", false);
|
||||
schema.add(userInputField);
|
||||
|
||||
JSONObject checkboxField = new JSONObject();
|
||||
checkboxField.put("key", "labels");
|
||||
checkboxField.put("type", "checkbox");
|
||||
checkboxField.put("required", true);
|
||||
schema.add(checkboxField);
|
||||
|
||||
startData.put("startFormSchema", schema);
|
||||
String content = workflowJson(
|
||||
array(node("s1", "startNode", null, startData)),
|
||||
new JSONArray()
|
||||
);
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(content, WorkflowCheckStage.SAVE, null);
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "START_FORM_USER_INPUT_REQUIRED");
|
||||
assertHasCode(result, "START_FORM_USER_INPUT_TYPE_INVALID");
|
||||
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 {
|
||||
WorkflowCheckService service = new WorkflowCheckService();
|
||||
ChainParser parser = ChainParser.builder()
|
||||
@@ -243,6 +367,7 @@ public class WorkflowCheckServiceTest {
|
||||
.build();
|
||||
parser.addNodeParser("workflow-node", new WorkflowNodeParser());
|
||||
parser.addNodeParser("search-dataset-node", new SearchDatasetNodeParser());
|
||||
parser.addNodeParser("make-file", new MakeFileNodeParser());
|
||||
setField(service, "chainParser", parser);
|
||||
setField(service, "workflowService", mockWorkflowService(workflowStore));
|
||||
setField(service, "workflowDatacenterContentService", new WorkflowDatacenterContentService());
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
package tech.easyflow.ai.easyagentsflow.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.easyagents.flow.core.parser.ChainParser;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.node.SearchDatasetNodeParser;
|
||||
import tech.easyflow.ai.node.WorkflowNodeParser;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.math.BigInteger;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* {@link WorkflowRunningParameterResolver} 测试。
|
||||
*/
|
||||
public class WorkflowRunningParameterResolverTest {
|
||||
|
||||
/**
|
||||
* 应返回显式配置的开场表单元信息与字段 Schema。
|
||||
*
|
||||
* @throws Exception 反射注入失败
|
||||
*/
|
||||
@Test
|
||||
public void testBuildRunningParametersViewShouldKeepStartFormSchema() throws Exception {
|
||||
WorkflowRunningParameterResolver resolver = newResolver();
|
||||
JSONObject startData = data("开始");
|
||||
JSONArray schema = new JSONArray();
|
||||
|
||||
JSONObject userInputField = new JSONObject();
|
||||
userInputField.put("key", "user_input");
|
||||
userInputField.put("label", "主问题");
|
||||
userInputField.put("type", "text");
|
||||
userInputField.put("required", true);
|
||||
schema.add(userInputField);
|
||||
|
||||
JSONObject fileField = new JSONObject();
|
||||
fileField.put("key", "attachments");
|
||||
fileField.put("label", "附件");
|
||||
fileField.put("type", "file");
|
||||
fileField.put("required", false);
|
||||
schema.add(fileField);
|
||||
|
||||
JSONObject meta = new JSONObject();
|
||||
meta.put("title", "问答入口");
|
||||
meta.put("description", "请先填写信息");
|
||||
meta.put("submitText", "立即开始");
|
||||
startData.put("startFormMeta", meta);
|
||||
startData.put("startFormSchema", schema);
|
||||
startData.put("parameters", startParameters());
|
||||
|
||||
Workflow workflow = workflow(
|
||||
workflowJson(
|
||||
array(
|
||||
node("s1", "startNode", null, startData),
|
||||
node("e1", "endNode", null, data("结束"))
|
||||
),
|
||||
array(edge("e1", "s1", "e1"))
|
||||
)
|
||||
);
|
||||
|
||||
Map<String, Object> result = resolver.buildRunningParametersView(workflow);
|
||||
Assert.assertNotNull(result);
|
||||
Assert.assertEquals("问答入口", ((Map<?, ?>) result.get("startFormMeta")).get("title"));
|
||||
List<Map<String, Object>> fields = (List<Map<String, Object>>) result.get("startFormSchema");
|
||||
Assert.assertEquals(2, fields.size());
|
||||
Assert.assertEquals("user_input", fields.get(0).get("key"));
|
||||
Assert.assertEquals("text", fields.get(0).get("type"));
|
||||
Assert.assertEquals("attachments", fields.get(1).get("key"));
|
||||
Assert.assertEquals("file", fields.get(1).get("type"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧工作流仅保留 parameters 时,也应补出最小开场表单 Schema。
|
||||
*
|
||||
* @throws Exception 反射注入失败
|
||||
*/
|
||||
@Test
|
||||
public void testBuildRunningParametersViewShouldFallbackToParameters() throws Exception {
|
||||
WorkflowRunningParameterResolver resolver = newResolver();
|
||||
JSONObject startData = data("开始");
|
||||
startData.put("parameters", startParameters());
|
||||
|
||||
Workflow workflow = workflow(
|
||||
workflowJson(
|
||||
array(
|
||||
node("s1", "startNode", null, startData),
|
||||
node("e1", "endNode", null, data("结束"))
|
||||
),
|
||||
array(edge("e1", "s1", "e1"))
|
||||
)
|
||||
);
|
||||
|
||||
Map<String, Object> result = resolver.buildRunningParametersView(workflow);
|
||||
Assert.assertNotNull(result);
|
||||
List<Map<String, Object>> fields = (List<Map<String, Object>>) result.get("startFormSchema");
|
||||
Assert.assertEquals(2, fields.size());
|
||||
Assert.assertEquals("user_input", fields.get(0).get("key"));
|
||||
Assert.assertEquals("textarea", fields.get(0).get("type"));
|
||||
Assert.assertEquals(Boolean.TRUE, fields.get(0).get("required"));
|
||||
Assert.assertEquals("attachments", fields.get(1).get("key"));
|
||||
Assert.assertEquals("file", fields.get(1).get("type"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧工作流若不存在系统问题参数,运行态不应凭空注入 user_input。
|
||||
*
|
||||
* @throws Exception 反射注入失败
|
||||
*/
|
||||
@Test
|
||||
public void testBuildRunningParametersViewShouldNotInjectUserInputForLegacyParametersWithoutSystemField() throws Exception {
|
||||
WorkflowRunningParameterResolver resolver = newResolver();
|
||||
JSONObject startData = data("开始");
|
||||
startData.put("parameters", startParametersWithoutUserInput());
|
||||
|
||||
Workflow workflow = workflow(
|
||||
workflowJson(
|
||||
array(
|
||||
node("s1", "startNode", null, startData),
|
||||
node("e1", "endNode", null, data("结束"))
|
||||
),
|
||||
array(edge("e1", "s1", "e1"))
|
||||
)
|
||||
);
|
||||
|
||||
Map<String, Object> result = resolver.buildRunningParametersView(workflow);
|
||||
Assert.assertNotNull(result);
|
||||
List<Map<String, Object>> fields = (List<Map<String, Object>>) result.get("startFormSchema");
|
||||
Assert.assertEquals(1, fields.size());
|
||||
Assert.assertEquals("attachments", fields.get(0).get("key"));
|
||||
Assert.assertEquals("file", fields.get(0).get("type"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件参数运行值应统一归一化为数组并按 filePath 去重。
|
||||
*
|
||||
* @throws Exception 反射注入失败
|
||||
*/
|
||||
@Test
|
||||
public void testNormalizeRuntimeVariablesShouldWrapFileValueIntoArray() throws Exception {
|
||||
WorkflowRunningParameterResolver resolver = newResolver();
|
||||
Map<String, Object> variables = new LinkedHashMap<>();
|
||||
variables.put("attachments", fileValue("demo.pdf", "/files/demo.pdf", 1024L));
|
||||
|
||||
Map<String, Object> normalized = resolver.normalizeRuntimeVariables(workflowContentWithStartParameters(), variables);
|
||||
|
||||
Object attachments = normalized.get("attachments");
|
||||
Assert.assertTrue(attachments instanceof List<?>);
|
||||
Assert.assertEquals(1, ((List<?>) attachments).size());
|
||||
Assert.assertTrue(((List<?>) attachments).get(0) instanceof Map<?, ?>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 多文件参数应按 filePath 去重并保留已有非文件变量。
|
||||
*
|
||||
* @throws Exception 反射注入失败
|
||||
*/
|
||||
@Test
|
||||
public void testNormalizeRuntimeVariablesShouldDedupeFileArray() throws Exception {
|
||||
WorkflowRunningParameterResolver resolver = newResolver();
|
||||
Map<String, Object> variables = new LinkedHashMap<>();
|
||||
variables.put("user_input", "hello");
|
||||
variables.put("attachments", List.of(
|
||||
fileValue("a.pdf", "/files/a.pdf", 1024L),
|
||||
fileValue("a-copy.pdf", "/files/a.pdf", 1024L),
|
||||
fileValue("b.pdf", "/files/b.pdf", 1024L)
|
||||
));
|
||||
|
||||
Map<String, Object> normalized = resolver.normalizeRuntimeVariables(workflowContentWithStartParameters(), variables);
|
||||
|
||||
Assert.assertEquals("hello", normalized.get("user_input"));
|
||||
Object attachments = normalized.get("attachments");
|
||||
Assert.assertTrue(attachments instanceof List<?>);
|
||||
Assert.assertEquals(2, ((List<?>) attachments).size());
|
||||
}
|
||||
|
||||
private static WorkflowRunningParameterResolver newResolver() throws Exception {
|
||||
WorkflowRunningParameterResolver resolver = new WorkflowRunningParameterResolver();
|
||||
ChainParser parser = ChainParser.builder()
|
||||
.withDefaultParsers(true)
|
||||
.build();
|
||||
parser.addNodeParser("workflow-node", new WorkflowNodeParser());
|
||||
parser.addNodeParser("search-dataset-node", new SearchDatasetNodeParser());
|
||||
setField(resolver, "chainParser", parser);
|
||||
setField(resolver, "workflowDatacenterContentService", new WorkflowDatacenterContentService());
|
||||
return resolver;
|
||||
}
|
||||
|
||||
private static Workflow workflow(String content) {
|
||||
Workflow workflow = new Workflow();
|
||||
workflow.setId(BigInteger.ONE);
|
||||
workflow.setTitle("测试工作流");
|
||||
workflow.setDescription("用于测试运行参数");
|
||||
workflow.setIcon("icon.png");
|
||||
workflow.setContent(content);
|
||||
return workflow;
|
||||
}
|
||||
|
||||
private static String workflowContentWithStartParameters() {
|
||||
JSONObject startData = data("开始");
|
||||
startData.put("parameters", startParameters());
|
||||
return workflowJson(
|
||||
array(
|
||||
node("s1", "startNode", null, startData),
|
||||
node("e1", "endNode", null, data("结束"))
|
||||
),
|
||||
array(edge("e1", "s1", "e1"))
|
||||
);
|
||||
}
|
||||
|
||||
private static JSONArray startParameters() {
|
||||
JSONArray parameters = new JSONArray();
|
||||
|
||||
JSONObject userInput = new JSONObject();
|
||||
userInput.put("name", "user_input");
|
||||
userInput.put("dataType", "String");
|
||||
userInput.put("refType", "input");
|
||||
userInput.put("required", true);
|
||||
userInput.put("contentType", "text");
|
||||
userInput.put("formType", "textarea");
|
||||
userInput.put("formLabel", "用户问题");
|
||||
userInput.put("formPlaceholder", "请输入用户问题");
|
||||
parameters.add(userInput);
|
||||
|
||||
JSONObject fileField = new JSONObject();
|
||||
fileField.put("name", "attachments");
|
||||
fileField.put("dataType", "File");
|
||||
fileField.put("refType", "input");
|
||||
fileField.put("required", false);
|
||||
fileField.put("contentType", "file");
|
||||
fileField.put("formType", "input");
|
||||
fileField.put("formLabel", "附件");
|
||||
parameters.add(fileField);
|
||||
return parameters;
|
||||
}
|
||||
|
||||
private static JSONArray startParametersWithoutUserInput() {
|
||||
JSONArray parameters = new JSONArray();
|
||||
|
||||
JSONObject fileField = new JSONObject();
|
||||
fileField.put("name", "attachments");
|
||||
fileField.put("dataType", "File");
|
||||
fileField.put("refType", "input");
|
||||
fileField.put("required", false);
|
||||
fileField.put("contentType", "file");
|
||||
fileField.put("formType", "input");
|
||||
fileField.put("formLabel", "附件");
|
||||
parameters.add(fileField);
|
||||
return parameters;
|
||||
}
|
||||
|
||||
private static Map<String, Object> fileValue(String fileName, String filePath, Long size) {
|
||||
Map<String, Object> value = new LinkedHashMap<>();
|
||||
value.put("fileName", fileName);
|
||||
value.put("filePath", filePath);
|
||||
value.put("size", size);
|
||||
value.put("contentType", "application/pdf");
|
||||
value.put("url", filePath);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void setField(Object target, String fieldName, Object value) throws Exception {
|
||||
Field field = WorkflowRunningParameterResolver.class.getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
|
||||
private static String workflowJson(JSONArray nodes, JSONArray edges) {
|
||||
JSONObject root = new JSONObject();
|
||||
root.put("nodes", nodes);
|
||||
root.put("edges", edges);
|
||||
return root.toJSONString();
|
||||
}
|
||||
|
||||
private static JSONArray array(JSONObject... objects) {
|
||||
JSONArray array = new JSONArray();
|
||||
for (JSONObject object : objects) {
|
||||
array.add(object);
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
private static JSONObject node(String id, String type, String parentId, JSONObject data) {
|
||||
JSONObject node = new JSONObject();
|
||||
node.put("id", id);
|
||||
node.put("type", type);
|
||||
if (parentId != null) {
|
||||
node.put("parentId", parentId);
|
||||
}
|
||||
node.put("data", data);
|
||||
return node;
|
||||
}
|
||||
|
||||
private static JSONObject data(String title) {
|
||||
JSONObject data = new JSONObject();
|
||||
data.put("title", title);
|
||||
return data;
|
||||
}
|
||||
|
||||
private static JSONObject edge(String id, String source, String target) {
|
||||
JSONObject edge = new JSONObject();
|
||||
edge.put("id", id);
|
||||
edge.put("source", source);
|
||||
edge.put("target", target);
|
||||
return edge;
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,9 @@ import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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) {
|
||||
Map<String, Object> value = new HashMap<String, Object>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
"\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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package tech.easyflow.chatlog.domain.dto;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* 聊天助手使用排行项。
|
||||
*
|
||||
* @param assistantId 智能体 ID
|
||||
* @param assistantName 智能体名称
|
||||
* @param sessionTotal 会话总数
|
||||
* @param messageTotal 消息总数
|
||||
*/
|
||||
public record ChatAssistantUsageRank(BigInteger assistantId,
|
||||
String assistantName,
|
||||
long sessionTotal,
|
||||
long messageTotal) {
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package tech.easyflow.chatlog.domain.dto;
|
||||
|
||||
/**
|
||||
* 聊天工作台汇总统计。
|
||||
*
|
||||
* @param sessionTotal 会话总数
|
||||
* @param messageTotal 消息总数
|
||||
* @param activeAssistantTotal 活跃智能体数
|
||||
*/
|
||||
public record ChatDashboardSummary(long sessionTotal, long messageTotal, long activeAssistantTotal) {
|
||||
|
||||
/**
|
||||
* 创建空汇总结果。
|
||||
*
|
||||
* @return 空汇总结果
|
||||
*/
|
||||
public static ChatDashboardSummary empty() {
|
||||
return new ChatDashboardSummary(0L, 0L, 0L);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package tech.easyflow.chatlog.domain.dto;
|
||||
|
||||
/**
|
||||
* 聊天工作台趋势项。
|
||||
*
|
||||
* @param bucketKey 趋势桶标识,日趋势为 yyyy-MM-dd,小时趋势为 yyyy-MM-dd HH:00:00
|
||||
* @param sessionTotal 会话总数
|
||||
* @param messageTotal 消息总数
|
||||
*/
|
||||
public record ChatDashboardTrend(String bucketKey, long sessionTotal, long messageTotal) {
|
||||
}
|
||||
@@ -3,6 +3,10 @@ package tech.easyflow.chatlog.repository.analyticaldb;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.jdbc.core.ParameterizedPreparedStatementSetter;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.util.StringUtils;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
|
||||
@@ -19,6 +23,7 @@ import java.math.BigInteger;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Collections;
|
||||
@@ -204,6 +209,160 @@ public class ChatAnalyticalDBRepository {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询聊天工作台汇总统计。
|
||||
*
|
||||
* @param startDate 开始日期,包含
|
||||
* @param endDate 结束日期,不包含
|
||||
* @param tenantId 租户 ID,空表示全局
|
||||
* @return 聊天汇总统计
|
||||
*/
|
||||
public ChatDashboardSummary queryDashboardSummary(LocalDate startDate, LocalDate endDate, BigInteger tenantId) {
|
||||
assertAvailable();
|
||||
List<Object> args = new java.util.ArrayList<>();
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT ")
|
||||
.append("ifNull(sum(message_count), 0) AS message_total, ")
|
||||
.append("ifNull(sum(session_count), 0) AS session_total, ")
|
||||
.append("uniqExact(dimension_id) AS active_assistant_total ")
|
||||
.append("FROM dws_chat_assistant_day ")
|
||||
.append("WHERE stat_date >= toDate(?) AND stat_date < toDate(?)");
|
||||
args.add(startDate.toString());
|
||||
args.add(endDate.toString());
|
||||
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
||||
|
||||
ChatDashboardSummary summary = analyticalDBOperations.queryOne(
|
||||
sql.toString(),
|
||||
(rs, rowNum) -> new ChatDashboardSummary(
|
||||
rs.getLong("session_total"),
|
||||
rs.getLong("message_total"),
|
||||
rs.getLong("active_assistant_total")
|
||||
),
|
||||
args.toArray()
|
||||
);
|
||||
return summary == null ? ChatDashboardSummary.empty() : summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询聊天工作台日趋势。
|
||||
*
|
||||
* @param startDate 开始日期,包含
|
||||
* @param endDate 结束日期,不包含
|
||||
* @param tenantId 租户 ID,空表示全局
|
||||
* @return 日趋势列表
|
||||
*/
|
||||
public List<ChatDashboardTrend> queryDashboardTrends(LocalDate startDate, LocalDate endDate, BigInteger tenantId) {
|
||||
assertAvailable();
|
||||
List<Object> args = new java.util.ArrayList<>();
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT toString(stat_date) AS bucket_key, ")
|
||||
.append("ifNull(sum(message_count), 0) AS message_total, ")
|
||||
.append("ifNull(sum(session_count), 0) AS session_total ")
|
||||
.append("FROM dws_chat_assistant_day ")
|
||||
.append("WHERE stat_date >= toDate(?) AND stat_date < toDate(?)");
|
||||
args.add(startDate.toString());
|
||||
args.add(endDate.toString());
|
||||
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
||||
sql.append(" GROUP BY stat_date ORDER BY stat_date ASC");
|
||||
|
||||
return analyticalDBOperations.query(
|
||||
sql.toString(),
|
||||
(rs, rowNum) -> new ChatDashboardTrend(
|
||||
rs.getString("bucket_key"),
|
||||
rs.getLong("session_total"),
|
||||
rs.getLong("message_total")
|
||||
),
|
||||
args.toArray()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询聊天工作台小时趋势。
|
||||
*
|
||||
* @param startTime 开始时间,包含
|
||||
* @param endTime 结束时间,不包含
|
||||
* @param tenantId 租户 ID,空表示全局
|
||||
* @return 小时趋势列表
|
||||
*/
|
||||
public List<ChatDashboardTrend> queryDashboardHourlyTrends(LocalDateTime startTime,
|
||||
LocalDateTime endTime,
|
||||
BigInteger tenantId) {
|
||||
assertAvailable();
|
||||
List<Object> args = new java.util.ArrayList<>();
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT formatDateTime(toStartOfHour(l.created), '%Y-%m-%d %H:00:00') AS bucket_key, ")
|
||||
.append("count() AS message_total, ")
|
||||
.append("uniqExact(l.session_id) AS session_total ")
|
||||
.append("FROM ods_chat_log l ")
|
||||
.append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = l.session_id ")
|
||||
.append("WHERE l.created >= toDateTime(?) AND l.created < toDateTime(?)");
|
||||
args.add(CH_DATE_TIME_FORMATTER.format(startTime));
|
||||
args.add(CH_DATE_TIME_FORMATTER.format(endTime));
|
||||
appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id");
|
||||
sql.append(" GROUP BY bucket_key ORDER BY bucket_key ASC");
|
||||
|
||||
return analyticalDBOperations.query(
|
||||
sql.toString(),
|
||||
(rs, rowNum) -> new ChatDashboardTrend(
|
||||
rs.getString("bucket_key"),
|
||||
rs.getLong("session_total"),
|
||||
rs.getLong("message_total")
|
||||
),
|
||||
args.toArray()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询聊天助手使用排行。
|
||||
*
|
||||
* @param startDate 开始日期,包含
|
||||
* @param endDate 结束日期,不包含
|
||||
* @param tenantId 租户 ID,空表示全局
|
||||
* @param limit 返回条数
|
||||
* @return 排行列表
|
||||
*/
|
||||
public List<ChatAssistantUsageRank> queryAssistantUsageRanks(LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
BigInteger tenantId,
|
||||
int limit) {
|
||||
assertAvailable();
|
||||
int safeLimit = Math.max(limit, 1);
|
||||
List<Object> args = new java.util.ArrayList<>();
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT agg.assistant_id, snapshot.assistant_name, agg.session_total, agg.message_total ")
|
||||
.append("FROM (")
|
||||
.append("SELECT dimension_id AS assistant_id, ")
|
||||
.append("ifNull(sum(session_count), 0) AS session_total, ")
|
||||
.append("ifNull(sum(message_count), 0) AS message_total ")
|
||||
.append("FROM dws_chat_assistant_day ")
|
||||
.append("WHERE stat_date >= toDate(?) AND stat_date < toDate(?)");
|
||||
args.add(startDate.toString());
|
||||
args.add(endDate.toString());
|
||||
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
||||
sql.append(" GROUP BY dimension_id")
|
||||
.append(") agg ")
|
||||
.append("LEFT JOIN (")
|
||||
.append("SELECT assistant_id, argMax(assistant_name, modified) AS assistant_name ")
|
||||
.append("FROM ods_chat_session FINAL WHERE is_deleted = 0 ");
|
||||
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
||||
sql.append(" GROUP BY assistant_id")
|
||||
.append(") snapshot ON snapshot.assistant_id = agg.assistant_id ")
|
||||
.append("ORDER BY agg.message_total DESC, agg.session_total DESC, agg.assistant_id ASC ")
|
||||
.append("LIMIT ?");
|
||||
args.add(safeLimit);
|
||||
|
||||
return analyticalDBOperations.query(
|
||||
sql.toString(),
|
||||
(rs, rowNum) -> new ChatAssistantUsageRank(
|
||||
bigInteger(rs.getObject("assistant_id")),
|
||||
rs.getString("assistant_name"),
|
||||
rs.getLong("session_total"),
|
||||
rs.getLong("message_total")
|
||||
),
|
||||
args.toArray()
|
||||
);
|
||||
}
|
||||
|
||||
public void refreshDws(Set<LocalDate> dates) {
|
||||
if (!enabled() || dates.isEmpty()) {
|
||||
return;
|
||||
@@ -212,8 +371,15 @@ public class ChatAnalyticalDBRepository {
|
||||
String dateLiteral = date.toString();
|
||||
analyticalDBOperations.update("ALTER TABLE dws_chat_assistant_day DELETE WHERE stat_date = toDate(?)", dateLiteral);
|
||||
analyticalDBOperations.update("INSERT INTO dws_chat_assistant_day " +
|
||||
"SELECT toDate(created) AS stat_date, assistant_id AS dimension_id, uniqExact(session_id) AS session_count, count() AS message_count " +
|
||||
"FROM ods_chat_log WHERE toDate(created) = toDate(?) GROUP BY stat_date, dimension_id", dateLiteral);
|
||||
"SELECT toDate(l.created) AS stat_date, " +
|
||||
"l.assistant_id AS dimension_id, " +
|
||||
"ifNull(s.tenant_id, toUInt64(0)) AS tenant_id, " +
|
||||
"uniqExact(l.session_id) AS session_count, " +
|
||||
"count() AS message_count " +
|
||||
"FROM ods_chat_log l " +
|
||||
"LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = l.session_id " +
|
||||
"WHERE toDate(l.created) = toDate(?) " +
|
||||
"GROUP BY stat_date, dimension_id, tenant_id", dateLiteral);
|
||||
|
||||
analyticalDBOperations.update("ALTER TABLE dws_chat_user_day DELETE WHERE stat_date = toDate(?)", dateLiteral);
|
||||
analyticalDBOperations.update("INSERT INTO dws_chat_user_day " +
|
||||
@@ -313,6 +479,22 @@ public class ChatAnalyticalDBRepository {
|
||||
return new BigInteger(String.valueOf(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加可选租户过滤,超管场景可不传租户。
|
||||
*
|
||||
* @param sql SQL 构造器
|
||||
* @param args 参数列表
|
||||
* @param tenantId 租户 ID
|
||||
* @param columnName 列名
|
||||
*/
|
||||
private void appendOptionalTenantFilter(StringBuilder sql, List<Object> args, BigInteger tenantId, String columnName) {
|
||||
if (tenantId == null || !StringUtils.hasText(columnName)) {
|
||||
return;
|
||||
}
|
||||
sql.append(" AND ").append(columnName).append(" = ?");
|
||||
args.add(tenantId);
|
||||
}
|
||||
|
||||
private void appendSessionFilters(ChatSessionFilterQuery query, StringBuilder sql, List<Object> args) {
|
||||
if (query == null) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package tech.easyflow.chatlog.service;
|
||||
|
||||
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 聊天工作台统计查询服务。
|
||||
*/
|
||||
public interface ChatDashboardQueryService {
|
||||
|
||||
/**
|
||||
* 查询聊天汇总指标。
|
||||
*
|
||||
* @param startDate 开始日期,包含当天
|
||||
* @param endDate 结束日期,不包含当天
|
||||
* @param tenantId 租户 ID,空表示全局
|
||||
* @return 聊天汇总指标
|
||||
*/
|
||||
ChatDashboardSummary querySummary(LocalDate startDate, LocalDate endDate, BigInteger tenantId);
|
||||
|
||||
/**
|
||||
* 查询聊天日趋势。
|
||||
*
|
||||
* @param startDate 开始日期,包含当天
|
||||
* @param endDate 结束日期,不包含当天
|
||||
* @param tenantId 租户 ID,空表示全局
|
||||
* @return 聊天日趋势
|
||||
*/
|
||||
List<ChatDashboardTrend> queryTrends(LocalDate startDate, LocalDate endDate, BigInteger tenantId);
|
||||
|
||||
/**
|
||||
* 查询聊天小时趋势。
|
||||
*
|
||||
* @param startTime 开始时间,包含
|
||||
* @param endTime 结束时间,不包含
|
||||
* @param tenantId 租户 ID,空表示全局
|
||||
* @return 聊天小时趋势
|
||||
*/
|
||||
List<ChatDashboardTrend> queryHourlyTrends(LocalDateTime startTime, LocalDateTime endTime, BigInteger tenantId);
|
||||
|
||||
/**
|
||||
* 查询智能体使用排行。
|
||||
*
|
||||
* @param startDate 开始日期,包含当天
|
||||
* @param endDate 结束日期,不包含当天
|
||||
* @param tenantId 租户 ID,空表示全局
|
||||
* @param limit 返回条数
|
||||
* @return 智能体使用排行
|
||||
*/
|
||||
List<ChatAssistantUsageRank> queryAssistantUsageRanks(LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
BigInteger tenantId,
|
||||
int limit);
|
||||
|
||||
/**
|
||||
* 当前分析库是否可用。
|
||||
*
|
||||
* @return true 表示可用
|
||||
*/
|
||||
boolean available();
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package tech.easyflow.chatlog.service.impl;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
||||
import tech.easyflow.chatlog.repository.analyticaldb.ChatAnalyticalDBRepository;
|
||||
import tech.easyflow.chatlog.service.ChatDashboardQueryService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 基于分析库的聊天工作台统计查询实现。
|
||||
*/
|
||||
@Service
|
||||
public class ChatDashboardQueryServiceImpl implements ChatDashboardQueryService {
|
||||
|
||||
private final ChatAnalyticalDBRepository analyticalDBRepository;
|
||||
|
||||
public ChatDashboardQueryServiceImpl(ChatAnalyticalDBRepository analyticalDBRepository) {
|
||||
this.analyticalDBRepository = analyticalDBRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询聊天汇总指标。
|
||||
*
|
||||
* @param startDate 开始日期,包含当天
|
||||
* @param endDate 结束日期,不包含当天
|
||||
* @param tenantId 租户 ID,空表示全局
|
||||
* @return 聊天汇总指标
|
||||
*/
|
||||
@Override
|
||||
public ChatDashboardSummary querySummary(LocalDate startDate, LocalDate endDate, BigInteger tenantId) {
|
||||
if (!available()) {
|
||||
return ChatDashboardSummary.empty();
|
||||
}
|
||||
return analyticalDBRepository.queryDashboardSummary(startDate, endDate, tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询聊天日趋势。
|
||||
*
|
||||
* @param startDate 开始日期,包含当天
|
||||
* @param endDate 结束日期,不包含当天
|
||||
* @param tenantId 租户 ID,空表示全局
|
||||
* @return 聊天日趋势
|
||||
*/
|
||||
@Override
|
||||
public List<ChatDashboardTrend> queryTrends(LocalDate startDate, LocalDate endDate, BigInteger tenantId) {
|
||||
if (!available()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return analyticalDBRepository.queryDashboardTrends(startDate, endDate, tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询聊天小时趋势。
|
||||
*
|
||||
* @param startTime 开始时间,包含
|
||||
* @param endTime 结束时间,不包含
|
||||
* @param tenantId 租户 ID,空表示全局
|
||||
* @return 聊天小时趋势
|
||||
*/
|
||||
@Override
|
||||
public List<ChatDashboardTrend> queryHourlyTrends(LocalDateTime startTime, LocalDateTime endTime, BigInteger tenantId) {
|
||||
if (!available()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return analyticalDBRepository.queryDashboardHourlyTrends(startTime, endTime, tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询智能体使用排行。
|
||||
*
|
||||
* @param startDate 开始日期,包含当天
|
||||
* @param endDate 结束日期,不包含当天
|
||||
* @param tenantId 租户 ID,空表示全局
|
||||
* @param limit 返回条数
|
||||
* @return 智能体使用排行
|
||||
*/
|
||||
@Override
|
||||
public List<ChatAssistantUsageRank> queryAssistantUsageRanks(LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
BigInteger tenantId,
|
||||
int limit) {
|
||||
if (!available()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return analyticalDBRepository.queryAssistantUsageRanks(startDate, endDate, tenantId, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前分析库是否可用。
|
||||
*
|
||||
* @return true 表示可用
|
||||
*/
|
||||
@Override
|
||||
public boolean available() {
|
||||
return analyticalDBRepository.enabled();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
ALTER TABLE dws_chat_assistant_day
|
||||
ADD COLUMN IF NOT EXISTS `tenant_id` UInt64 DEFAULT 0 AFTER `dimension_id`;
|
||||
|
||||
ALTER TABLE dws_chat_assistant_day DELETE WHERE `tenant_id` = 0;
|
||||
|
||||
INSERT INTO dws_chat_assistant_day
|
||||
SELECT
|
||||
toDate(l.created) AS stat_date,
|
||||
l.assistant_id AS dimension_id,
|
||||
ifNull(s.tenant_id, toUInt64(0)) AS tenant_id,
|
||||
uniqExact(l.session_id) AS session_count,
|
||||
count() AS message_count
|
||||
FROM ods_chat_log l
|
||||
LEFT JOIN (
|
||||
SELECT id, tenant_id
|
||||
FROM ods_chat_session FINAL
|
||||
) s ON s.id = l.session_id
|
||||
GROUP BY stat_date, dimension_id, tenant_id;
|
||||
@@ -8,30 +8,45 @@ export interface DashboardOverviewQuery {
|
||||
|
||||
export interface DashboardSummary {
|
||||
activeUserTotal: number;
|
||||
activeAssistantTotal: number;
|
||||
botTotal: number;
|
||||
chatMessageTotal: number;
|
||||
chatSessionTotal: number;
|
||||
knowledgeBaseTotal: number;
|
||||
userTotal: number;
|
||||
workflowTotal: number;
|
||||
}
|
||||
|
||||
export interface DashboardChatStatus {
|
||||
available: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DashboardTrendItem {
|
||||
activeUserTotal: number;
|
||||
chatMessageTotal: number;
|
||||
chatSessionTotal: number;
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface DashboardDistributionItem {
|
||||
activeUserTotal: number;
|
||||
assistantId?: number | string;
|
||||
avgMessagePerSession?: number;
|
||||
botTotal: number;
|
||||
key: string;
|
||||
knowledgeBaseTotal: number;
|
||||
label: string;
|
||||
messageTotal?: number;
|
||||
sessionTotal?: number;
|
||||
userTotal: number;
|
||||
value: number;
|
||||
workflowTotal: number;
|
||||
}
|
||||
|
||||
export interface DashboardOverviewResponse {
|
||||
chatStatus: DashboardChatStatus;
|
||||
distribution: DashboardDistributionItem[];
|
||||
query: DashboardOverviewQuery;
|
||||
summary: DashboardSummary;
|
||||
|
||||
@@ -42,6 +42,16 @@
|
||||
"fileGeneration": "FileGeneration",
|
||||
"fileSettings": "FileSettings",
|
||||
"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",
|
||||
"saveData": "SaveData",
|
||||
"saveDataset": "Write Data",
|
||||
@@ -105,6 +115,9 @@
|
||||
"fileGeneration": "Generate Word, PDF, HTML, etc. files for users to download",
|
||||
"fileType": "Please select the type of file to generate",
|
||||
"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",
|
||||
"saveData": "Save data to data hub",
|
||||
"saveDataset": "Write data into a managed table",
|
||||
|
||||
@@ -42,6 +42,16 @@
|
||||
"fileGeneration": "文件生成",
|
||||
"fileSettings": "文件设置",
|
||||
"fileDownloadURL": "文件下载地址",
|
||||
"targetFormat": "输出文档类型",
|
||||
"fileName": "文件名",
|
||||
"documents": "文档列表",
|
||||
"count": "数量",
|
||||
"fileNamePlaceholder": "例如:会议纪要",
|
||||
"templateStyle": "模板样式",
|
||||
"defaultTemplateStyle": "默认样式",
|
||||
"plain_text": "纯文本",
|
||||
"markdown": "Markdown",
|
||||
"html": "HTML",
|
||||
"pluginSelect": "插件选择",
|
||||
"saveData": "保存数据",
|
||||
"saveDataset": "写入数据",
|
||||
@@ -105,6 +115,9 @@
|
||||
"fileGeneration": "生成 Word、PDF、HTML 等文件供用户下载",
|
||||
"fileType": "请选择生成的文件类型",
|
||||
"fileDownloadURL": "生成后的文件地址",
|
||||
"targetFormat": "选择最终要导出的文档格式",
|
||||
"fileName": "可选,系统会自动补齐目标扩展名并清洗非法字符",
|
||||
"templateStyle": "首版仅提供默认样式,未知值会自动回退",
|
||||
"plugin": "选择定义好的插件",
|
||||
"saveData": "保存数据到数据中枢",
|
||||
"saveDataset": "将数据写入已接入表",
|
||||
|
||||
@@ -14,6 +14,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['choose']);
|
||||
@@ -29,7 +33,7 @@ function closeDialog() {
|
||||
dialogVisible.value = false;
|
||||
}
|
||||
function confirm() {
|
||||
emit('choose', currentChoose.value, props.attrName);
|
||||
emit('choose', props.multiple ? chooseResources.value : currentChoose.value, props.attrName);
|
||||
closeDialog();
|
||||
}
|
||||
watch(
|
||||
@@ -55,7 +59,11 @@ watch(
|
||||
:page-sizes="[8, 12, 16, 20]"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<ResourceCardList v-model="chooseResources" :data="pageList" />
|
||||
<ResourceCardList
|
||||
v-model="chooseResources"
|
||||
:data="pageList"
|
||||
:multiple="props.multiple"
|
||||
/>
|
||||
</template>
|
||||
</PageData>
|
||||
<template #footer>
|
||||
|
||||
@@ -376,7 +376,7 @@ async function getWorkflowInfo(workflowId: any) {
|
||||
: {};
|
||||
tinyFlowData.value = isWorkflowDataEmpty(parsedContent)
|
||||
? createInitialWorkflowData()
|
||||
: parsedContent;
|
||||
: normalizeWorkflowStartNodes(parsedContent);
|
||||
syncNavTitle(workflowInfo.value?.title || '');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,15 +8,19 @@ import { $t } from '#/locales';
|
||||
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
||||
|
||||
import {
|
||||
appendWorkflowFileValues,
|
||||
buildWorkflowFileValueFromResource,
|
||||
buildWorkflowFileValueFromUpload,
|
||||
formatWorkflowFileSize,
|
||||
isWorkflowFileValue,
|
||||
normalizeWorkflowFileValues,
|
||||
validateWorkflowFileSelection,
|
||||
validateWorkflowFileValues,
|
||||
WORKFLOW_FILE_LIMITS,
|
||||
} from './workflowFileValue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object as () => Record<string, any> | undefined,
|
||||
type: [Array, Object],
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
@@ -26,9 +30,7 @@ const emit = defineEmits(['update:modelValue']);
|
||||
const uploadLoading = ref(false);
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const currentFile = computed(() =>
|
||||
isWorkflowFileValue(props.modelValue) ? props.modelValue : undefined,
|
||||
);
|
||||
const currentFiles = computed(() => normalizeWorkflowFileValues(props.modelValue));
|
||||
|
||||
function triggerSelectFile() {
|
||||
if (uploadLoading.value) {
|
||||
@@ -39,17 +41,24 @@ function triggerSelectFile() {
|
||||
|
||||
async function handleNativeFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
const files = Array.from(input.files || []);
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
uploadLoading.value = true;
|
||||
try {
|
||||
const res = await api.upload('/api/v1/commons/upload', { file }, {});
|
||||
const fileValue = buildWorkflowFileValueFromUpload(file, res?.data?.path);
|
||||
emit('update:modelValue', fileValue);
|
||||
} catch (error) {
|
||||
ElMessage.error('文件上传失败');
|
||||
validateWorkflowFileSelection(currentFiles.value, files);
|
||||
const uploadedFiles = [];
|
||||
for (const file of files) {
|
||||
const res = await api.upload('/api/v1/commons/upload', { file }, {});
|
||||
uploadedFiles.push(buildWorkflowFileValueFromUpload(file, res?.data?.path));
|
||||
}
|
||||
const nextFiles = appendWorkflowFileValues(currentFiles.value, uploadedFiles);
|
||||
validateWorkflowFileValues(nextFiles);
|
||||
emit('update:modelValue', nextFiles);
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '文件上传失败');
|
||||
console.error('工作流文件上传失败', error);
|
||||
} finally {
|
||||
uploadLoading.value = false;
|
||||
@@ -57,17 +66,27 @@ async function handleNativeFileChange(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleChooseResource(resource: any) {
|
||||
function handleChooseResource(resources: any) {
|
||||
try {
|
||||
const fileValue = buildWorkflowFileValueFromResource(resource || {});
|
||||
emit('update:modelValue', fileValue);
|
||||
const resourceList = Array.isArray(resources) ? resources : [resources];
|
||||
const fileValues = resourceList
|
||||
.map((resource) => buildWorkflowFileValueFromResource(resource || {}))
|
||||
.filter(Boolean);
|
||||
const nextFiles = appendWorkflowFileValues(currentFiles.value, fileValues);
|
||||
validateWorkflowFileValues(nextFiles);
|
||||
emit('update:modelValue', nextFiles);
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '素材文件选择失败');
|
||||
}
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
emit('update:modelValue', undefined);
|
||||
function removeFile(filePath: string) {
|
||||
const nextFiles = currentFiles.value.filter((item) => item.filePath !== filePath);
|
||||
emit('update:modelValue', nextFiles);
|
||||
}
|
||||
|
||||
function clearFiles() {
|
||||
emit('update:modelValue', []);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -77,23 +96,41 @@ function clearFile() {
|
||||
ref="fileInputRef"
|
||||
class="workflow-file-input__native"
|
||||
type="file"
|
||||
multiple
|
||||
@change="handleNativeFileChange"
|
||||
/>
|
||||
|
||||
<div v-if="currentFile" class="workflow-file-input__summary">
|
||||
<div class="workflow-file-input__name">
|
||||
{{ currentFile.fileName }}
|
||||
</div>
|
||||
<div class="workflow-file-input__meta">
|
||||
<span>{{ formatWorkflowFileSize(currentFile.size) }}</span>
|
||||
<ElLink
|
||||
v-if="currentFile.url || currentFile.filePath"
|
||||
:href="currentFile.url || currentFile.filePath"
|
||||
target="_blank"
|
||||
type="primary"
|
||||
>
|
||||
{{ $t('button.view') }}
|
||||
</ElLink>
|
||||
<div class="workflow-file-input__hint">
|
||||
最多 {{ WORKFLOW_FILE_LIMITS.maxCount }} 个文件,单个不超过
|
||||
{{ formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxSingleSize) }},总计不超过
|
||||
{{ formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxTotalSize) }}
|
||||
</div>
|
||||
|
||||
<div v-if="currentFiles.length > 0" class="workflow-file-input__list">
|
||||
<div
|
||||
v-for="item in currentFiles"
|
||||
:key="item.filePath"
|
||||
class="workflow-file-input__summary"
|
||||
>
|
||||
<div class="workflow-file-input__content">
|
||||
<div class="workflow-file-input__name">
|
||||
{{ item.fileName }}
|
||||
</div>
|
||||
<div class="workflow-file-input__meta">
|
||||
<span>{{ formatWorkflowFileSize(item.size) }}</span>
|
||||
<ElLink
|
||||
v-if="item.url || item.filePath"
|
||||
:href="item.url || item.filePath"
|
||||
target="_blank"
|
||||
type="primary"
|
||||
>
|
||||
{{ $t('button.view') }}
|
||||
</ElLink>
|
||||
</div>
|
||||
</div>
|
||||
<ElButton text type="danger" @click="removeFile(item.filePath)">
|
||||
{{ $t('button.delete') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,16 +141,16 @@ function clearFile() {
|
||||
:loading="uploadLoading"
|
||||
@click="triggerSelectFile"
|
||||
>
|
||||
{{ currentFile ? $t('button.replace') : $t('button.upload') }}
|
||||
{{ currentFiles.length > 0 ? '继续上传' : $t('button.upload') }}
|
||||
</ElButton>
|
||||
<ChooseResource attr-name="file" @choose="handleChooseResource" />
|
||||
<ChooseResource attr-name="file" multiple @choose="handleChooseResource" />
|
||||
<ElButton
|
||||
v-if="currentFile"
|
||||
v-if="currentFiles.length > 0"
|
||||
text
|
||||
type="danger"
|
||||
@click="clearFile"
|
||||
@click="clearFiles"
|
||||
>
|
||||
{{ $t('button.delete') }}
|
||||
清空
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,13 +167,34 @@ function clearFile() {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workflow-file-input__hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.workflow-file-input__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.workflow-file-input__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 10px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.workflow-file-input__content {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.workflow-file-input__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance } from 'element-plus';
|
||||
|
||||
import { computed, onUnmounted, ref } from 'vue';
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { Position } from '@element-plus/icons-vue';
|
||||
import { ElButton, ElForm, ElFormItem } from 'element-plus';
|
||||
@@ -35,9 +35,74 @@ defineExpose({
|
||||
const runForm = ref<FormInstance>();
|
||||
const runParams = ref<any>({});
|
||||
const submitLoading = ref(false);
|
||||
const parameters = computed(() => {
|
||||
return props.workflowParams.parameters;
|
||||
const startFormMeta = computed(() => {
|
||||
const meta = props.workflowParams?.startFormMeta || {};
|
||||
return {
|
||||
title: String(meta.title || '').trim() || props.workflowParams?.title || '',
|
||||
description:
|
||||
String(meta.description || '').trim() ||
|
||||
props.workflowParams?.description ||
|
||||
'',
|
||||
submitText: String(meta.submitText || '').trim() || $t('button.run'),
|
||||
};
|
||||
});
|
||||
const parameters = computed(() => {
|
||||
const schema = Array.isArray(props.workflowParams?.startFormSchema)
|
||||
? props.workflowParams.startFormSchema
|
||||
: [];
|
||||
if (schema.length === 0) {
|
||||
return props.workflowParams.parameters || [];
|
||||
}
|
||||
return schema.map((field: any) => {
|
||||
const type = String(field.type || '').trim() || 'text';
|
||||
return {
|
||||
name: field.key,
|
||||
formLabel: field.label || field.key,
|
||||
formDescription: field.description || '',
|
||||
formPlaceholder: field.placeholder || '',
|
||||
required: Boolean(field.required),
|
||||
defaultValue: field.defaultValue,
|
||||
enums: Array.isArray(field.options) ? field.options : [],
|
||||
contentType: type === 'file' ? 'file' : 'text',
|
||||
formType: type === 'text' ? 'input' : type === 'file' ? 'input' : type,
|
||||
dataType:
|
||||
type === 'checkbox' ? 'Array' : type === 'file' ? 'File' : 'String',
|
||||
};
|
||||
});
|
||||
});
|
||||
watch(
|
||||
parameters,
|
||||
(items) => {
|
||||
const nextRunParams = { ...runParams.value };
|
||||
let changed = false;
|
||||
for (const item of items || []) {
|
||||
if (nextRunParams[item.name] !== undefined) {
|
||||
continue;
|
||||
}
|
||||
if (item.defaultValue !== undefined && item.defaultValue !== '') {
|
||||
nextRunParams[item.name] = item.defaultValue;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (item.formType === 'checkbox') {
|
||||
nextRunParams[item.name] = [];
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (item.contentType === 'file') {
|
||||
nextRunParams[item.name] = [];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
runParams.value = nextRunParams;
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
);
|
||||
const executeId = ref('');
|
||||
function resume(data: any) {
|
||||
data.executeId = executeId.value;
|
||||
@@ -110,6 +175,20 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div>
|
||||
<ElForm label-position="top" ref="runForm" :model="runParams">
|
||||
<div
|
||||
v-if="startFormMeta.title || startFormMeta.description"
|
||||
class="workflow-form__header"
|
||||
>
|
||||
<div v-if="startFormMeta.title" class="workflow-form__title">
|
||||
{{ startFormMeta.title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="startFormMeta.description"
|
||||
class="workflow-form__description"
|
||||
>
|
||||
{{ startFormMeta.description }}
|
||||
</div>
|
||||
</div>
|
||||
<WorkflowFormItem
|
||||
v-model:run-params="runParams"
|
||||
:parameters="parameters"
|
||||
@@ -121,11 +200,28 @@ onUnmounted(() => {
|
||||
:loading="submitLoading"
|
||||
:icon="Position"
|
||||
>
|
||||
{{ $t('button.run') }}
|
||||
{{ startFormMeta.submitText }}
|
||||
</ElButton>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.workflow-form__header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.workflow-form__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.workflow-form__description {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -53,6 +53,31 @@ function getCheckboxOptions(item: any) {
|
||||
}
|
||||
return [];
|
||||
}
|
||||
function buildRules(item: any) {
|
||||
if (!item.required) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
required: true,
|
||||
validator: (_rule: any, value: any, callback: any) => {
|
||||
if (Array.isArray(value)) {
|
||||
callback(value.length > 0 ? undefined : new Error($t('message.required')));
|
||||
return;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
callback(
|
||||
Object.keys(value).length > 0
|
||||
? undefined
|
||||
: new Error($t('message.required')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
callback(value ? undefined : new Error($t('message.required')));
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
function updateParam(name: string, value: any) {
|
||||
const newValue = { ...props.runParams, [name]: value };
|
||||
emit('update:runParams', newValue);
|
||||
@@ -68,9 +93,7 @@ function choose(data: any, propName: string) {
|
||||
:prop="`${propPrefix}${item.name}`"
|
||||
:key="idx"
|
||||
:label="item.formLabel || item.name"
|
||||
:rules="
|
||||
item.required ? [{ required: true, message: $t('message.required') }] : []
|
||||
"
|
||||
:rules="buildRules(item)"
|
||||
>
|
||||
<template v-if="getContentType(item) === 'text'">
|
||||
<ElInput
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
appendWorkflowFileValues,
|
||||
buildWorkflowFileValueFromResource,
|
||||
buildWorkflowFileValueFromUpload,
|
||||
formatWorkflowFileSize,
|
||||
isWorkflowFileValue,
|
||||
normalizeWorkflowFileValues,
|
||||
validateWorkflowFileSelection,
|
||||
validateWorkflowFileValues,
|
||||
} from '../workflowFileValue';
|
||||
|
||||
describe('workflowFileValue', () => {
|
||||
@@ -49,4 +53,99 @@ describe('workflowFileValue', () => {
|
||||
expect(isWorkflowFileValue({ fileName: 'demo.pdf' })).toBe(false);
|
||||
expect(formatWorkflowFileSize(2048)).toBe('2.0 KB');
|
||||
});
|
||||
|
||||
it('归一化并去重多文件值', () => {
|
||||
const result = normalizeWorkflowFileValues([
|
||||
{
|
||||
fileName: 'a.pdf',
|
||||
filePath: 'https://example.com/a.pdf',
|
||||
},
|
||||
{
|
||||
fileName: 'a-copy.pdf',
|
||||
filePath: 'https://example.com/a.pdf',
|
||||
},
|
||||
{
|
||||
fileName: 'b.pdf',
|
||||
filePath: 'https://example.com/b.pdf',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]?.fileName).toBe('a.pdf');
|
||||
expect(result[1]?.fileName).toBe('b.pdf');
|
||||
});
|
||||
|
||||
it('追加文件时按 filePath 去重', () => {
|
||||
const result = appendWorkflowFileValues(
|
||||
[
|
||||
{
|
||||
fileName: 'a.pdf',
|
||||
filePath: 'https://example.com/a.pdf',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
fileName: 'a-duplicate.pdf',
|
||||
filePath: 'https://example.com/a.pdf',
|
||||
},
|
||||
{
|
||||
fileName: 'b.pdf',
|
||||
filePath: 'https://example.com/b.pdf',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1]?.fileName).toBe('b.pdf');
|
||||
});
|
||||
|
||||
it('超出文件限制时抛出错误', () => {
|
||||
expect(() =>
|
||||
validateWorkflowFileValues(
|
||||
new Array(11).fill(null).map((_, index) => ({
|
||||
fileName: `file-${index}.pdf`,
|
||||
filePath: `https://example.com/${index}.pdf`,
|
||||
size: 1024,
|
||||
})),
|
||||
),
|
||||
).toThrow('最多上传 10 个文件');
|
||||
});
|
||||
|
||||
it('上传前校验文件数量限制', () => {
|
||||
const currentFiles = new Array(9).fill(null).map((_, index) => ({
|
||||
fileName: `file-${index}.pdf`,
|
||||
filePath: `https://example.com/${index}.pdf`,
|
||||
size: 1024,
|
||||
}));
|
||||
|
||||
expect(() =>
|
||||
validateWorkflowFileSelection(currentFiles, [
|
||||
new File(['a'], 'a.pdf'),
|
||||
new File(['b'], 'b.pdf'),
|
||||
]),
|
||||
).toThrow('最多上传 10 个文件');
|
||||
});
|
||||
|
||||
it('上传前校验单文件大小限制', () => {
|
||||
const oversizedFile = new File([new Uint8Array(6 * 1024 * 1024)], 'large.pdf');
|
||||
|
||||
expect(() =>
|
||||
validateWorkflowFileSelection([], [oversizedFile]),
|
||||
).toThrow('单个文件不能超过 5.0 MB');
|
||||
});
|
||||
|
||||
it('上传前校验总大小限制', () => {
|
||||
const currentFiles = [
|
||||
{
|
||||
fileName: 'existing.pdf',
|
||||
filePath: 'https://example.com/existing.pdf',
|
||||
size: 49 * 1024 * 1024,
|
||||
},
|
||||
];
|
||||
const incomingFile = new File([new Uint8Array(2 * 1024 * 1024)], 'new.pdf');
|
||||
|
||||
expect(() =>
|
||||
validateWorkflowFileSelection(currentFiles, [incomingFile]),
|
||||
).toThrow('文件总大小不能超过 50.0 MB');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,12 @@ export interface WorkflowResourceLike {
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
export const WORKFLOW_FILE_LIMITS = {
|
||||
maxCount: 10,
|
||||
maxSingleSize: 5 * 1024 * 1024,
|
||||
maxTotalSize: 50 * 1024 * 1024,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 基于上传结果构建工作流文件值。
|
||||
*/
|
||||
@@ -78,6 +84,87 @@ export function isWorkflowFileValue(value: unknown): value is WorkflowFileValue
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化单文件或多文件值。
|
||||
*/
|
||||
export function normalizeWorkflowFileValues(value: unknown): WorkflowFileValue[] {
|
||||
const candidates = Array.isArray(value) ? value : [value];
|
||||
const result: WorkflowFileValue[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const candidate of candidates) {
|
||||
if (!isWorkflowFileValue(candidate)) {
|
||||
continue;
|
||||
}
|
||||
const key = String(candidate.filePath).trim();
|
||||
if (!key || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
result.push(candidate);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加文件并按 filePath 去重。
|
||||
*/
|
||||
export function appendWorkflowFileValues(
|
||||
currentValue: unknown,
|
||||
incomingValues: WorkflowFileValue[],
|
||||
): WorkflowFileValue[] {
|
||||
return normalizeWorkflowFileValues([
|
||||
...normalizeWorkflowFileValues(currentValue),
|
||||
...incomingValues,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在上传前基于原生 File 列表校验文件限制,避免无效文件先落库。
|
||||
*/
|
||||
export function validateWorkflowFileSelection(
|
||||
currentValue: unknown,
|
||||
incomingFiles: File[],
|
||||
) {
|
||||
const currentFiles = normalizeWorkflowFileValues(currentValue);
|
||||
const totalCount = currentFiles.length + incomingFiles.length;
|
||||
if (totalCount > WORKFLOW_FILE_LIMITS.maxCount) {
|
||||
throw new Error(`最多上传 ${WORKFLOW_FILE_LIMITS.maxCount} 个文件`);
|
||||
}
|
||||
|
||||
let totalSize = currentFiles.reduce((sum, item) => sum + Number(item.size || 0), 0);
|
||||
for (const file of incomingFiles) {
|
||||
if (file.size > WORKFLOW_FILE_LIMITS.maxSingleSize) {
|
||||
throw new Error(`单个文件不能超过 ${formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxSingleSize)}`);
|
||||
}
|
||||
totalSize += file.size;
|
||||
}
|
||||
|
||||
if (totalSize > WORKFLOW_FILE_LIMITS.maxTotalSize) {
|
||||
throw new Error(`文件总大小不能超过 ${formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxTotalSize)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验最终文件列表是否满足工作流限制。
|
||||
*/
|
||||
export function validateWorkflowFileValues(values: WorkflowFileValue[]) {
|
||||
const normalized = normalizeWorkflowFileValues(values);
|
||||
if (normalized.length > WORKFLOW_FILE_LIMITS.maxCount) {
|
||||
throw new Error(`最多上传 ${WORKFLOW_FILE_LIMITS.maxCount} 个文件`);
|
||||
}
|
||||
let totalSize = 0;
|
||||
for (const item of normalized) {
|
||||
const size = Number(item.size || 0);
|
||||
if (size > WORKFLOW_FILE_LIMITS.maxSingleSize) {
|
||||
throw new Error(`单个文件不能超过 ${formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxSingleSize)}`);
|
||||
}
|
||||
totalSize += size;
|
||||
}
|
||||
if (totalSize > WORKFLOW_FILE_LIMITS.maxTotalSize) {
|
||||
throw new Error(`文件总大小不能超过 ${formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxTotalSize)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 友好格式化文件大小。
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,14 +24,37 @@ export default {
|
||||
],
|
||||
outputDefs: [
|
||||
{
|
||||
name: 'content',
|
||||
title: $t('aiWorkflow.parsedText'),
|
||||
dataType: 'String',
|
||||
name: 'documents',
|
||||
title: $t('aiWorkflow.documents'),
|
||||
dataType: 'Array',
|
||||
dataTypeDisabled: true,
|
||||
required: true,
|
||||
parametersAddEnable: false,
|
||||
description: $t('aiWorkflow.descriptions.parsedText'),
|
||||
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',
|
||||
title: $t('aiWorkflow.parsedText'),
|
||||
dataType: 'String',
|
||||
dataTypeDisabled: true,
|
||||
required: true,
|
||||
parametersAddEnable: false,
|
||||
deleteDisabled: true,
|
||||
nameDisabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -2,6 +2,117 @@ import { $t } from '#/locales';
|
||||
|
||||
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 {
|
||||
[nodeNames.makeFileNode]: {
|
||||
title: $t('aiWorkflow.fileGeneration'),
|
||||
@@ -9,8 +120,8 @@ export default {
|
||||
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>',
|
||||
sortNo: 802,
|
||||
parametersAddEnable: true,
|
||||
outputDefsAddEnable: true,
|
||||
parametersAddEnable: false,
|
||||
outputDefsAddEnable: false,
|
||||
forms: [
|
||||
{
|
||||
type: 'heading',
|
||||
@@ -18,16 +129,42 @@ export default {
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
label: $t('documentCollection.splitterDoc.fileType'),
|
||||
description: $t('aiWorkflow.descriptions.fileType'),
|
||||
name: 'suffix',
|
||||
defaultValue: 'docx',
|
||||
label: $t('aiWorkflow.targetFormat'),
|
||||
description: $t('aiWorkflow.descriptions.targetFormat'),
|
||||
name: 'targetFormat',
|
||||
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: [
|
||||
{
|
||||
label: 'docx',
|
||||
value: 'docx',
|
||||
label: $t('aiWorkflow.defaultTemplateStyle'),
|
||||
value: DEFAULT_TEMPLATE_STYLE,
|
||||
},
|
||||
],
|
||||
onValueChange: (value: string | number | boolean | undefined, data: Record<string, any>) =>
|
||||
getMakeFileNodeFormPatch('templateStyle', value, data),
|
||||
},
|
||||
],
|
||||
parameters: [
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { EchartsUIType } from '@easyflow/plugins/echarts';
|
||||
|
||||
import type {
|
||||
DashboardDistributionItem,
|
||||
DashboardOverviewQuery,
|
||||
DashboardOverviewResponse,
|
||||
DashboardRange,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
import { AnalysisChartCard } from '@easyflow/common-ui';
|
||||
import { EchartsUI, useEcharts } from '@easyflow/plugins/echarts';
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
import { convertToRgb } from '@easyflow/utils';
|
||||
|
||||
import { RefreshRight } from '@element-plus/icons-vue';
|
||||
import { ElButton, ElEmpty, ElRadioButton, ElRadioGroup } from 'element-plus';
|
||||
@@ -28,6 +30,7 @@ import { ElButton, ElEmpty, ElRadioButton, ElRadioGroup } from 'element-plus';
|
||||
import { getDashboardOverview } from '#/api/dashboard';
|
||||
|
||||
interface SummaryCardItem {
|
||||
available?: boolean;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
@@ -54,27 +57,48 @@ const rangeOptions: Array<{ label: string; value: DashboardRange }> = [
|
||||
];
|
||||
|
||||
const emptySummary: DashboardSummary = {
|
||||
activeAssistantTotal: 0,
|
||||
activeUserTotal: 0,
|
||||
botTotal: 0,
|
||||
chatMessageTotal: 0,
|
||||
chatSessionTotal: 0,
|
||||
knowledgeBaseTotal: 0,
|
||||
userTotal: 0,
|
||||
workflowTotal: 0,
|
||||
};
|
||||
|
||||
const summary = computed(() => overview.value?.summary ?? emptySummary);
|
||||
const trends = computed<DashboardTrendItem[]>(
|
||||
() => overview.value?.trends ?? [],
|
||||
const trends = computed<DashboardTrendItem[]>(() => overview.value?.trends ?? []);
|
||||
const distribution = computed<DashboardDistributionItem[]>(
|
||||
() => overview.value?.distribution ?? [],
|
||||
);
|
||||
const chatAvailable = computed(() => overview.value?.chatStatus?.available !== false);
|
||||
const chatStatusMessage = computed(
|
||||
() => overview.value?.chatStatus?.message || '聊天数据不可用',
|
||||
);
|
||||
|
||||
const summaryCards = computed<SummaryCardItem[]>(() => [
|
||||
{ label: '用户总量', value: formatCount(summary.value.userTotal) },
|
||||
{ label: '活跃用户', value: formatCount(summary.value.activeUserTotal) },
|
||||
{ label: '助手数量', value: formatCount(summary.value.botTotal) },
|
||||
{ label: '工作流数量', value: formatCount(summary.value.workflowTotal) },
|
||||
{
|
||||
label: '知识库数量',
|
||||
value: formatCount(summary.value.knowledgeBaseTotal),
|
||||
},
|
||||
{
|
||||
available: chatAvailable.value,
|
||||
label: '聊天消息总数',
|
||||
value: formatOptionalCount(summary.value.chatMessageTotal, chatAvailable.value),
|
||||
},
|
||||
{
|
||||
available: chatAvailable.value,
|
||||
label: '聊天会话总数',
|
||||
value: formatOptionalCount(summary.value.chatSessionTotal, chatAvailable.value),
|
||||
},
|
||||
{
|
||||
available: chatAvailable.value,
|
||||
label: '活跃智能体数',
|
||||
value: formatOptionalCount(summary.value.activeAssistantTotal, chatAvailable.value),
|
||||
},
|
||||
]);
|
||||
|
||||
const updatedAtText = computed(() => {
|
||||
@@ -131,15 +155,24 @@ async function loadOverview() {
|
||||
|
||||
async function renderCharts() {
|
||||
await nextTick();
|
||||
if (!chatAvailable.value) {
|
||||
return;
|
||||
}
|
||||
renderTrendChart();
|
||||
}
|
||||
|
||||
function renderTrendChart() {
|
||||
const xAxisData = trends.value.map((item) => item.label);
|
||||
const activeUserData = trends.value.map((item) => item.activeUserTotal);
|
||||
const messageData = trends.value.map((item) => item.chatMessageTotal);
|
||||
const sessionData = trends.value.map((item) => item.chatSessionTotal);
|
||||
const primaryColor = getChartTokenColor('--primary');
|
||||
const successColor = getChartTokenColor('--success');
|
||||
const axisColor = getChartTokenColor('--border');
|
||||
const tooltipLineColor = getChartTokenColor('--accent');
|
||||
const textColor = getChartTokenColor('--foreground');
|
||||
|
||||
renderTrendEcharts({
|
||||
color: ['hsl(var(--primary))'],
|
||||
color: [primaryColor, successColor],
|
||||
grid: {
|
||||
bottom: 18,
|
||||
containLabel: true,
|
||||
@@ -148,21 +181,38 @@ function renderTrendChart() {
|
||||
top: 24,
|
||||
},
|
||||
legend: {
|
||||
itemGap: 18,
|
||||
top: 0,
|
||||
icon: 'circle',
|
||||
itemGap: 24,
|
||||
itemHeight: 10,
|
||||
itemWidth: 10,
|
||||
padding: [4, 12, 8, 12],
|
||||
textStyle: {
|
||||
color: textColor,
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
},
|
||||
top: 4,
|
||||
},
|
||||
tooltip: {
|
||||
axisPointer: {
|
||||
lineStyle: {
|
||||
color: tooltipLineColor,
|
||||
width: 1,
|
||||
},
|
||||
type: 'line',
|
||||
},
|
||||
trigger: 'axis',
|
||||
},
|
||||
xAxis: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: 'hsl(var(--border))',
|
||||
color: axisColor,
|
||||
},
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
boundaryGap: false,
|
||||
data: xAxisData,
|
||||
type: 'category',
|
||||
},
|
||||
@@ -175,7 +225,7 @@ function renderTrendChart() {
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: 'hsl(var(--border))',
|
||||
color: axisColor,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
@@ -183,10 +233,57 @@ function renderTrendChart() {
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: activeUserData,
|
||||
name: '活跃用户',
|
||||
data: messageData,
|
||||
emphasis: {
|
||||
focus: 'none',
|
||||
itemStyle: {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 3,
|
||||
color: primaryColor,
|
||||
},
|
||||
scale: true,
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: primaryColor,
|
||||
borderWidth: 2,
|
||||
color: '#ffffff',
|
||||
},
|
||||
lineStyle: {
|
||||
color: primaryColor,
|
||||
width: 3,
|
||||
},
|
||||
name: '消息数',
|
||||
smooth: true,
|
||||
symbolSize: 8,
|
||||
showSymbol: false,
|
||||
symbol: 'circle',
|
||||
symbolSize: 9,
|
||||
type: 'line',
|
||||
},
|
||||
{
|
||||
data: sessionData,
|
||||
emphasis: {
|
||||
focus: 'none',
|
||||
itemStyle: {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 3,
|
||||
color: successColor,
|
||||
},
|
||||
scale: true,
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: successColor,
|
||||
borderWidth: 2,
|
||||
color: '#ffffff',
|
||||
},
|
||||
lineStyle: {
|
||||
color: successColor,
|
||||
width: 3,
|
||||
},
|
||||
name: '会话数',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
symbol: 'circle',
|
||||
symbolSize: 9,
|
||||
type: 'line',
|
||||
},
|
||||
],
|
||||
@@ -205,6 +302,28 @@ function formatCount(value?: number) {
|
||||
return Number(value || 0).toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function formatOptionalCount(value: number | undefined, available: boolean) {
|
||||
return available ? formatCount(value) : '--';
|
||||
}
|
||||
|
||||
function getChartTokenColor(variableName: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return '#3b82f6';
|
||||
}
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(variableName)
|
||||
.trim();
|
||||
return value ? convertToRgb(`hsl(${value})`) : '#3b82f6';
|
||||
}
|
||||
|
||||
function formatAvg(value?: number) {
|
||||
const safeValue = Number(value || 0);
|
||||
return safeValue.toLocaleString('zh-CN', {
|
||||
maximumFractionDigits: 1,
|
||||
minimumFractionDigits: safeValue > 0 && safeValue < 10 ? 1 : 0,
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
@@ -270,7 +389,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<section
|
||||
v-if="isLoading && !overview"
|
||||
class="grid gap-4 md:grid-cols-2 xl:grid-cols-4"
|
||||
class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
<div
|
||||
v-for="item in 8"
|
||||
@@ -294,7 +413,7 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
|
||||
<template v-else>
|
||||
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div
|
||||
v-for="item in summaryCards"
|
||||
:key="item.label"
|
||||
@@ -308,16 +427,72 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
{{ item.value }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.available === false"
|
||||
class="text-muted-foreground mt-2 text-xs"
|
||||
>
|
||||
{{ chatStatusMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<AnalysisChartCard title="趋势变化">
|
||||
<div class="space-y-2">
|
||||
<p class="text-muted-foreground text-sm">
|
||||
观察活跃用户在所选时间范围内的变化趋势。
|
||||
</p>
|
||||
<EchartsUI ref="trendChartRef" height="360px" />
|
||||
<section class="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
|
||||
<AnalysisChartCard title="聊天趋势">
|
||||
<template v-if="chatAvailable">
|
||||
<div class="space-y-2">
|
||||
<p class="text-muted-foreground text-sm">
|
||||
观察所选时间范围内消息数与会话数的趋势变化。
|
||||
</p>
|
||||
<EchartsUI ref="trendChartRef" height="360px" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="flex min-h-[360px] items-center justify-center">
|
||||
<ElEmpty :description="chatStatusMessage" />
|
||||
</div>
|
||||
</AnalysisChartCard>
|
||||
|
||||
<AnalysisChartCard title="智能体排行">
|
||||
<template v-if="chatAvailable">
|
||||
<div v-if="distribution.length" class="space-y-3">
|
||||
<div
|
||||
v-for="(item, index) in distribution"
|
||||
:key="item.key || item.label"
|
||||
class="border-border/60 bg-muted/20 flex items-start justify-between rounded-2xl border px-4 py-4"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="bg-primary/10 text-primary flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-semibold"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-semibold">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-1 text-xs">
|
||||
消息 {{ formatCount(item.messageTotal) }} · 会话
|
||||
{{ formatCount(item.sessionTotal) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-foreground text-lg font-semibold">
|
||||
{{ formatAvg(item.avgMessagePerSession) }}
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-1 text-xs">
|
||||
平均每会话消息数
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex min-h-[360px] items-center justify-center">
|
||||
<ElEmpty description="暂无聊天排行数据" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="flex min-h-[360px] items-center justify-center">
|
||||
<ElEmpty :description="chatStatusMessage" />
|
||||
</div>
|
||||
</AnalysisChartCard>
|
||||
</section>
|
||||
|
||||
@@ -71,6 +71,47 @@
|
||||
const onRunTest = options.onRunTest;
|
||||
|
||||
const { updateEdgeData } = useUpdateEdgeData();
|
||||
const pendingParentRepairs = new Set<string>();
|
||||
|
||||
function scheduleOrphanParentRepair(nodeId: string) {
|
||||
if (!nodeId || pendingParentRepairs.has(nodeId)) {
|
||||
return;
|
||||
}
|
||||
pendingParentRepairs.add(nodeId);
|
||||
queueMicrotask(() => {
|
||||
pendingParentRepairs.delete(nodeId);
|
||||
const currentNode = getNode(nodeId);
|
||||
const parentId = asString(currentNode?.parentId).trim();
|
||||
if (!currentNode || !parentId || getNode(parentId)) {
|
||||
return;
|
||||
}
|
||||
svelteFlow.updateNode(nodeId, {
|
||||
parentId: undefined
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getResolvedParentId(node: null | Node | undefined) {
|
||||
const parentId = asString(node?.parentId).trim();
|
||||
if (!parentId) {
|
||||
return undefined;
|
||||
}
|
||||
if (getNode(parentId)) {
|
||||
return parentId;
|
||||
}
|
||||
if (node?.id) {
|
||||
scheduleOrphanParentRepair(node.id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function repairOrphanParentNodes() {
|
||||
store.getNodes().forEach((node) => {
|
||||
if (asString(node.parentId).trim() && !getResolvedParentId(node)) {
|
||||
scheduleOrphanParentRepair(node.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getEventClientPosition(event: any) {
|
||||
if (typeof event?.clientX === 'number' && typeof event?.clientY === 'number') {
|
||||
@@ -353,24 +394,27 @@
|
||||
const isValidConnection = (conn: any) => {
|
||||
const sourceNode = getNode(conn.source)!;
|
||||
const targetNode = getNode(conn.target)!;
|
||||
const sourceParentId = getResolvedParentId(sourceNode);
|
||||
const targetParentId = getResolvedParentId(targetNode);
|
||||
|
||||
// 阻止循环节点连接到父级节点 或者 父级节点连接到子级节点
|
||||
if (conn.sourceHandle === 'loop_handle' || sourceNode.parentId) {
|
||||
if (conn.sourceHandle === 'loop_handle' || sourceParentId) {
|
||||
const edges = svelteFlow.getEdges();
|
||||
for (let edge of edges) {
|
||||
if (edge.target === conn.target) {
|
||||
const edgeSourceNode = getNode(edge.source) as Node;
|
||||
if (conn.sourceHandle === 'loop_handle' && edgeSourceNode.parentId !== sourceNode.id) {
|
||||
const edgeSourceParentId = getResolvedParentId(edgeSourceNode);
|
||||
if (conn.sourceHandle === 'loop_handle' && edgeSourceParentId !== sourceNode.id) {
|
||||
return false;
|
||||
}
|
||||
if (sourceNode.parentId && edgeSourceNode.parentId !== sourceNode.parentId) {
|
||||
if (sourceParentId && edgeSourceParentId !== sourceParentId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourceNode.parentId && targetNode.parentId && targetNode.parentId !== sourceNode.id) {
|
||||
if (!sourceParentId && targetParentId && targetParentId !== sourceNode.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -446,12 +490,14 @@
|
||||
closeNodePicker();
|
||||
|
||||
const toNode = state.toNode as Node;
|
||||
if (toNode.parentId) {
|
||||
const targetParentId = getResolvedParentId(toNode);
|
||||
if (targetParentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fromNode = state.fromNode as Node;
|
||||
const fromHande = state.fromHandle as any;
|
||||
const sourceParentId = getResolvedParentId(fromNode);
|
||||
|
||||
const newNode = {
|
||||
position: { ...toNode.position }
|
||||
@@ -459,8 +505,8 @@
|
||||
|
||||
if (fromHande.id === 'loop_handle') {
|
||||
newNode.parentId = fromNode.id;
|
||||
} else if (fromNode.parentId) {
|
||||
newNode.parentId = fromNode.parentId;
|
||||
} else if (sourceParentId) {
|
||||
newNode.parentId = sourceParentId;
|
||||
}
|
||||
|
||||
if (newNode.parentId) {
|
||||
@@ -514,10 +560,11 @@
|
||||
showEdgePanel = false;
|
||||
}
|
||||
const targetNode = getNode(edge.target) as Node;
|
||||
if (targetNode && targetNode.parentId) {
|
||||
const targetParentId = getResolvedParentId(targetNode as Node);
|
||||
if (targetNode && targetParentId) {
|
||||
const nodeEdges = getEdgesByTarget(edge.target);
|
||||
// const loopNode = getNode(targetNode.parentId) as Node;
|
||||
const { x, y } = getNodeRelativePosition(targetNode.parentId);
|
||||
const { x, y } = getNodeRelativePosition(targetParentId);
|
||||
if (nodeEdges.length === 0) {
|
||||
svelteFlow.updateNode(targetNode.id, {
|
||||
parentId: undefined,
|
||||
@@ -543,7 +590,7 @@
|
||||
for (let i = 0; i < nodeEdges.length; i++) {
|
||||
const edge = nodeEdges[i];
|
||||
const sourceNode = getNode(edge.source) as Node;
|
||||
if (sourceNode.parentId || sourceNode.type === 'loopNode') {
|
||||
if (getResolvedParentId(sourceNode) || sourceNode.type === 'loopNode') {
|
||||
hasSameParent = true;
|
||||
break;
|
||||
}
|
||||
@@ -680,6 +727,7 @@
|
||||
|
||||
onMount(() => {
|
||||
store.updateEdges((edges) => edges.map((edge) => ensureEdgeVisualDefaults(edge)));
|
||||
repairOrphanParentNodes();
|
||||
if (!readonly) {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('paste', handleGlobalPaste);
|
||||
|
||||
@@ -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 {Snippet} from 'svelte';
|
||||
|
||||
const { children, primary, ...rest }: MyHTMLButtonAttributes & {
|
||||
const { children, primary, onclick, ...rest }: MyHTMLButtonAttributes & {
|
||||
children?: Snippet;
|
||||
primary?: boolean;
|
||||
} = $props();
|
||||
</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?.()}
|
||||
</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>
|
||||
</span>
|
||||
{/if}
|
||||
<span class="tf-parameter-name">{item.label}</span>
|
||||
<span class="tf-parameter-name">{item.displayLabel || item.label}</span>
|
||||
</div>
|
||||
{#if item.dataType}
|
||||
<span class="tf-parameter-type">{item.dataType}</span>
|
||||
{/if}
|
||||
{#if item.itemTypeLabel}
|
||||
<span class="tf-parameter-meta">{item.itemTypeLabel}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -768,4 +771,11 @@
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tf-parameter-meta {
|
||||
color: var(--tf-text-muted);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,15 @@
|
||||
import {useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
||||
import {contentTypes, startFormTypes} from '#consts';
|
||||
import type {Parameter} from '#types';
|
||||
import {store} from '#store/stores.svelte';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import {
|
||||
renameStartFieldReferencesInNodes,
|
||||
removeStartFormField,
|
||||
START_NODE_TYPE,
|
||||
SYSTEM_START_PARAM_NAME,
|
||||
updateStartFormField,
|
||||
} from '../../utils/workflowNodeFields';
|
||||
|
||||
const { parameter, index }: {
|
||||
parameter: Parameter,
|
||||
@@ -20,10 +28,108 @@
|
||||
...(node?.current?.data?.parameters as Array<Parameter>)[index]
|
||||
};
|
||||
});
|
||||
let isSystemStartParam = $derived.by(() => {
|
||||
return param.systemReserved === true && param.name === SYSTEM_START_PARAM_NAME;
|
||||
});
|
||||
let isStartNodeInputParam = $derived.by(() => {
|
||||
return node?.current?.type === START_NODE_TYPE && param.refType === 'input';
|
||||
});
|
||||
let availableFormTypes = $derived.by(() => {
|
||||
if (isSystemStartParam) {
|
||||
return startFormTypes.filter((item) => item.value === 'input' || item.value === 'textarea');
|
||||
}
|
||||
return startFormTypes;
|
||||
});
|
||||
let displayFormTypeValue = $derived.by(() => {
|
||||
if (isStartNodeInputParam && param.contentType === 'file') {
|
||||
return ['file'];
|
||||
}
|
||||
return param.formType ? [param.formType] : [];
|
||||
});
|
||||
let displayParamName = $derived.by(() => {
|
||||
if (isSystemStartParam) {
|
||||
return '用户问题';
|
||||
}
|
||||
return param.name;
|
||||
});
|
||||
|
||||
const { updateNodeData } = useSvelteFlow();
|
||||
|
||||
const toStartFormFieldType = (value: string) => {
|
||||
if (value === 'input') {
|
||||
return 'text';
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const trimString = (value: unknown) => {
|
||||
return value == null ? '' : String(value).trim();
|
||||
};
|
||||
|
||||
const applyStartFieldPatch = (fieldKey: string, patch: Record<string, any>) => {
|
||||
const currentFieldId = trimString(param.id);
|
||||
store.updateNodes((nodes) => {
|
||||
const edges = store.getEdges();
|
||||
let nextFieldKey = fieldKey;
|
||||
const nextNodes = nodes.map((currentNode) => {
|
||||
if (currentNode.id !== currentNodeId) {
|
||||
return currentNode;
|
||||
}
|
||||
const nextData = updateStartFormField(currentNode.data as Record<string, any>, fieldKey, patch);
|
||||
const nextSchema = Array.isArray(nextData.startFormSchema) ? nextData.startFormSchema : [];
|
||||
const matchedField = nextSchema.find((field) => {
|
||||
return (currentFieldId && trimString(field?.id) === currentFieldId)
|
||||
|| trimString(field?.key) === fieldKey;
|
||||
});
|
||||
nextFieldKey = trimString(matchedField?.key) || fieldKey;
|
||||
return {
|
||||
...currentNode,
|
||||
data: {
|
||||
...((currentNode.data || {}) as Record<string, any>),
|
||||
...nextData
|
||||
}
|
||||
};
|
||||
});
|
||||
return renameStartFieldReferencesInNodes(
|
||||
nextNodes,
|
||||
edges,
|
||||
currentNodeId,
|
||||
fieldKey,
|
||||
nextFieldKey
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const updateParameter = (key: string, value: any) => {
|
||||
if (isStartNodeInputParam) {
|
||||
const fieldKey = param.name || '';
|
||||
if (!fieldKey) {
|
||||
return;
|
||||
}
|
||||
let patch: Record<string, any> = {};
|
||||
if (key === 'name') {
|
||||
patch = { key: value };
|
||||
} else if (key === 'required') {
|
||||
patch = { required: Boolean(value) };
|
||||
} else if (key === 'formType') {
|
||||
patch = { type: toStartFormFieldType(value) };
|
||||
} else if (key === 'formLabel') {
|
||||
patch = { label: value };
|
||||
} else if (key === 'formDescription') {
|
||||
patch = { description: value };
|
||||
} else if (key === 'formPlaceholder') {
|
||||
patch = { placeholder: value };
|
||||
} else if (key === 'enums') {
|
||||
patch = { options: Array.isArray(value) ? value : [] };
|
||||
} else if (key === 'contentType') {
|
||||
patch = { type: value === 'file' ? 'file' : 'text' };
|
||||
}
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return;
|
||||
}
|
||||
applyStartFieldPatch(fieldKey, patch);
|
||||
return;
|
||||
}
|
||||
updateNodeData(currentNodeId, (node) => {
|
||||
let parameters = node.data.parameters as Array<Parameter>;
|
||||
(parameters[index] as any)[key] = value;
|
||||
@@ -51,6 +157,9 @@
|
||||
const updateFormType = (item: any) => {
|
||||
const newValue = item.value;
|
||||
updateParameter('formType', newValue);
|
||||
if (isSystemStartParam) {
|
||||
updateParameter('contentType', 'text');
|
||||
}
|
||||
};
|
||||
|
||||
const updateContentType = (item: any) => {
|
||||
@@ -61,6 +170,13 @@
|
||||
|
||||
let triggerObject: any;
|
||||
const handleDelete = () => {
|
||||
if (isStartNodeInputParam) {
|
||||
updateNodeData(currentNodeId, (node) => {
|
||||
return removeStartFormField(node.data as Record<string, any>, param.name);
|
||||
});
|
||||
triggerObject?.hide();
|
||||
return;
|
||||
}
|
||||
updateNodeData(currentNodeId, (node) => {
|
||||
let parameters = node.data.parameters as Array<Parameter>;
|
||||
parameters.splice(index, 1);
|
||||
@@ -75,7 +191,7 @@
|
||||
|
||||
|
||||
<div class="input-item">
|
||||
<Input style="width: 100%;" value={param.name} placeholder="请输入参数名称"
|
||||
<Input style="width: 100%;" value={displayParamName} placeholder="请输入参数名称"
|
||||
disabled={param.nameDisabled === true}
|
||||
oninput={updateName} />
|
||||
</div>
|
||||
@@ -94,22 +210,21 @@
|
||||
<div class="input-more-setting">
|
||||
{#if param.systemReserved}
|
||||
<div class="input-more-item">
|
||||
系统入口参数,当前不可编辑。
|
||||
系统入口参数,名称和必填规则固定,可调整展示标题、说明、占位符和输入方式。
|
||||
</div>
|
||||
{/if}
|
||||
<div class="input-more-item">
|
||||
数据内容:
|
||||
<Select items={contentTypes} style="width: 100%" defaultValue={["text"]}
|
||||
value={param.contentType ? [param.contentType] : []}
|
||||
disabled={param.systemReserved === true}
|
||||
disabled={param.systemReserved === true || isStartNodeInputParam}
|
||||
onSelect={updateContentType}
|
||||
/>
|
||||
</div>
|
||||
<div class="input-more-item">
|
||||
输入方式:
|
||||
<Select items={startFormTypes} style="width: 100%" defaultValue={["input"]}
|
||||
value={param.formType ? [param.formType] : []}
|
||||
disabled={param.systemReserved === true}
|
||||
<Select items={availableFormTypes} style="width: 100%" defaultValue={["input"]}
|
||||
value={displayFormTypeValue}
|
||||
onSelect={updateFormType}
|
||||
/>
|
||||
</div>
|
||||
@@ -127,21 +242,21 @@
|
||||
数据标题:
|
||||
<Textarea rows={1} style="width: 100%;" onchange={(event)=>{
|
||||
updateParamByEvent('formLabel', event)
|
||||
}} disabled={param.systemReserved === true} value={param.formLabel} />
|
||||
}} value={param.formLabel} />
|
||||
</div>
|
||||
|
||||
<div class="input-more-item">
|
||||
数据描述:
|
||||
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
|
||||
updateParamByEvent('formDescription', event)
|
||||
}} disabled={param.systemReserved === true} value={param.formDescription} />
|
||||
}} value={param.formDescription} />
|
||||
</div>
|
||||
|
||||
<div class="input-more-item">
|
||||
占位符:
|
||||
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
|
||||
updateParamByEvent('formPlaceholder', event)
|
||||
}} disabled={param.systemReserved === true} value={param.formPlaceholder} />
|
||||
}} value={param.formPlaceholder} />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
} from '@xyflow/svelte';
|
||||
import {Button, Collapse, FloatingTrigger, Input, Textarea} from '../base';
|
||||
import {type Snippet} from 'svelte';
|
||||
import {onDestroy, onMount} from 'svelte';
|
||||
import {useDeleteNode} from '../utils/useDeleteNode.svelte';
|
||||
import {useCopyNode} from '../utils/useCopyNode.svelte';
|
||||
import {getOptions} from '../utils/NodeUtils';
|
||||
@@ -73,6 +74,38 @@
|
||||
options.onNodeExecute?.(getNode(id)!);
|
||||
};
|
||||
let currentNodeId = getCurrentNodeId();
|
||||
let wrapperElement: HTMLDivElement | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let resizeFrame = 0;
|
||||
|
||||
const scheduleUpdateNodeInternals = () => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
if (resizeFrame) {
|
||||
cancelAnimationFrame(resizeFrame);
|
||||
}
|
||||
resizeFrame = requestAnimationFrame(() => {
|
||||
updateNodeInternals(id);
|
||||
});
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
scheduleUpdateNodeInternals();
|
||||
if (typeof ResizeObserver !== 'undefined' && wrapperElement) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
scheduleUpdateNodeInternals();
|
||||
});
|
||||
resizeObserver.observe(wrapperElement);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (resizeFrame) {
|
||||
cancelAnimationFrame(resizeFrame);
|
||||
}
|
||||
resizeObserver?.disconnect();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@@ -250,7 +283,7 @@
|
||||
</NodeToolbar>
|
||||
{/if}
|
||||
|
||||
<div class="tf-node-wrapper {wrapperClass}">
|
||||
<div class="tf-node-wrapper {wrapperClass}" bind:this={wrapperElement}>
|
||||
<div class="tf-node-wrapper-body">
|
||||
<Collapse {items} activeKeys={activeKeys} onChange={(_,actionKeys) => {
|
||||
updateNodeData(id, {expand: actionKeys?.includes('key')})
|
||||
|
||||
@@ -40,6 +40,13 @@
|
||||
});
|
||||
|
||||
const { updateNodeData } = useSvelteFlow();
|
||||
const displayParameterName = $derived.by(() => {
|
||||
const baseName = currentParameter.name || '';
|
||||
if (!baseName) {
|
||||
return '';
|
||||
}
|
||||
return baseName;
|
||||
});
|
||||
|
||||
const updateAttribute = (key: string, value: any) => {
|
||||
updateNodeData(currentNodeId, (node) => {
|
||||
@@ -138,11 +145,13 @@
|
||||
|
||||
|
||||
<div class="input-item">
|
||||
{#if (position.length > 1)}
|
||||
{#each position as p} {/each}
|
||||
{/if}
|
||||
<Input style="width: 100%;" value={currentParameter.name} placeholder={placeholder}
|
||||
oninput={(e)=>{updateByEvent('name',e)}} disabled={currentParameter.nameDisabled === true} />
|
||||
<div class="output-name-shell" class:output-name-shell--child={position.length > 1}>
|
||||
{#if position.length > 1}
|
||||
<span class="output-branch-marker"></span>
|
||||
{/if}
|
||||
<Input style="width: 100%;" value={displayParameterName} placeholder={placeholder}
|
||||
oninput={(e)=>{updateByEvent('name',e)}} disabled={currentParameter.nameDisabled === true} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-item">
|
||||
<Select items={currentParameter.dataTypeItems || parameterDataTypes} style="width: 100%" defaultValue={["String"]}
|
||||
@@ -200,6 +209,27 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -221,4 +251,3 @@
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
import {Button, Chosen, Heading, Input, Select, Textarea} from '../base';
|
||||
import RefParameterList from '../core/RefParameterList.svelte';
|
||||
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 OutputDefList from '../core/OutputDefList.svelte';
|
||||
import {fillParameterId} from '../utils/useAddParameter.svelte.js';
|
||||
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||
|
||||
const { data, ...rest }: {
|
||||
@@ -23,15 +22,31 @@
|
||||
const editorParameters = $derived.by(() => {
|
||||
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>) => {
|
||||
updateNodeDataInner(currentNodeId, data);
|
||||
};
|
||||
|
||||
const updateNodeDataByEvent = (name: string, event: Event) => {
|
||||
updateNodeData({
|
||||
[name]: (event.target as any)?.value
|
||||
});
|
||||
const resolveFormValue = (form: any) => {
|
||||
return form.resolveValue?.(currentNodeData) ?? currentNodeData?.[form.name] ?? form.defaultValue;
|
||||
};
|
||||
|
||||
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 = {
|
||||
@@ -118,21 +133,21 @@
|
||||
mode="input"
|
||||
placeholder={form.placeholder}
|
||||
style="width: 100%"
|
||||
value={data[form.name] || form.defaultValue}
|
||||
value={resolveFormValue(form)}
|
||||
parameters={editorParameters}
|
||||
{...form.attrs}
|
||||
oninput={(e)=>{
|
||||
updateNodeDataByEvent(form.name,e)
|
||||
oninput={(e:any)=>{
|
||||
updateNodeDataByEvent(form,e)
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<Input
|
||||
placeholder={form.placeholder}
|
||||
style="width: 100%"
|
||||
value={data[form.name] || form.defaultValue}
|
||||
value={resolveFormValue(form)}
|
||||
{...form.attrs}
|
||||
onchange={(e)=>{
|
||||
updateNodeDataByEvent(form.name,e)
|
||||
updateNodeDataByEvent(form,e)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
@@ -146,11 +161,11 @@
|
||||
rows={3}
|
||||
placeholder={form.placeholder}
|
||||
style="width: 100%"
|
||||
value={data[form.name] || form.defaultValue}
|
||||
value={resolveFormValue(form)}
|
||||
parameters={editorParameters}
|
||||
{...form.attrs}
|
||||
oninput={(e)=>{
|
||||
updateNodeDataByEvent(form.name,e)
|
||||
oninput={(e:any)=>{
|
||||
updateNodeDataByEvent(form,e)
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
@@ -158,10 +173,10 @@
|
||||
rows={3}
|
||||
placeholder={form.placeholder}
|
||||
style="width: 100%"
|
||||
value={data[form.name] || form.defaultValue}
|
||||
value={resolveFormValue(form)}
|
||||
{...form.attrs}
|
||||
onchange={(e)=>{
|
||||
updateNodeDataByEvent(form.name,e)
|
||||
updateNodeDataByEvent(form,e)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
@@ -176,19 +191,16 @@
|
||||
type="range"
|
||||
{...form.attrs}
|
||||
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>
|
||||
{:else if form.type === 'select'}
|
||||
<div class="setting-title">{form.label}</div>
|
||||
<div class="setting-item">
|
||||
<Select items={form.options||[]} style="width: 100%" placeholder={form.placeholder} onSelect={(item)=>{
|
||||
const newValue = item.value;
|
||||
updateNodeData({
|
||||
[form.name]: newValue
|
||||
})
|
||||
}} value={data[form.name] ? [data[form.name]] : [form.defaultValue]} />
|
||||
<Select items={resolveFormOptions(form)} style="width: 100%" placeholder={form.placeholder} onSelect={(item)=>{
|
||||
updateFormValue(form, item.value)
|
||||
}} value={[resolveFormValue(form)]} />
|
||||
</div>
|
||||
{:else if form.type === 'chosen'}
|
||||
<div class="setting-title">{form.label}</div>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script lang="ts">
|
||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||
import {Heading} from '../base';
|
||||
import {Heading, Input, Textarea} from '../base';
|
||||
import {Button} from '../base/index.js';
|
||||
import {type NodeProps} from '@xyflow/svelte';
|
||||
import DefinedParameterList from '../core/DefinedParameterList.svelte';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||
import {useSvelteFlow} from '@xyflow/svelte';
|
||||
import {
|
||||
ensureStartNodeParameters,
|
||||
hasSystemStartParameter,
|
||||
appendStartFormField,
|
||||
isSystemStartParameter,
|
||||
normalizeStartNodeData,
|
||||
normalizeStartFormMeta,
|
||||
} from '../../utils/workflowNodeFields';
|
||||
|
||||
const { data, ...rest }: {
|
||||
@@ -19,25 +19,39 @@
|
||||
} = $props();
|
||||
|
||||
const currentNodeId = getCurrentNodeId();
|
||||
const { addParameter } = useAddParameter();
|
||||
const { updateNodeData } = useSvelteFlow();
|
||||
|
||||
$effect(() => {
|
||||
const currentParameters = (data.parameters as Array<any>) || [];
|
||||
if (!hasSystemStartParameter(currentParameters)) {
|
||||
return;
|
||||
}
|
||||
const parameters = ensureStartNodeParameters(currentParameters);
|
||||
if (JSON.stringify(currentParameters) !== JSON.stringify(parameters)) {
|
||||
updateNodeData(currentNodeId, {
|
||||
parameters
|
||||
});
|
||||
const normalizedData = normalizeStartNodeData((data || {}) as Record<string, any>, {
|
||||
allowLegacyParametersOnly: true
|
||||
});
|
||||
const nextData = {
|
||||
...(data || {}),
|
||||
...normalizedData,
|
||||
};
|
||||
if (JSON.stringify(data || {}) !== JSON.stringify(nextData)) {
|
||||
updateNodeData(currentNodeId, nextData);
|
||||
}
|
||||
});
|
||||
|
||||
const updateStartFormMeta = (key: 'title' | 'description' | 'submitText', value: string) => {
|
||||
updateNodeData(currentNodeId, (node) => {
|
||||
const normalizedMeta = normalizeStartFormMeta(node.data?.startFormMeta as Record<string, any>);
|
||||
return {
|
||||
startFormMeta: {
|
||||
...normalizedMeta,
|
||||
[key]: value,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
let currentParameters = $derived.by(() => {
|
||||
return ((data.parameters as Array<any>) || []);
|
||||
});
|
||||
let startFormMeta = $derived.by(() => {
|
||||
return normalizeStartFormMeta((data.startFormMeta as Record<string, any>) || {});
|
||||
});
|
||||
let systemParameters = $derived.by(() => {
|
||||
return currentParameters.filter((parameter) => isSystemStartParameter(parameter));
|
||||
});
|
||||
@@ -55,11 +69,48 @@
|
||||
d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 15C10.3431 15 9 13.6569 9 12C9 10.3431 10.3431 9 12 9C13.6569 9 15 10.3431 15 12C15 13.6569 13.6569 15 12 15Z"></path>
|
||||
</svg>
|
||||
{/snippet}
|
||||
<div class="param-section">
|
||||
<div class="heading">
|
||||
<Heading level={3}>开场表单</Heading>
|
||||
</div>
|
||||
<div class="section-description">配置开始问答的标题、说明和提交按钮文案。</div>
|
||||
<div class="meta-form">
|
||||
<div class="meta-form-item">
|
||||
<div class="meta-label">标题</div>
|
||||
<Input
|
||||
style="width: 100%;"
|
||||
value={startFormMeta.title}
|
||||
placeholder="请输入表单标题"
|
||||
oninput={(event) => updateStartFormMeta('title', (event.target as HTMLInputElement)?.value || '')}
|
||||
/>
|
||||
</div>
|
||||
<div class="meta-form-item">
|
||||
<div class="meta-label">说明</div>
|
||||
<Textarea
|
||||
rows={2}
|
||||
style="width: 100%;"
|
||||
value={startFormMeta.description}
|
||||
placeholder="请输入表单说明"
|
||||
oninput={(event) => updateStartFormMeta('description', (event.target as HTMLTextAreaElement)?.value || '')}
|
||||
/>
|
||||
</div>
|
||||
<div class="meta-form-item">
|
||||
<div class="meta-label">提交按钮</div>
|
||||
<Input
|
||||
style="width: 100%;"
|
||||
value={startFormMeta.submitText}
|
||||
placeholder="请输入按钮文案"
|
||||
oninput={(event) => updateStartFormMeta('submitText', (event.target as HTMLInputElement)?.value || '')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="param-section">
|
||||
<div class="heading">
|
||||
<Heading level={3}>系统入口</Heading>
|
||||
</div>
|
||||
<div class="section-description">固定入口参数,作为工作流默认输入来源。</div>
|
||||
<div class="section-description">固定主问题字段,名称与必填规则固定,可调整展示方式与提示文案。</div>
|
||||
<DefinedParameterList parameters={systemParameters} emptyText="暂无系统入口参数" />
|
||||
</div>
|
||||
|
||||
@@ -67,14 +118,20 @@
|
||||
<div class="heading">
|
||||
<Heading level={3}>自定义参数</Heading>
|
||||
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
||||
addParameter(currentNodeId, "parameters", {refType: "input", name: "newParam", formType: "input", contentType: "text"});
|
||||
updateNodeData(currentNodeId, (node) => {
|
||||
return appendStartFormField(node.data as Record<string, any>, {
|
||||
label: '新字段',
|
||||
type: 'text',
|
||||
placeholder: '请输入内容',
|
||||
});
|
||||
});
|
||||
}}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="section-description">这里添加额外输入参数,不影响默认入口参数。</div>
|
||||
<div class="section-description">这里添加额外收集字段,字段顺序按列表顺序保存,首版不提供拖拽排序。</div>
|
||||
<DefinedParameterList parameters={customParameters} emptyText="暂无自定义参数" />
|
||||
</div>
|
||||
</NodeWrapper>
|
||||
@@ -93,6 +150,23 @@
|
||||
border-top: 1px solid var(--tf-border-color);
|
||||
}
|
||||
|
||||
.meta-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.meta-form-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 12px;
|
||||
color: var(--tf-text-secondary);
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -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 { Parameter } from '#types';
|
||||
import { getCurrentNodeId, getOptions } from '#components/utils/NodeUtils';
|
||||
import { getStartNodeParameterLabel } from '#components/utils/startNodeParameterLabel';
|
||||
import { nodeIcons } from '../../consts';
|
||||
|
||||
const fillRefNodeIds = (
|
||||
@@ -21,24 +22,39 @@ const getChildren = (
|
||||
parentId: string,
|
||||
nodeIsChildren: boolean,
|
||||
nodeType: string,
|
||||
parentPathLabel = '',
|
||||
parentIsCollection = false,
|
||||
) => {
|
||||
if (!params || params.length === 0) return [];
|
||||
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
|
||||
? `Array<${param.dataType || 'String'}>`
|
||||
: param.dataType || 'String';
|
||||
const label = param.formLabel || param.displayName || param.name;
|
||||
return {
|
||||
label,
|
||||
label: pathLabel,
|
||||
dataType: dataType,
|
||||
value: parentId + '.' + param.name,
|
||||
selectable: true,
|
||||
nodeType: nodeType,
|
||||
displayLabel: pathLabel,
|
||||
pathLabel,
|
||||
itemTypeLabel: parentIsCollection ? '数组项字段' : undefined,
|
||||
isCollection,
|
||||
children: getChildren(
|
||||
param.children,
|
||||
parentId + '.' + param.name,
|
||||
nodeIsChildren,
|
||||
nodeType,
|
||||
pathLabel,
|
||||
isCollection,
|
||||
),
|
||||
};
|
||||
});
|
||||
@@ -72,8 +88,7 @@ const nodeToOptions = (
|
||||
const dataType = nodeIsChildren
|
||||
? `Array<${parameter.dataType || 'String'}>`
|
||||
: parameter.dataType || 'String';
|
||||
const label =
|
||||
parameter.formLabel || parameter.displayName || parameter.name;
|
||||
const label = getStartNodeParameterLabel(parameter);
|
||||
children.push({
|
||||
label,
|
||||
dataType: dataType,
|
||||
|
||||
@@ -82,6 +82,7 @@ export const startFormTypes = [
|
||||
{ label: '下拉菜单', value: 'select' },
|
||||
{ label: '单选', value: 'radio' },
|
||||
{ label: '多选', value: 'checkbox' },
|
||||
{ label: '文件上传', value: 'file' },
|
||||
];
|
||||
|
||||
export const confirmFormTypes = [
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from './types';
|
||||
export * from './Tinyflow';
|
||||
export * from './components/TinyflowComponent.svelte';
|
||||
export * from './utils/workflowNodeFields';
|
||||
export * from './utils/sanitize';
|
||||
|
||||
@@ -15,6 +15,9 @@ export type SelectItem = {
|
||||
nodeType?: string;
|
||||
dataType?: string;
|
||||
displayLabel?: string;
|
||||
pathLabel?: string;
|
||||
itemTypeLabel?: string;
|
||||
isCollection?: boolean;
|
||||
tags?: string[];
|
||||
children?: SelectItem[];
|
||||
};
|
||||
@@ -46,6 +49,12 @@ export type CustomNodeForm = {
|
||||
defaultValue?: string | number | boolean;
|
||||
attrs?: Record<string, any>;
|
||||
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;
|
||||
chosen?: {
|
||||
labelDataKey: string;
|
||||
@@ -66,6 +75,7 @@ export type CustomNode = {
|
||||
icon?: string;
|
||||
sortNo?: number;
|
||||
group?: 'base' | 'tools';
|
||||
renderFirst?: boolean;
|
||||
rootClass?: string;
|
||||
rootStyle?: string;
|
||||
parameters?: Parameter[];
|
||||
@@ -108,6 +118,9 @@ export type Parameter = {
|
||||
name?: string;
|
||||
nameDisabled?: boolean;
|
||||
displayName?: string;
|
||||
pathLabel?: string;
|
||||
itemTypeLabel?: string;
|
||||
isCollection?: boolean;
|
||||
dataType?: string;
|
||||
dataTypeItems?: SelectItem[];
|
||||
dataTypeDisabled?: boolean;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { Edge, Node } from '@xyflow/svelte';
|
||||
|
||||
import {
|
||||
appendStartFormField,
|
||||
buildAutoBindingPatch,
|
||||
buildSequentialFieldBindingPatches,
|
||||
buildFieldBindingPatch,
|
||||
@@ -12,7 +13,11 @@ import {
|
||||
createInitialWorkflowData,
|
||||
ensureStartNodeParameters,
|
||||
FIELD_BINDING_META_KEY,
|
||||
normalizeStartNodeData,
|
||||
normalizeWorkflowStartNodes,
|
||||
renameStartFieldReferencesInNodes,
|
||||
removeStartFormField,
|
||||
updateStartFormField,
|
||||
} from './workflowNodeFields';
|
||||
|
||||
describe('workflow node fields', () => {
|
||||
@@ -28,6 +33,246 @@ describe('workflow node fields', () => {
|
||||
expect(parameters[0]?.name).toBe('user_input');
|
||||
expect(parameters[0]?.systemReserved).toBe(true);
|
||||
expect(parameters[0]?.required).toBe(true);
|
||||
expect(initial.nodes[0]?.data?.startFormMeta).toMatchObject({
|
||||
title: '开始问答',
|
||||
submitText: '开始',
|
||||
});
|
||||
expect(initial.nodes[0]?.data?.startFormSchema?.[0]).toMatchObject({
|
||||
key: 'user_input',
|
||||
type: 'textarea',
|
||||
systemReserved: true,
|
||||
required: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('appends custom start form field into schema source of truth', () => {
|
||||
const initial = createInitialWorkflowData();
|
||||
const startNode = initial.nodes[0]!;
|
||||
const nextData = appendStartFormField(startNode.data as Record<string, any>, {
|
||||
type: 'select',
|
||||
options: ['售前', '售后'],
|
||||
});
|
||||
|
||||
expect(nextData.startFormSchema).toHaveLength(2);
|
||||
expect(nextData.startFormSchema?.[1]).toMatchObject({
|
||||
key: 'select_field',
|
||||
label: '下拉字段',
|
||||
type: 'select',
|
||||
options: ['售前', '售后'],
|
||||
});
|
||||
expect((nextData.parameters as any[])?.[1]).toMatchObject({
|
||||
name: 'select_field',
|
||||
formLabel: '下拉字段',
|
||||
formType: 'select',
|
||||
enums: ['售前', '售后'],
|
||||
});
|
||||
});
|
||||
|
||||
it('updates generated field key when switching field type', () => {
|
||||
const initial = createInitialWorkflowData();
|
||||
const appended = appendStartFormField(initial.nodes[0]?.data as Record<string, any>, {
|
||||
type: 'text',
|
||||
});
|
||||
|
||||
const updated = updateStartFormField(appended, 'text_field', {
|
||||
type: 'file',
|
||||
placeholder: '请选择文件',
|
||||
});
|
||||
expect(updated.startFormSchema?.find((item: any) => item.key === 'file_field'))
|
||||
.toMatchObject({
|
||||
key: 'file_field',
|
||||
label: '文件字段',
|
||||
type: 'file',
|
||||
placeholder: '请选择文件',
|
||||
});
|
||||
expect((updated.parameters as any[]).find((item) => item.name === 'file_field'))
|
||||
.toMatchObject({
|
||||
name: 'file_field',
|
||||
dataType: 'File',
|
||||
contentType: 'file',
|
||||
});
|
||||
});
|
||||
|
||||
it('updates and removes custom start form fields through schema source of truth', () => {
|
||||
const initial = createInitialWorkflowData();
|
||||
const appended = appendStartFormField(initial.nodes[0]?.data as Record<string, any>, {
|
||||
key: 'attachments',
|
||||
label: '附件',
|
||||
type: 'text',
|
||||
placeholder: '请输入内容',
|
||||
});
|
||||
|
||||
const updated = updateStartFormField(appended, 'attachments', {
|
||||
type: 'file',
|
||||
placeholder: '请选择文件',
|
||||
});
|
||||
expect(updated.startFormSchema?.find((item: any) => item.key === 'attachments'))
|
||||
.toMatchObject({
|
||||
type: 'file',
|
||||
placeholder: '请选择文件',
|
||||
});
|
||||
expect((updated.parameters as any[]).find((item) => item.name === 'attachments'))
|
||||
.toMatchObject({
|
||||
dataType: 'File',
|
||||
contentType: 'file',
|
||||
});
|
||||
|
||||
const removed = removeStartFormField(updated, 'attachments');
|
||||
expect(removed.startFormSchema).toHaveLength(1);
|
||||
expect((removed.parameters as any[]).map((item) => item.name)).toEqual([
|
||||
'user_input',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps custom start field parameter id stable when renaming key', () => {
|
||||
const initial = createInitialWorkflowData();
|
||||
const appended = appendStartFormField(initial.nodes[0]?.data as Record<string, any>, {
|
||||
type: 'text',
|
||||
});
|
||||
const previousField = appended.startFormSchema?.find((item: any) => item.key === 'text_field');
|
||||
const previousParameter = (appended.parameters as any[]).find(
|
||||
(item) => item.name === 'text_field',
|
||||
);
|
||||
|
||||
const updated = updateStartFormField(appended, 'text_field', {
|
||||
key: 'topic',
|
||||
label: '主题',
|
||||
});
|
||||
const nextField = updated.startFormSchema?.find((item: any) => item.key === 'topic');
|
||||
const nextParameter = (updated.parameters as any[]).find(
|
||||
(item) => item.name === 'topic',
|
||||
);
|
||||
|
||||
expect(previousField?.id).toBeTruthy();
|
||||
expect(nextField?.id).toBe(previousField?.id);
|
||||
expect(nextParameter?.id).toBe(previousParameter?.id);
|
||||
});
|
||||
|
||||
it('renames downstream token and managed references when start field key changes', () => {
|
||||
const initialStartData = appendStartFormField(
|
||||
createInitialWorkflowData().nodes[0]?.data as Record<string, any>,
|
||||
{
|
||||
type: 'text',
|
||||
},
|
||||
);
|
||||
const startNode: Node = {
|
||||
id: 'start_1',
|
||||
type: 'startNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: '开始节点',
|
||||
...initialStartData,
|
||||
},
|
||||
};
|
||||
const renamedStartData = updateStartFormField(
|
||||
startNode.data as Record<string, any>,
|
||||
'text_field',
|
||||
{
|
||||
key: 'topic',
|
||||
label: '主题',
|
||||
},
|
||||
);
|
||||
const llmNode: Node = {
|
||||
id: 'llm_1',
|
||||
type: 'llmNode',
|
||||
position: { x: 120, y: 0 },
|
||||
data: {
|
||||
title: '大模型',
|
||||
userPrompt: '请围绕 {{start_1.text_field}} 生成内容',
|
||||
parameters: [
|
||||
{
|
||||
id: 'param_1',
|
||||
name: 'start_1.text_field',
|
||||
ref: 'start_1.text_field',
|
||||
refType: 'ref',
|
||||
autoManaged: true,
|
||||
formLabel: '开始节点 > 文本字段',
|
||||
displayName: '开始节点 > 文本字段',
|
||||
},
|
||||
],
|
||||
[FIELD_BINDING_META_KEY]: {
|
||||
userPrompt: {
|
||||
autoFilledFrom: 'start_1.text_field',
|
||||
userModified: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const edges: Edge[] = [
|
||||
{ id: 'edge_1', source: 'start_1', target: 'llm_1' } as Edge,
|
||||
];
|
||||
|
||||
const nextNodes = renameStartFieldReferencesInNodes(
|
||||
[
|
||||
{
|
||||
...startNode,
|
||||
data: {
|
||||
...(startNode.data as Record<string, any>),
|
||||
...renamedStartData,
|
||||
},
|
||||
},
|
||||
llmNode,
|
||||
],
|
||||
edges,
|
||||
'start_1',
|
||||
'text_field',
|
||||
'topic',
|
||||
);
|
||||
const nextLlmNode = nextNodes.find((node) => node.id === 'llm_1')!;
|
||||
const nextParameter = (nextLlmNode.data?.parameters as any[])?.[0];
|
||||
|
||||
expect(nextLlmNode.data?.userPrompt).toBe('请围绕 {{start_1.topic}} 生成内容');
|
||||
expect(nextParameter?.name).toBe('start_1.topic');
|
||||
expect(nextParameter?.ref).toBe('start_1.topic');
|
||||
expect(nextParameter?.displayName).toBe('开始节点 > topic');
|
||||
expect((nextLlmNode.data as any)?.[FIELD_BINDING_META_KEY]?.userPrompt).toMatchObject({
|
||||
autoFilledFrom: 'start_1.topic',
|
||||
userModified: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses custom start parameter name for reference display', () => {
|
||||
const startData = appendStartFormField(
|
||||
createInitialWorkflowData().nodes[0]?.data as Record<string, any>,
|
||||
{
|
||||
key: 'topic_name',
|
||||
label: '下拉字段',
|
||||
type: 'select',
|
||||
options: ['A', 'B'],
|
||||
},
|
||||
);
|
||||
const startNode: Node = {
|
||||
id: 'start_1',
|
||||
type: 'startNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: '开始节点',
|
||||
...startData,
|
||||
},
|
||||
};
|
||||
const llmNode: Node = {
|
||||
id: 'llm_1',
|
||||
type: 'llmNode',
|
||||
position: { x: 120, y: 0 },
|
||||
data: {
|
||||
title: '大模型',
|
||||
parameters: [],
|
||||
},
|
||||
};
|
||||
const edges: Edge[] = [
|
||||
{ id: 'edge_1', source: 'start_1', target: 'llm_1' } as Edge,
|
||||
];
|
||||
|
||||
const parameters = buildEditorReferenceParameters(
|
||||
'llm_1',
|
||||
[startNode, llmNode],
|
||||
edges,
|
||||
[],
|
||||
);
|
||||
const topicParameter = parameters.find((item) => item.name === 'start_1.topic_name');
|
||||
|
||||
expect(topicParameter?.displayName).toBe('开始节点 > topic_name');
|
||||
expect(topicParameter?.formLabel).toBe('开始节点 > topic_name');
|
||||
});
|
||||
|
||||
it('builds upstream reference candidates from start node', () => {
|
||||
@@ -69,6 +314,112 @@ describe('workflow node fields', () => {
|
||||
).toBe('流程开始 > 用户问题');
|
||||
});
|
||||
|
||||
it('uses output parameter name for reference display', () => {
|
||||
const knowledgeNode: Node = {
|
||||
id: 'knowledge_1',
|
||||
type: 'knowledgeNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: '知识库',
|
||||
outputDefs: [
|
||||
{
|
||||
name: 'documents',
|
||||
formLabel: '文档列表',
|
||||
children: [
|
||||
{
|
||||
name: 'content',
|
||||
formLabel: '正文内容',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const llmNode: Node = {
|
||||
id: 'llm_1',
|
||||
type: 'llmNode',
|
||||
position: { x: 120, y: 0 },
|
||||
data: {
|
||||
title: '大模型',
|
||||
parameters: [],
|
||||
},
|
||||
};
|
||||
const edges: Edge[] = [
|
||||
{ id: 'edge_1', source: 'knowledge_1', target: 'llm_1' } as Edge,
|
||||
];
|
||||
|
||||
const parameters = buildEditorReferenceParameters(
|
||||
'llm_1',
|
||||
[knowledgeNode, llmNode],
|
||||
edges,
|
||||
[],
|
||||
);
|
||||
const documentsParameter = parameters.find((item) => item.name === 'knowledge_1.documents');
|
||||
const contentParameter = parameters.find((item) => item.name === 'knowledge_1.documents.content');
|
||||
|
||||
expect(documentsParameter?.displayName).toBe('知识库 > documents');
|
||||
expect(documentsParameter?.formLabel).toBe('知识库 > documents');
|
||||
expect(documentsParameter?.pathLabel).toBe('documents');
|
||||
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', () => {
|
||||
const documentNode: Node = {
|
||||
id: 'doc_1',
|
||||
type: 'document-node',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: '文档解析',
|
||||
outputDefs: [
|
||||
{
|
||||
name: 'documents',
|
||||
formLabel: '文档列表',
|
||||
children: [
|
||||
{
|
||||
name: 'fileName',
|
||||
formLabel: '文件名',
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
formLabel: '正文内容',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const llmNode: Node = {
|
||||
id: 'llm_1',
|
||||
type: 'llmNode',
|
||||
position: { x: 120, y: 0 },
|
||||
data: {
|
||||
title: '大模型',
|
||||
parameters: [],
|
||||
},
|
||||
};
|
||||
const edges: Edge[] = [
|
||||
{ id: 'edge_1', source: 'doc_1', target: 'llm_1' } as Edge,
|
||||
];
|
||||
|
||||
const parameters = buildEditorReferenceParameters(
|
||||
'llm_1',
|
||||
[documentNode, llmNode],
|
||||
edges,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(parameters.find((item) => item.name === 'doc_1.documents')?.displayName)
|
||||
.toBe('文档解析 > documents');
|
||||
expect(parameters.find((item) => item.name === 'doc_1.documents.fileName')?.displayName)
|
||||
.toBe('文档解析 > documents.[].fileName');
|
||||
expect(parameters.find((item) => item.name === 'doc_1.documents.content')?.displayName)
|
||||
.toBe('文档解析 > documents.[].content');
|
||||
});
|
||||
|
||||
it('applies default binding to llm user prompt after connect', () => {
|
||||
const startNode: Node = {
|
||||
id: 'start_1',
|
||||
@@ -436,6 +787,86 @@ describe('workflow node fields', () => {
|
||||
expect(result).toEqual(legacyParameters);
|
||||
});
|
||||
|
||||
it('normalizes start node schema from parameters', () => {
|
||||
const normalized = normalizeStartNodeData({
|
||||
parameters: [
|
||||
{
|
||||
id: 'system_1',
|
||||
name: 'user_input',
|
||||
refType: 'input',
|
||||
required: true,
|
||||
formType: 'input',
|
||||
formLabel: '问题',
|
||||
formPlaceholder: '请输入问题',
|
||||
},
|
||||
{
|
||||
id: 'file_1',
|
||||
name: 'attachments',
|
||||
refType: 'input',
|
||||
dataType: 'File',
|
||||
contentType: 'file',
|
||||
formLabel: '附件',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(normalized.startFormSchema).toHaveLength(2);
|
||||
expect(normalized.startFormSchema[0]).toMatchObject({
|
||||
key: 'user_input',
|
||||
type: 'text',
|
||||
systemReserved: true,
|
||||
required: true,
|
||||
});
|
||||
expect(normalized.startFormSchema[1]).toMatchObject({
|
||||
key: 'attachments',
|
||||
type: 'file',
|
||||
required: false,
|
||||
});
|
||||
expect(normalized.parameters[1]).toMatchObject({
|
||||
name: 'attachments',
|
||||
contentType: 'file',
|
||||
dataType: 'File',
|
||||
});
|
||||
});
|
||||
|
||||
it('forces invalid user_input schema back to required text input', () => {
|
||||
const normalized = normalizeStartNodeData({
|
||||
startFormSchema: [
|
||||
{
|
||||
key: 'user_input',
|
||||
label: '主问题',
|
||||
type: 'radio',
|
||||
required: false,
|
||||
options: ['A', 'B'],
|
||||
},
|
||||
],
|
||||
startFormMeta: {
|
||||
title: '',
|
||||
description: '',
|
||||
submitText: '',
|
||||
},
|
||||
});
|
||||
|
||||
expect(normalized.startFormSchema[0]).toMatchObject({
|
||||
key: 'user_input',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
systemReserved: true,
|
||||
});
|
||||
expect(normalized.startFormMeta).toMatchObject({
|
||||
title: '开始问答',
|
||||
description: '请先补充必要信息,再开始执行工作流。',
|
||||
submitText: '开始',
|
||||
});
|
||||
expect(normalized.parameters[0]).toMatchObject({
|
||||
name: 'user_input',
|
||||
formType: 'textarea',
|
||||
required: true,
|
||||
contentType: 'text',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes only start nodes that already contain fixed user_input', () => {
|
||||
const normalizedWorkflow = normalizeWorkflowStartNodes({
|
||||
nodes: [
|
||||
@@ -466,6 +897,9 @@ describe('workflow node fields', () => {
|
||||
expect(normalizedWorkflow.nodes[0]?.data?.parameters?.[0]?.required).toBe(
|
||||
true,
|
||||
);
|
||||
expect(normalizedWorkflow.nodes[0]?.data?.startFormSchema?.[0]?.key).toBe(
|
||||
'user_input',
|
||||
);
|
||||
expect(normalizedWorkflow.nodes[1]?.data?.parameters).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,66 @@ export type SingleRunModel =
|
||||
fields: SingleRunFieldDescriptor[];
|
||||
};
|
||||
|
||||
export type StartFormFieldType =
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'radio'
|
||||
| 'checkbox'
|
||||
| 'select'
|
||||
| 'file';
|
||||
|
||||
export type StartFormMeta = {
|
||||
title: string;
|
||||
description: string;
|
||||
submitText: string;
|
||||
};
|
||||
|
||||
export type StartFormFieldSchema = {
|
||||
id?: string;
|
||||
key: string;
|
||||
label: string;
|
||||
type: StartFormFieldType;
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
defaultValue?: string | string[];
|
||||
options: string[];
|
||||
systemReserved?: boolean;
|
||||
};
|
||||
|
||||
const START_FORM_FIELD_TYPE_SET = new Set<StartFormFieldType>([
|
||||
'text',
|
||||
'textarea',
|
||||
'radio',
|
||||
'checkbox',
|
||||
'select',
|
||||
'file',
|
||||
]);
|
||||
const OPTION_FIELD_TYPE_SET = new Set<StartFormFieldType>([
|
||||
'radio',
|
||||
'checkbox',
|
||||
'select',
|
||||
]);
|
||||
const DEFAULT_START_FORM_TITLE = '开始问答';
|
||||
const DEFAULT_START_FORM_DESCRIPTION = '请先补充必要信息,再开始执行工作流。';
|
||||
const DEFAULT_START_FORM_SUBMIT_TEXT = '开始';
|
||||
const START_FORM_DEFAULT_FIELD_KEY_BY_TYPE: Record<StartFormFieldType, string> = {
|
||||
text: 'text_field',
|
||||
textarea: 'textarea_field',
|
||||
radio: 'radio_field',
|
||||
checkbox: 'checkbox_field',
|
||||
select: 'select_field',
|
||||
file: 'file_field',
|
||||
};
|
||||
const START_FORM_DEFAULT_FIELD_LABEL_BY_TYPE: Record<StartFormFieldType, string> = {
|
||||
text: '文本字段',
|
||||
textarea: '长文本字段',
|
||||
radio: '单选字段',
|
||||
checkbox: '多选字段',
|
||||
select: '下拉字段',
|
||||
file: '文件字段',
|
||||
};
|
||||
|
||||
type FieldBindingMeta = Record<
|
||||
string,
|
||||
{
|
||||
@@ -43,6 +103,25 @@ function asString(value: unknown) {
|
||||
return value == null ? '' : String(value);
|
||||
}
|
||||
|
||||
function trimString(value: unknown) {
|
||||
return asString(value).trim();
|
||||
}
|
||||
|
||||
function ensureStringArray(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map((item) => asString(item).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function isSupportedStartFormFieldType(value: unknown): value is StartFormFieldType {
|
||||
return START_FORM_FIELD_TYPE_SET.has(asString(value).trim() as StartFormFieldType);
|
||||
}
|
||||
|
||||
function isOptionFieldType(value: unknown): value is StartFormFieldType {
|
||||
return OPTION_FIELD_TYPE_SET.has(asString(value).trim() as StartFormFieldType);
|
||||
}
|
||||
|
||||
function cloneParameter(parameter: Parameter): Parameter {
|
||||
return {
|
||||
...parameter,
|
||||
@@ -74,6 +153,45 @@ function getParameterLabel(parameter?: Parameter | null) {
|
||||
);
|
||||
}
|
||||
|
||||
function getReferenceParameterLabel(parameter?: Parameter | null) {
|
||||
if (isSystemStartParameter(parameter)) {
|
||||
return 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) {
|
||||
const displayName =
|
||||
asString(parameter.displayName).trim() ||
|
||||
@@ -114,6 +232,7 @@ function flattenOutputDefs(
|
||||
parameters: Parameter[],
|
||||
parentPath = '',
|
||||
parentLabel = '',
|
||||
parentIsCollection = false,
|
||||
): Parameter[] {
|
||||
if (!parameters.length) {
|
||||
return [];
|
||||
@@ -126,17 +245,21 @@ function flattenOutputDefs(
|
||||
}
|
||||
|
||||
const path = parentPath ? `${parentPath}.${rawName}` : rawName;
|
||||
const label = parentLabel
|
||||
? `${parentLabel}.${getParameterLabel(parameter)}`
|
||||
: getParameterLabel(parameter);
|
||||
const referenceLabel = getReferenceParameterLabel(parameter);
|
||||
const label = buildCollectionAwareLabel(referenceLabel, parentLabel, parentIsCollection);
|
||||
const displayName = `${getNodeTitle(node)} > ${label}`;
|
||||
const isCollection = isCollectionOutputParameter(parameter);
|
||||
const fullRef = `${node.id}.${path}`;
|
||||
const baseCandidate: Parameter = ensureParameterId({
|
||||
name: fullRef,
|
||||
ref: fullRef,
|
||||
refType: 'ref',
|
||||
dataType: parameter.dataType || 'String',
|
||||
displayName: `${getNodeTitle(node)} > ${label}`,
|
||||
formLabel: `${getNodeTitle(node)} > ${label}`,
|
||||
displayName,
|
||||
formLabel: displayName,
|
||||
pathLabel: label,
|
||||
itemTypeLabel: parentIsCollection ? '数组项字段' : undefined,
|
||||
isCollection,
|
||||
nameDisabled: true,
|
||||
dataTypeDisabled: true,
|
||||
deleteDisabled: true,
|
||||
@@ -148,6 +271,7 @@ function flattenOutputDefs(
|
||||
parameter.children || [],
|
||||
path,
|
||||
label,
|
||||
isCollection,
|
||||
);
|
||||
|
||||
return [baseCandidate, ...children];
|
||||
@@ -167,8 +291,8 @@ function getNodeReferenceParameters(node: Node): Parameter[] {
|
||||
name: `${node.id}.${asString(parameter.name).trim()}`,
|
||||
ref: `${node.id}.${asString(parameter.name).trim()}`,
|
||||
refType: 'ref',
|
||||
displayName: `${getNodeTitle(node)} > ${getParameterLabel(parameter)}`,
|
||||
formLabel: `${getNodeTitle(node)} > ${getParameterLabel(parameter)}`,
|
||||
displayName: `${getNodeTitle(node)} > ${getReferenceParameterLabel(parameter)}`,
|
||||
formLabel: `${getNodeTitle(node)} > ${getReferenceParameterLabel(parameter)}`,
|
||||
nameDisabled: true,
|
||||
dataTypeDisabled: true,
|
||||
deleteDisabled: true,
|
||||
@@ -233,17 +357,115 @@ function toManagedRefParameter(refPath: string, candidate?: Parameter): Paramete
|
||||
});
|
||||
}
|
||||
|
||||
export function createSystemStartParameter(): Parameter {
|
||||
function normalizeSystemStartFormType(value: unknown): 'input' | 'textarea' {
|
||||
return trimString(value) === 'input' ? 'input' : 'textarea';
|
||||
}
|
||||
|
||||
function normalizeStartFormFieldUiType(
|
||||
value: unknown,
|
||||
fallback: StartFormFieldType = 'text',
|
||||
): StartFormFieldType {
|
||||
const normalized = trimString(value);
|
||||
if (normalized === 'input') {
|
||||
return 'text';
|
||||
}
|
||||
if (isSupportedStartFormFieldType(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getDefaultStartFormFieldKeyBase(type: StartFormFieldType) {
|
||||
return START_FORM_DEFAULT_FIELD_KEY_BY_TYPE[type];
|
||||
}
|
||||
|
||||
function getDefaultStartFormFieldLabel(type: StartFormFieldType) {
|
||||
return START_FORM_DEFAULT_FIELD_LABEL_BY_TYPE[type];
|
||||
}
|
||||
|
||||
function buildUniqueStartFormFieldKey(
|
||||
type: StartFormFieldType,
|
||||
existingKeys: string[],
|
||||
) {
|
||||
const normalizedExistingKeys = new Set(
|
||||
existingKeys.map((item) => trimString(item)).filter(Boolean),
|
||||
);
|
||||
const baseKey = getDefaultStartFormFieldKeyBase(type);
|
||||
if (!normalizedExistingKeys.has(baseKey)) {
|
||||
return baseKey;
|
||||
}
|
||||
let index = 2;
|
||||
while (normalizedExistingKeys.has(`${baseKey}_${index}`)) {
|
||||
index += 1;
|
||||
}
|
||||
return `${baseKey}_${index}`;
|
||||
}
|
||||
|
||||
function isAutoGeneratedStartFormFieldKey(key: unknown) {
|
||||
const normalizedKey = trimString(key);
|
||||
if (!normalizedKey) {
|
||||
return false;
|
||||
}
|
||||
if (/^field_[A-Za-z0-9]+$/.test(normalizedKey)) {
|
||||
return true;
|
||||
}
|
||||
return Object.values(START_FORM_DEFAULT_FIELD_KEY_BY_TYPE).some((baseKey) => {
|
||||
return normalizedKey === baseKey || new RegExp(`^${baseKey}_[0-9]+$`).test(normalizedKey);
|
||||
});
|
||||
}
|
||||
|
||||
function isDefaultStartFormFieldLabel(label: unknown) {
|
||||
const normalizedLabel = trimString(label);
|
||||
if (!normalizedLabel) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedLabel === '新字段') {
|
||||
return true;
|
||||
}
|
||||
return Object.values(START_FORM_DEFAULT_FIELD_LABEL_BY_TYPE).includes(normalizedLabel);
|
||||
}
|
||||
|
||||
function ensureStartFormFieldId(
|
||||
fieldId: unknown,
|
||||
existingParameter?: Parameter | null,
|
||||
) {
|
||||
return trimString(fieldId) || trimString(existingParameter?.id) || genShortId();
|
||||
}
|
||||
|
||||
function createDefaultStartFormMeta(): StartFormMeta {
|
||||
return {
|
||||
title: DEFAULT_START_FORM_TITLE,
|
||||
description: DEFAULT_START_FORM_DESCRIPTION,
|
||||
submitText: DEFAULT_START_FORM_SUBMIT_TEXT,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeStartFormMeta(meta?: Partial<StartFormMeta> | null): StartFormMeta {
|
||||
const fallback = createDefaultStartFormMeta();
|
||||
return {
|
||||
title: trimString(meta?.title) || fallback.title,
|
||||
description: trimString(meta?.description) || fallback.description,
|
||||
submitText: trimString(meta?.submitText) || fallback.submitText,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSystemStartParameter(parameter?: Parameter | null): Parameter {
|
||||
const fallbackLabel = SYSTEM_START_PARAM_LABEL;
|
||||
const formLabel = trimString(parameter?.formLabel) || fallbackLabel;
|
||||
const formType = normalizeSystemStartFormType(parameter?.formType);
|
||||
return ensureParameterId({
|
||||
...cloneParameter(parameter || {}),
|
||||
name: SYSTEM_START_PARAM_NAME,
|
||||
dataType: 'String',
|
||||
refType: 'input',
|
||||
required: true,
|
||||
contentType: 'text',
|
||||
formType: 'input',
|
||||
formLabel: SYSTEM_START_PARAM_LABEL,
|
||||
formPlaceholder: '请输入用户问题',
|
||||
displayName: `流程开始 > ${SYSTEM_START_PARAM_LABEL}`,
|
||||
formType,
|
||||
formLabel,
|
||||
formDescription: asString(parameter?.formDescription),
|
||||
formPlaceholder: trimString(parameter?.formPlaceholder) || '请输入用户问题',
|
||||
defaultValue: asString(parameter?.defaultValue),
|
||||
displayName: `流程开始 > ${formLabel}`,
|
||||
nameDisabled: true,
|
||||
dataTypeDisabled: true,
|
||||
deleteDisabled: true,
|
||||
@@ -252,6 +474,399 @@ export function createSystemStartParameter(): Parameter {
|
||||
});
|
||||
}
|
||||
|
||||
function parameterTypeToStartFormFieldType(parameter?: Parameter | null): StartFormFieldType {
|
||||
if (
|
||||
trimString(parameter?.contentType) === 'file' ||
|
||||
trimString(parameter?.dataType).toLowerCase() === 'file'
|
||||
) {
|
||||
return 'file';
|
||||
}
|
||||
const formType = trimString(parameter?.formType);
|
||||
if (formType === 'textarea') {
|
||||
return 'textarea';
|
||||
}
|
||||
if (formType === 'select') {
|
||||
return 'select';
|
||||
}
|
||||
if (formType === 'radio') {
|
||||
return 'radio';
|
||||
}
|
||||
if (formType === 'checkbox') {
|
||||
return 'checkbox';
|
||||
}
|
||||
return 'text';
|
||||
}
|
||||
|
||||
function fieldTypeToDataType(type: StartFormFieldType) {
|
||||
if (type === 'checkbox') {
|
||||
return 'Array';
|
||||
}
|
||||
if (type === 'file') {
|
||||
return 'File';
|
||||
}
|
||||
return 'String';
|
||||
}
|
||||
|
||||
function fieldTypeToContentType(type: StartFormFieldType) {
|
||||
return type === 'file' ? 'file' : 'text';
|
||||
}
|
||||
|
||||
function fieldTypeToFormType(type: StartFormFieldType) {
|
||||
if (type === 'text') {
|
||||
return 'input';
|
||||
}
|
||||
if (type === 'file') {
|
||||
return 'input';
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
function normalizeStartFormField(
|
||||
field: Partial<StartFormFieldSchema> | null | undefined,
|
||||
existingParameter?: Parameter,
|
||||
): StartFormFieldSchema | null {
|
||||
const key = trimString(field?.key) || trimString(existingParameter?.name);
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackType = parameterTypeToStartFormFieldType(existingParameter);
|
||||
const requestedType = isSupportedStartFormFieldType(field?.type)
|
||||
? field?.type
|
||||
: fallbackType;
|
||||
const isSystemField =
|
||||
field?.systemReserved === true || key === SYSTEM_START_PARAM_NAME;
|
||||
const type = isSystemField
|
||||
? requestedType === 'text'
|
||||
? 'text'
|
||||
: 'textarea'
|
||||
: requestedType;
|
||||
const options = isOptionFieldType(type)
|
||||
? ensureStringArray(field?.options || existingParameter?.enums)
|
||||
: [];
|
||||
|
||||
const defaultValue = Array.isArray(field?.defaultValue)
|
||||
? ensureStringArray(field?.defaultValue)
|
||||
: asString(field?.defaultValue ?? existingParameter?.defaultValue);
|
||||
|
||||
return {
|
||||
id: ensureStartFormFieldId(field?.id, existingParameter),
|
||||
key,
|
||||
label:
|
||||
trimString(field?.label) ||
|
||||
trimString(existingParameter?.formLabel) ||
|
||||
trimString(existingParameter?.displayName) ||
|
||||
key,
|
||||
type,
|
||||
required: isSystemField ? true : Boolean(field?.required ?? existingParameter?.required),
|
||||
placeholder:
|
||||
trimString(field?.placeholder) || trimString(existingParameter?.formPlaceholder),
|
||||
description:
|
||||
trimString(field?.description) || trimString(existingParameter?.formDescription),
|
||||
defaultValue,
|
||||
options,
|
||||
systemReserved: isSystemField,
|
||||
};
|
||||
}
|
||||
|
||||
function parameterToStartFormField(parameter?: Parameter | null) {
|
||||
return normalizeStartFormField(
|
||||
{
|
||||
id: trimString(parameter?.id),
|
||||
key: trimString(parameter?.name),
|
||||
label: trimString(parameter?.formLabel),
|
||||
type: parameterTypeToStartFormFieldType(parameter),
|
||||
required: Boolean(parameter?.required),
|
||||
placeholder: trimString(parameter?.formPlaceholder),
|
||||
description: trimString(parameter?.formDescription),
|
||||
defaultValue: parameter?.defaultValue,
|
||||
options: parameter?.enums || [],
|
||||
systemReserved: isSystemStartParameter(parameter),
|
||||
},
|
||||
parameter || undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function startFormFieldToParameter(
|
||||
field: StartFormFieldSchema,
|
||||
existingParameter?: Parameter,
|
||||
): Parameter {
|
||||
if (field.systemReserved || field.key === SYSTEM_START_PARAM_NAME) {
|
||||
return normalizeSystemStartParameter({
|
||||
...existingParameter,
|
||||
id: trimString(existingParameter?.id) || trimString(field.id),
|
||||
formLabel: field.label,
|
||||
formDescription: field.description,
|
||||
formPlaceholder: field.placeholder,
|
||||
formType: field.type === 'text' ? 'input' : 'textarea',
|
||||
defaultValue: Array.isArray(field.defaultValue)
|
||||
? field.defaultValue.join('\n')
|
||||
: asString(field.defaultValue),
|
||||
});
|
||||
}
|
||||
|
||||
const formLabel = trimString(field.label) || field.key;
|
||||
return ensureParameterId({
|
||||
...cloneParameter(existingParameter || {}),
|
||||
id: trimString(existingParameter?.id) || trimString(field.id),
|
||||
name: field.key,
|
||||
dataType: fieldTypeToDataType(field.type),
|
||||
refType: 'input',
|
||||
required: Boolean(field.required),
|
||||
contentType: fieldTypeToContentType(field.type),
|
||||
formType: fieldTypeToFormType(field.type),
|
||||
formLabel,
|
||||
formDescription: asString(field.description),
|
||||
formPlaceholder: asString(field.placeholder),
|
||||
defaultValue: Array.isArray(field.defaultValue)
|
||||
? field.defaultValue.join('\n')
|
||||
: asString(field.defaultValue),
|
||||
enums: isOptionFieldType(field.type) ? ensureStringArray(field.options) : [],
|
||||
displayName: `流程开始 > ${formLabel}`,
|
||||
systemReserved: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeStartFormSchema(
|
||||
schema?: Partial<StartFormFieldSchema>[] | null,
|
||||
parameters?: Parameter[] | null,
|
||||
) {
|
||||
const parameterCandidates = Array.isArray(parameters)
|
||||
? parameters.map(ensureParameterId)
|
||||
: [];
|
||||
const parameterMap = new Map(
|
||||
parameterCandidates
|
||||
.map((parameter) => [trimString(parameter.name), parameter] as const)
|
||||
.filter(([key]) => key.length > 0),
|
||||
);
|
||||
const parameterIdMap = new Map(
|
||||
parameterCandidates
|
||||
.map((parameter) => [trimString(parameter.id), parameter] as const)
|
||||
.filter(([key]) => key.length > 0),
|
||||
);
|
||||
|
||||
const source = Array.isArray(schema) && schema.length > 0
|
||||
? schema
|
||||
: parameterCandidates
|
||||
.map((parameter) => parameterToStartFormField(parameter))
|
||||
.filter(Boolean);
|
||||
|
||||
const result: StartFormFieldSchema[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const item of source) {
|
||||
const fieldId = trimString((item as Partial<StartFormFieldSchema>)?.id);
|
||||
const key =
|
||||
trimString((item as Partial<StartFormFieldSchema>)?.key) ||
|
||||
trimString((item as any)?.name);
|
||||
if (!key || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeStartFormField(
|
||||
item as Partial<StartFormFieldSchema>,
|
||||
(fieldId ? parameterIdMap.get(fieldId) : undefined) || parameterMap.get(key),
|
||||
);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized.key);
|
||||
if (normalized.key === SYSTEM_START_PARAM_NAME) {
|
||||
result.unshift(normalized);
|
||||
} else {
|
||||
result.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
if (!seen.has(SYSTEM_START_PARAM_NAME)) {
|
||||
const systemParameter = parameterCandidates.find((parameter) =>
|
||||
isSystemStartParameter(parameter),
|
||||
);
|
||||
result.unshift(
|
||||
normalizeStartFormField(
|
||||
{
|
||||
id: trimString(systemParameter?.id),
|
||||
key: SYSTEM_START_PARAM_NAME,
|
||||
label: trimString(systemParameter?.formLabel) || SYSTEM_START_PARAM_LABEL,
|
||||
type:
|
||||
normalizeSystemStartFormType(systemParameter?.formType) === 'input'
|
||||
? 'text'
|
||||
: 'textarea',
|
||||
required: true,
|
||||
placeholder: trimString(systemParameter?.formPlaceholder),
|
||||
description: trimString(systemParameter?.formDescription),
|
||||
defaultValue: asString(systemParameter?.defaultValue),
|
||||
options: [],
|
||||
systemReserved: true,
|
||||
},
|
||||
systemParameter,
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createCustomStartFormField(
|
||||
field?: Partial<StartFormFieldSchema> | null,
|
||||
existingKeys: string[] = [],
|
||||
): StartFormFieldSchema {
|
||||
const type = normalizeStartFormFieldUiType(field?.type, 'text');
|
||||
const key = trimString(field?.key) || buildUniqueStartFormFieldKey(type, existingKeys);
|
||||
return {
|
||||
id: ensureStartFormFieldId(field?.id),
|
||||
key,
|
||||
label: trimString(field?.label) || getDefaultStartFormFieldLabel(type),
|
||||
type,
|
||||
required: Boolean(field?.required),
|
||||
placeholder:
|
||||
trimString(field?.placeholder) ||
|
||||
(type === 'file' ? '请选择文件' : '请输入内容'),
|
||||
description: trimString(field?.description),
|
||||
defaultValue: Array.isArray(field?.defaultValue)
|
||||
? ensureStringArray(field?.defaultValue)
|
||||
: asString(field?.defaultValue),
|
||||
options: isOptionFieldType(type) ? ensureStringArray(field?.options) : [],
|
||||
systemReserved: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function appendStartFormField(
|
||||
data?: Record<string, any> | null,
|
||||
field?: Partial<StartFormFieldSchema> | null,
|
||||
) {
|
||||
const currentData = (data || {}) as Record<string, any>;
|
||||
const currentParameters = Array.isArray(currentData.parameters)
|
||||
? (currentData.parameters as Parameter[])
|
||||
: [];
|
||||
const schema = normalizeStartFormSchema(currentData.startFormSchema, currentParameters);
|
||||
const nextField = createCustomStartFormField(
|
||||
field,
|
||||
schema.map((item) => item.key),
|
||||
);
|
||||
return normalizeStartNodeData({
|
||||
...currentData,
|
||||
startFormSchema: [...schema, nextField],
|
||||
});
|
||||
}
|
||||
|
||||
export function updateStartFormField(
|
||||
data: Record<string, any> | null | undefined,
|
||||
currentKey: string,
|
||||
patch: Partial<StartFormFieldSchema>,
|
||||
) {
|
||||
const currentData = (data || {}) as Record<string, any>;
|
||||
const currentParameters = Array.isArray(currentData.parameters)
|
||||
? (currentData.parameters as Parameter[])
|
||||
: [];
|
||||
const schema = normalizeStartFormSchema(currentData.startFormSchema, currentParameters);
|
||||
const nextSchema = schema.map((field) => {
|
||||
if (field.key !== currentKey) {
|
||||
return field;
|
||||
}
|
||||
const nextType = normalizeStartFormFieldUiType(patch.type, field.type);
|
||||
const shouldAutoRenameKey =
|
||||
!trimString(patch.key) &&
|
||||
patch.type != null &&
|
||||
isAutoGeneratedStartFormFieldKey(field.key);
|
||||
const shouldAutoRenameLabel =
|
||||
!trimString(patch.label) &&
|
||||
patch.type != null &&
|
||||
isDefaultStartFormFieldLabel(field.label);
|
||||
return {
|
||||
...field,
|
||||
...patch,
|
||||
key: trimString(patch.key) || (
|
||||
shouldAutoRenameKey
|
||||
? buildUniqueStartFormFieldKey(
|
||||
nextType,
|
||||
schema
|
||||
.filter((item) => item.key !== field.key)
|
||||
.map((item) => item.key),
|
||||
)
|
||||
: field.key
|
||||
),
|
||||
label: trimString(patch.label) || (
|
||||
shouldAutoRenameLabel ? getDefaultStartFormFieldLabel(nextType) : field.label
|
||||
),
|
||||
type: nextType,
|
||||
};
|
||||
});
|
||||
return normalizeStartNodeData({
|
||||
...currentData,
|
||||
startFormSchema: nextSchema,
|
||||
});
|
||||
}
|
||||
|
||||
export function removeStartFormField(
|
||||
data?: Record<string, any> | null,
|
||||
key?: string | null,
|
||||
) {
|
||||
const currentKey = trimString(key);
|
||||
if (!currentKey || currentKey === SYSTEM_START_PARAM_NAME) {
|
||||
return normalizeStartNodeData(data || {});
|
||||
}
|
||||
const currentData = (data || {}) as Record<string, any>;
|
||||
const currentParameters = Array.isArray(currentData.parameters)
|
||||
? (currentData.parameters as Parameter[])
|
||||
: [];
|
||||
const schema = normalizeStartFormSchema(currentData.startFormSchema, currentParameters);
|
||||
return normalizeStartNodeData({
|
||||
...currentData,
|
||||
startFormSchema: schema.filter((field) => field.key !== currentKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeStartNodeData(
|
||||
data?: Record<string, any> | null,
|
||||
options?: {
|
||||
allowLegacyParametersOnly?: boolean;
|
||||
},
|
||||
) {
|
||||
const currentData = (data || {}) as Record<string, any>;
|
||||
const currentParameters = Array.isArray(currentData.parameters)
|
||||
? (currentData.parameters as Parameter[])
|
||||
: [];
|
||||
const hasSchema = Array.isArray(currentData.startFormSchema);
|
||||
const hasSystemParameter = hasSystemStartParameter(currentParameters);
|
||||
|
||||
if (!hasSchema && !hasSystemParameter && options?.allowLegacyParametersOnly) {
|
||||
return {
|
||||
parameters: currentParameters.map(ensureParameterId),
|
||||
startFormMeta: currentData.startFormMeta,
|
||||
startFormSchema: currentData.startFormSchema,
|
||||
};
|
||||
}
|
||||
|
||||
const schema = normalizeStartFormSchema(currentData.startFormSchema, currentParameters);
|
||||
const parameterMap = new Map(
|
||||
currentParameters
|
||||
.map((parameter) => [trimString(parameter.name), parameter] as const)
|
||||
.filter(([key]) => key.length > 0),
|
||||
);
|
||||
const parameterIdMap = new Map(
|
||||
currentParameters
|
||||
.map((parameter) => [trimString(parameter.id), parameter] as const)
|
||||
.filter(([key]) => key.length > 0),
|
||||
);
|
||||
const parameters = schema.map((field) =>
|
||||
startFormFieldToParameter(
|
||||
field,
|
||||
(trimString(field.id) ? parameterIdMap.get(trimString(field.id)) : undefined) ||
|
||||
parameterMap.get(field.key),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
parameters,
|
||||
startFormMeta: normalizeStartFormMeta(currentData.startFormMeta),
|
||||
startFormSchema: schema,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSystemStartParameter(): Parameter {
|
||||
return normalizeSystemStartParameter();
|
||||
}
|
||||
|
||||
export function isSystemStartParameter(parameter?: Parameter | null) {
|
||||
if (!parameter) {
|
||||
return false;
|
||||
@@ -273,18 +888,12 @@ export function ensureStartNodeParameters(parameters?: Parameter[]) {
|
||||
const source = Array.isArray(parameters)
|
||||
? parameters.map(cloneParameter)
|
||||
: [];
|
||||
const fixed = createSystemStartParameter();
|
||||
const index = source.findIndex((parameter) => isSystemStartParameter(parameter));
|
||||
|
||||
if (index >= 0) {
|
||||
const existing = source[index]!;
|
||||
source[index] = ensureParameterId({
|
||||
...existing,
|
||||
...fixed,
|
||||
id: existing.id || fixed.id,
|
||||
});
|
||||
source[index] = normalizeSystemStartParameter(source[index]!);
|
||||
} else {
|
||||
source.unshift(fixed);
|
||||
source.unshift(createSystemStartParameter());
|
||||
}
|
||||
|
||||
const customParameters = source
|
||||
@@ -295,6 +904,14 @@ export function ensureStartNodeParameters(parameters?: Parameter[]) {
|
||||
}
|
||||
|
||||
export function createInitialWorkflowData() {
|
||||
const startData = normalizeStartNodeData(
|
||||
{
|
||||
parameters: ensureStartNodeParameters(),
|
||||
},
|
||||
{
|
||||
allowLegacyParametersOnly: false,
|
||||
},
|
||||
);
|
||||
return {
|
||||
nodes: [
|
||||
{
|
||||
@@ -303,7 +920,7 @@ export function createInitialWorkflowData() {
|
||||
position: { x: 80, y: 180 },
|
||||
data: {
|
||||
title: '开始节点',
|
||||
parameters: ensureStartNodeParameters(),
|
||||
...startData,
|
||||
},
|
||||
} satisfies Node,
|
||||
],
|
||||
@@ -326,22 +943,29 @@ export function normalizeWorkflowStartNodes<T extends Record<string, any>>(data:
|
||||
if (node?.type !== START_NODE_TYPE) {
|
||||
return node;
|
||||
}
|
||||
const currentParameters = Array.isArray(node.data?.parameters)
|
||||
? (node.data.parameters as Parameter[])
|
||||
const currentData = (node.data || {}) as Record<string, any>;
|
||||
const currentParameters = Array.isArray(currentData.parameters)
|
||||
? (currentData.parameters as Parameter[])
|
||||
: [];
|
||||
if (!hasSystemStartParameter(currentParameters)) {
|
||||
const shouldNormalize =
|
||||
Array.isArray(currentData.startFormSchema) ||
|
||||
hasSystemStartParameter(currentParameters);
|
||||
if (!shouldNormalize) {
|
||||
return node;
|
||||
}
|
||||
const normalizedParameters = ensureStartNodeParameters(currentParameters);
|
||||
if (JSON.stringify(currentParameters) === JSON.stringify(normalizedParameters)) {
|
||||
const normalizedData = normalizeStartNodeData(currentData);
|
||||
const nextData = {
|
||||
...currentData,
|
||||
...normalizedData,
|
||||
};
|
||||
if (JSON.stringify(currentData) === JSON.stringify(nextData)) {
|
||||
return node;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...(node.data || {}),
|
||||
parameters: normalizedParameters,
|
||||
...nextData,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -563,6 +1187,148 @@ function getSupportedFieldKeys(nodeType: string | undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
function replaceStartFieldReferenceValue(
|
||||
value: unknown,
|
||||
oldRefPath: string,
|
||||
newRefPath: string,
|
||||
): unknown {
|
||||
if (typeof value === 'string') {
|
||||
const oldToken = toToken(oldRefPath);
|
||||
const newToken = toToken(newRefPath);
|
||||
if (value === oldRefPath) {
|
||||
return newRefPath;
|
||||
}
|
||||
if (!value.includes(oldToken)) {
|
||||
return value;
|
||||
}
|
||||
return value.split(oldToken).join(newToken);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
let changed = false;
|
||||
const nextValue = value.map((item) => {
|
||||
const nextItem = replaceStartFieldReferenceValue(item, oldRefPath, newRefPath);
|
||||
if (nextItem !== item) {
|
||||
changed = true;
|
||||
}
|
||||
return nextItem;
|
||||
});
|
||||
return changed ? nextValue : value;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
let changed = false;
|
||||
const nextValue = Object.fromEntries(
|
||||
Object.entries(value).map(([key, itemValue]) => {
|
||||
const nextItemValue = replaceStartFieldReferenceValue(
|
||||
itemValue,
|
||||
oldRefPath,
|
||||
newRefPath,
|
||||
);
|
||||
if (nextItemValue !== itemValue) {
|
||||
changed = true;
|
||||
}
|
||||
return [key, nextItemValue];
|
||||
}),
|
||||
);
|
||||
return changed ? nextValue : value;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function collectDownstreamNodeIds(rootNodeId: string, edges: Edge[]) {
|
||||
const nodeIds = new Set<string>();
|
||||
|
||||
const visit = (nodeId: string) => {
|
||||
if (!nodeId || nodeIds.has(nodeId)) {
|
||||
return;
|
||||
}
|
||||
nodeIds.add(nodeId);
|
||||
edges
|
||||
.filter((edge) => edge.source === nodeId && edge.sourceHandle !== 'loop_handle')
|
||||
.forEach((edge) => {
|
||||
if (edge.target) {
|
||||
visit(edge.target);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
visit(rootNodeId);
|
||||
nodeIds.delete(rootNodeId);
|
||||
return Array.from(nodeIds);
|
||||
}
|
||||
|
||||
export function renameStartFieldReferencesInNodes(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
startNodeId: string,
|
||||
currentKey: string,
|
||||
nextKey: string,
|
||||
): Node[] {
|
||||
const normalizedStartNodeId = trimString(startNodeId);
|
||||
const normalizedCurrentKey = trimString(currentKey);
|
||||
const normalizedNextKey = trimString(nextKey);
|
||||
if (
|
||||
!normalizedStartNodeId ||
|
||||
!normalizedCurrentKey ||
|
||||
!normalizedNextKey ||
|
||||
normalizedCurrentKey === normalizedNextKey
|
||||
) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const oldRefPath = `${normalizedStartNodeId}.${normalizedCurrentKey}`;
|
||||
const newRefPath = `${normalizedStartNodeId}.${normalizedNextKey}`;
|
||||
|
||||
const nextNodes = nodes.map((node) => {
|
||||
if (node.id === normalizedStartNodeId) {
|
||||
return node;
|
||||
}
|
||||
const nextData = replaceStartFieldReferenceValue(
|
||||
node.data,
|
||||
oldRefPath,
|
||||
newRefPath,
|
||||
) as Record<string, any> | undefined;
|
||||
if (nextData === node.data) {
|
||||
return node;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
data: nextData,
|
||||
};
|
||||
});
|
||||
|
||||
const affectedNodeIds = collectDownstreamNodeIds(normalizedStartNodeId, edges);
|
||||
if (affectedNodeIds.length === 0) {
|
||||
return nextNodes;
|
||||
}
|
||||
|
||||
const patches = buildSequentialFieldBindingPatches(
|
||||
affectedNodeIds,
|
||||
nextNodes,
|
||||
edges,
|
||||
);
|
||||
if (patches.length === 0) {
|
||||
return nextNodes;
|
||||
}
|
||||
|
||||
const patchMap = new Map(patches.map((item) => [item.nodeId, item.patch] as const));
|
||||
return nextNodes.map((node) => {
|
||||
const patch = patchMap.get(node.id);
|
||||
if (!patch) {
|
||||
return node;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...((node.data || {}) as Record<string, any>),
|
||||
...patch,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getSingleRunFieldDescriptors(
|
||||
node: Pick<Node, 'type' | 'data'>,
|
||||
): SingleRunFieldDescriptor[] {
|
||||
@@ -778,12 +1544,15 @@ export function buildSingleRunParameters(node: Pick<Node, 'type' | 'data'> | nul
|
||||
}
|
||||
|
||||
if (node.type === START_NODE_TYPE) {
|
||||
const parameters = Array.isArray(node.data.parameters)
|
||||
? ((node.data.parameters as Parameter[]) || [])
|
||||
const normalizedData = normalizeStartNodeData(
|
||||
(node.data || {}) as Record<string, any>,
|
||||
{
|
||||
allowLegacyParametersOnly: true,
|
||||
},
|
||||
);
|
||||
return Array.isArray(normalizedData.parameters)
|
||||
? (normalizedData.parameters as Parameter[])
|
||||
: [];
|
||||
return hasSystemStartParameter(parameters)
|
||||
? ensureStartNodeParameters(parameters)
|
||||
: parameters;
|
||||
}
|
||||
|
||||
const parameters = Array.isArray(node.data.parameters)
|
||||
|
||||
@@ -12,6 +12,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['choose']);
|
||||
@@ -27,7 +31,7 @@ function closeDialog() {
|
||||
dialogVisible.value = false;
|
||||
}
|
||||
function confirm() {
|
||||
emit('choose', currentChoose.value, props.attrName);
|
||||
emit('choose', props.multiple ? chooseResources.value : currentChoose.value, props.attrName);
|
||||
closeDialog();
|
||||
}
|
||||
watch(
|
||||
@@ -56,7 +60,11 @@ watch(
|
||||
:page-sizes="[8, 12, 16, 20]"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<ResourceCardList v-model="chooseResources" :data="pageList" />
|
||||
<ResourceCardList
|
||||
v-model="chooseResources"
|
||||
:data="pageList"
|
||||
:multiple="props.multiple"
|
||||
/>
|
||||
</template>
|
||||
</PageData>
|
||||
<template #footer>
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { ElButton, ElLink, ElMessage } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
||||
|
||||
import {
|
||||
appendWorkflowFileValues,
|
||||
buildWorkflowFileValueFromResource,
|
||||
buildWorkflowFileValueFromUpload,
|
||||
formatWorkflowFileSize,
|
||||
normalizeWorkflowFileValues,
|
||||
validateWorkflowFileSelection,
|
||||
validateWorkflowFileValues,
|
||||
WORKFLOW_FILE_LIMITS,
|
||||
} from './workflowFileValue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [Array, Object],
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const uploadLoading = ref(false);
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const currentFiles = computed(() => normalizeWorkflowFileValues(props.modelValue));
|
||||
|
||||
function triggerSelectFile() {
|
||||
if (uploadLoading.value) {
|
||||
return;
|
||||
}
|
||||
fileInputRef.value?.click();
|
||||
}
|
||||
|
||||
async function handleNativeFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = Array.from(input.files || []);
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
uploadLoading.value = true;
|
||||
try {
|
||||
validateWorkflowFileSelection(currentFiles.value, files);
|
||||
const uploadedFiles = [];
|
||||
for (const file of files) {
|
||||
const res = await api.upload('/api/v1/commons/upload', { file }, {});
|
||||
uploadedFiles.push(buildWorkflowFileValueFromUpload(file, res?.data?.path));
|
||||
}
|
||||
const nextFiles = appendWorkflowFileValues(currentFiles.value, uploadedFiles);
|
||||
validateWorkflowFileValues(nextFiles);
|
||||
emit('update:modelValue', nextFiles);
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '文件上传失败');
|
||||
console.error('工作流文件上传失败', error);
|
||||
} finally {
|
||||
uploadLoading.value = false;
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleChooseResource(resources: any) {
|
||||
try {
|
||||
const resourceList = Array.isArray(resources) ? resources : [resources];
|
||||
const fileValues = resourceList
|
||||
.map((resource) => buildWorkflowFileValueFromResource(resource || {}))
|
||||
.filter(Boolean);
|
||||
const nextFiles = appendWorkflowFileValues(currentFiles.value, fileValues);
|
||||
validateWorkflowFileValues(nextFiles);
|
||||
emit('update:modelValue', nextFiles);
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '素材文件选择失败');
|
||||
}
|
||||
}
|
||||
|
||||
function removeFile(filePath: string) {
|
||||
const nextFiles = currentFiles.value.filter((item) => item.filePath !== filePath);
|
||||
emit('update:modelValue', nextFiles);
|
||||
}
|
||||
|
||||
function clearFiles() {
|
||||
emit('update:modelValue', []);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workflow-file-input">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
class="workflow-file-input__native"
|
||||
type="file"
|
||||
multiple
|
||||
@change="handleNativeFileChange"
|
||||
/>
|
||||
|
||||
<div class="workflow-file-input__hint">
|
||||
最多 {{ WORKFLOW_FILE_LIMITS.maxCount }} 个文件,单个不超过
|
||||
{{ formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxSingleSize) }},总计不超过
|
||||
{{ formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxTotalSize) }}
|
||||
</div>
|
||||
|
||||
<div v-if="currentFiles.length > 0" class="workflow-file-input__list">
|
||||
<div
|
||||
v-for="item in currentFiles"
|
||||
:key="item.filePath"
|
||||
class="workflow-file-input__summary"
|
||||
>
|
||||
<div class="workflow-file-input__content">
|
||||
<div class="workflow-file-input__name">
|
||||
{{ item.fileName }}
|
||||
</div>
|
||||
<div class="workflow-file-input__meta">
|
||||
<span>{{ formatWorkflowFileSize(item.size) }}</span>
|
||||
<ElLink
|
||||
v-if="item.url || item.filePath"
|
||||
:href="item.url || item.filePath"
|
||||
target="_blank"
|
||||
type="primary"
|
||||
>
|
||||
{{ $t('button.view') }}
|
||||
</ElLink>
|
||||
</div>
|
||||
</div>
|
||||
<ElButton text type="danger" @click="removeFile(item.filePath)">
|
||||
{{ $t('button.delete') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workflow-file-input__actions">
|
||||
<ElButton
|
||||
type="primary"
|
||||
plain
|
||||
:loading="uploadLoading"
|
||||
@click="triggerSelectFile"
|
||||
>
|
||||
{{ currentFiles.length > 0 ? '继续上传' : $t('button.upload') }}
|
||||
</ElButton>
|
||||
<ChooseResource attr-name="file" multiple @choose="handleChooseResource" />
|
||||
<ElButton
|
||||
v-if="currentFiles.length > 0"
|
||||
text
|
||||
type="danger"
|
||||
@click="clearFiles"
|
||||
>
|
||||
清空
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workflow-file-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.workflow-file-input__native {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workflow-file-input__hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.workflow-file-input__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.workflow-file-input__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 10px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.workflow-file-input__content {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.workflow-file-input__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.workflow-file-input__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.workflow-file-input__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance } from 'element-plus';
|
||||
|
||||
import { computed, onUnmounted, ref } from 'vue';
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { Position } from '@element-plus/icons-vue';
|
||||
import { ElButton, ElForm, ElFormItem } from 'element-plus';
|
||||
@@ -34,9 +34,74 @@ defineExpose({
|
||||
const runForm = ref<FormInstance>();
|
||||
const runParams = ref<any>({});
|
||||
const submitLoading = ref(false);
|
||||
const parameters = computed(() => {
|
||||
return props.workflowParams.parameters;
|
||||
const startFormMeta = computed(() => {
|
||||
const meta = props.workflowParams?.startFormMeta || {};
|
||||
return {
|
||||
title: String(meta.title || '').trim() || props.workflowParams?.title || '',
|
||||
description:
|
||||
String(meta.description || '').trim() ||
|
||||
props.workflowParams?.description ||
|
||||
'',
|
||||
submitText: String(meta.submitText || '').trim() || $t('button.run'),
|
||||
};
|
||||
});
|
||||
const parameters = computed(() => {
|
||||
const schema = Array.isArray(props.workflowParams?.startFormSchema)
|
||||
? props.workflowParams.startFormSchema
|
||||
: [];
|
||||
if (schema.length === 0) {
|
||||
return props.workflowParams.parameters || [];
|
||||
}
|
||||
return schema.map((field: any) => {
|
||||
const type = String(field.type || '').trim() || 'text';
|
||||
return {
|
||||
name: field.key,
|
||||
formLabel: field.label || field.key,
|
||||
formDescription: field.description || '',
|
||||
formPlaceholder: field.placeholder || '',
|
||||
required: Boolean(field.required),
|
||||
defaultValue: field.defaultValue,
|
||||
enums: Array.isArray(field.options) ? field.options : [],
|
||||
contentType: type === 'file' ? 'file' : 'text',
|
||||
formType: type === 'text' ? 'input' : type === 'file' ? 'input' : type,
|
||||
dataType:
|
||||
type === 'checkbox' ? 'Array' : type === 'file' ? 'File' : 'String',
|
||||
};
|
||||
});
|
||||
});
|
||||
watch(
|
||||
parameters,
|
||||
(items) => {
|
||||
const nextRunParams = { ...runParams.value };
|
||||
let changed = false;
|
||||
for (const item of items || []) {
|
||||
if (nextRunParams[item.name] !== undefined) {
|
||||
continue;
|
||||
}
|
||||
if (item.defaultValue !== undefined && item.defaultValue !== '') {
|
||||
nextRunParams[item.name] = item.defaultValue;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (item.formType === 'checkbox') {
|
||||
nextRunParams[item.name] = [];
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (item.contentType === 'file') {
|
||||
nextRunParams[item.name] = [];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
runParams.value = nextRunParams;
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
);
|
||||
const executeId = ref('');
|
||||
function resume(data: any) {
|
||||
data.executeId = executeId.value;
|
||||
@@ -109,6 +174,20 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div>
|
||||
<ElForm label-position="top" ref="runForm" :model="runParams">
|
||||
<div
|
||||
v-if="startFormMeta.title || startFormMeta.description"
|
||||
class="workflow-form__header"
|
||||
>
|
||||
<div v-if="startFormMeta.title" class="workflow-form__title">
|
||||
{{ startFormMeta.title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="startFormMeta.description"
|
||||
class="workflow-form__description"
|
||||
>
|
||||
{{ startFormMeta.description }}
|
||||
</div>
|
||||
</div>
|
||||
<WorkflowFormItem
|
||||
v-model:run-params="runParams"
|
||||
:parameters="parameters"
|
||||
@@ -120,11 +199,28 @@ onUnmounted(() => {
|
||||
:loading="submitLoading"
|
||||
:icon="Position"
|
||||
>
|
||||
{{ $t('button.run') }}
|
||||
{{ startFormMeta.submitText }}
|
||||
</ElButton>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.workflow-form__header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.workflow-form__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.workflow-form__description {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
|
||||
import { $t } from '#/locales';
|
||||
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
||||
import WorkflowFileInput from '#/views/ai/workflow/components/WorkflowFileInput.vue';
|
||||
|
||||
const props = defineProps({
|
||||
parameters: {
|
||||
@@ -27,10 +28,19 @@ const props = defineProps({
|
||||
});
|
||||
const emit = defineEmits(['update:runParams']);
|
||||
function getContentType(item: any) {
|
||||
return item.contentType || 'text';
|
||||
if (item.contentType) {
|
||||
return item.contentType;
|
||||
}
|
||||
if (String(item.dataType || '').toLowerCase() === 'file') {
|
||||
return 'file';
|
||||
}
|
||||
return 'text';
|
||||
}
|
||||
function isResource(contentType: any) {
|
||||
return ['audio', 'file', 'image', 'video'].includes(contentType);
|
||||
return ['audio', 'image', 'video'].includes(contentType);
|
||||
}
|
||||
function isFileContentType(contentType: any) {
|
||||
return contentType === 'file';
|
||||
}
|
||||
function getCheckboxOptions(item: any) {
|
||||
if (item.enums) {
|
||||
@@ -43,6 +53,31 @@ function getCheckboxOptions(item: any) {
|
||||
}
|
||||
return [];
|
||||
}
|
||||
function buildRules(item: any) {
|
||||
if (!item.required) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
required: true,
|
||||
validator: (_rule: any, value: any, callback: any) => {
|
||||
if (Array.isArray(value)) {
|
||||
callback(value.length > 0 ? undefined : new Error($t('message.required')));
|
||||
return;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
callback(
|
||||
Object.keys(value).length > 0
|
||||
? undefined
|
||||
: new Error($t('message.required')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
callback(value ? undefined : new Error($t('message.required')));
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
function updateParam(name: string, value: any) {
|
||||
const newValue = { ...props.runParams, [name]: value };
|
||||
emit('update:runParams', newValue);
|
||||
@@ -58,9 +93,7 @@ function choose(data: any, propName: string) {
|
||||
:prop="`${propPrefix}${item.name}`"
|
||||
:key="idx"
|
||||
:label="item.formLabel || item.name"
|
||||
:rules="
|
||||
item.required ? [{ required: true, message: $t('message.required') }] : []
|
||||
"
|
||||
:rules="buildRules(item)"
|
||||
>
|
||||
<template v-if="getContentType(item) === 'text'">
|
||||
<ElInput
|
||||
@@ -105,6 +138,12 @@ function choose(data: any, propName: string) {
|
||||
:placeholder="item.formPlaceholder"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="isFileContentType(getContentType(item))">
|
||||
<WorkflowFileInput
|
||||
:model-value="runParams[item.name]"
|
||||
@update:model-value="(val) => updateParam(item.name, val)"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="isResource(getContentType(item))">
|
||||
<ElInput
|
||||
:model-value="runParams[item.name]"
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* 工作流运行态单文件值。
|
||||
*/
|
||||
export interface WorkflowFileValue {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
contentType?: string;
|
||||
size?: number;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材对象最小字段约束。
|
||||
*/
|
||||
export interface WorkflowResourceLike {
|
||||
fileSize?: number | string;
|
||||
resourceName?: string;
|
||||
resourceUrl?: string;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
export const WORKFLOW_FILE_LIMITS = {
|
||||
maxCount: 10,
|
||||
maxSingleSize: 5 * 1024 * 1024,
|
||||
maxTotalSize: 50 * 1024 * 1024,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 基于上传结果构建工作流文件值。
|
||||
*/
|
||||
export function buildWorkflowFileValueFromUpload(
|
||||
file: File,
|
||||
path: string,
|
||||
): WorkflowFileValue {
|
||||
const resolvedPath = String(path || '').trim();
|
||||
if (!resolvedPath) {
|
||||
throw new Error('上传结果缺少文件路径');
|
||||
}
|
||||
return {
|
||||
fileName: file.name,
|
||||
filePath: resolvedPath,
|
||||
contentType: file.type || '',
|
||||
size: file.size,
|
||||
url: resolvedPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于素材对象构建工作流文件值。
|
||||
*/
|
||||
export function buildWorkflowFileValueFromResource(
|
||||
resource: WorkflowResourceLike,
|
||||
): WorkflowFileValue {
|
||||
const resourceUrl = String(resource?.resourceUrl || '').trim();
|
||||
if (!resourceUrl) {
|
||||
throw new Error('素材缺少 resourceUrl');
|
||||
}
|
||||
const resourceName = String(resource?.resourceName || '').trim();
|
||||
const suffix = String(resource?.suffix || '').trim();
|
||||
const fallbackFileName = resourceUrl.split('/').pop()?.split('?')[0] || '';
|
||||
return {
|
||||
fileName:
|
||||
resourceName && suffix
|
||||
? `${resourceName}.${suffix}`
|
||||
: resourceName || fallbackFileName,
|
||||
filePath: resourceUrl,
|
||||
contentType: '',
|
||||
size: toNumber(resource?.fileSize),
|
||||
url: resourceUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为有效的工作流文件值。
|
||||
*/
|
||||
export function isWorkflowFileValue(value: unknown): value is WorkflowFileValue {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const candidate = value as Partial<WorkflowFileValue>;
|
||||
return Boolean(
|
||||
String(candidate.fileName || '').trim() &&
|
||||
String(candidate.filePath || '').trim(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化单文件或多文件值。
|
||||
*/
|
||||
export function normalizeWorkflowFileValues(value: unknown): WorkflowFileValue[] {
|
||||
const candidates = Array.isArray(value) ? value : [value];
|
||||
const result: WorkflowFileValue[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const candidate of candidates) {
|
||||
if (!isWorkflowFileValue(candidate)) {
|
||||
continue;
|
||||
}
|
||||
const key = String(candidate.filePath).trim();
|
||||
if (!key || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
result.push(candidate);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加文件并按 filePath 去重。
|
||||
*/
|
||||
export function appendWorkflowFileValues(
|
||||
currentValue: unknown,
|
||||
incomingValues: WorkflowFileValue[],
|
||||
): WorkflowFileValue[] {
|
||||
return normalizeWorkflowFileValues([
|
||||
...normalizeWorkflowFileValues(currentValue),
|
||||
...incomingValues,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在上传前基于原生 File 列表校验文件限制,避免无效文件先落库。
|
||||
*/
|
||||
export function validateWorkflowFileSelection(
|
||||
currentValue: unknown,
|
||||
incomingFiles: File[],
|
||||
) {
|
||||
const currentFiles = normalizeWorkflowFileValues(currentValue);
|
||||
const totalCount = currentFiles.length + incomingFiles.length;
|
||||
if (totalCount > WORKFLOW_FILE_LIMITS.maxCount) {
|
||||
throw new Error(`最多上传 ${WORKFLOW_FILE_LIMITS.maxCount} 个文件`);
|
||||
}
|
||||
|
||||
let totalSize = currentFiles.reduce((sum, item) => sum + Number(item.size || 0), 0);
|
||||
for (const file of incomingFiles) {
|
||||
if (file.size > WORKFLOW_FILE_LIMITS.maxSingleSize) {
|
||||
throw new Error(`单个文件不能超过 ${formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxSingleSize)}`);
|
||||
}
|
||||
totalSize += file.size;
|
||||
}
|
||||
|
||||
if (totalSize > WORKFLOW_FILE_LIMITS.maxTotalSize) {
|
||||
throw new Error(`文件总大小不能超过 ${formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxTotalSize)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验最终文件列表是否满足工作流限制。
|
||||
*/
|
||||
export function validateWorkflowFileValues(values: WorkflowFileValue[]) {
|
||||
const normalized = normalizeWorkflowFileValues(values);
|
||||
if (normalized.length > WORKFLOW_FILE_LIMITS.maxCount) {
|
||||
throw new Error(`最多上传 ${WORKFLOW_FILE_LIMITS.maxCount} 个文件`);
|
||||
}
|
||||
let totalSize = 0;
|
||||
for (const item of normalized) {
|
||||
const size = Number(item.size || 0);
|
||||
if (size > WORKFLOW_FILE_LIMITS.maxSingleSize) {
|
||||
throw new Error(`单个文件不能超过 ${formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxSingleSize)}`);
|
||||
}
|
||||
totalSize += size;
|
||||
}
|
||||
if (totalSize > WORKFLOW_FILE_LIMITS.maxTotalSize) {
|
||||
throw new Error(`文件总大小不能超过 ${formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxTotalSize)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 友好格式化文件大小。
|
||||
*/
|
||||
export function formatWorkflowFileSize(size?: number): string {
|
||||
if (!size || Number.isNaN(size) || size <= 0) {
|
||||
return '-';
|
||||
}
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
if (size < 1024 * 1024 * 1024) {
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function toNumber(value?: number | string) {
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
13
pom.xml
13
pom.xml
@@ -35,6 +35,7 @@
|
||||
<sa-token.version>1.40.0</sa-token.version>
|
||||
<commonmark.version>0.18.0</commonmark.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-compress.version>1.28.0</commons-compress.version>
|
||||
<fastexcel.version>1.2.0</fastexcel.version>
|
||||
@@ -298,12 +299,24 @@
|
||||
<version>${commonmark.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.commonmark</groupId>
|
||||
<artifactId>commonmark-ext-gfm-tables</artifactId>
|
||||
<version>${commonmark.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>${jsoup.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.openhtmltopdf</groupId>
|
||||
<artifactId>openhtmltopdf-pdfbox</artifactId>
|
||||
<version>${openhtmltopdf.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>commons-io</groupId>
|
||||
<artifactId>commons-io</artifactId>
|
||||
|
||||
Reference in New Issue
Block a user