feat: 支持工作流插件复用与试运行

- 新增工作流插件类型、发布快照同步、实时可用性与下线影响检查

- 收口绑定候选、分类权限、间接环路校验与运行态优雅降级

- 补齐管理端工作流插件配置、详情与试运行界面及定向测试
This commit is contained in:
2026-04-12 13:15:13 +08:00
parent 6da90e2296
commit 47655a728b
57 changed files with 4018 additions and 780 deletions

View File

@@ -4,6 +4,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.BotPlugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.tree.Tree;
@@ -58,7 +59,14 @@ public class BotPluginController extends BaseCurdController<BotPluginService, Bo
List<BotPlugin> visibleList = new ArrayList<>();
for (BotPlugin relation : botPlugins) {
Plugin plugin = relation.getAiPlugin();
if (plugin == null || pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())) {
if (plugin == null) {
visibleList.add(relation);
continue;
}
if (PluginType.isWorkflow(plugin.getType())) {
continue;
}
if (pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())) {
visibleList.add(relation);
}
}
@@ -73,7 +81,13 @@ public class BotPluginController extends BaseCurdController<BotPluginService, Bo
List<Plugin> plugins = botPluginService.getList(botId);
List<Plugin> visibleList = new ArrayList<>();
for (Plugin plugin : plugins) {
if (plugin == null || pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())) {
if (plugin == null) {
continue;
}
if (PluginType.isWorkflow(plugin.getType())) {
continue;
}
if (pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())) {
visibleList.add(plugin);
}
}
@@ -105,6 +119,9 @@ public class BotPluginController extends BaseCurdController<BotPluginService, Bo
if (pluginItem.getPluginId() != null) {
Plugin plugin = pluginService.getById(pluginItem.getPluginId());
if (plugin != null) {
if (PluginType.isWorkflow(plugin.getType())) {
throw new tech.easyflow.common.web.exceptions.BusinessException("当前版本暂不支持聊天助手绑定工作流插件");
}
pluginVisibilityService.assertPluginVisible(plugin.getCreatedBy(), plugin.getId(), "无权限绑定插件");
}
}

View File

@@ -1,9 +1,11 @@
package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.springframework.web.bind.annotation.*;
import tech.easyflow.ai.entity.PluginCategory;
import tech.easyflow.ai.entity.PluginCategoryMapping;
import tech.easyflow.ai.service.PluginCategoryMappingService;
import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.jsonbody.JsonBody;
@@ -21,6 +23,7 @@ import java.util.List;
*/
@RestController
@RequestMapping("/api/v1/pluginCategoryMapping")
@UsePermission(moduleName = "/api/v1/plugin")
public class PluginCategoryMappingController extends BaseCurdController<PluginCategoryMappingService, PluginCategoryMapping> {
public PluginCategoryMappingController(PluginCategoryMappingService service) {
super(service);
@@ -30,6 +33,7 @@ public class PluginCategoryMappingController extends BaseCurdController<PluginCa
private PluginCategoryMappingService relationService;
@PostMapping("/updateRelation")
@SaCheckPermission("/api/v1/plugin/save")
public Result<Boolean> updateRelation(
@JsonBody(value="pluginId") BigInteger pluginId,
@JsonBody(value="categoryIds") ArrayList<BigInteger> categoryIds

View File

@@ -8,19 +8,30 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.ai.service.PluginService;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
@@ -47,6 +58,14 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
private PluginVisibilityService pluginVisibilityService;
@Resource
private ModelService modelService;
@Resource
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
@Resource
private WorkflowService workflowService;
@Resource
private ResourceAccessService resourceAccessService;
@Resource
private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
@Override
protected Result<?> onSaveOrUpdateBefore(Plugin entity, boolean isSave) {
@@ -79,7 +98,8 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
public Result<List<Plugin>> getList(){
QueryWrapper queryWrapper = QueryWrapper.create().select();
applyCategoryPermission(queryWrapper);
return Result.ok(service.getMapper().selectListByQuery(queryWrapper));
List<Plugin> plugins = service.getMapper().selectListWithRelationsByQuery(queryWrapper);
return Result.ok(pluginService.preparePluginsForCurrentUser(plugins, true, false));
}
@GetMapping("/pageByCategory")
@@ -101,6 +121,23 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
}
}
@GetMapping("/workflowCandidates")
@SaCheckPermission("/api/v1/plugin/query")
public Result<List<Workflow>> workflowCandidates(String keyword) {
QueryWrapper queryWrapper = QueryWrapper.create();
workflowVisibilityQueryHelper.applyReadableAccess(queryWrapper);
queryWrapper.eq("publish_status", tech.easyflow.ai.enums.PublishStatus.PUBLISHED.getCode());
if (keyword != null && !keyword.isBlank()) {
queryWrapper.like("title", keyword.trim());
}
queryWrapper.orderBy("modified desc");
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
List<Workflow> workflows = workflowService.list(queryWrapper).stream()
.filter(workflow -> canBindWorkflowCandidate(workflow, loginAccount))
.collect(java.util.stream.Collectors.toCollection(ArrayList::new));
return Result.ok(workflows);
}
@GetMapping("/modelList")
@SaCheckPermission("/api/v1/plugin/query")
public Result<List<Model>> modelList(Model entity, Boolean asTree, String sortKey, String sortType) {
@@ -110,14 +147,24 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
@Override
protected Page<Plugin> queryPage(Page<Plugin> page, QueryWrapper queryWrapper) {
applyCategoryPermission(queryWrapper);
return service.getMapper().paginateWithRelations(page, queryWrapper);
List<Plugin> totalList = service.getMapper().selectListWithRelationsByQuery(queryWrapper);
boolean availableOnly = isAvailableOnly();
List<Plugin> prepared = pluginService.preparePluginsForCurrentUser(totalList, !availableOnly, availableOnly);
long total = prepared.size();
int fromIndex = Math.max(0, Math.toIntExact((page.getPageNumber() - 1) * page.getPageSize()));
if (fromIndex >= prepared.size()) {
return new Page<>(Collections.emptyList(), page.getPageNumber(), page.getPageSize(), total);
}
int toIndex = Math.min(prepared.size(), Math.toIntExact(fromIndex + page.getPageSize()));
return new Page<>(prepared.subList(fromIndex, toIndex), page.getPageNumber(), page.getPageSize(), total);
}
@Override
public Result<Plugin> detail(String id) {
Plugin plugin = service.getById(id);
Plugin plugin = service.getMapper().selectOneWithRelationsById(id);
if (plugin != null) {
pluginVisibilityService.assertPluginVisible(plugin.getCreatedBy(), plugin.getId(), "无权限访问插件");
pluginService.preparePluginForCurrentUser(plugin);
}
return Result.ok(plugin);
}
@@ -138,4 +185,30 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
}
queryWrapper.and(PLUGIN.CREATED_BY.eq(access.getAccountIdAsLong()).or(PLUGIN.ID.in(pluginIds)));
}
private boolean isAvailableOnly() {
HttpServletRequest request = currentHttpRequest();
return request != null && "true".equalsIgnoreCase(request.getParameter("availableOnly"));
}
private HttpServletRequest currentHttpRequest() {
org.springframework.web.context.request.ServletRequestAttributes attributes =
(org.springframework.web.context.request.ServletRequestAttributes)
org.springframework.web.context.request.RequestContextHolder.getRequestAttributes();
return attributes == null ? null : attributes.getRequest();
}
private boolean canBindWorkflowCandidate(Workflow workflow, LoginAccount loginAccount) {
if (workflow == null || loginAccount == null || loginAccount.getId() == null) {
return false;
}
if (workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) {
return false;
}
Workflow publishedWorkflow = workflowService.toPublishedView(workflow);
if (!workflowPluginSnapshotResolver.isSupportedForWorkflowPlugin(publishedWorkflow)) {
return false;
}
return resourceAccessService.canAccess(loginAccount, CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE);
}
}

View File

@@ -1,30 +1,49 @@
package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
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.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.BotPlugin;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.BotPluginService;
import tech.easyflow.ai.service.PluginService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import javax.annotation.Resource;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 控制层。
@@ -45,6 +64,18 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
@Resource
private BotPluginService botPluginService;
@Resource
private PluginService pluginService;
@Resource
private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
@Resource
private WorkflowService workflowService;
@Resource
private ChainExecutor chainExecutor;
@Resource
private TinyFlowService tinyFlowService;
@Resource
private WorkflowCheckService workflowCheckService;
@PostMapping("/tool/save")
@SaCheckPermission("/api/v1/plugin/save")
@@ -87,8 +118,18 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
if (record == null) {
return Result.ok(nodeData);
}
Plugin plugin = pluginService.getById(record.getPluginId());
nodeData.put("pluginId", record.getId().toString());
nodeData.put("pluginName", record.getName());
if (plugin != null) {
plugin = pluginService.preparePluginForCurrentUser(plugin);
nodeData.put("pluginType", plugin.getType());
nodeData.put("workflowId", plugin.getWorkflowId());
nodeData.put("workflowTitle", plugin.getWorkflowTitle());
nodeData.put("available", plugin.getAvailable());
nodeData.put("reasonCode", plugin.getReasonCode());
nodeData.put("reasonMessage", plugin.getReasonMessage());
}
JSONArray parameters = new JSONArray();
JSONArray outputDefs = new JSONArray();
@@ -104,6 +145,7 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
handleArray(array);
outputDefs = array;
}
nodeData.put("schemaHash", resolveSchemaHash(record, plugin));
nodeData.put("parameters", parameters);
nodeData.put("outputDefs", outputDefs);
return Result.ok(nodeData);
@@ -119,6 +161,71 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
return pluginItemService.pluginToolTest(inputData, pluginToolId);
}
/**
* 工作流插件异步试运行。
*
* @param inputData 输入参数 JSON
* @param pluginToolId 插件工具 ID
* @return 执行 ID
*/
@PostMapping("/testAsync")
@SaCheckPermission("/api/v1/plugin/query")
public Result<String> pluginToolTestAsync(@JsonBody(value = "inputData", required = true) String inputData,
@JsonBody(value = "pluginToolId", required = true) BigInteger pluginToolId) {
PluginItem pluginItem = pluginItemService.getById(pluginToolId);
Plugin plugin = requireWorkflowPlugin(pluginItem);
Plugin preparedPlugin = pluginService.preparePluginForCurrentUser(plugin);
if (Boolean.FALSE.equals(preparedPlugin.getAvailable())) {
throw new BusinessException(preparedPlugin.getReasonMessage());
}
Workflow workflow = workflowService.getPublishedById(preparedPlugin.getWorkflowId());
if (workflow == null) {
throw new BusinessException("未找到已发布工作流");
}
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
Map<String, Object> variables = JSON.parseObject(inputData, Map.class);
if (variables == null) {
variables = new HashMap<>();
}
if (StpUtil.isLogin()) {
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
}
String executeId = chainExecutor.executeAsync(
PublishedWorkflowDefinitionIds.published(String.valueOf(workflow.getId())),
variables
);
return Result.ok(executeId);
}
/**
* 获取工作流插件试运行状态。
*
* @param executeId 执行 ID
* @param nodes 节点列表
* @return 链路状态
*/
@PostMapping("/testChainStatus")
@SaCheckPermission("/api/v1/plugin/query")
public Result<ChainInfo> pluginToolTestChainStatus(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("nodes") List<NodeInfo> nodes) {
return Result.ok(tinyFlowService.getChainStatus(executeId, nodes));
}
/**
* 恢复工作流插件试运行。
*
* @param executeId 执行 ID
* @param confirmParams 恢复参数
* @return 空结果
*/
@PostMapping("/testResume")
@SaCheckPermission("/api/v1/plugin/query")
public Result<Void> pluginToolTestResume(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
chainExecutor.resumeAsync(executeId, confirmParams);
return Result.ok();
}
private void handleArray(JSONArray array) {
for (Object o : array) {
JSONObject obj = (JSONObject) o;
@@ -134,6 +241,40 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
}
}
private String resolveSchemaHash(PluginItem record, Plugin plugin) {
if (record == null) {
return null;
}
if (StrUtil.isNotBlank(record.getSchemaHash())) {
return record.getSchemaHash();
}
if (plugin == null || !PluginType.isWorkflow(plugin.getType()) || plugin.getWorkflowId() == null) {
return null;
}
tech.easyflow.ai.entity.Workflow workflow = workflowService.getPublishedById(plugin.getWorkflowId());
if (workflow == null) {
return null;
}
return workflowPluginSnapshotResolver.resolveSchemaHash(workflow);
}
private Plugin requireWorkflowPlugin(PluginItem pluginItem) {
if (pluginItem == null) {
throw new BusinessException("插件工具不存在");
}
Plugin plugin = pluginService.getById(pluginItem.getPluginId());
if (plugin == null) {
throw new BusinessException("插件不存在");
}
if (!PluginType.isWorkflow(plugin.getType())) {
throw new BusinessException("当前工具不是工作流插件");
}
if (plugin.getWorkflowId() == null) {
throw new BusinessException("插件未绑定工作流");
}
return plugin;
}
@Override
protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
@@ -144,6 +285,15 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
if (exists){
return Result.fail(1, "此工具还关联着bot请先取消关联");
}
if (ids.size() == 1) {
PluginItem pluginItem = pluginItemService.getById(ids.iterator().next());
if (pluginItem != null) {
Plugin plugin = pluginService.getById(pluginItem.getPluginId());
if (plugin != null && PluginType.isWorkflow(plugin.getType())) {
return Result.fail(1, "工作流插件工具由系统自动维护,不支持删除");
}
}
}
return null;
}

View File

@@ -120,6 +120,12 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
if (workflow == null) {
return Result.fail(1, "工作流不存在");
}
if (variables == null) {
variables = new HashMap<>();
}
if (StpUtil.isLogin()) {
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
}
Map<String, Object> res = chainExecutor.executeNode(workflowId.toString(), nodeId, variables);
return Result.ok(res);
}

View File

@@ -1,6 +1,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;
@@ -18,7 +19,9 @@ import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.jsonbody.JsonBody;
import java.math.BigInteger;
@@ -75,6 +78,12 @@ public class PublicWorkflowController {
if (workflow == null) {
return Result.fail(1, "工作流不存在");
}
if (variables == null) {
variables = new HashMap<>();
}
if (StpUtil.isLogin()) {
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
}
Map<String, Object> res = chainExecutor.executeNode(workflowId.toString(), nodeId, variables);
return Result.ok(res);
}

View File

@@ -83,6 +83,12 @@ public class UcWorkflowController extends BaseCurdController<WorkflowService, Wo
if (workflow == null) {
return Result.fail(1, "工作流不存在");
}
if (variables == null) {
variables = new HashMap<>();
}
if (StpUtil.isLogin()) {
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
}
Map<String, Object> res = chainExecutor.executeNode(workflowId.toString(), nodeId, variables);
return Result.ok(res);
}

View File

@@ -11,6 +11,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.entity.WorkflowExecStep;
@@ -62,7 +63,11 @@ public class ChainEventListenerForSave implements ChainEventListener {
log.info("ChainStartEvent: {}", event);
ChainDefinition definition = chain.getDefinition();
ChainState state = chain.getState();
Workflow workflow = workflowService.getById(definition.getId());
Workflow workflow = resolveWorkflow(definition);
if (workflow == null) {
log.error("ChainStartEvent: workflow not found, definitionId={}", definition.getId());
return;
}
String instanceId = state.getInstanceId();
WorkflowExecResult existed = workflowExecResultService.getByExecKey(instanceId);
if (existed != null) {
@@ -176,4 +181,26 @@ public class ChainEventListenerForSave implements ChainEventListener {
ChainState chainState = chain.getChainStateRepository().load(parentInstanceId);
return findAncestorState(chainState, chain);
}
/**
* 根据定义 ID 解析当前执行所对应的工作流。
* 已发布快照执行会使用 published 前缀,需要先还原为真实工作流 ID。
*/
private Workflow resolveWorkflow(ChainDefinition definition) {
if (definition == null || StrUtil.isBlank(definition.getId())) {
return null;
}
String definitionId = definition.getId();
String workflowId = PublishedWorkflowDefinitionIds.unwrap(definitionId);
try {
java.math.BigInteger id = new java.math.BigInteger(workflowId);
if (PublishedWorkflowDefinitionIds.isPublished(definitionId)) {
return workflowService.getPublishedById(id);
}
return workflowService.getById(id);
} catch (NumberFormatException ex) {
log.error("Unsupported workflow definition id: {}", definitionId, ex);
return null;
}
}
}

View File

@@ -9,7 +9,11 @@ import org.springframework.util.StringUtils;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckIssue;
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.plugin.workflow.dependency.WorkflowPluginDependencyService;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.datacenter.execution.model.DatacenterSchemaResponse;
@@ -40,6 +44,7 @@ public class WorkflowCheckService {
private static final String TYPE_END = "endNode";
private static final String TYPE_LOOP = "loopNode";
private static final String TYPE_WORKFLOW = "workflow-node";
private static final String TYPE_PLUGIN = "plugin-node";
@Resource
private WorkflowService workflowService;
@@ -47,6 +52,12 @@ public class WorkflowCheckService {
private ChainParser chainParser;
@Resource
private WorkflowDatacenterContentService workflowDatacenterContentService;
@Resource
private WorkflowPluginDependencyService workflowPluginDependencyService;
@Resource
private PluginItemService pluginItemService;
@Resource
private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
public WorkflowCheckResult checkWorkflow(BigInteger workflowId, WorkflowCheckStage stage) {
if (workflowId == null) {
@@ -66,6 +77,9 @@ public class WorkflowCheckService {
List<WorkflowCheckIssue> issues = new ArrayList<>();
Set<String> issueKeys = new LinkedHashSet<>();
ParsedWorkflow parsedWorkflow = parseAndCheckBase(content, issues, issueKeys);
if (parsedWorkflow != null) {
checkPluginSchemaHashes(parsedWorkflow, issues, issueKeys);
}
if (stage == WorkflowCheckStage.PRE_EXECUTE && parsedWorkflow != null) {
runStrictChecks(content, parsedWorkflow, currentWorkflowId, issues, issueKeys);
@@ -394,6 +408,10 @@ public class WorkflowCheckService {
for (NodeView node : parsed.nodes) {
if (!TYPE_WORKFLOW.equals(node.type)) {
if (!TYPE_PLUGIN.equals(node.type)) {
continue;
}
checkPluginWorkflowReference(node, currentWorkflowIdString, currentContent, contentCache, issues, issueKeys);
continue;
}
String workflowId = getWorkflowIdInNode(node);
@@ -510,12 +528,86 @@ public class WorkflowCheckService {
refs.add(workflowId);
}
}
refs.addAll(workflowPluginDependencyService.extractWorkflowIdsFromPluginNodes(content));
} catch (Exception ignored) {
// ignore
}
return refs;
}
private void checkPluginWorkflowReference(NodeView node,
String currentWorkflowIdString,
String currentContent,
Map<String, String> contentCache,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
String pluginWorkflowId = getWorkflowIdInPluginNode(node);
if (!StringUtils.hasText(pluginWorkflowId)) {
addIssue(issues, issueKeys, "PLUGIN_WORKFLOW_REF_NOT_FOUND",
"插件节点未绑定有效工作流插件", node.id, null, node.name);
return;
}
if (StringUtils.hasText(currentWorkflowIdString) && currentWorkflowIdString.equals(pluginWorkflowId)) {
addIssue(issues, issueKeys, "PLUGIN_WORKFLOW_REF_CYCLE",
"插件递归引用:工作流不能通过插件引用自身", node.id, null, node.name);
return;
}
String workflowContent = loadWorkflowContent(pluginWorkflowId, currentWorkflowIdString, currentContent, contentCache);
if (!StringUtils.hasText(workflowContent)) {
addIssue(issues, issueKeys, "PLUGIN_WORKFLOW_REF_NOT_FOUND",
"插件绑定工作流不存在: " + pluginWorkflowId, node.id, null, node.name);
}
}
private void checkPluginSchemaHashes(ParsedWorkflow parsed,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
for (NodeView node : parsed.nodes) {
if (!TYPE_PLUGIN.equals(node.type) || node.data == null) {
continue;
}
String pluginItemId = trimToNull(node.data.getString("pluginId"));
if (!StringUtils.hasText(pluginItemId)) {
continue;
}
String workflowId = workflowPluginDependencyService.resolveWorkflowIdByPluginItemId(pluginItemId);
if (!StringUtils.hasText(workflowId)) {
continue;
}
String latestSchemaHash = resolveLatestPluginSchemaHash(pluginItemId, workflowId);
if (!StringUtils.hasText(latestSchemaHash)) {
continue;
}
String currentSchemaHash = trimToNull(node.data.getString("schemaHash"));
if (!latestSchemaHash.equals(currentSchemaHash)) {
addIssue(issues, issueKeys, "PLUGIN_SCHEMA_OUTDATED",
"当前插件节点绑定工作流的已发布参数契约已更新,请重新选择插件同步节点定义",
node.id, null, node.name);
}
}
}
private String getWorkflowIdInPluginNode(NodeView node) {
if (node == null || node.data == null) {
return null;
}
return workflowPluginDependencyService.resolveWorkflowIdByPluginItemId(
trimToNull(node.data.getString("pluginId"))
);
}
private String resolveLatestPluginSchemaHash(String pluginItemId, String workflowId) {
PluginItem pluginItem = pluginItemService.getById(pluginItemId);
if (pluginItem != null && StringUtils.hasText(pluginItem.getSchemaHash())) {
return pluginItem.getSchemaHash();
}
Workflow workflow = workflowService.getPublishedById(new BigInteger(workflowId));
if (workflow == null) {
return null;
}
return workflowPluginSnapshotResolver.resolveSchemaHash(workflow);
}
private String formatCyclePath(LinkedHashSet<String> visiting, String cycleStart) {
List<String> chain = new ArrayList<>();
boolean started = false;

View File

@@ -19,6 +19,18 @@ public class Plugin extends PluginBase {
@RelationOneToMany(selfField = "id", targetField = "pluginId", targetTable = "tb_plugin_item")
private List<PluginItem> tools;
@com.mybatisflex.annotation.Column(ignore = true)
private String workflowTitle;
@com.mybatisflex.annotation.Column(ignore = true)
private Boolean available;
@com.mybatisflex.annotation.Column(ignore = true)
private String reasonCode;
@com.mybatisflex.annotation.Column(ignore = true)
private String reasonMessage;
public String getTitle() {
return this.getName();
}
@@ -30,4 +42,36 @@ public class Plugin extends PluginBase {
public void setTools(List<PluginItem> tools) {
this.tools = tools;
}
public String getWorkflowTitle() {
return workflowTitle;
}
public void setWorkflowTitle(String workflowTitle) {
this.workflowTitle = workflowTitle;
}
public Boolean getAvailable() {
return available;
}
public void setAvailable(Boolean available) {
this.available = available;
}
public String getReasonCode() {
return reasonCode;
}
public void setReasonCode(String reasonCode) {
this.reasonCode = reasonCode;
}
public String getReasonMessage() {
return reasonMessage;
}
public void setReasonMessage(String reasonMessage) {
this.reasonMessage = reasonMessage;
}
}

View File

@@ -42,6 +42,12 @@ public class PluginBase implements Serializable {
@Column(comment = "类型")
private Integer type;
/**
* 绑定工作流ID
*/
@Column(comment = "绑定工作流ID")
private BigInteger workflowId;
/**
* 基础URL
*/
@@ -148,6 +154,14 @@ public class PluginBase implements Serializable {
this.type = type;
}
public BigInteger getWorkflowId() {
return workflowId;
}
public void setWorkflowId(BigInteger workflowId) {
this.workflowId = workflowId;
}
public String getBaseUrl() {
return baseUrl;
}

View File

@@ -90,6 +90,12 @@ public class PluginItemBase implements Serializable {
@Column(comment = "英文名称")
private String englishName;
/**
* 工作流插件输入输出契约哈希
*/
@Column(comment = "工作流插件输入输出契约哈希")
private String schemaHash;
public BigInteger getId() {
return id;
}
@@ -194,4 +200,12 @@ public class PluginItemBase implements Serializable {
this.englishName = englishName;
}
public String getSchemaHash() {
return schemaHash;
}
public void setSchemaHash(String schemaHash) {
this.schemaHash = schemaHash;
}
}

View File

@@ -0,0 +1,52 @@
package tech.easyflow.ai.enums;
/**
* 插件类型枚举。
*/
public enum PluginType {
HTTP(1),
WORKFLOW(2);
private final int code;
PluginType(int code) {
this.code = code;
}
/**
* 获取类型编码。
*
* @return 类型编码
*/
public int getCode() {
return code;
}
/**
* 根据编码解析类型,空值或未知值均按 HTTP 处理,兼容历史数据。
*
* @param code 类型编码
* @return 插件类型
*/
public static PluginType from(Integer code) {
if (code != null) {
for (PluginType value : values()) {
if (value.code == code) {
return value;
}
}
}
return HTTP;
}
/**
* 判断是否为工作流插件。
*
* @param code 类型编码
* @return 是否为工作流插件
*/
public static boolean isWorkflow(Integer code) {
return from(code) == WORKFLOW;
}
}

View File

@@ -4,11 +4,23 @@ import com.easyagents.core.model.chat.tool.Tool;
import com.alibaba.fastjson.JSON;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.node.BaseNode;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityDecision;
import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityService;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.PluginService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.utils.WorkFlowUtil;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.util.SpringContextUtil;
import java.math.BigInteger;
import java.util.LinkedHashMap;
import java.util.Collections;
import java.util.Map;
@@ -32,6 +44,11 @@ public class PluginToolNode extends BaseNode {
if (tool == null) {
return Collections.emptyMap();
}
PluginService pluginService = SpringContextUtil.getBean(PluginService.class);
Plugin plugin = pluginService.getById(tool.getPluginId());
if (plugin != null && PluginType.isWorkflow(plugin.getType())) {
return executeWorkflowPlugin(chain, map, plugin);
}
Tool function = tool.toFunction();
if (function == null) {
return Collections.emptyMap();
@@ -49,6 +66,43 @@ public class PluginToolNode extends BaseNode {
return JSON.parseObject(JSON.toJSONString(result), Map.class);
}
private Map<String, Object> executeWorkflowPlugin(Chain chain, Map<String, Object> map, Plugin plugin) {
WorkflowPluginAvailabilityService availabilityService =
SpringContextUtil.getBean(WorkflowPluginAvailabilityService.class);
LoginAccount operator = WorkFlowUtil.getOperator(chain);
WorkflowPluginAvailabilityDecision decision = availabilityService.evaluate(plugin, operator);
if (!decision.isAvailable()) {
return buildSkippedResult(decision);
}
WorkflowService workflowService = SpringContextUtil.getBean(WorkflowService.class);
Workflow workflow = workflowService.getPublishedById(plugin.getWorkflowId());
if (workflow == null) {
return buildSkippedResult(decision);
}
WorkflowPluginSnapshotResolver snapshotResolver = SpringContextUtil.getBean(WorkflowPluginSnapshotResolver.class);
Map<String, Object> workflowVariables = new LinkedHashMap<>();
workflowVariables.put(Constants.LOGIN_USER_KEY, operator);
if (map != null && !map.isEmpty()) {
workflowVariables.putAll(map);
}
Object result = snapshotResolver.buildWorkflowTool(workflow).invoke(workflowVariables);
if (result == null) {
return Collections.emptyMap();
}
if (result instanceof Map<?, ?> resultMap) {
return (Map<String, Object>) resultMap;
}
return JSON.parseObject(JSON.toJSONString(result), Map.class);
}
private Map<String, Object> buildSkippedResult(WorkflowPluginAvailabilityDecision decision) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("skipped", true);
result.put("reasonCode", decision.getReasonCode());
result.put("reasonMessage", decision.getReasonMessage());
return result;
}
public BigInteger getPluginId() {
return pluginId;
}

View File

@@ -0,0 +1,67 @@
package tech.easyflow.ai.plugin.workflow.availability;
/**
* 工作流插件实时可用性判定结果。
*/
public class WorkflowPluginAvailabilityDecision {
private boolean visible;
private boolean available;
private boolean snapshotPresent;
private String reasonCode;
private String reasonMessage;
private String workflowTitle;
public boolean isVisible() {
return visible;
}
public void setVisible(boolean visible) {
this.visible = visible;
}
public boolean isAvailable() {
return available;
}
public void setAvailable(boolean available) {
this.available = available;
}
public boolean isSnapshotPresent() {
return snapshotPresent;
}
public void setSnapshotPresent(boolean snapshotPresent) {
this.snapshotPresent = snapshotPresent;
}
public String getReasonCode() {
return reasonCode;
}
public void setReasonCode(String reasonCode) {
this.reasonCode = reasonCode;
}
public String getReasonMessage() {
return reasonMessage;
}
public void setReasonMessage(String reasonMessage) {
this.reasonMessage = reasonMessage;
}
public String getWorkflowTitle() {
return workflowTitle;
}
public void setWorkflowTitle(String workflowTitle) {
this.workflowTitle = workflowTitle;
}
}

View File

@@ -0,0 +1,28 @@
package tech.easyflow.ai.plugin.workflow.availability;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.common.entity.LoginAccount;
/**
* 工作流插件可用性判定服务。
*/
public interface WorkflowPluginAvailabilityService {
/**
* 计算当前登录用户视角下的工作流插件可见性与可用性。
*
* @param plugin 插件
* @return 判定结果
*/
WorkflowPluginAvailabilityDecision evaluateForCurrentUser(Plugin plugin);
WorkflowPluginAvailabilityDecision evaluate(Plugin plugin, LoginAccount loginAccount);
/**
* 判断当前用户是否可在管理页继续看到不可用插件。
*
* @param plugin 插件
* @return 是否允许在管理视角保留展示
*/
boolean canViewUnavailableInManagement(Plugin plugin);
}

View File

@@ -0,0 +1,126 @@
package tech.easyflow.ai.plugin.workflow.availability;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.Map;
/**
* 工作流插件实时可用性判定实现。
*/
@Service
public class WorkflowPluginAvailabilityServiceImpl implements WorkflowPluginAvailabilityService {
private final WorkflowService workflowService;
private final ResourceAccessService resourceAccessService;
private final CategoryPermissionService categoryPermissionService;
private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
public WorkflowPluginAvailabilityServiceImpl(WorkflowService workflowService,
ResourceAccessService resourceAccessService,
CategoryPermissionService categoryPermissionService,
WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver) {
this.workflowService = workflowService;
this.resourceAccessService = resourceAccessService;
this.categoryPermissionService = categoryPermissionService;
this.workflowPluginSnapshotResolver = workflowPluginSnapshotResolver;
}
/**
* {@inheritDoc}
*/
@Override
public WorkflowPluginAvailabilityDecision evaluateForCurrentUser(Plugin plugin) {
return evaluate(plugin, SaTokenUtil.getLoginAccount());
}
@Override
public WorkflowPluginAvailabilityDecision evaluate(Plugin plugin, LoginAccount loginAccount) {
WorkflowPluginAvailabilityDecision decision = new WorkflowPluginAvailabilityDecision();
decision.setVisible(true);
decision.setAvailable(true);
if (plugin == null || !PluginType.isWorkflow(plugin.getType())) {
return decision;
}
BigInteger workflowId = plugin.getWorkflowId();
if (workflowId == null) {
return unavailable(decision, "WORKFLOW_BINDING_MISSING", "当前插件未绑定工作流");
}
Workflow workflow = workflowService.getById(workflowId);
if (workflow == null) {
return unavailable(decision, "WORKFLOW_NOT_FOUND", "当前插件绑定的工作流不存在");
}
decision.setWorkflowTitle(workflow.getTitle());
Map<String, Object> snapshot = workflow.getPublishedSnapshotJson();
boolean snapshotPresent = !CollectionUtils.isEmpty(snapshot);
decision.setSnapshotPresent(snapshotPresent);
if (!snapshotPresent) {
return unavailable(decision, "WORKFLOW_SNAPSHOT_MISSING", "当前插件绑定工作流没有可用发布快照");
}
PublishStatus publishStatus = PublishStatus.from(workflow.getPublishStatus());
if (!publishStatus.isExternallyVisible()) {
return unavailable(decision, "WORKFLOW_OFFLINE", "当前节点绑定工作流已下线");
}
Workflow publishedWorkflow = workflowService.toPublishedView(workflow);
if (!workflowPluginSnapshotResolver.isSupportedForWorkflowPlugin(publishedWorkflow)) {
return unavailable(decision, "WORKFLOW_MULTI_END_UNSUPPORTED", "当前节点绑定工作流包含多个结束节点,暂不支持作为插件使用");
}
if (!resourceAccessService.canAccess(loginAccount, CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE)) {
return unavailable(decision, "WORKFLOW_NO_PERMISSION", "当前用户无权使用目标工作流");
}
return decision;
}
/**
* {@inheritDoc}
*/
@Override
public boolean canViewUnavailableInManagement(Plugin plugin) {
if (plugin == null) {
return false;
}
if (categoryPermissionService.isCurrentSuperAdmin()) {
return true;
}
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
if (loginAccount == null || loginAccount.getId() == null || plugin.getCreatedBy() == null) {
return false;
}
return loginAccount.getId().equals(BigInteger.valueOf(plugin.getCreatedBy()));
}
/**
* 标记为不可见且不可用。
*
* @param decision 判定结果
* @param reasonCode 原因编码
* @param reasonMessage 原因说明
* @return 判定结果
*/
private WorkflowPluginAvailabilityDecision unavailable(WorkflowPluginAvailabilityDecision decision,
String reasonCode,
String reasonMessage) {
decision.setVisible(false);
decision.setAvailable(false);
decision.setReasonCode(reasonCode);
decision.setReasonMessage(reasonMessage);
return decision;
}
}

View File

@@ -0,0 +1,34 @@
package tech.easyflow.ai.plugin.workflow.binding;
import tech.easyflow.ai.entity.Plugin;
import java.math.BigInteger;
/**
* 工作流插件绑定服务。
*/
public interface WorkflowPluginBindingService {
/**
* 创建工作流插件并生成系统维护工具。
*
* @param plugin 插件
* @return 已保存插件
*/
Plugin saveWorkflowPlugin(Plugin plugin);
/**
* 更新工作流插件并刷新系统维护工具。
*
* @param plugin 插件
* @return 是否更新成功
*/
boolean updateWorkflowPlugin(Plugin plugin);
/**
* 同步某个工作流关联的所有工作流插件。
*
* @param workflowId 工作流 ID
*/
void syncByWorkflowId(BigInteger workflowId);
}

View File

@@ -0,0 +1,227 @@
package tech.easyflow.ai.plugin.workflow.binding;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.mapper.PluginItemMapper;
import tech.easyflow.ai.mapper.PluginMapper;
import tech.easyflow.ai.plugin.workflow.dependency.WorkflowPluginDependencyService;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.Date;
import java.util.List;
/**
* 工作流插件绑定服务实现。
*/
@Service
public class WorkflowPluginBindingServiceImpl implements WorkflowPluginBindingService {
private final PluginMapper pluginMapper;
private final PluginItemMapper pluginItemMapper;
private final WorkflowService workflowService;
private final ResourceAccessService resourceAccessService;
private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
private final WorkflowPluginDependencyService workflowPluginDependencyService;
public WorkflowPluginBindingServiceImpl(PluginMapper pluginMapper,
PluginItemMapper pluginItemMapper,
WorkflowService workflowService,
ResourceAccessService resourceAccessService,
WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver,
WorkflowPluginDependencyService workflowPluginDependencyService) {
this.pluginMapper = pluginMapper;
this.pluginItemMapper = pluginItemMapper;
this.workflowService = workflowService;
this.resourceAccessService = resourceAccessService;
this.workflowPluginSnapshotResolver = workflowPluginSnapshotResolver;
this.workflowPluginDependencyService = workflowPluginDependencyService;
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Plugin saveWorkflowPlugin(Plugin plugin) {
LoginAccount loginAccount = requireLogin();
Workflow workflow = requirePublishedWorkflow(plugin.getWorkflowId(), "无权限绑定工作流");
normalizeWorkflowPlugin(plugin, loginAccount);
int insert = pluginMapper.insert(plugin);
if (insert <= 0) {
throw new BusinessException("保存失败");
}
PluginItem pluginItem = new PluginItem();
pluginItem.setCreated(new Date());
workflowPluginSnapshotResolver.syncPluginItemFromPublishedWorkflow(plugin, pluginItem, workflow.getId());
if (pluginItemMapper.insert(pluginItem) <= 0) {
throw new BusinessException("保存工作流插件工具失败");
}
return plugin;
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateWorkflowPlugin(Plugin plugin) {
if (plugin.getId() == null) {
throw new BusinessException("插件ID不能为空");
}
Plugin existed = pluginMapper.selectOneById(plugin.getId());
if (existed == null) {
throw new BusinessException("插件不存在");
}
if (PluginType.from(existed.getType()) != PluginType.WORKFLOW) {
throw new BusinessException("暂不支持在现有 HTTP 插件与工作流插件之间切换类型");
}
Workflow workflow = requirePublishedWorkflow(plugin.getWorkflowId(), "无权限绑定工作流");
if (workflowPluginDependencyService.containsPluginReferenceTransitivelyInPublishedSnapshot(workflow.getId(), existed.getId())) {
throw new BusinessException("目标工作流已通过子流程或插件链路引用当前插件,无法形成递归绑定");
}
normalizeWorkflowPlugin(plugin, null);
int updated = pluginMapper.update(plugin);
if (updated <= 0) {
throw new BusinessException("更新失败");
}
syncSinglePlugin(existed.getId(), workflow.getId());
return true;
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void syncByWorkflowId(BigInteger workflowId) {
if (workflowId == null) {
return;
}
QueryWrapper wrapper = QueryWrapper.create()
.eq(Plugin::getWorkflowId, workflowId)
.eq(Plugin::getType, PluginType.WORKFLOW.getCode());
List<Plugin> plugins = pluginMapper.selectListByQuery(wrapper);
for (Plugin plugin : plugins) {
syncSinglePlugin(plugin.getId(), workflowId);
}
}
private void syncSinglePlugin(BigInteger pluginId, BigInteger workflowId) {
Plugin plugin = pluginMapper.selectOneById(pluginId);
if (plugin == null) {
return;
}
Workflow workflow = requirePublishedWorkflowForSync(workflowId);
PluginItem pluginItem = getOrCreateSystemTool(pluginId);
workflowPluginSnapshotResolver.syncPluginItemFromPublishedWorkflow(plugin, pluginItem, workflow.getId());
if (pluginItem.getId() == null) {
pluginItem.setCreated(new Date());
if (pluginItemMapper.insert(pluginItem) <= 0) {
throw new BusinessException("同步工作流插件工具失败");
}
} else if (pluginItemMapper.update(pluginItem) <= 0) {
throw new BusinessException("同步工作流插件工具失败");
}
}
private PluginItem getOrCreateSystemTool(BigInteger pluginId) {
QueryWrapper wrapper = QueryWrapper.create().eq(PluginItem::getPluginId, pluginId);
List<PluginItem> pluginItems = pluginItemMapper.selectListByQuery(wrapper);
if (pluginItems == null || pluginItems.isEmpty()) {
PluginItem pluginItem = new PluginItem();
pluginItem.setPluginId(pluginId);
return pluginItem;
}
PluginItem pluginItem = pluginItems.get(0);
if (pluginItems.size() > 1) {
for (int i = 1; i < pluginItems.size(); i++) {
pluginItemMapper.deleteById(pluginItems.get(i).getId());
}
}
return pluginItem;
}
private Workflow requirePublishedWorkflow(BigInteger workflowId, String denyMessage) {
if (workflowId == null) {
throw new BusinessException("请选择已发布工作流");
}
Workflow workflow = workflowService.getById(workflowId);
if (workflow == null) {
throw new BusinessException("工作流不存在");
}
resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE, denyMessage);
PublishStatus publishStatus = PublishStatus.from(workflow.getPublishStatus());
if (publishStatus != PublishStatus.PUBLISHED) {
throw new BusinessException("仅已发布工作流可被封装为插件");
}
if (workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) {
throw new BusinessException("目标工作流缺少已发布快照");
}
workflowPluginSnapshotResolver.assertSupportedForWorkflowPlugin(workflowService.toPublishedView(workflow));
return workflow;
}
private Workflow requirePublishedWorkflowForSync(BigInteger workflowId) {
if (workflowId == null) {
throw new BusinessException("目标工作流不存在");
}
Workflow workflow = workflowService.getById(workflowId);
if (workflow == null) {
throw new BusinessException("目标工作流不存在");
}
PublishStatus publishStatus = PublishStatus.from(workflow.getPublishStatus());
if (publishStatus != PublishStatus.PUBLISHED) {
throw new BusinessException("仅已发布工作流可同步到工作流插件");
}
if (workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) {
throw new BusinessException("目标工作流缺少已发布快照");
}
workflowPluginSnapshotResolver.assertSupportedForWorkflowPlugin(workflowService.toPublishedView(workflow));
return workflow;
}
private void normalizeWorkflowPlugin(Plugin plugin, LoginAccount loginAccount) {
plugin.setType(PluginType.WORKFLOW.getCode());
plugin.setBaseUrl(null);
plugin.setAuthType(null);
plugin.setPosition(null);
plugin.setHeaders(null);
plugin.setTokenKey(null);
plugin.setTokenValue(null);
if (plugin.getCreated() == null) {
plugin.setCreated(new Date());
}
if (loginAccount != null) {
plugin.setCreatedBy(loginAccount.getId().longValue());
plugin.setDeptId(loginAccount.getDeptId() == null ? null : loginAccount.getDeptId().longValue());
plugin.setTenantId(loginAccount.getTenantId() == null ? null : loginAccount.getTenantId().longValue());
}
if (!StringUtils.hasText(plugin.getAlias())) {
plugin.setAlias(null);
}
}
private LoginAccount requireLogin() {
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
if (loginAccount == null || loginAccount.getId() == null) {
throw new BusinessException("当前未登录");
}
return loginAccount;
}
}

View File

@@ -0,0 +1,73 @@
package tech.easyflow.ai.plugin.workflow.dependency;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.vo.OfflineImpactBindingVo;
import java.math.BigInteger;
import java.util.List;
import java.util.Set;
/**
* 工作流插件依赖分析服务。
*/
public interface WorkflowPluginDependencyService {
/**
* 查询被某个工作流引用的插件列表。
*
* @param workflowId 工作流 ID
* @return 插件引用列表
*/
List<OfflineImpactBindingVo> listPluginsByWorkflowId(BigInteger workflowId);
/**
* 解析工作流内容中通过插件间接引用到的工作流 ID。
*
* @param content 工作流内容
* @return 引用到的工作流 ID 集合
*/
Set<String> extractWorkflowIdsFromPluginNodes(String content);
/**
* 判断工作流内容是否引用了指定插件。
*
* @param content 工作流内容
* @param pluginId 插件 ID
* @return 是否引用
*/
boolean containsPluginReference(String content, BigInteger pluginId);
/**
* 判断某个工作流是否经由子流程/工作流插件链路递归引用了指定插件。
*
* @param workflowId 工作流 ID
* @param pluginId 插件 ID
* @return 是否存在递归引用
*/
boolean containsPluginReferenceTransitively(BigInteger workflowId, BigInteger pluginId);
/**
* 判断某个工作流的已发布快照是否经由子流程/工作流插件链路递归引用了指定插件。
*
* @param workflowId 工作流 ID
* @param pluginId 插件 ID
* @return 是否存在递归引用
*/
boolean containsPluginReferenceTransitivelyInPublishedSnapshot(BigInteger workflowId, BigInteger pluginId);
/**
* 查询工作流插件。
*
* @param pluginId 插件 ID
* @return 插件
*/
Plugin getWorkflowPlugin(BigInteger pluginId);
/**
* 根据插件工具 ID 解析目标工作流 ID。
*
* @param pluginItemId 插件工具 ID
* @return 工作流 ID
*/
String resolveWorkflowIdByPluginItemId(String pluginItemId);
}

View File

@@ -0,0 +1,334 @@
package tech.easyflow.ai.plugin.workflow.dependency;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.mapper.PluginItemMapper;
import tech.easyflow.ai.mapper.PluginMapper;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.vo.OfflineImpactBindingVo;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 工作流插件依赖分析实现。
*/
@Service
public class WorkflowPluginDependencyServiceImpl implements WorkflowPluginDependencyService {
private static final String TYPE_PLUGIN = "plugin-node";
private static final String TYPE_WORKFLOW = "workflow-node";
private final PluginMapper pluginMapper;
private final PluginItemMapper pluginItemMapper;
private final WorkflowService workflowService;
public WorkflowPluginDependencyServiceImpl(PluginMapper pluginMapper,
PluginItemMapper pluginItemMapper,
WorkflowService workflowService) {
this.pluginMapper = pluginMapper;
this.pluginItemMapper = pluginItemMapper;
this.workflowService = workflowService;
}
/**
* {@inheritDoc}
*/
@Override
public List<OfflineImpactBindingVo> listPluginsByWorkflowId(BigInteger workflowId) {
if (workflowId == null) {
return Collections.emptyList();
}
QueryWrapper wrapper = QueryWrapper.create().eq(Plugin::getWorkflowId, workflowId);
List<Plugin> plugins = pluginMapper.selectListByQuery(wrapper);
if (plugins == null || plugins.isEmpty()) {
return Collections.emptyList();
}
List<OfflineImpactBindingVo> result = new ArrayList<>(plugins.size());
for (Plugin plugin : plugins) {
if (!PluginType.isWorkflow(plugin.getType())) {
continue;
}
OfflineImpactBindingVo vo = new OfflineImpactBindingVo();
vo.setId(plugin.getId());
vo.setTitle(plugin.getName());
result.add(vo);
}
return result;
}
/**
* {@inheritDoc}
*/
@Override
public Set<String> extractWorkflowIdsFromPluginNodes(String content) {
if (!StringUtils.hasText(content)) {
return Collections.emptySet();
}
Set<String> workflowIds = new LinkedHashSet<>();
try {
Object parsed = JSON.parse(content);
if (!(parsed instanceof JSONObject root)) {
return workflowIds;
}
JSONArray nodes = root.getJSONArray("nodes");
if (nodes == null) {
return workflowIds;
}
for (int i = 0; i < nodes.size(); i++) {
JSONObject node = nodes.getJSONObject(i);
if (node == null || !TYPE_PLUGIN.equals(node.getString("type"))) {
continue;
}
JSONObject data = node.getJSONObject("data");
String pluginItemId = data == null ? null : data.getString("pluginId");
if (!StringUtils.hasText(pluginItemId)) {
continue;
}
Plugin plugin = getPluginByPluginItemId(pluginItemId);
if (plugin == null || !PluginType.isWorkflow(plugin.getType()) || plugin.getWorkflowId() == null) {
continue;
}
workflowIds.add(String.valueOf(plugin.getWorkflowId()));
}
} catch (Exception ignored) {
// ignore
}
return workflowIds;
}
/**
* {@inheritDoc}
*/
@Override
public boolean containsPluginReference(String content, BigInteger pluginId) {
if (!StringUtils.hasText(content) || pluginId == null) {
return false;
}
try {
Object parsed = JSON.parse(content);
if (!(parsed instanceof JSONObject root)) {
return false;
}
JSONArray nodes = root.getJSONArray("nodes");
if (nodes == null) {
return false;
}
String expected = String.valueOf(pluginId);
for (int i = 0; i < nodes.size(); i++) {
JSONObject node = nodes.getJSONObject(i);
if (node == null || !TYPE_PLUGIN.equals(node.getString("type"))) {
continue;
}
JSONObject data = node.getJSONObject("data");
String pluginItemId = data == null ? null : data.getString("pluginId");
Plugin plugin = getPluginByPluginItemId(pluginItemId);
if (plugin != null && expected.equals(String.valueOf(plugin.getId()))) {
return true;
}
}
} catch (Exception ignored) {
// ignore
}
return false;
}
/**
* {@inheritDoc}
*/
@Override
public boolean containsPluginReferenceTransitively(BigInteger workflowId, BigInteger pluginId) {
if (workflowId == null || pluginId == null) {
return false;
}
return containsPluginReferenceTransitively(
String.valueOf(workflowId),
pluginId,
false,
new LinkedHashSet<>(),
new HashMap<>()
);
}
/**
* {@inheritDoc}
*/
@Override
public boolean containsPluginReferenceTransitivelyInPublishedSnapshot(BigInteger workflowId, BigInteger pluginId) {
if (workflowId == null || pluginId == null) {
return false;
}
return containsPluginReferenceTransitively(
String.valueOf(workflowId),
pluginId,
true,
new LinkedHashSet<>(),
new HashMap<>()
);
}
/**
* {@inheritDoc}
*/
@Override
public Plugin getWorkflowPlugin(BigInteger pluginId) {
if (pluginId == null) {
return null;
}
Plugin plugin = pluginMapper.selectOneById(pluginId);
if (plugin == null || !PluginType.isWorkflow(plugin.getType())) {
return null;
}
return plugin;
}
/**
* {@inheritDoc}
*/
@Override
public String resolveWorkflowIdByPluginItemId(String pluginItemId) {
Plugin plugin = getPluginByPluginItemId(pluginItemId);
if (plugin == null || !PluginType.isWorkflow(plugin.getType()) || plugin.getWorkflowId() == null) {
return null;
}
return String.valueOf(plugin.getWorkflowId());
}
private Plugin getPluginByPluginItemId(String pluginItemId) {
PluginItem pluginItem = pluginItemMapper.selectOneById(pluginItemId);
if (pluginItem == null || pluginItem.getPluginId() == null) {
return null;
}
return pluginMapper.selectOneById(pluginItem.getPluginId());
}
private boolean containsPluginReferenceTransitively(String workflowId,
BigInteger pluginId,
boolean publishedOnly,
Set<String> visitingWorkflowIds,
Map<String, Boolean> cache) {
if (!StringUtils.hasText(workflowId)) {
return false;
}
Boolean cached = cache.get(workflowId);
if (cached != null) {
return cached;
}
if (!visitingWorkflowIds.add(workflowId)) {
return false;
}
boolean result = false;
try {
String workflowContent = resolveWorkflowContent(workflowId, publishedOnly);
if (!StringUtils.hasText(workflowContent)) {
cache.put(workflowId, false);
return false;
}
result = containsPluginReferenceTransitivelyInContent(
workflowContent,
pluginId,
publishedOnly,
visitingWorkflowIds,
cache
);
cache.put(workflowId, result);
return result;
} finally {
visitingWorkflowIds.remove(workflowId);
}
}
private boolean containsPluginReferenceTransitivelyInContent(String content,
BigInteger pluginId,
boolean publishedOnly,
Set<String> visitingWorkflowIds,
Map<String, Boolean> cache) {
if (!StringUtils.hasText(content) || pluginId == null) {
return false;
}
try {
Object parsed = JSON.parse(content);
if (!(parsed instanceof JSONObject root)) {
return false;
}
JSONArray nodes = root.getJSONArray("nodes");
if (nodes == null || nodes.isEmpty()) {
return false;
}
String expectedPluginId = String.valueOf(pluginId);
for (int i = 0; i < nodes.size(); i++) {
JSONObject node = nodes.getJSONObject(i);
if (node == null) {
continue;
}
String nodeType = node.getString("type");
JSONObject data = node.getJSONObject("data");
if (TYPE_PLUGIN.equals(nodeType)) {
String pluginItemId = data == null ? null : data.getString("pluginId");
Plugin plugin = getPluginByPluginItemId(pluginItemId);
if (plugin == null) {
continue;
}
if (expectedPluginId.equals(String.valueOf(plugin.getId()))) {
return true;
}
if (PluginType.isWorkflow(plugin.getType())
&& plugin.getWorkflowId() != null
&& containsPluginReferenceTransitively(
String.valueOf(plugin.getWorkflowId()),
pluginId,
publishedOnly,
visitingWorkflowIds,
cache
)) {
return true;
}
continue;
}
if (TYPE_WORKFLOW.equals(nodeType)) {
String refWorkflowId = data == null ? null : data.getString("workflowId");
if (containsPluginReferenceTransitively(
refWorkflowId,
pluginId,
publishedOnly,
visitingWorkflowIds,
cache
)) {
return true;
}
}
}
} catch (Exception ignored) {
// ignore
}
return false;
}
private String resolveWorkflowContent(String workflowId, boolean publishedOnly) {
tech.easyflow.ai.entity.Workflow workflow = publishedOnly
? getPublishedWorkflow(workflowId)
: workflowService.getById(workflowId);
return workflow == null ? null : workflow.getContent();
}
private tech.easyflow.ai.entity.Workflow getPublishedWorkflow(String workflowId) {
try {
return workflowService.getPublishedById(new BigInteger(workflowId));
} catch (Exception ignored) {
return null;
}
}
}

View File

@@ -0,0 +1,231 @@
package tech.easyflow.ai.plugin.workflow.snapshot;
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.Node;
import com.easyagents.flow.core.node.EndNode;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
import tech.easyflow.ai.easyagents.tool.WorkflowTool;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.service.WorkflowService;
import com.easyagents.flow.core.parser.ChainParser;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.List;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* 工作流插件快照解析服务。
*/
@Service
public class WorkflowPluginSnapshotResolver {
private final WorkflowService workflowService;
private final ChainParser chainParser;
private final WorkflowDatacenterContentService workflowDatacenterContentService;
public WorkflowPluginSnapshotResolver(WorkflowService workflowService,
ChainParser chainParser,
WorkflowDatacenterContentService workflowDatacenterContentService) {
this.workflowService = workflowService;
this.chainParser = chainParser;
this.workflowDatacenterContentService = workflowDatacenterContentService;
}
/**
* 使用已发布视图刷新工作流插件工具定义。
*
* @param plugin 插件
* @param pluginItem 插件工具
* @param workflowId 工作流 ID
*/
public void syncPluginItemFromPublishedWorkflow(Plugin plugin, PluginItem pluginItem, java.math.BigInteger workflowId) {
Workflow workflow = workflowService.getPublishedById(workflowId);
if (workflow == null) {
throw new BusinessException("工作流不存在");
}
assertSupportedForWorkflowPlugin(workflow);
ChainDefinition definition = parseDefinition(workflow);
pluginItem.setPluginId(plugin.getId());
pluginItem.setName(plugin.getName());
pluginItem.setEnglishName(workflow.getEnglishName());
pluginItem.setDescription(resolveDescription(plugin, workflow));
pluginItem.setBasePath(null);
pluginItem.setRequestMethod("WORKFLOW");
JSONArray inputDefinitions = resolveInputDefinitions(definition);
JSONArray outputDefinitions = resolveOutputDefinitions(definition);
pluginItem.setInputData(JSON.toJSONString(inputDefinitions));
pluginItem.setOutputData(JSON.toJSONString(outputDefinitions));
pluginItem.setSchemaHash(resolveSchemaHash(inputDefinitions, outputDefinitions));
pluginItem.setServiceStatus(1);
pluginItem.setStatus(1);
}
/**
* 构建工作流插件运行工具。
*
* @param workflow 工作流已发布视图
* @return 工作流工具
*/
public WorkflowTool buildWorkflowTool(Workflow workflow) {
return new WorkflowTool(
workflow,
false,
PublishedWorkflowDefinitionIds.published(String.valueOf(workflow.getId()))
);
}
/**
* 解析输入参数定义。
*
* @param definition 流程定义
* @return 输入参数数组
*/
public JSONArray resolveInputDefinitions(ChainDefinition definition) {
JSONArray inputs = JSON.parseArray(JSON.toJSONString(definition.getStartParameters()));
markWorkflowPluginInput(inputs);
return inputs == null ? new JSONArray() : inputs;
}
/**
* 解析输出参数定义。
*
* @param definition 流程定义
* @return 输出参数数组
*/
public JSONArray resolveOutputDefinitions(ChainDefinition definition) {
JSONArray outputs = new JSONArray();
List<Node> nodes = definition.getNodes();
if (nodes == null) {
return outputs;
}
for (Node node : nodes) {
if (node instanceof EndNode endNode) {
outputs = JSON.parseArray(JSON.toJSONString(endNode.getOutputDefs()));
}
}
return outputs == null ? new JSONArray() : outputs;
}
/**
* 解析工作流插件契约哈希。
*
* @param workflow 工作流
* @return 哈希值
*/
public String resolveSchemaHash(Workflow workflow) {
ChainDefinition definition = parseDefinition(workflow);
return resolveSchemaHash(resolveInputDefinitions(definition), resolveOutputDefinitions(definition));
}
/**
* 解析工作流插件契约哈希。
*
* @param inputDefinitions 输入定义
* @param outputDefinitions 输出定义
* @return 哈希值
*/
public String resolveSchemaHash(JSONArray inputDefinitions, JSONArray outputDefinitions) {
JSONObject payload = new JSONObject();
payload.put("inputs", inputDefinitions == null ? new JSONArray() : inputDefinitions);
payload.put("outputs", outputDefinitions == null ? new JSONArray() : outputDefinitions);
return sha256Hex(JSON.toJSONString(payload));
}
/**
* 校验工作流是否支持被封装为工作流插件。
*
* @param workflow 工作流
*/
public void assertSupportedForWorkflowPlugin(Workflow workflow) {
ChainDefinition definition = parseDefinition(workflow);
int endNodeCount = countEndNodes(definition);
// 一期先收敛为“单结束节点才能封装为插件”,后续若要支持多结束节点,
// 需要先补齐统一输出契约、父流程节点 schema 同步和结果展示策略。
if (endNodeCount != 1) {
throw new BusinessException("工作流插件仅支持单一结束节点,当前工作流不可封装为插件");
}
}
/**
* 判断工作流是否为单结束节点结构。
*
* @param workflow 工作流
* @return 单结束节点返回 true
*/
public boolean isSupportedForWorkflowPlugin(Workflow workflow) {
ChainDefinition definition = parseDefinition(workflow);
return countEndNodes(definition) == 1;
}
/**
* 解析已发布工作流定义。
*
* @param workflow 工作流已发布视图
* @return 流程定义
*/
public ChainDefinition parseDefinition(Workflow workflow) {
String preparedContent = workflowDatacenterContentService.prepareContent(workflow.getContent());
return chainParser.parse(preparedContent);
}
private String resolveDescription(Plugin plugin, Workflow workflow) {
if (plugin.getDescription() != null && !plugin.getDescription().isBlank()) {
return plugin.getDescription();
}
return workflow.getDescription();
}
private void markWorkflowPluginInput(JSONArray parameters) {
if (parameters == null) {
return;
}
for (Object parameter : parameters) {
if (!(parameter instanceof com.alibaba.fastjson2.JSONObject obj)) {
continue;
}
obj.put("refType", "ref");
JSONArray children = obj.getJSONArray("children");
if (children != null) {
markWorkflowPluginInput(children);
}
}
}
private int countEndNodes(ChainDefinition definition) {
List<Node> nodes = definition == null ? null : definition.getNodes();
if (nodes == null || nodes.isEmpty()) {
return 0;
}
int count = 0;
for (Node node : nodes) {
if (node instanceof EndNode) {
count++;
}
}
return count;
}
private String sha256Hex(String source) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(source.getBytes(StandardCharsets.UTF_8));
StringBuilder builder = new StringBuilder(bytes.length * 2);
for (byte current : bytes) {
builder.append(Character.forDigit((current >> 4) & 0xF, 16));
builder.append(Character.forDigit(current & 0xF, 16));
}
return builder.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 algorithm unavailable", e);
}
}
}

View File

@@ -6,6 +6,8 @@ import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.BotWorkflow;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.plugin.workflow.binding.WorkflowPluginBindingService;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.BotWorkflowService;
import tech.easyflow.ai.service.ResourceOfflineImpactService;
import tech.easyflow.ai.service.WorkflowService;
@@ -31,18 +33,24 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
private final ResourceAccessService resourceAccessService;
private final BotWorkflowService botWorkflowService;
private final ResourceOfflineImpactService resourceOfflineImpactService;
private final WorkflowPluginBindingService workflowPluginBindingService;
private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
public WorkflowApprovalSubjectHandler(WorkflowService workflowService,
ResourceAccessService resourceAccessService,
ApprovalInstanceService approvalInstanceService,
BotWorkflowService botWorkflowService,
ResourceOfflineImpactService resourceOfflineImpactService,
WorkflowPluginBindingService workflowPluginBindingService,
WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver,
ObjectMapper objectMapper) {
super(approvalInstanceService, objectMapper);
this.workflowService = workflowService;
this.resourceAccessService = resourceAccessService;
this.botWorkflowService = botWorkflowService;
this.resourceOfflineImpactService = resourceOfflineImpactService;
this.workflowPluginBindingService = workflowPluginBindingService;
this.workflowPluginSnapshotResolver = workflowPluginSnapshotResolver;
}
@Override
@@ -116,6 +124,16 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
return snapshot;
}
@Override
protected Map<String, Object> buildPublishSnapshot(Workflow resource, PublishStatus currentStatus) {
Map<String, Object> snapshot = super.buildPublishSnapshot(resource, currentStatus);
OfflineImpactCheckVo impact = resourceOfflineImpactService.checkWorkflowImpact(resource.getId());
if (impact.isHasPluginBindings()) {
workflowPluginSnapshotResolver.assertSupportedForWorkflowPlugin(resource);
}
return snapshot;
}
@Override
protected void persistResourceState(BigInteger resourceId, PublishStatus publishStatus, BigInteger currentApprovalInstanceId) {
Workflow update = new Workflow();
@@ -135,6 +153,7 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
update.setPublishedAt(new java.util.Date());
update.setPublishedBy(operatorId);
workflowService.updateById(update);
workflowPluginBindingService.syncByWorkflowId(resourceId);
}
@Override
@@ -162,6 +181,9 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
if (impact.isHasBotBindings()) {
snapshot.put("botBindings", impact.getBotBindings());
}
if (impact.isHasPluginBindings()) {
snapshot.put("pluginBindings", impact.getPluginBindings());
}
}
@Override

View File

@@ -28,4 +28,12 @@ public interface PluginItemService extends IService<PluginItem> {
Result pluginToolTest(String inputData, BigInteger pluginToolId);
List<PluginItem> getByPluginId(String id);
/**
* 获取某个插件的系统维护工具。
*
* @param pluginId 插件 ID
* @return 工具
*/
PluginItem getSingleByPluginId(BigInteger pluginId);
}

View File

@@ -23,4 +23,22 @@ public interface PluginService extends IService<Plugin> {
Result pageByCategory(Long pageNumber, Long pageSize, int category);
boolean updatePlugin(Plugin plugin);
/**
* 按当前用户视角过滤并补充工作流插件可用性信息。
*
* @param plugins 插件列表
* @param managementView 是否为管理视角
* @param availableOnly 是否仅保留当前可用插件
* @return 过滤后的插件列表
*/
List<Plugin> preparePluginsForCurrentUser(List<Plugin> plugins, boolean managementView, boolean availableOnly);
/**
* 补充单个插件的工作流可用性信息。
*
* @param plugin 插件
* @return 原插件
*/
Plugin preparePluginForCurrentUser(Plugin plugin);
}

View File

@@ -31,6 +31,7 @@ import tech.easyflow.ai.easyagents.memory.RuntimeChatMemory;
import tech.easyflow.ai.easyagents.tool.WorkflowTool;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.*;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.mapper.BotMapper;
import tech.easyflow.ai.service.*;
@@ -117,6 +118,8 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
@Resource
private BotPluginService botPluginService;
@Resource
private PluginService pluginService;
@Resource
private PluginItemService pluginItemService;
@Resource
private BotMcpService botMcpService;
@@ -508,6 +511,12 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
List<PluginItem> pluginItems = pluginItemService.getMapper().selectListWithRelationsByQuery(queryTool);
if (pluginItems != null && !pluginItems.isEmpty()) {
for (PluginItem pluginItem : pluginItems) {
if (pluginItem.getPluginId() != null) {
Plugin plugin = pluginService.getById(pluginItem.getPluginId());
if (plugin != null && PluginType.isWorkflow(plugin.getType())) {
continue;
}
}
functionList.add(pluginItem.toFunction());
}
}

View File

@@ -1,15 +1,21 @@
package tech.easyflow.ai.service.impl;
import cn.hutool.core.collection.CollectionUtil;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginCategory;
import tech.easyflow.ai.entity.PluginCategoryMapping;
import tech.easyflow.ai.mapper.PluginCategoryMapper;
import tech.easyflow.ai.mapper.PluginCategoryMappingMapper;
import tech.easyflow.ai.mapper.PluginMapper;
import tech.easyflow.ai.service.PluginCategoryMappingService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.service.CategoryPermissionService;
import javax.annotation.Resource;
import java.math.BigInteger;
@@ -34,6 +40,12 @@ public class PluginCategoryMappingServiceImpl extends ServiceImpl<PluginCategory
@Resource
private PluginCategoryMapper pluginCategoryMapper;
@Resource
private PluginMapper pluginMapper;
@Resource
private PluginVisibilityService pluginVisibilityService;
@Resource
private CategoryPermissionService categoryPermissionService;
@Override
@Transactional
@@ -41,6 +53,11 @@ public class PluginCategoryMappingServiceImpl extends ServiceImpl<PluginCategory
if (pluginId == null) {
throw new BusinessException("插件ID不能为空");
}
Plugin plugin = pluginMapper.selectOneById(pluginId);
if (plugin == null) {
throw new BusinessException("插件不存在");
}
pluginVisibilityService.assertPluginVisible(plugin.getCreatedBy(), pluginId, "无权限访问插件");
List<BigInteger> targetCategoryIds = categoryIds == null
? Collections.emptyList()
@@ -48,6 +65,8 @@ public class PluginCategoryMappingServiceImpl extends ServiceImpl<PluginCategory
.filter(java.util.Objects::nonNull)
.distinct()
.collect(Collectors.toList());
assertCategoryIdsValid(targetCategoryIds);
assertCategoryAccess(targetCategoryIds);
QueryWrapper currentRelationQuery = QueryWrapper.create().select("category_id")
.from("tb_plugin_category_mapping")
@@ -80,6 +99,37 @@ public class PluginCategoryMappingServiceImpl extends ServiceImpl<PluginCategory
return true;
}
private void assertCategoryIdsValid(List<BigInteger> targetCategoryIds) {
if (CollectionUtil.isEmpty(targetCategoryIds)) {
return;
}
QueryWrapper queryWrapper = QueryWrapper.create()
.select(PluginCategory::getId)
.in(PluginCategory::getId, targetCategoryIds);
List<BigInteger> existedCategoryIds = pluginCategoryMapper.selectListByQueryAs(queryWrapper, BigInteger.class);
if (existedCategoryIds == null || existedCategoryIds.size() != new LinkedHashSet<>(targetCategoryIds).size()) {
throw new BusinessException("存在无效的插件分类");
}
}
private void assertCategoryAccess(List<BigInteger> targetCategoryIds) {
if (CollectionUtil.isEmpty(targetCategoryIds)) {
return;
}
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess("PLUGIN");
if (!access.isRestricted()) {
return;
}
Set<BigInteger> allowedCategoryIds = access.getCategoryIds();
if (CollectionUtil.isEmpty(allowedCategoryIds)) {
throw new BusinessException("无权限关联所选插件分类");
}
boolean allAllowed = targetCategoryIds.stream().allMatch(allowedCategoryIds::contains);
if (!allAllowed) {
throw new BusinessException("无权限关联所选插件分类");
}
}
@Override
public List<PluginCategory> getPluginCategories(BigInteger pluginId) {
QueryWrapper categoryQueryWrapper = QueryWrapper.create().select("category_id")

View File

@@ -5,14 +5,22 @@ import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.entity.BotPlugin;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.easyagents.tool.PluginTool;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityDecision;
import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityService;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.mapper.BotPluginMapper;
import tech.easyflow.ai.mapper.PluginMapper;
import tech.easyflow.ai.mapper.PluginItemMapper;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.common.domain.Result;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.domain.Result;
import javax.annotation.Resource;
import java.math.BigInteger;
@@ -37,9 +45,19 @@ public class PluginItemServiceImpl extends ServiceImpl<PluginItemMapper, PluginI
@Resource
private BotPluginMapper botPluginMapper;
@Resource
private WorkflowPluginAvailabilityService workflowPluginAvailabilityService;
@Resource
private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
@Resource
private WorkflowService workflowService;
@Override
public boolean savePluginTool(PluginItem pluginItem) {
Plugin plugin = pluginMapper.selectOneById(pluginItem.getPluginId());
if (plugin != null && PluginType.isWorkflow(plugin.getType())) {
throw new BusinessException("工作流插件工具由系统自动维护,不支持手动新增");
}
pluginItem.setCreated(new Date());
pluginItem.setRequestMethod("Post");
int insert = pluginItemMapper.insert(pluginItem);
@@ -61,14 +79,20 @@ public class PluginItemServiceImpl extends ServiceImpl<PluginItemMapper, PluginI
.select()
.eq(Plugin::getId, pluginItem.getPluginId());
Plugin plugin = pluginMapper.selectOneByQuery(queryAiPluginWrapper);
plugin = preparePluginForCurrentUser(plugin);
Map<String, Object> result = new HashMap<>();
result.put("data", pluginItem);
result.put("aiPlugin", plugin);
result.put("workflowSnapshot", buildWorkflowSnapshot(plugin));
return Result.ok(result);
}
@Override
public boolean updatePlugin(PluginItem pluginItem) {
Plugin existedPlugin = resolvePluginByPluginItemId(pluginItem.getId());
if (existedPlugin != null && PluginType.isWorkflow(existedPlugin.getType())) {
throw new BusinessException("工作流插件工具由系统自动维护,不支持手动修改");
}
int update = pluginItemMapper.update(pluginItem);
if (update <= 0) {
throw new BusinessException("修改失败");
@@ -113,10 +137,37 @@ public class PluginItemServiceImpl extends ServiceImpl<PluginItemMapper, PluginI
@Override
public Result<?> pluginToolTest(String inputData, BigInteger pluginToolId) {
PluginItem pluginItem = new PluginItem();
pluginItem.setId(pluginToolId);
pluginItem.setInputData(inputData);
PluginTool pluginTool = new PluginTool(pluginItem);
PluginItem pluginItem = pluginItemMapper.selectOneById(pluginToolId);
if (pluginItem == null) {
throw new BusinessException("插件工具不存在");
}
Plugin plugin = pluginMapper.selectOneById(pluginItem.getPluginId());
if (plugin == null) {
throw new BusinessException("插件不存在");
}
plugin = preparePluginForCurrentUser(plugin);
if (PluginType.isWorkflow(plugin.getType())) {
WorkflowPluginAvailabilityDecision decision = workflowPluginAvailabilityService.evaluateForCurrentUser(plugin);
if (!decision.isAvailable()) {
return Result.ok(buildUnavailableResult(decision));
}
Workflow workflow = workflowService.getPublishedById(plugin.getWorkflowId());
if (workflow == null) {
return Result.ok(buildUnavailableResult(decision));
}
Map<String, Object> args = com.alibaba.fastjson2.JSON.parseObject(inputData, Map.class);
Map<String, Object> variables = new LinkedHashMap<>();
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
if (loginAccount != null) {
variables.put(Constants.LOGIN_USER_KEY, loginAccount);
}
if (args != null && !args.isEmpty()) {
variables.putAll(args);
}
Object result = workflowPluginSnapshotResolver.buildWorkflowTool(workflow).invoke(variables);
return Result.ok(result);
}
tech.easyflow.ai.easyagents.tool.PluginTool pluginTool = new tech.easyflow.ai.easyagents.tool.PluginTool(pluginItem);
return Result.ok(pluginTool.runPluginTool(null, inputData, pluginToolId));
}
@@ -129,4 +180,82 @@ public class PluginItemServiceImpl extends ServiceImpl<PluginItemMapper, PluginI
return list(queryWrapper);
}
/**
* {@inheritDoc}
*/
@Override
public PluginItem getSingleByPluginId(BigInteger pluginId) {
if (pluginId == null) {
return null;
}
QueryWrapper queryWrapper = QueryWrapper.create().eq(PluginItem::getPluginId, pluginId);
List<PluginItem> items = pluginItemMapper.selectListByQuery(queryWrapper);
return items == null || items.isEmpty() ? null : items.get(0);
}
/**
* 按当前用户视角补充单个插件的可用性信息。
*
* @param plugin 插件
* @return 已补充可用性信息的插件
*/
private Plugin preparePluginForCurrentUser(Plugin plugin) {
if (plugin == null) {
return null;
}
plugin.setType(PluginType.from(plugin.getType()).getCode());
if (!PluginType.isWorkflow(plugin.getType())) {
plugin.setAvailable(true);
plugin.setReasonCode(null);
plugin.setReasonMessage(null);
return plugin;
}
WorkflowPluginAvailabilityDecision decision = workflowPluginAvailabilityService.evaluateForCurrentUser(plugin);
plugin.setWorkflowTitle(decision.getWorkflowTitle());
plugin.setAvailable(decision.isAvailable());
plugin.setReasonCode(decision.getReasonCode());
plugin.setReasonMessage(decision.getReasonMessage());
return plugin;
}
private Plugin resolvePluginByPluginItemId(BigInteger pluginItemId) {
if (pluginItemId == null) {
return null;
}
PluginItem existed = pluginItemMapper.selectOneById(pluginItemId);
if (existed == null || existed.getPluginId() == null) {
return null;
}
return pluginMapper.selectOneById(existed.getPluginId());
}
private Map<String, Object> buildUnavailableResult(WorkflowPluginAvailabilityDecision decision) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("skipped", true);
result.put("reasonCode", decision.getReasonCode());
result.put("reasonMessage", decision.getReasonMessage());
return result;
}
/**
* 构建工作流插件对应的已发布快照摘要。
*
* @param plugin 插件
* @return 快照摘要,不存在时返回 {@code null}
*/
private Map<String, Object> buildWorkflowSnapshot(Plugin plugin) {
if (plugin == null || !PluginType.isWorkflow(plugin.getType()) || plugin.getWorkflowId() == null) {
return null;
}
Workflow workflow = workflowService.getPublishedById(plugin.getWorkflowId());
if (workflow == null) {
return null;
}
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("title", workflow.getTitle());
snapshot.put("description", workflow.getDescription());
snapshot.put("content", workflow.getContent());
return snapshot;
}
}

View File

@@ -9,14 +9,20 @@ import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.ai.entity.*;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.mapper.PluginCategoryMappingMapper;
import tech.easyflow.ai.mapper.PluginMapper;
import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityDecision;
import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityService;
import tech.easyflow.ai.plugin.workflow.binding.WorkflowPluginBindingService;
import tech.easyflow.ai.service.BotPluginService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.PluginService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.service.CategoryPermissionService;
@@ -60,9 +66,18 @@ public class PluginServiceImpl extends ServiceImpl<PluginMapper, Plugin> impleme
private CategoryPermissionService categoryPermissionService;
@Resource
private PluginVisibilityService pluginVisibilityService;
@Resource
private WorkflowPluginBindingService workflowPluginBindingService;
@Resource
private WorkflowPluginAvailabilityService workflowPluginAvailabilityService;
@Override
public Plugin savePlugin(Plugin plugin) {
PluginType pluginType = PluginType.from(plugin.getType());
if (pluginType == PluginType.WORKFLOW) {
return workflowPluginBindingService.saveWorkflowPlugin(plugin);
}
normalizeHttpPlugin(plugin, true);
plugin.setCreated(new Date());
int insert = pluginMapper.insert(plugin);
if (insert <= 0) {
@@ -143,23 +158,89 @@ public class PluginServiceImpl extends ServiceImpl<PluginMapper, Plugin> impleme
return Result.ok(new Page<>(Collections.emptyList(), pageNumber, pageSize, 0L));
}
List<Plugin> totalPlugins = preparePluginsForCurrentUser(queryPluginsByIds(visiblePluginIds), true, false);
int fromIndex = Math.max(0, Math.toIntExact((pageNumber - 1) * pageSize));
if (fromIndex >= visiblePluginIds.size()) {
return Result.ok(new Page<>(Collections.emptyList(), pageNumber, pageSize, visiblePluginIds.size()));
if (fromIndex >= totalPlugins.size()) {
return Result.ok(new Page<>(Collections.emptyList(), pageNumber, pageSize, totalPlugins.size()));
}
int toIndex = Math.min(visiblePluginIds.size(), Math.toIntExact(fromIndex + pageSize));
List<BigInteger> currentPagePluginIds = new ArrayList<>(visiblePluginIds.subList(fromIndex, toIndex));
List<Plugin> plugins = queryPluginsByIds(currentPagePluginIds);
Page<Plugin> aiPluginPage = new Page<>(plugins, pageNumber, pageSize, visiblePluginIds.size());
int toIndex = Math.min(totalPlugins.size(), Math.toIntExact(fromIndex + pageSize));
Page<Plugin> aiPluginPage = new Page<>(new ArrayList<>(totalPlugins.subList(fromIndex, toIndex)),
pageNumber, pageSize, totalPlugins.size());
return Result.ok(aiPluginPage);
}
@Override
public boolean updatePlugin(Plugin plugin) {
Plugin existed = pluginMapper.selectOneById(plugin.getId());
if (existed == null) {
throw new BusinessException("插件不存在");
}
PluginType existedType = PluginType.from(existed.getType());
PluginType targetType = PluginType.from(plugin.getType() == null ? existed.getType() : plugin.getType());
if (existedType != targetType) {
throw new BusinessException("暂不支持切换插件类型");
}
if (targetType == PluginType.WORKFLOW) {
return workflowPluginBindingService.updateWorkflowPlugin(plugin);
}
normalizeHttpPlugin(plugin, false);
pluginMapper.update(plugin);
return true;
}
/**
* {@inheritDoc}
*/
@Override
public List<Plugin> preparePluginsForCurrentUser(List<Plugin> plugins, boolean managementView, boolean availableOnly) {
if (plugins == null || plugins.isEmpty()) {
return Collections.emptyList();
}
List<Plugin> result = new ArrayList<>();
for (Plugin plugin : plugins) {
Plugin prepared = preparePluginForCurrentUser(plugin);
if (!PluginType.isWorkflow(prepared.getType())) {
result.add(prepared);
continue;
}
boolean canKeepUnavailable = managementView && workflowPluginAvailabilityService.canViewUnavailableInManagement(prepared);
if (Boolean.TRUE.equals(prepared.getAvailable())) {
result.add(prepared);
continue;
}
if (availableOnly) {
continue;
}
if (canKeepUnavailable) {
result.add(prepared);
}
}
return result;
}
/**
* {@inheritDoc}
*/
@Override
public Plugin preparePluginForCurrentUser(Plugin plugin) {
if (plugin == null) {
return null;
}
plugin.setType(PluginType.from(plugin.getType()).getCode());
if (!PluginType.isWorkflow(plugin.getType())) {
plugin.setAvailable(true);
plugin.setReasonCode(null);
plugin.setReasonMessage(null);
return plugin;
}
WorkflowPluginAvailabilityDecision decision = workflowPluginAvailabilityService.evaluateForCurrentUser(plugin);
plugin.setWorkflowTitle(decision.getWorkflowTitle());
plugin.setAvailable(decision.isAvailable());
plugin.setReasonCode(decision.getReasonCode());
plugin.setReasonMessage(decision.getReasonMessage());
return plugin;
}
private List<BigInteger> queryCreatorPluginIds(List<BigInteger> pluginIds, Long creatorId) {
if (CollectionUtil.isEmpty(pluginIds) || creatorId == null) {
return Collections.emptyList();
@@ -175,7 +256,7 @@ public class PluginServiceImpl extends ServiceImpl<PluginMapper, Plugin> impleme
return Collections.emptyList();
}
QueryWrapper queryPluginWrapper = QueryWrapper.create().select().in(Plugin::getId, pluginIds);
List<Plugin> plugins = pluginMapper.selectListByQuery(queryPluginWrapper);
List<Plugin> plugins = pluginMapper.selectListWithRelationsByQuery(queryPluginWrapper);
Map<BigInteger, Plugin> pluginMap = plugins.stream().collect(Collectors.toMap(
Plugin::getId,
item -> item,
@@ -192,5 +273,24 @@ public class PluginServiceImpl extends ServiceImpl<PluginMapper, Plugin> impleme
return orderedPlugins;
}
/**
* 归一化 HTTP 插件基础字段。
*
* @param plugin 插件
* @param isSave 是否为创建
*/
private void normalizeHttpPlugin(Plugin plugin, boolean isSave) {
plugin.setType(PluginType.HTTP.getCode());
plugin.setWorkflowId(null);
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
if (isSave && loginAccount != null && loginAccount.getId() != null) {
plugin.setCreatedBy(loginAccount.getId().longValue());
plugin.setDeptId(loginAccount.getDeptId() == null ? null : loginAccount.getDeptId().longValue());
plugin.setTenantId(loginAccount.getTenantId() == null ? null : loginAccount.getTenantId().longValue());
}
if (plugin.getHeaders() != null && !(plugin.getHeaders() instanceof String)) {
plugin.setHeaders(plugin.getHeaders().toString());
}
}
}

View File

@@ -11,6 +11,7 @@ import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.entity.BotDocumentCollection;
import tech.easyflow.ai.entity.BotWorkflow;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.plugin.workflow.dependency.WorkflowPluginDependencyService;
import tech.easyflow.ai.service.BotDocumentCollectionService;
import tech.easyflow.ai.service.BotService;
import tech.easyflow.ai.service.BotWorkflowService;
@@ -49,17 +50,20 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe
private final BotService botService;
private final WorkflowService workflowService;
private final RedisLockExecutor redisLockExecutor;
private final WorkflowPluginDependencyService workflowPluginDependencyService;
public ResourceOfflineImpactServiceImpl(BotWorkflowService botWorkflowService,
BotDocumentCollectionService botDocumentCollectionService,
BotService botService,
WorkflowService workflowService,
RedisLockExecutor redisLockExecutor) {
RedisLockExecutor redisLockExecutor,
WorkflowPluginDependencyService workflowPluginDependencyService) {
this.botWorkflowService = botWorkflowService;
this.botDocumentCollectionService = botDocumentCollectionService;
this.botService = botService;
this.workflowService = workflowService;
this.redisLockExecutor = redisLockExecutor;
this.workflowPluginDependencyService = workflowPluginDependencyService;
}
/**
@@ -68,15 +72,16 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe
@Override
public OfflineImpactCheckVo checkWorkflowImpact(BigInteger workflowId) {
List<OfflineImpactBindingVo> botBindings = listBotsByWorkflowId(workflowId);
List<OfflineImpactBindingVo> pluginBindings = workflowPluginDependencyService.listPluginsByWorkflowId(workflowId);
OfflineImpactCheckVo result = new OfflineImpactCheckVo();
result.setCanProceed(true);
result.setBotBindings(botBindings);
result.setHasBotBindings(!botBindings.isEmpty());
result.setPluginBindings(pluginBindings);
result.setHasPluginBindings(!pluginBindings.isEmpty());
result.setWorkflowUsages(Collections.emptyList());
result.setHasWorkflowUsages(false);
result.setMessage(botBindings.isEmpty()
? "当前工作流下线后不会影响已有绑定"
: "当前工作流下线成功后,将自动从相关聊天助手中解绑");
result.setMessage(resolveWorkflowOfflineImpactMessage(botBindings, pluginBindings));
return result;
}
@@ -198,6 +203,20 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe
return result;
}
private String resolveWorkflowOfflineImpactMessage(List<OfflineImpactBindingVo> botBindings,
List<OfflineImpactBindingVo> pluginBindings) {
if (!pluginBindings.isEmpty() && !botBindings.isEmpty()) {
return "当前工作流被插件和聊天助手引用,下线后插件将不可用,聊天助手将自动解绑";
}
if (!pluginBindings.isEmpty()) {
return "当前工作流被插件引用,下线后相关插件将不可用";
}
if (!botBindings.isEmpty()) {
return "当前工作流下线成功后,将自动从相关聊天助手中解绑";
}
return "当前工作流下线后不会影响已有绑定";
}
private boolean containsKnowledgeReference(String content, BigInteger knowledgeId) {
if (!StringUtils.hasText(content) || knowledgeId == null) {
return false;

View File

@@ -14,10 +14,14 @@ public class OfflineImpactCheckVo {
private boolean hasWorkflowUsages;
private boolean hasPluginBindings;
private List<OfflineImpactBindingVo> botBindings = new ArrayList<>();
private List<OfflineImpactBindingVo> workflowUsages = new ArrayList<>();
private List<OfflineImpactBindingVo> pluginBindings = new ArrayList<>();
private String message;
/**
@@ -110,6 +114,22 @@ public class OfflineImpactCheckVo {
this.workflowUsages = workflowUsages;
}
public boolean isHasPluginBindings() {
return hasPluginBindings;
}
public void setHasPluginBindings(boolean hasPluginBindings) {
this.hasPluginBindings = hasPluginBindings;
}
public List<OfflineImpactBindingVo> getPluginBindings() {
return pluginBindings;
}
public void setPluginBindings(List<OfflineImpactBindingVo> pluginBindings) {
this.pluginBindings = pluginBindings;
}
/**
* 获取提示信息。
*

View File

@@ -0,0 +1,252 @@
package tech.easyflow.ai.plugin.workflow.dependency;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.mapper.PluginItemMapper;
import tech.easyflow.ai.mapper.PluginMapper;
import tech.easyflow.ai.service.WorkflowService;
import java.lang.reflect.Proxy;
import java.math.BigInteger;
import java.util.HashMap;
import java.util.Map;
public class WorkflowPluginDependencyServiceImplTest {
@Test
public void testPublishedCheckShouldIgnoreUnpublishedDraftCycle() {
WorkflowPluginDependencyServiceImpl service = newService(
workflows(
workflowVariant(rootWorkflowContent("2"), rootWorkflowContent("2")),
workflowVariant(pluginWorkflowContent("700"), terminalWorkflowContent())
),
plugins(),
pluginItems()
);
Assert.assertTrue(service.containsPluginReferenceTransitively(BigInteger.ONE, BigInteger.valueOf(900)));
Assert.assertFalse(service.containsPluginReferenceTransitivelyInPublishedSnapshot(BigInteger.ONE, BigInteger.valueOf(900)));
}
@Test
public void testPublishedCheckShouldBlockPublishedCycleEvenWhenDraftClean() {
WorkflowPluginDependencyServiceImpl service = newService(
workflows(
workflowVariant(rootWorkflowContent("2"), rootWorkflowContent("2")),
workflowVariant(terminalWorkflowContent(), pluginWorkflowContent("700"))
),
plugins(),
pluginItems()
);
Assert.assertFalse(service.containsPluginReferenceTransitively(BigInteger.ONE, BigInteger.valueOf(900)));
Assert.assertTrue(service.containsPluginReferenceTransitivelyInPublishedSnapshot(BigInteger.ONE, BigInteger.valueOf(900)));
}
private static WorkflowPluginDependencyServiceImpl newService(Map<String, WorkflowVariant> workflowStore,
Map<String, Plugin> pluginStore,
Map<String, PluginItem> pluginItemStore) {
return new WorkflowPluginDependencyServiceImpl(
mockPluginMapper(pluginStore),
mockPluginItemMapper(pluginItemStore),
mockWorkflowService(workflowStore)
);
}
private static Map<String, WorkflowVariant> workflows(WorkflowVariant root, WorkflowVariant child) {
Map<String, WorkflowVariant> workflows = new HashMap<>();
workflows.put("1", root);
workflows.put("2", child);
return workflows;
}
private static Map<String, Plugin> plugins() {
Map<String, Plugin> plugins = new HashMap<>();
Plugin plugin = new Plugin();
plugin.setId(BigInteger.valueOf(900));
plugin.setType(PluginType.WORKFLOW.getCode());
plugin.setWorkflowId(BigInteger.valueOf(30));
plugins.put("900", plugin);
return plugins;
}
private static Map<String, PluginItem> pluginItems() {
Map<String, PluginItem> pluginItems = new HashMap<>();
PluginItem pluginItem = new PluginItem();
pluginItem.setId(BigInteger.valueOf(700));
pluginItem.setPluginId(BigInteger.valueOf(900));
pluginItems.put("700", pluginItem);
return pluginItems;
}
private static WorkflowService mockWorkflowService(Map<String, WorkflowVariant> workflowStore) {
return (WorkflowService) Proxy.newProxyInstance(
WorkflowService.class.getClassLoader(),
new Class[]{WorkflowService.class},
(proxy, method, args) -> {
String methodName = method.getName();
if ("getById".equals(methodName)) {
return buildWorkflow(workflowStore, args == null ? null : args[0], false);
}
if ("getPublishedById".equals(methodName)) {
return buildWorkflow(workflowStore, args == null ? null : args[0], true);
}
if ("equals".equals(methodName)) {
return proxy == args[0];
}
if ("hashCode".equals(methodName)) {
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;
}
);
}
private static PluginMapper mockPluginMapper(Map<String, Plugin> pluginStore) {
return (PluginMapper) Proxy.newProxyInstance(
PluginMapper.class.getClassLoader(),
new Class[]{PluginMapper.class},
(proxy, method, args) -> {
if ("selectOneById".equals(method.getName())) {
return pluginStore.get(String.valueOf(args[0]));
}
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;
}
);
}
private static PluginItemMapper mockPluginItemMapper(Map<String, PluginItem> pluginItemStore) {
return (PluginItemMapper) Proxy.newProxyInstance(
PluginItemMapper.class.getClassLoader(),
new Class[]{PluginItemMapper.class},
(proxy, method, args) -> {
if ("selectOneById".equals(method.getName())) {
return pluginItemStore.get(String.valueOf(args[0]));
}
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;
}
);
}
private static Workflow buildWorkflow(Map<String, WorkflowVariant> workflowStore, Object idValue, boolean published) {
if (idValue == null) {
return null;
}
WorkflowVariant variant = workflowStore.get(String.valueOf(idValue));
if (variant == null) {
return null;
}
Workflow workflow = new Workflow();
try {
workflow.setId(new BigInteger(String.valueOf(idValue)));
} catch (Exception ignored) {
workflow.setId(null);
}
workflow.setContent(published ? variant.publishedContent : variant.draftContent);
return workflow;
}
private static WorkflowVariant workflowVariant(String draftContent, String publishedContent) {
WorkflowVariant variant = new WorkflowVariant();
variant.draftContent = draftContent;
variant.publishedContent = publishedContent;
return variant;
}
private static String rootWorkflowContent(String childWorkflowId) {
return workflowJson(array(workflowNode("wf-1", childWorkflowId)));
}
private static String pluginWorkflowContent(String pluginItemId) {
return workflowJson(array(pluginNode("plugin-1", pluginItemId)));
}
private static String terminalWorkflowContent() {
return workflowJson(new JSONArray());
}
private static String workflowJson(JSONArray nodes) {
JSONObject root = new JSONObject();
root.put("nodes", nodes);
root.put("edges", new JSONArray());
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 workflowNode(String id, String workflowId) {
JSONObject data = new JSONObject();
data.put("workflowId", workflowId);
return node(id, "workflow-node", data);
}
private static JSONObject pluginNode(String id, String pluginItemId) {
JSONObject data = new JSONObject();
data.put("pluginId", pluginItemId);
return node(id, "plugin-node", data);
}
private static JSONObject node(String id, String type, JSONObject data) {
JSONObject node = new JSONObject();
node.put("id", id);
node.put("type", type);
node.put("data", data);
return node;
}
private static class WorkflowVariant {
private String draftContent;
private String publishedContent;
}
}

View File

@@ -1,5 +1,6 @@
package tech.easyflow.system.service;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import java.math.BigInteger;
@@ -9,11 +10,17 @@ public interface CategoryPermissionService {
boolean isCurrentSuperAdmin();
boolean isSuperAdmin(LoginAccount loginAccount);
RoleCategoryAccessSnapshot getCurrentAccess(String resourceType);
RoleCategoryAccessSnapshot getAccess(String resourceType, LoginAccount loginAccount);
Set<BigInteger> getCurrentVisibleCategoryIds(String resourceType);
boolean canAccessCategory(String resourceType, BigInteger createdBy, BigInteger categoryId);
boolean canAccessCategory(LoginAccount loginAccount, String resourceType, BigInteger createdBy, BigInteger categoryId);
void assertCategoryResourceVisible(String resourceType, BigInteger createdBy, BigInteger categoryId, String message);
}

View File

@@ -1,5 +1,6 @@
package tech.easyflow.system.service;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.permission.resource.VisibilityResource;
@@ -8,5 +9,7 @@ public interface ResourceAccessService {
boolean canAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action);
boolean canAccess(LoginAccount loginAccount, CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action);
void assertAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action, String message);
}

View File

@@ -44,6 +44,11 @@ public class CategoryPermissionServiceImpl implements CategoryPermissionService
return false;
}
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
return isSuperAdmin(loginAccount);
}
@Override
public boolean isSuperAdmin(LoginAccount loginAccount) {
return loginAccount != null && isSuperAdmin(loginAccount.getId());
}
@@ -53,6 +58,11 @@ public class CategoryPermissionServiceImpl implements CategoryPermissionService
return new RoleCategoryAccessSnapshot(resourceType, null, false, true, Collections.emptySet());
}
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
return getAccess(resourceType, loginAccount);
}
@Override
public RoleCategoryAccessSnapshot getAccess(String resourceType, LoginAccount loginAccount) {
if (loginAccount == null) {
return new RoleCategoryAccessSnapshot(resourceType, null, false, true, Collections.emptySet());
}
@@ -100,6 +110,12 @@ public class CategoryPermissionServiceImpl implements CategoryPermissionService
return snapshot.canAccess(createdBy, categoryId);
}
@Override
public boolean canAccessCategory(LoginAccount loginAccount, String resourceType, BigInteger createdBy, BigInteger categoryId) {
RoleCategoryAccessSnapshot snapshot = getAccess(resourceType, loginAccount);
return snapshot.canAccess(createdBy, categoryId);
}
@Override
public void assertCategoryResourceVisible(String resourceType, BigInteger createdBy, BigInteger categoryId, String message) {
if (!canAccessCategory(resourceType, createdBy, categoryId)) {

View File

@@ -26,15 +26,19 @@ public class ResourceAccessServiceImpl implements ResourceAccessService {
@Override
public boolean canAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action) {
return canAccess(SaTokenUtil.getLoginAccount(), resourceType, resource, action);
}
@Override
public boolean canAccess(LoginAccount loginAccount, CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action) {
if (resource == null) {
return false;
}
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
if (loginAccount == null || loginAccount.getId() == null) {
return false;
}
BigInteger accountId = loginAccount.getId();
if (categoryPermissionService.isCurrentSuperAdmin()) {
if (categoryPermissionService.isSuperAdmin(loginAccount)) {
return true;
}
if (accountId.equals(resource.getCreatedBy())) {
@@ -43,7 +47,7 @@ public class ResourceAccessServiceImpl implements ResourceAccessService {
if (ResourceAction.MANAGE == action) {
return false;
}
if (!categoryPermissionService.canAccessCategory(resourceType.getCode(), resource.getCreatedBy(), resource.getCategoryId())) {
if (!categoryPermissionService.canAccessCategory(loginAccount, resourceType.getCode(), resource.getCreatedBy(), resource.getCategoryId())) {
return false;
}
VisibilityScope scope = VisibilityScope.fromOrDefault(resource.getVisibilityScope(), VisibilityScope.PRIVATE);

View File

@@ -0,0 +1,2 @@
ALTER TABLE tb_plugin_item
ADD COLUMN schema_hash varchar(128) NULL COMMENT '工作流插件输入输出契约哈希' AFTER english_name;

View File

@@ -0,0 +1,8 @@
ALTER TABLE `tb_plugin`
ADD COLUMN `workflow_id` bigint NULL DEFAULT NULL COMMENT '绑定工作流ID' AFTER `type`;
UPDATE `tb_plugin`
SET `type` = 1
WHERE `type` IS NULL;
CREATE INDEX `idx_tb_plugin_workflow_id` ON `tb_plugin` (`workflow_id`);

View File

@@ -75,6 +75,9 @@
"submitDeleteApprovalConfirm": "Delete the current workflow?",
"offlineImpactBoundBotsIntro": "This workflow is currently bound to the following bots:",
"offlineImpactBoundBotsFooter": "After the workflow goes offline, the system will automatically remove it from these bots.",
"offlineImpactBoundPluginsIntro": "This workflow is currently bound to the following plugins:",
"offlineImpactBoundPluginsFooter": "After offline approval succeeds, these plugins will automatically become unavailable and show the reason in plugin management.",
"offlineImpactBoundMixedFooter": "After offline approval succeeds, the system will remove the workflow from bots and mark the related plugins as unavailable.",
"publishPendingHint": "There is already an approval in progress for this workflow.",
"deletePendingHint": "There is already an approval in progress for this workflow.",
"check": "Check",

View File

@@ -5,7 +5,11 @@
"name": "Name",
"description": "Description",
"type": "Type",
"typeHttp": "HTTP Plugin",
"typeWorkflow": "Workflow Plugin",
"baseUrl": "BaseUrl",
"workflowId": "Bound workflow",
"workflowTitle": "Workflow title",
"authType": "AuthType",
"created": "Created",
"icon": "Icon",
@@ -20,7 +24,8 @@
"placeholder": {
"name": "Please enter plugin name",
"description": "Please enter plugin description",
"categorize": "Please enter categorize"
"categorize": "Please enter categorize",
"workflow": "Please select a published workflow"
},
"button": {
"addPlugin": "Add Plugin",
@@ -29,5 +34,11 @@
},
"toolsManagement": "Tools Management",
"searchUsers": "Search Users",
"parameterValue": "ParameterValue"
"parameterValue": "ParameterValue",
"workflow": "Workflow",
"workflowPluginHint": "Workflow plugins mirror the published snapshot of the target workflow. Availability is evaluated in real time against workflow permissions and approval status.",
"workflowPluginUnavailable": "This workflow plugin is unavailable",
"workflowSnapshotSynced": "Published snapshot synced",
"reasonMessage": "Reason",
"onlyPublishedWorkflow": "Only published workflows that you can currently access are selectable."
}

View File

@@ -13,6 +13,7 @@
"debugStatus": "DebugStatus",
"englishName": "EnglishName",
"createPluginTool": "Create tool",
"systemManaged": "System synced",
"pluginToolEdit": {
"basicInfo": "Basic Info",
"configureInputParameters": "Configure input parameters",
@@ -21,11 +22,16 @@
"toolPath": "Tool path",
"requestMethod": "RequestMethod",
"runResult": "Run result",
"run": "run"
"run": "run",
"workflowTarget": "Target workflow",
"unavailableHint": "The bound workflow is currently unavailable, so execution will not be started.",
"runWorkflowStepsEmpty": "After starting a trial run, each node execution result will be shown here.",
"workflowStepsPending": "The trial run has started. Waiting for node execution details..."
},
"parameterName": "Name",
"parameterDescription": "Description",
"parameterType": "Type",
"direction": "Direction",
"inputMethod": "InputMethod",
"required": "Required",
"defaultValue": "DefaultValue",

View File

@@ -75,6 +75,9 @@
"submitDeleteApprovalConfirm": "确认删除当前工作流吗?",
"offlineImpactBoundBotsIntro": "当前工作流被以下聊天助手绑定:",
"offlineImpactBoundBotsFooter": "下线成功后,系统会自动从这些聊天助手中解绑该工作流。",
"offlineImpactBoundPluginsIntro": "当前工作流被以下插件绑定:",
"offlineImpactBoundPluginsFooter": "下线审批通过后,这些插件会自动变为不可用,并在插件页展示对应原因。",
"offlineImpactBoundMixedFooter": "下线审批通过后,系统会自动从聊天助手中解绑该工作流,同时让相关插件进入不可用状态。",
"publishPendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
"deletePendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
"check": "检查",

View File

@@ -5,7 +5,11 @@
"name": "名称",
"description": "描述",
"type": "类型",
"typeHttp": "HTTP 插件",
"typeWorkflow": "工作流插件",
"baseUrl": "基础URL",
"workflowId": "绑定工作流",
"workflowTitle": "工作流名称",
"authType": "认证方式",
"created": "创建时间",
"icon": "图标地址",
@@ -20,7 +24,8 @@
"placeholder": {
"name": "请输入插件名称",
"description": "请输入插件描述",
"categorize": "请选择分类"
"categorize": "请选择分类",
"workflow": "请选择已发布工作流"
},
"button": {
"addPlugin": "新增插件",
@@ -29,5 +34,11 @@
},
"toolsManagement": "工具管理",
"searchUsers": "搜索用户",
"parameterValue": "参数值"
"parameterValue": "参数值",
"workflow": "工作流",
"workflowPluginHint": "工作流插件会自动镜像目标工作流的已发布快照,插件可用性会实时跟随工作流权限和审批状态变化。",
"workflowPluginUnavailable": "当前工作流插件不可用",
"workflowSnapshotSynced": "已同步发布快照",
"reasonMessage": "不可用原因",
"onlyPublishedWorkflow": "仅支持选择已发布且当前可访问的工作流。"
}

View File

@@ -13,6 +13,7 @@
"debugStatus": "调试状态【0失败 1成功】",
"englishName": "英文名称",
"createPluginTool": "创建工具",
"systemManaged": "系统同步",
"pluginToolEdit": {
"basicInfo": "基本信息",
"configureInputParameters": "配置输入参数",
@@ -21,11 +22,16 @@
"toolPath": "工具路径",
"requestMethod": "请求方法",
"runResult": "运行结果",
"run": "运行"
"run": "运行",
"workflowTarget": "目标工作流",
"unavailableHint": "当前绑定工作流不可用,本次不会发起执行。",
"runWorkflowStepsEmpty": "开始试运行后,这里会展示每个节点的执行结果。",
"workflowStepsPending": "试运行已发起,正在等待节点执行信息..."
},
"parameterName": "参数名称",
"parameterDescription": "参数描述",
"parameterType": "参数类型",
"direction": "方向",
"inputMethod": "传入方法",
"required": "是否必填",
"defaultValue": "默认值",

View File

@@ -1,12 +1,14 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { EasyFlowFormModal } from '@easyflow/common-ui';
import { Plus, Remove } from '@element-plus/icons-vue';
import {
ElAlert,
ElButton,
ElForm,
ElFormItem,
ElIcon,
@@ -22,15 +24,28 @@ import { api } from '#/api/request';
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
import { $t } from '#/locales';
const emit = defineEmits(['reload']);
const embeddingLlmList = ref<any>([]);
const rerankerLlmList = ref<any>([]);
const categoryList = ref<any[]>([]);
interface headersType {
interface HeaderItem {
label: string;
value: string;
}
const authTypeList = ref<headersType[]>([
interface WorkflowCandidate {
id: string;
title: string;
}
const emit = defineEmits(['reload']);
const saveForm = ref<FormInstance>();
const dialogVisible = ref(false);
const isAdd = ref(true);
const btnLoading = ref(false);
const categoryList = ref<any[]>([]);
const workflowCandidates = ref<WorkflowCandidate[]>([]);
const tempAddHeaders = ref<HeaderItem[]>([]);
const entity = ref<any>(createDefaultEntity());
const authTypeList = [
{
label: 'None',
value: 'none',
@@ -39,79 +54,211 @@ const authTypeList = ref<headersType[]>([
label: 'Service token / ApiKey',
value: 'apiKey',
},
]);
onMounted(() => {
api.get('/api/v1/plugin/modelList?supportEmbed=true').then((res) => {
embeddingLlmList.value = res.data;
});
api
.get('/api/v1/plugin/modelList?supportRerankerLlmList=true')
.then((res) => {
rerankerLlmList.value = res.data;
});
api.get('/api/v1/pluginCategory/list').then((res) => {
if (res.errorCode === 0) {
categoryList.value = res.data;
}
});
});
defineExpose({
openDialog,
});
const saveForm = ref<FormInstance>();
// variables
const dialogVisible = ref(false);
const isAdd = ref(true);
const tempAddHeaders = ref<headersType[]>([]);
const entity = ref<any>({
alias: '',
categoryIds: [],
deptId: '',
icon: '',
title: '',
authType: 'none',
description: '',
englishName: '',
headers: '',
position: '',
});
const btnLoading = ref(false);
const rules = ref({
];
const pluginTypeOptions = [
{
label: $t('plugin.typeHttp'),
value: 1,
},
{
label: $t('plugin.typeWorkflow'),
value: 2,
},
];
const isWorkflowType = computed(
() => Number(entity.value.type || 1) === 2,
);
const rules = computed<FormRules>(() => ({
name: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
description: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
baseUrl: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
{
validator: (_rule, value, callback) => {
if (!isWorkflowType.value && !value) {
callback(new Error($t('message.required')));
return;
}
callback();
},
trigger: 'blur',
},
],
workflowId: [
{
validator: (_rule, value, callback) => {
if (isWorkflowType.value && !value) {
callback(new Error($t('message.required')));
return;
}
callback();
},
trigger: 'change',
},
],
authType: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
tokenKey: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
tokenValue: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
{
validator: (_rule, value, callback) => {
if (!isWorkflowType.value && !value) {
callback(new Error($t('message.required')));
return;
}
callback();
},
trigger: 'change',
},
],
position: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
{
validator: (_rule, value, callback) => {
if (
!isWorkflowType.value &&
entity.value.authType === 'apiKey' &&
!value
) {
callback(new Error($t('message.required')));
return;
}
callback();
},
trigger: 'change',
},
],
tokenKey: [
{
validator: (_rule, value, callback) => {
if (
!isWorkflowType.value &&
entity.value.authType === 'apiKey' &&
!value
) {
callback(new Error($t('message.required')));
return;
}
callback();
},
trigger: 'blur',
},
],
tokenValue: [
{
validator: (_rule, value, callback) => {
if (
!isWorkflowType.value &&
entity.value.authType === 'apiKey' &&
!value
) {
callback(new Error($t('message.required')));
return;
}
callback();
},
trigger: 'blur',
},
],
}));
watch(
() => entity.value.type,
(type) => {
if (Number(type || 1) === 2) {
entity.value.baseUrl = '';
entity.value.authType = 'none';
entity.value.position = '';
entity.value.tokenKey = '';
entity.value.tokenValue = '';
tempAddHeaders.value = [];
return;
}
entity.value.workflowId = '';
entity.value.workflowTitle = '';
entity.value.available = true;
entity.value.reasonMessage = '';
},
);
onMounted(async () => {
await Promise.all([loadCategories(), loadWorkflowCandidates()]);
});
// functions
defineExpose({
openDialog,
});
function createDefaultEntity() {
return {
alias: '',
authType: 'none',
available: true,
baseUrl: '',
categoryIds: [],
deptId: '',
description: '',
englishName: '',
headers: '',
icon: '',
name: '',
pluginType: 1,
position: '',
reasonMessage: '',
title: '',
tokenKey: '',
tokenValue: '',
type: 1,
workflowId: '',
workflowTitle: '',
};
}
async function loadCategories() {
const res = await api.get('/api/v1/pluginCategory/list');
if (res.errorCode === 0) {
categoryList.value = res.data;
}
}
async function loadWorkflowCandidates(keyword: string = '') {
const res = await api.get('/api/v1/plugin/workflowCandidates', {
params: {
keyword,
},
});
if (res.errorCode === 0) {
workflowCandidates.value = Array.isArray(res.data) ? res.data : [];
ensureCurrentWorkflowOption();
}
}
function ensureCurrentWorkflowOption() {
if (!entity.value.workflowId || !entity.value.workflowTitle) {
return;
}
const exists = workflowCandidates.value.some(
(item) => String(item.id) === String(entity.value.workflowId),
);
if (!exists) {
workflowCandidates.value.unshift({
id: entity.value.workflowId,
title: entity.value.workflowTitle,
});
}
}
function openDialog(row: any) {
tempAddHeaders.value = [];
if (row.id) {
isAdd.value = false;
if (row.headers) {
tempAddHeaders.value = JSON.parse(row.headers);
}
}
tempAddHeaders.value = row.headers ? JSON.parse(row.headers) : [];
isAdd.value = !row.id;
entity.value = {
...createDefaultEntity(),
...row,
authType: row.authType || 'none',
categoryIds: row.categoryIds?.map((item: any) => item.id) || [],
type: Number(row.type || row.pluginType || 1),
};
ensureCurrentWorkflowOption();
dialogVisible.value = true;
}
@@ -131,83 +278,86 @@ async function syncPluginCategories(pluginId: string, categoryIds: string[]) {
}
}
function save() {
saveForm.value?.validate((valid) => {
if (valid) {
btnLoading.value = true;
function normalizePayload() {
const plainEntity = { ...entity.value };
const plainHeaders = [...tempAddHeaders.value];
const categoryIds = [...(plainEntity.categoryIds || [])];
delete plainEntity.categoryIds;
if (isAdd.value) {
api
.post('/api/v1/plugin/plugin/save', {
if (isWorkflowType.value) {
plainEntity.baseUrl = '';
plainEntity.headers = [];
plainEntity.authType = 'none';
plainEntity.position = '';
plainEntity.tokenKey = '';
plainEntity.tokenValue = '';
}
return {
payload: {
...plainEntity,
headers: plainHeaders,
})
.then(async (res) => {
if (res.errorCode === 0) {
const pluginId =
res.data?.id || plainEntity.id || entity.value.id;
headers: isWorkflowType.value ? [] : [...tempAddHeaders.value],
},
categoryIds,
};
}
function save() {
saveForm.value?.validate(async (valid) => {
if (!valid) {
return;
}
btnLoading.value = true;
const { payload, categoryIds } = normalizePayload();
const requestUrl = isAdd.value
? '/api/v1/plugin/plugin/save'
: '/api/v1/plugin/plugin/update';
try {
const res = await api.post(requestUrl, payload);
if (res.errorCode !== 0) {
ElMessage.error(res.message);
return;
}
const pluginId = res.data?.id || payload.id || entity.value.id;
if (!pluginId) {
throw new Error('插件保存成功但未返回插件ID');
}
await syncPluginCategories(pluginId, categoryIds);
dialogVisible.value = false;
ElMessage.success($t('message.saveOkMessage'));
ElMessage.success(
isAdd.value ? $t('message.saveOkMessage') : $t('message.updateOkMessage'),
);
emit('reload');
} else {
ElMessage.error(res.message);
}
})
.catch((error) => {
} catch (error: any) {
ElMessage.error(error?.message || $t('message.saveFailMessage'));
})
.finally(() => {
} finally {
btnLoading.value = false;
});
} else {
api
.post('/api/v1/plugin/plugin/update', {
...plainEntity,
headers: plainHeaders,
})
.then(async (res) => {
if (res.errorCode === 0) {
await syncPluginCategories(entity.value.id, categoryIds);
dialogVisible.value = false;
ElMessage.success($t('message.updateOkMessage'));
emit('reload');
} else {
ElMessage.error(res.message);
}
})
.catch((error) => {
ElMessage.error(error?.message || $t('message.saveFailMessage'));
})
.finally(() => {
btnLoading.value = false;
});
}
}
});
}
function closeDialog() {
saveForm.value?.resetFields();
isAdd.value = true;
tempAddHeaders.value = [];
entity.value = {};
entity.value = createDefaultEntity();
dialogVisible.value = false;
}
function addHeader() {
tempAddHeaders.value.push({
label: '',
value: '',
});
}
function removeHeader(index: number) {
tempAddHeaders.value.splice(index, 1);
}
function handleWorkflowChange(value: string) {
const target = workflowCandidates.value.find(
(item) => String(item.id) === String(value),
);
entity.value.workflowTitle = target?.title || '';
}
</script>
<template>
@@ -237,15 +387,22 @@ function removeHeader(index: number) {
>
<UploadAvatar v-model="entity.icon" />
</ElFormItem>
<ElFormItem prop="type" :label="$t('plugin.type')">
<ElSelect v-model="entity.type" :disabled="!isAdd">
<ElOption
v-for="item in pluginTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem prop="name" :label="$t('plugin.name')">
<ElInput
v-model.trim="entity.name"
:placeholder="$t('plugin.placeholder.name')"
/>
</ElFormItem>
<ElFormItem prop="baseUrl" :label="$t('plugin.baseUrl')">
<ElInput v-model.trim="entity.baseUrl" />
</ElFormItem>
<ElFormItem prop="description" :label="$t('plugin.description')">
<ElInput
v-model.trim="entity.description"
@@ -270,21 +427,67 @@ function removeHeader(index: number) {
/>
</ElSelect>
</ElFormItem>
<template v-if="isWorkflowType">
<ElAlert
type="info"
:closable="false"
show-icon
class="mb-4"
:title="$t('plugin.workflowPluginHint')"
/>
<ElFormItem prop="workflowId" :label="$t('plugin.workflowId')">
<ElSelect
v-model="entity.workflowId"
filterable
remote
reserve-keyword
:remote-method="loadWorkflowCandidates"
:placeholder="$t('plugin.placeholder.workflow')"
@change="handleWorkflowChange"
>
<ElOption
v-for="item in workflowCandidates"
:key="item.id"
:label="item.title"
:value="item.id"
/>
</ElSelect>
<div class="form-helper-text">
{{ $t('plugin.onlyPublishedWorkflow') }}
</div>
</ElFormItem>
<ElAlert
v-if="!entity.available && entity.reasonMessage"
type="warning"
:closable="false"
show-icon
:title="$t('plugin.workflowPluginUnavailable')"
:description="entity.reasonMessage"
/>
</template>
<template v-else>
<ElFormItem prop="baseUrl" :label="$t('plugin.baseUrl')">
<ElInput v-model.trim="entity.baseUrl" />
</ElFormItem>
<ElFormItem prop="Headers" label="Headers">
<div
class="headers-container-reduce flex flex-row gap-4"
v-for="(item, index) in tempAddHeaders"
:key="index"
class="headers-container-reduce flex flex-row gap-4"
>
<div class="head-con-content flex flex-row gap-4">
<ElInput v-model.trim="item.label" placeholder="header name" />
<ElInput v-model.trim="item.value" placeholder="header value" />
<ElIcon size="20" @click="removeHeader" style="cursor: pointer">
<ElIcon
size="20"
style="cursor: pointer"
@click="removeHeader(index)"
>
<Remove />
</ElIcon>
</div>
</div>
<ElButton @click="addHeader" class="addHeadersBtn">
<ElButton class="addHeadersBtn" @click="addHeader">
<ElIcon size="18" style="margin-right: 4px">
<Plus />
</ElIcon>
@@ -302,9 +505,9 @@ function removeHeader(index: number) {
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="entity.authType === 'apiKey'"
prop="position"
:label="$t('plugin.position')"
v-if="entity.authType === 'apiKey'"
>
<ElRadioGroup v-model="entity.position">
<ElRadio value="headers">headers</ElRadio>
@@ -312,19 +515,20 @@ function removeHeader(index: number) {
</ElRadioGroup>
</ElFormItem>
<ElFormItem
v-if="entity.authType === 'apiKey'"
prop="tokenKey"
:label="$t('plugin.tokenKey')"
v-if="entity.authType === 'apiKey'"
>
<ElInput v-model.trim="entity.tokenKey" />
</ElFormItem>
<ElFormItem
v-if="entity.authType === 'apiKey'"
prop="tokenValue"
:label="$t('plugin.tokenValue')"
v-if="entity.authType === 'apiKey'"
>
<ElInput v-model.trim="entity.tokenValue" />
</ElFormItem>
</template>
</ElForm>
</EasyFlowFormModal>
</template>
@@ -346,4 +550,11 @@ function removeHeader(index: number) {
align-items: center;
margin-bottom: 8px;
}
.form-helper-text {
margin-top: 6px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -17,6 +17,7 @@ import {
ElInput,
ElMessage,
ElMessageBox,
ElTag,
} from 'element-plus';
import { api } from '#/api/request';
@@ -94,6 +95,10 @@ const actions: ActionButton[] = [
},
},
];
const pluginTypeTagMap = {
1: $t('plugin.typeHttp'),
2: $t('plugin.typeWorkflow'),
};
const categoryList = ref<PluginCategory[]>([]);
const controlBtns = [
{
@@ -265,11 +270,26 @@ const handleClickCategory = (item: PluginCategory) => {
title-field="title"
icon-field="icon"
desc-field="description"
tag-field="type"
:tag-map="pluginTypeTagMap"
:data="pageList"
:primary-action="primaryAction"
:actions="actions"
:default-icon="defaultPluginIcon"
/>
>
<template #corner="{ item }">
<ElTag
v-if="item.type === 2 && item.available === false"
type="danger"
effect="light"
round
>
{{
item.reasonMessage || $t('plugin.workflowPluginUnavailable')
}}
</ElTag>
</template>
</CardPage>
</template>
</PageData>
</div>

View File

@@ -9,12 +9,14 @@ const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
editable: false,
isEditOutput: false,
payloadMode: 'plugin',
});
const emit = defineEmits<Emits>();
export interface TreeTableNode {
key: string;
id?: string;
name: string;
description: string;
method?: 'Body' | 'Header' | 'Path' | 'Query';
@@ -29,6 +31,7 @@ interface Props {
modelValue?: TreeTableNode[];
editable?: boolean;
isEditOutput?: boolean;
payloadMode?: 'plugin' | 'workflow';
}
const data = ref<TreeTableNode[]>([]);
@@ -48,8 +51,12 @@ watch(
);
// 计算缩进宽度
const getNodeKey = (record?: Partial<TreeTableNode>): string => {
return String(record?.key || record?.id || '');
};
const getIndentWidth = (record: TreeTableNode): number => {
const level = String(record.key).split('-').length - 1;
const level = getNodeKey(record).split('-').length - 1;
const indentSize = 20;
return level > 0 ? level * indentSize : 0;
};
@@ -65,7 +72,7 @@ const onExpand = (_row: TreeTableNode, expandedRows: TreeTableNode[]) => {
};
// 验证字段
const validateFields = (): boolean => {
const validatePluginFields = (): boolean => {
const newErrors: Record<
string,
Partial<Record<keyof TreeTableNode, string>>
@@ -75,6 +82,7 @@ const validateFields = (): boolean => {
const checkNode = (node: TreeTableNode): boolean => {
const { name, description, method, type } = node;
const nodeErrors: Partial<Record<keyof TreeTableNode, string>> = {};
const nodeKey = getNodeKey(node);
if (!name?.trim()) {
nodeErrors.name = $t('message.cannotBeEmpty.name');
@@ -96,8 +104,8 @@ const validateFields = (): boolean => {
isValid = false;
}
if (Object.keys(nodeErrors).length > 0) {
newErrors[node.key] = nodeErrors;
if (nodeKey && Object.keys(nodeErrors).length > 0) {
newErrors[nodeKey] = nodeErrors;
}
if (node.children) {
@@ -117,17 +125,118 @@ const validateFields = (): boolean => {
return isValid;
};
const isBlankValue = (value: unknown): boolean => {
if (value === null || value === undefined) {
return true;
}
if (typeof value === 'string') {
return value.trim().length === 0;
}
if (Array.isArray(value)) {
return value.length === 0;
}
if (typeof value === 'object') {
return Object.keys(value as Record<string, unknown>).length === 0;
}
return false;
};
const normalizeNodeType = (node: TreeTableNode): string => {
return String(node.type || '').trim();
};
const parseWorkflowNodeValue = (node: TreeTableNode): any => {
const type = normalizeNodeType(node);
if (node.children?.length) {
if (type === 'Object') {
return buildWorkflowPayload(node.children);
}
if (type.includes('Array')) {
return parseArrayValue(node);
}
}
if (type.includes('Array')) {
return parseArrayValue(node);
}
return node.defaultValue;
};
const parseArrayValue = (node: TreeTableNode): any[] => {
if (Array.isArray(node.defaultValue as any)) {
return node.defaultValue as any[];
}
const raw = String(node.defaultValue || '').trim();
if (!raw) {
return [];
}
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [parsed];
} catch {
return [raw];
}
};
const buildWorkflowPayload = (nodes: TreeTableNode[]): Record<string, any> => {
const payload: Record<string, any> = {};
for (const node of nodes) {
if (!node?.name) {
continue;
}
payload[node.name] = parseWorkflowNodeValue(node);
}
return payload;
};
const validateWorkflowFields = (): boolean => {
const newErrors: Record<
string,
Partial<Record<keyof TreeTableNode, string>>
> = {};
let isValid = true;
const checkNode = (node: TreeTableNode) => {
const nodeKey = getNodeKey(node);
const nodeErrors: Partial<Record<keyof TreeTableNode, string>> = {};
const value = parseWorkflowNodeValue(node);
if (node.required && isBlankValue(value)) {
nodeErrors.defaultValue = $t('message.required');
isValid = false;
}
if (nodeKey && Object.keys(nodeErrors).length > 0) {
newErrors[nodeKey] = nodeErrors;
}
node.children?.forEach(checkNode);
};
data.value.forEach((node) => {
checkNode(node);
});
errors.value = newErrors;
return isValid;
};
// 判断是否为根节点
const isRootNode = (record: TreeTableNode): boolean => {
return !record.key.includes('-');
return !getNodeKey(record).includes('-');
};
// 提交参数
const handleSubmitParams = () => {
if (!validateFields()) {
const valid =
props.payloadMode === 'workflow'
? validateWorkflowFields()
: validatePluginFields();
if (valid !== true) {
ElMessage.error($t('message.completeForm'));
return;
}
if (props.payloadMode === 'workflow') {
return buildWorkflowPayload(data.value);
}
return data.value;
};
@@ -145,7 +254,7 @@ interface Emits {
<div class="tree-table-container">
<ElTable
:data="data"
row-key="key"
:row-key="getNodeKey"
:border="true"
size="default"
:expand-row-keys="expandedKeys"
@@ -160,7 +269,7 @@ interface Emits {
<template #default="{ row }">
<div class="name-cell">
<div
v-if="!props.editable"
v-if="!props.editable || props.payloadMode === 'workflow'"
:style="{ paddingLeft: `${getIndentWidth(row)}px` }"
>
{{ row.name || '' }}
@@ -175,11 +284,11 @@ interface Emits {
/>
</div>
<div
v-if="errors[row.key]?.name"
v-if="errors[getNodeKey(row)]?.name"
class="error-message"
:style="{ marginLeft: `${getIndentWidth(row)}px` }"
>
{{ errors[row.key]?.name }}
{{ errors[getNodeKey(row)]?.name }}
</div>
</div>
</div>
@@ -195,12 +304,19 @@ interface Emits {
<template #default="{ row }">
<span v-if="row.type === 'Object'"></span>
<span v-else-if="!props.editable">{{ row.defaultValue || '' }}</span>
<div v-else class="value-input-wrapper">
<ElInput
v-else
v-model="row.defaultValue"
@input="handleDataChange"
:disabled="!props.editable"
/>
<div
v-if="errors[getNodeKey(row)]?.defaultValue"
class="error-message"
>
{{ errors[getNodeKey(row)]?.defaultValue }}
</div>
</div>
</template>
</ElTableColumn>
</ElTable>
@@ -243,6 +359,12 @@ interface Emits {
color: #ff4d4f;
}
.value-input-wrapper {
display: flex;
flex-direction: column;
gap: 2px;
}
.action-buttons .el-button {
display: flex;
align-items: center;

View File

@@ -1,16 +1,18 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { nextTick, onUnmounted, ref, watch } from 'vue';
import { EasyFlowPanelModal } from '@easyflow/common-ui';
import { $t } from '@easyflow/locales';
import { preferences } from '@easyflow/preferences';
import { sortNodes } from '@easyflow/utils';
import { VideoPlay } from '@element-plus/icons-vue';
import { ElButton, ElMenu, ElMenuItem } from 'element-plus';
import { ElAlert, ElButton, ElMenu, ElMenuItem, ElMessage } from 'element-plus';
import { JsonViewer } from 'vue3-json-viewer';
import { api } from '#/api/request';
import PluginRunParams from '#/views/ai/plugin/PluginRunParams.vue';
import WorkflowSteps from '#/views/ai/workflow/components/WorkflowSteps.vue';
import 'vue3-json-viewer/dist/vue3-json-viewer.css';
@@ -26,42 +28,159 @@ watch(
},
);
const dialogVisible = ref(false);
const openDialog = () => {
getPluginToolInfo();
runResultResponse.value = null;
dialogVisible.value = true;
};
const runTitle = ref('');
const runResult = ref('');
const inputDataParams = ref<any>(null);
const runResultResponse = ref<any>(null);
function getPluginToolInfo() {
api
.post('/api/v1/pluginItem/tool/search', {
const runParamsRef = ref();
const runLoading = ref(false);
const pluginAvailable = ref(true);
const pluginReasonMessage = ref('');
const isWorkflowPlugin = ref(false);
const workflowId = ref<null | string>(null);
const workflowSnapshot = ref<any>(null);
const workflowNodeJson = ref<any[]>([]);
const pollingNodes = ref<any[]>([]);
const executeId = ref('');
const pollingData = ref<any>({ nodes: {} });
const initSignal = ref(false);
const pollingTimer = ref<null | ReturnType<typeof setInterval>>(null);
const activeIndex = ref('1');
const dialogContentKey = ref(0);
const dialogPreparing = ref(false);
defineExpose({
openDialog,
});
async function openDialog() {
if (dialogPreparing.value) {
return;
}
dialogPreparing.value = true;
resetDialogState();
const ready = await getPluginToolInfo();
dialogPreparing.value = false;
if (!ready) {
return;
}
dialogContentKey.value += 1;
await nextTick();
dialogVisible.value = true;
}
function resetExecutionState() {
stopPolling();
runResultResponse.value = null;
runLoading.value = false;
executeId.value = '';
pollingData.value = { nodes: {} };
initSignal.value = !initSignal.value;
activeIndex.value = '1';
}
function resetDialogState() {
resetExecutionState();
runTitle.value = '';
runResult.value = '';
inputDataParams.value = [];
pluginAvailable.value = true;
pluginReasonMessage.value = '';
isWorkflowPlugin.value = false;
workflowId.value = null;
workflowSnapshot.value = null;
workflowNodeJson.value = [];
pollingNodes.value = [];
}
async function getPluginToolInfo() {
try {
const res = await api.post('/api/v1/pluginItem/tool/search', {
aiPluginToolId: props.pluginToolId,
})
.then((res) => {
if (res.errorCode === 0) {
});
if (res.errorCode !== 0 || !res.data) {
ElMessage.error(res?.message || '加载试运行信息失败');
return false;
}
runTitle.value = `${res.data.aiPlugin.title} - ${res.data.data.name} ${$t(
'pluginItem.inputData',
)}`;
runResult.value = `${$t('pluginItem.pluginToolEdit.runResult')}`;
inputDataParams.value = JSON.parse(res.data.data.inputData || '[]');
pluginAvailable.value = res.data.aiPlugin?.available !== false;
pluginReasonMessage.value = res.data.aiPlugin?.reasonMessage || '';
isWorkflowPlugin.value = Number(res.data.aiPlugin?.type || 1) === 2;
workflowId.value = res.data.aiPlugin?.workflowId
? String(res.data.aiPlugin.workflowId)
: null;
workflowSnapshot.value = res.data.workflowSnapshot || null;
hydrateWorkflowNodes(workflowSnapshot.value);
return true;
} catch (error) {
ElMessage.error(buildErrorResult(error).error);
return false;
}
});
}
const activeIndex = ref('1');
defineExpose({
openDialog,
});
function handleSelect(index: string) {
activeIndex.value = index;
function hydrateWorkflowNodes(snapshot: any) {
workflowNodeJson.value = [];
pollingNodes.value = [];
const content = snapshot?.content;
if (!content) {
return;
}
const runParamsRef = ref();
const runLoading = ref(false);
try {
const workflowContent = JSON.parse(content);
workflowNodeJson.value = sortNodes(workflowContent) || [];
pollingNodes.value = Array.isArray(workflowContent?.nodes)
? workflowContent.nodes.map((node: any) => ({
nodeId: node.id,
nodeName: node?.data?.title || node.id,
}))
: [];
} catch (error) {
console.error('解析工作流插件快照失败', error);
}
}
function buildErrorResult(error: any) {
const responseData = error?.response?.data ?? {};
return {
error:
responseData?.error ||
responseData?.message ||
error?.message ||
'试运行失败',
};
}
function buildUnavailableResult() {
runResultResponse.value = {
skipped: true,
reasonMessage:
pluginReasonMessage.value ||
$t('pluginItem.pluginToolEdit.unavailableHint'),
};
}
function handleSubmitRun() {
if (!pluginAvailable.value) {
buildUnavailableResult();
return;
}
const runParams = runParamsRef.value?.handleSubmitParams?.();
if (runParams === null || runParams === undefined) {
return;
}
if (isWorkflowPlugin.value) {
handleWorkflowSubmit(runParams);
return;
}
handleHttpSubmit(runParams);
}
function handleHttpSubmit(runParams: any) {
runLoading.value = true;
const runParams = runParamsRef.value.handleSubmitParams();
api
.post('/api/v1/pluginItem/test', {
pluginToolId: props.pluginToolId,
@@ -72,9 +191,160 @@ function handleSubmitRun() {
runResultResponse.value = res.data;
activeIndex.value = '2';
}
})
.catch((error) => {
runResultResponse.value = buildErrorResult(error);
})
.finally(() => {
runLoading.value = false;
});
}
function handleWorkflowSubmit(runParams: any) {
if (!workflowId.value) {
runResultResponse.value = {
error: '当前插件未绑定有效工作流,无法试运行。',
};
return;
}
resetExecutionState();
runLoading.value = true;
api
.post('/api/v1/pluginItem/testAsync', {
pluginToolId: props.pluginToolId,
inputData: JSON.stringify(runParams),
})
.then((res) => {
if (res.errorCode === 0 && res.data) {
executeId.value = String(res.data);
pollingData.value = {
executeId: executeId.value,
status: 1,
nodes: {},
};
runResultResponse.value = pollingData.value;
activeIndex.value = '2';
startPolling(executeId.value);
}
})
.catch((error) => {
runResultResponse.value = buildErrorResult(error);
})
.finally(() => {
runLoading.value = false;
});
}
function startPolling(nextExecuteId: string) {
stopPolling();
pollingTimer.value = setInterval(() => {
executePolling(nextExecuteId);
}, 1000);
}
function stopPolling() {
if (pollingTimer.value) {
clearInterval(pollingTimer.value);
pollingTimer.value = null;
}
}
function executePolling(nextExecuteId: string) {
api
.post('/api/v1/pluginItem/testChainStatus', {
executeId: nextExecuteId,
nodes: pollingNodes.value,
})
.then((res) => {
if (res.errorCode !== 0) {
return;
}
const nextData = {
...res.data,
nodes: res.data?.nodes || {},
};
pollingData.value = nextData;
runResultResponse.value = nextData;
if (nextData.status !== 1) {
stopPolling();
}
})
.catch((error) => {
stopPolling();
runResultResponse.value = buildErrorResult(error);
});
}
function resumeChain(payload: any) {
if (!executeId.value) {
return;
}
api
.post('/api/v1/pluginItem/testResume', {
executeId: executeId.value,
confirmParams: payload?.confirmParams || {},
})
.then((res) => {
if (res.errorCode === 0) {
startPolling(executeId.value);
}
})
.catch((error) => {
runResultResponse.value = buildErrorResult(error);
});
}
function showWorkflowSteps() {
return isWorkflowPlugin.value === true;
}
function showWorkflowStepList() {
return showWorkflowSteps();
}
function showWorkflowResultAlert() {
return (
showWorkflowSteps() &&
(!!runResultResponse.value?.error || !!runResultResponse.value?.skipped)
);
}
function workflowResultAlertType() {
return runResultResponse.value?.skipped ? 'warning' : 'error';
}
function workflowResultAlertMessage() {
return (
runResultResponse.value?.reasonMessage ||
runResultResponse.value?.error ||
''
);
}
function showWorkflowStepsEmpty() {
return showWorkflowSteps() && workflowNodeJson.value.length === 0;
}
function showHttpResultEmpty() {
return (
showWorkflowSteps() !== true &&
activeIndex.value === '2' &&
!runResultResponse.value
);
}
function handleSelect(index: string) {
activeIndex.value = index;
}
function closeDialog() {
stopPolling();
dialogVisible.value = false;
}
onUnmounted(() => {
stopPolling();
});
</script>
<template>
@@ -83,27 +353,37 @@ function handleSubmitRun() {
width="80%"
align-center
:title="$t('pluginItem.pluginToolEdit.trialRun')"
:before-close="() => (dialogVisible = false)"
:before-close="closeDialog"
>
<div class="run-test-container">
<div :key="dialogContentKey" class="run-test-container">
<div class="run-test-params">
<div class="run-title-style">
{{ runTitle }}
</div>
<ElAlert
v-if="!pluginAvailable"
class="mb-4"
type="warning"
:closable="false"
show-icon
:title="$t('pluginItem.pluginToolEdit.unavailableHint')"
:description="pluginReasonMessage"
/>
<div>
<PluginRunParams
v-model="inputDataParams"
:editable="true"
:is-edit-output="true"
ref="runParamsRef"
v-model="inputDataParams"
:editable="pluginAvailable"
:is-edit-output="true"
:payload-mode="isWorkflowPlugin ? 'workflow' : 'plugin'"
/>
</div>
</div>
<div class="run-test-result">
<div class="run-title-style">
{{ runResult }}
{{ showWorkflowSteps() ? $t('aiWorkflow.steps') : runResult }}
</div>
<div>
<div v-if="!showWorkflowSteps()">
<ElMenu
:default-active="activeIndex"
class="el-menu-demo"
@@ -116,6 +396,28 @@ function handleSubmitRun() {
</ElMenu>
</div>
<div class="run-res-json">
<template v-if="showWorkflowSteps()">
<ElAlert
v-if="showWorkflowResultAlert()"
class="run-result-alert"
:type="workflowResultAlertType()"
:closable="false"
show-icon
:title="workflowResultAlertMessage()"
/>
<WorkflowSteps
v-if="showWorkflowStepList()"
:workflow-id="workflowId"
:node-json="workflowNodeJson"
:init-signal="initSignal"
:polling-data="pollingData"
@resume="resumeChain"
/>
<div v-if="showWorkflowStepsEmpty()" class="run-result-placeholder">
{{ $t('pluginItem.pluginToolEdit.runWorkflowStepsEmpty') }}
</div>
</template>
<template v-else>
<JsonViewer
v-if="activeIndex === '1'"
:value="inputDataParams || {}"
@@ -124,24 +426,28 @@ function handleSubmitRun() {
:theme="themeMode"
/>
<JsonViewer
v-if="activeIndex === '2'"
v-if="activeIndex === '2' && runResultResponse"
:value="runResultResponse || {}"
copyable
:expand-depth="Infinity"
:theme="themeMode"
/>
<div v-if="showHttpResultEmpty()" class="run-result-placeholder">
{{ $t('common.noDataAvailable') }}
</div>
</template>
</div>
</div>
</div>
<template #footer>
<ElButton @click="dialogVisible = false">
<ElButton @click="closeDialog">
{{ $t('button.cancel') }}
</ElButton>
<ElButton
type="primary"
:icon="VideoPlay"
@click="handleSubmitRun"
:loading="runLoading"
@click="handleSubmitRun"
>
{{ $t('pluginItem.pluginToolEdit.run') }}
</ElButton>
@@ -163,21 +469,34 @@ function handleSubmitRun() {
overflow: auto;
}
.run-res-json {
flex: 1;
width: 100%;
overflow: auto;
}
.run-test-result {
display: flex;
flex: 1;
flex-direction: column;
}
.name-cell {
position: relative;
min-width: 100%;
.run-res-json {
flex: 1;
width: 100%;
overflow: auto;
}
.run-result-alert {
margin-bottom: 16px;
}
.run-result-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
padding: 24px;
color: var(--el-text-color-secondary);
font-size: 14px;
text-align: center;
border: 1px dashed var(--el-border-color);
border-radius: 10px;
background: var(--el-fill-color-extra-light);
}
.run-title-style {
@@ -186,30 +505,6 @@ function handleSubmitRun() {
font-weight: bold;
}
.editable-name {
display: flex;
flex-direction: column;
gap: 2px;
}
.name-input-wrapper {
display: flex;
align-items: center;
width: 100%;
}
.name-input-wrapper .el-input {
box-sizing: border-box;
width: 100%;
}
.error-message {
margin-top: 2px;
font-size: 12px;
line-height: 1.2;
color: #ff4d4f;
}
:deep(.el-table td.el-table__cell.first-column div) {
display: flex;
gap: 2px;

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { $t } from '@easyflow/locales';
@@ -14,23 +14,19 @@ import {
ElMessage,
ElOption,
ElSelect,
ElTable,
ElTableColumn,
} from 'element-plus';
import { api } from '#/api/request';
import PluginInputAndOutParams from '#/views/ai/plugin/PluginInputAndOutParams.vue';
import PluginRunTestModal from '#/views/ai/plugin/PluginRunTestModal.vue';
import WorkflowApprovalSnapshotPreview from '#/views/system/approval/components/WorkflowApprovalSnapshotPreview.vue';
const route = useRoute();
const router = useRouter();
const toolId = ref<string>((route.query.id as string) || '');
onMounted(() => {
if (!toolId.value) {
return;
}
getPluginToolInfo();
});
const pluginToolInfo = ref<any>({
name: '',
englishName: '',
@@ -41,23 +37,12 @@ const pluginToolInfo = ref<any>({
const pluginInfo = ref<any>({});
const pluginInputData = ref<any[]>([]);
const pluginOutputData = ref<any[]>([]);
function getPluginToolInfo() {
api
.post('/api/v1/pluginItem/tool/search', {
aiPluginToolId: toolId.value,
})
.then((res) => {
if (res.errorCode === 0) {
pluginToolInfo.value = res.data.data;
pluginInfo.value = res.data.aiPlugin;
pluginInputData.value = JSON.parse(res.data.data.inputData || '[]');
pluginOutputData.value = JSON.parse(res.data.data.outputData || '[]');
}
});
}
const pluginInputParamsEditable = ref(false);
const pluginOutputParamsEditable = ref(false);
const isWorkflowPlugin = ref(false);
const pluginUnavailable = ref(false);
const pluginUnavailableReason = ref('');
const workflowSnapshot = ref<any>(null);
const pluginBasicCollapse = ref({
title: $t('pluginItem.pluginToolEdit.basicInfo'),
@@ -74,33 +59,12 @@ const pluginBasicCollapseOutputParams = ref({
isOpen: false,
isEdit: false,
});
const pluginInputParamsRef = ref();
const pluginOutputParamsRef = ref();
const handleClickHeader = (index: number) => {
switch (index) {
case 1: {
pluginBasicCollapse.value.isOpen = !pluginBasicCollapse.value.isOpen;
break;
}
case 2: {
pluginBasicCollapseInputParams.value.isOpen =
!pluginBasicCollapseInputParams.value.isOpen;
const saveForm = ref();
const runTestRef = ref();
break;
}
case 3: {
pluginBasicCollapseOutputParams.value.isOpen =
!pluginBasicCollapseOutputParams.value.isOpen;
break;
}
// No default
}
};
const back = () => {
router.back();
};
const rules = reactive({
name: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
requestMethod: [
@@ -125,118 +89,7 @@ const rules = reactive({
},
],
});
const saveForm = ref();
const updatePluginTool = (index: number) => {
if (index === 1) {
if (!saveForm.value) return;
saveForm.value.validate((valid: boolean) => {
if (valid) {
updatePluginToolInfo(index);
}
});
} else {
updatePluginToolInfo(index);
}
};
const updatePluginToolInfo = (index: number) => {
api
.post('/api/v1/pluginItem/tool/update', {
id: toolId.value,
name: pluginToolInfo.value.name,
englishName: pluginToolInfo.value.englishName,
description: pluginToolInfo.value.description,
basePath: pluginToolInfo.value.basePath,
requestMethod: pluginToolInfo.value.requestMethod,
inputData: JSON.stringify(pluginInputData.value),
outputData: JSON.stringify(pluginOutputData.value),
})
.then((res) => {
if (res.errorCode === 0) {
ElMessage.success($t('message.updateOkMessage'));
switch (index) {
case 1: {
pluginBasicCollapse.value.isEdit = false;
break;
}
case 2: {
pluginBasicCollapseInputParams.value.isEdit = false;
break;
}
case 3: {
pluginBasicCollapseOutputParams.value.isEdit = false;
break;
}
// No default
}
}
});
};
const handleEdit = (index: number) => {
switch (index) {
case 1: {
pluginBasicCollapse.value.isEdit = true;
break;
}
case 2: {
pluginBasicCollapseInputParams.value.isEdit = true;
pluginBasicCollapseInputParams.value.isOpen = true;
pluginInputParamsEditable.value = true;
break;
}
case 3: {
pluginBasicCollapseOutputParams.value.isEdit = true;
pluginBasicCollapseOutputParams.value.isOpen = true;
pluginOutputParamsEditable.value = true;
break;
}
// No default
}
};
const handleSave = (index: number) => {
if (index === 2) {
try {
// 调用校验方法,若抛异常则进入 catch
pluginInputParamsRef.value.handleSubmitParams();
} catch (error) {
console.error('校验失败:', error);
return;
}
}
if (index === 3) {
try {
pluginOutputParamsRef.value.handleSubmitParams();
} catch (error) {
console.error('校验失败:', error);
return;
}
}
pluginInputParamsEditable.value = false;
updatePluginTool(index);
};
const handleCancel = (index: number) => {
getPluginToolInfo();
switch (index) {
case 1: {
pluginBasicCollapse.value.isEdit = false;
break;
}
case 2: {
pluginBasicCollapseInputParams.value.isEdit = false;
pluginInputParamsEditable.value = false;
break;
}
case 3: {
pluginBasicCollapseOutputParams.value.isEdit = false;
pluginOutputParamsEditable.value = false;
break;
}
// No default
}
};
const requestMethodOptions = [
{
label: 'POST',
@@ -259,30 +112,259 @@ const requestMethodOptions = [
value: 'PATCH',
},
];
const runTestRef = ref();
const handleOpenRunModal = () => {
onMounted(() => {
if (!toolId.value) {
return;
}
getPluginToolInfo();
});
const workflowParameterRows = computed(() => [
...flattenParameterRows(pluginInputData.value, 'input'),
...flattenParameterRows(pluginOutputData.value, 'output'),
]);
function getPluginToolInfo() {
api
.post('/api/v1/pluginItem/tool/search', {
aiPluginToolId: toolId.value,
})
.then((res) => {
if (res.errorCode === 0) {
pluginToolInfo.value = res.data.data;
pluginInfo.value = res.data.aiPlugin;
pluginInputData.value = JSON.parse(res.data.data.inputData || '[]');
pluginOutputData.value = JSON.parse(res.data.data.outputData || '[]');
isWorkflowPlugin.value = Number(res.data.aiPlugin?.type || 1) === 2;
pluginUnavailable.value = res.data.aiPlugin?.available === false;
pluginUnavailableReason.value = res.data.aiPlugin?.reasonMessage || '';
workflowSnapshot.value = res.data.workflowSnapshot || null;
}
});
}
function handleClickHeader(index: number) {
switch (index) {
case 1: {
pluginBasicCollapse.value.isOpen = !pluginBasicCollapse.value.isOpen;
break;
}
case 2: {
pluginBasicCollapseInputParams.value.isOpen =
!pluginBasicCollapseInputParams.value.isOpen;
break;
}
case 3: {
pluginBasicCollapseOutputParams.value.isOpen =
!pluginBasicCollapseOutputParams.value.isOpen;
break;
}
}
}
function back() {
router.back();
}
function updatePluginTool(index: number) {
if (index === 1) {
if (!saveForm.value) {
return;
}
saveForm.value.validate((valid: boolean) => {
if (valid) {
updatePluginToolInfo(index);
}
});
return;
}
updatePluginToolInfo(index);
}
function updatePluginToolInfo(index: number) {
api
.post('/api/v1/pluginItem/tool/update', {
id: toolId.value,
name: pluginToolInfo.value.name,
englishName: pluginToolInfo.value.englishName,
description: pluginToolInfo.value.description,
basePath: pluginToolInfo.value.basePath,
requestMethod: pluginToolInfo.value.requestMethod,
inputData: JSON.stringify(pluginInputData.value),
outputData: JSON.stringify(pluginOutputData.value),
})
.then((res) => {
if (res.errorCode === 0) {
ElMessage.success($t('message.updateOkMessage'));
switch (index) {
case 1: {
pluginBasicCollapse.value.isEdit = false;
break;
}
case 2: {
pluginBasicCollapseInputParams.value.isEdit = false;
break;
}
case 3: {
pluginBasicCollapseOutputParams.value.isEdit = false;
break;
}
}
}
});
}
function handleEdit(index: number) {
if (isWorkflowPlugin.value) {
return;
}
switch (index) {
case 1: {
pluginBasicCollapse.value.isEdit = true;
break;
}
case 2: {
pluginBasicCollapseInputParams.value.isEdit = true;
pluginBasicCollapseInputParams.value.isOpen = true;
pluginInputParamsEditable.value = true;
break;
}
case 3: {
pluginBasicCollapseOutputParams.value.isEdit = true;
pluginBasicCollapseOutputParams.value.isOpen = true;
pluginOutputParamsEditable.value = true;
break;
}
}
}
function handleSave(index: number) {
if (index === 2) {
pluginInputParamsRef.value.handleSubmitParams();
}
if (index === 3) {
pluginOutputParamsRef.value.handleSubmitParams();
}
pluginInputParamsEditable.value = false;
updatePluginTool(index);
}
function handleCancel(index: number) {
getPluginToolInfo();
switch (index) {
case 1: {
pluginBasicCollapse.value.isEdit = false;
break;
}
case 2: {
pluginBasicCollapseInputParams.value.isEdit = false;
pluginInputParamsEditable.value = false;
break;
}
case 3: {
pluginBasicCollapseOutputParams.value.isEdit = false;
pluginOutputParamsEditable.value = false;
break;
}
}
}
function handleOpenRunModal() {
runTestRef.value.openDialog();
}
function flattenParameterRows(
parameters: any[] = [],
direction: 'input' | 'output',
parentPath = '',
): any[] {
return parameters.flatMap((item) => {
const currentName = String(item?.name || '').trim();
const currentPath = currentName
? parentPath
? `${parentPath}.${currentName}`
: currentName
: parentPath || '-';
const currentRow = {
key: String(item?.id || item?.key || currentPath),
direction,
name: currentPath,
type: String(item?.dataType || item?.type || '-'),
};
const children = Array.isArray(item?.children)
? flattenParameterRows(item.children, direction, currentPath)
: [];
return [currentRow, ...children];
});
}
</script>
<template>
<div class="accordion-container">
<div class="controls-header">
<ElButton @click="back" :icon="Back">
<ElButton :icon="Back" @click="back">
{{ $t('button.back') }}
</ElButton>
<ElButton type="primary" :icon="VideoPlay" @click="handleOpenRunModal">
{{ $t('pluginItem.pluginToolEdit.trialRun') }}
</ElButton>
</div>
<!-- 折叠面板列表 -->
<div class="accordion-list">
<!-- 基本信息-->
<template v-if="isWorkflowPlugin">
<div class="workflow-tool-layout">
<section class="workflow-tool-card">
<div class="workflow-tool-card__header">
<div class="workflow-tool-card__title">
{{ $t('pluginItem.inputData') }} / {{ $t('pluginItem.outputData') }}
</div>
</div>
<ElTable
:data="workflowParameterRows"
border
class="workflow-tool-table"
empty-text="-"
>
<ElTableColumn
prop="direction"
:label="$t('pluginItem.direction')"
width="120"
>
<template #default="{ row }">
{{ row.direction === 'input' ? $t('pluginItem.inputData') : $t('pluginItem.outputData') }}
</template>
</ElTableColumn>
<ElTableColumn
prop="name"
:label="$t('pluginItem.parameterName')"
min-width="280"
/>
<ElTableColumn
prop="type"
:label="$t('pluginItem.parameterType')"
min-width="160"
/>
</ElTable>
</section>
<section class="workflow-tool-card">
<div class="workflow-tool-card__header">
<div class="workflow-tool-card__title">
快照
</div>
</div>
<WorkflowApprovalSnapshotPreview
:title="workflowSnapshot?.title || pluginInfo.workflowTitle || pluginToolInfo.name"
:description="''"
:content="workflowSnapshot?.content || ''"
/>
</section>
</div>
</template>
<div v-else class="accordion-list">
<div
class="accordion-item"
:class="{ 'accordion-item--active': pluginBasicCollapse.isOpen }"
>
<!-- 面板头部 -->
<div class="accordion-header" @click="handleClickHeader(1)">
<div class="column-header-container">
<div
@@ -295,37 +377,38 @@ const handleOpenRunModal = () => {
</div>
<h3 class="accordion-title">{{ pluginBasicCollapse.title }}</h3>
</div>
<div>
<div v-if="!isWorkflowPlugin">
<ElButton
@click.stop="handleEdit(1)"
type="primary"
v-if="!pluginBasicCollapse.isEdit"
type="primary"
@click.stop="handleEdit(1)"
>
{{ $t('button.edit') }}
</ElButton>
<ElButton
@click.stop="handleCancel(1)"
v-if="pluginBasicCollapse.isEdit"
@click.stop="handleCancel(1)"
>
{{ $t('button.cancel') }}
</ElButton>
<ElButton
@click.stop="handleSave(1)"
type="primary"
v-if="pluginBasicCollapse.isEdit"
type="primary"
@click.stop="handleSave(1)"
>
{{ $t('button.save') }}
</ElButton>
</div>
<ElTag v-else type="info" effect="plain">
{{ $t('pluginItem.systemManaged') }}
</ElTag>
</div>
<!-- 面板内容 -->
<div
class="accordion-content"
:class="{ 'accordion-content--open': pluginBasicCollapse.isOpen }"
>
<div class="accordion-content-inner">
<!--编辑基本信息-->
<div v-show="pluginBasicCollapse.isEdit">
<div class="plugin-tool-info-edit-container">
<ElForm
@@ -381,7 +464,6 @@ const handleOpenRunModal = () => {
</ElForm>
</div>
</div>
<!--显示基本信息-->
<div
v-show="!pluginBasicCollapse.isEdit"
class="plugin-tool-info-view-container"
@@ -402,6 +484,13 @@ const handleOpenRunModal = () => {
</div>
<div>{{ pluginToolInfo.description }}</div>
</div>
<div v-if="isWorkflowPlugin" class="plugin-tool-view-item">
<div class="view-item-title">
{{ $t('pluginItem.pluginToolEdit.workflowTarget') }}:
</div>
<div>{{ pluginInfo.workflowTitle || '-' }}</div>
</div>
<template v-else>
<div class="plugin-tool-view-item">
<div class="view-item-title">
{{ $t('pluginItem.pluginToolEdit.toolPath') }}:
@@ -416,18 +505,18 @@ const handleOpenRunModal = () => {
{{ pluginToolInfo.requestMethod }}
</div>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- 输入参数-->
<div
class="accordion-item"
:class="{
'accordion-item--active': pluginBasicCollapseInputParams.isOpen,
}"
>
<!-- 面板头部 -->
<div class="accordion-header" @click="handleClickHeader(2)">
<div class="column-header-container">
<div
@@ -445,31 +534,30 @@ const handleOpenRunModal = () => {
{{ pluginBasicCollapseInputParams.title }}
</h3>
</div>
<div>
<div v-if="!isWorkflowPlugin">
<ElButton
@click.stop="handleEdit(2)"
type="primary"
v-if="!pluginBasicCollapseInputParams.isEdit"
type="primary"
@click.stop="handleEdit(2)"
>
{{ $t('button.edit') }}
</ElButton>
<ElButton
@click.stop="handleCancel(2)"
v-if="pluginBasicCollapseInputParams.isEdit"
@click.stop="handleCancel(2)"
>
{{ $t('button.cancel') }}
</ElButton>
<ElButton
@click.stop="handleSave(2)"
type="primary"
v-if="pluginBasicCollapseInputParams.isEdit"
type="primary"
@click.stop="handleSave(2)"
>
{{ $t('button.save') }}
</ElButton>
</div>
</div>
<!--输入参数-->
<div
class="accordion-content"
:class="{
@@ -486,14 +574,13 @@ const handleOpenRunModal = () => {
</div>
</div>
</div>
<!-- 输出参数-->
<div
class="accordion-item"
:class="{
'accordion-item--active': pluginBasicCollapseOutputParams.isOpen,
}"
>
<!-- 面板头部 -->
<div class="accordion-header" @click="handleClickHeader(3)">
<div class="column-header-container">
<div
@@ -511,31 +598,30 @@ const handleOpenRunModal = () => {
{{ pluginBasicCollapseOutputParams.title }}
</h3>
</div>
<div>
<div v-if="!isWorkflowPlugin">
<ElButton
@click.stop="handleEdit(3)"
type="primary"
v-if="!pluginBasicCollapseOutputParams.isEdit"
type="primary"
@click.stop="handleEdit(3)"
>
{{ $t('button.edit') }}
</ElButton>
<ElButton
@click.stop="handleCancel(3)"
v-if="pluginBasicCollapseOutputParams.isEdit"
@click.stop="handleCancel(3)"
>
{{ $t('button.cancel') }}
</ElButton>
<ElButton
@click.stop="handleSave(3)"
type="primary"
v-if="pluginBasicCollapseOutputParams.isEdit"
type="primary"
@click.stop="handleSave(3)"
>
{{ $t('button.save') }}
</ElButton>
</div>
</div>
<!--输出参数-->
<div
class="accordion-content"
:class="{
@@ -544,8 +630,8 @@ const handleOpenRunModal = () => {
>
<div class="accordion-content-inner">
<PluginInputAndOutParams
v-model="pluginOutputData"
ref="pluginOutputParamsRef"
v-model="pluginOutputData"
:editable="pluginOutputParamsEditable"
:is-edit-output="true"
/>
@@ -553,211 +639,133 @@ const handleOpenRunModal = () => {
</div>
</div>
</div>
<!-- 试运行模态框-->
<PluginRunTestModal ref="runTestRef" :plugin-tool-id="toolId" />
</div>
</template>
<style scoped>
/* 响应式设计 */
@media (max-width: 768px) {
.accordion-container {
padding: 15px;
}
.controls {
display: flex;
flex-direction: column;
gap: 15px;
align-items: stretch;
}
.control-group {
justify-content: center;
}
.title {
font-size: 1.5rem;
}
.accordion-header {
padding: 14px 16px;
}
.accordion-title {
font-size: 1rem;
}
}
.accordion-container {
max-width: 100%;
padding: 20px;
margin: 0 auto;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
gap: 16px;
padding: 24px;
}
.controls-header {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.title {
margin-bottom: 8px;
font-size: 2rem;
font-weight: 600;
color: var(--el-text-color-secondary);
text-align: center;
}
.subtitle {
margin-bottom: 30px;
font-size: 1.1rem;
color: var(--el-text-color-secondary);
text-align: center;
}
/* 控制面板样式 */
.controls {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
margin-bottom: 30px;
background: var(--el-bg-color);
border: 1px solid #e9ecef;
border-radius: 8px;
}
.control-group {
display: flex;
gap: 15px;
align-items: center;
}
.checkbox-label {
display: flex;
gap: 8px;
align-items: center;
font-size: 14px;
color: var(--el-text-color-secondary);
cursor: pointer;
}
.checkbox {
width: 16px;
height: 16px;
}
.control-btn {
padding: 8px 16px;
font-size: 14px;
color: var(--el-text-color-secondary);
cursor: pointer;
background: var(--el-bg-color);
border-radius: 4px;
transition: all 0.3s ease;
}
.control-btn:hover {
background: #3498db;
background: var(--el-color-primary-light-9);
}
/* 折叠面板列表 */
.accordion-list {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 20px;
gap: 16px;
}
.workflow-tool-layout {
display: flex;
flex-direction: column;
gap: 16px;
}
.workflow-tool-card {
padding: 20px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 16px;
}
.workflow-tool-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
}
.workflow-tool-card__title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.workflow-tool-table {
width: 100%;
}
.accordion-item {
overflow: hidden;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 8px;
transition: all 0.3s ease;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 16px;
}
.accordion-item:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 10%);
.accordion-item--active {
border-color: var(--el-color-primary-light-5);
}
.accordion-header {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
padding: 18px 20px;
cursor: pointer;
user-select: none;
background: hsl(var(--background));
transition: background-color 0.3s ease;
}
.accordion-title {
padding-left: 12px;
margin: 0;
font-size: 1.1rem;
font-weight: 500;
.column-header-container {
display: flex;
gap: 10px;
align-items: center;
}
.accordion-icon {
font-size: 12px;
color: #7f8c8d;
transition: transform 0.3s ease;
display: inline-flex;
transition: transform 0.2s ease;
}
.accordion-icon--rotated {
transform: rotate(180deg);
}
.accordion-title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.accordion-content {
max-height: 0;
overflow: hidden;
background: hsl(var(--background));
transition: max-height 0.4s ease;
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.2s ease;
}
.accordion-content--open {
max-height: 2000px;
grid-template-rows: 1fr;
}
.accordion-content-inner {
padding: 20px;
border-top: 1px solid hsl(var(--border));
}
.accordion-content-inner p {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
.column-header-container {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 0;
padding: 0 20px 20px;
overflow: hidden;
}
.plugin-tool-info-view-container {
display: flex;
flex-direction: column;
gap: 25px;
gap: 12px;
}
.plugin-tool-view-item {
display: flex;
gap: 8px;
align-items: center;
font-size: 14px;
line-height: 22px;
}
.view-item-title {
width: 70px;
/* text-align: right; */
/* margin-right: 12px; */
min-width: 108px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -25,6 +25,10 @@ const props = defineProps({
required: true,
type: String,
},
pluginType: {
default: 1,
type: Number,
},
});
const router = useRouter();
defineExpose({
@@ -100,7 +104,7 @@ const pluginToolReload = () => {
{{ $t('button.edit') }}
</ElButton>
<ElDropdown>
<ElDropdown v-if="Number(props.pluginType || 1) !== 2">
<ElButton link :icon="MoreFilled" />
<template #dropdown>

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { markRaw, ref } from 'vue';
import { computed, markRaw, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { $t } from '@easyflow/locales';
import { Back, Plus } from '@element-plus/icons-vue';
import { api } from '#/api/request';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import PluginToolTable from '#/views/ai/plugin/PluginToolTable.vue';
@@ -13,26 +14,49 @@ const route = useRoute();
const router = useRouter();
const pluginId = ref<string>((route.query.id as string) || '');
const headerButtons = [
const pluginInfo = ref<any>({});
const pluginToolRef = ref();
const headerButtons = computed<any[]>(() => {
const buttons: any[] = [
{
key: 'back',
text: $t('button.back'),
icon: markRaw(Back),
data: { action: 'back' },
},
{
];
if (Number(pluginInfo.value?.type || 1) !== 2) {
buttons.push({
key: 'createTool',
text: $t('pluginItem.createPluginTool'),
icon: markRaw(Plus),
type: 'primary',
data: { action: 'createTool' },
},
];
const handleSearch = (params: any) => {
});
}
return buttons;
});
onMounted(() => {
loadPluginInfo();
});
async function loadPluginInfo() {
if (!pluginId.value) {
return;
}
const res = await api.get(`/api/v1/plugin/detail?id=${pluginId.value}`);
if (res.errorCode === 0) {
pluginInfo.value = res.data || {};
}
}
function handleSearch(params: any) {
pluginToolRef.value.handleSearch(params);
};
const handleButtonClick = (event: any) => {
// 根据按钮 key 执行不同操作
}
function handleButtonClick(event: any) {
switch (event.key) {
case 'back': {
router.push({ path: '/ai/plugin' });
@@ -43,8 +67,7 @@ const handleButtonClick = (event: any) => {
break;
}
}
};
const pluginToolRef = ref();
}
</script>
<template>
@@ -56,7 +79,11 @@ const pluginToolRef = ref();
/>
<div class="bg-background border-border flex-1 rounded-lg border p-5">
<PluginToolTable :plugin-id="pluginId" ref="pluginToolRef" />
<PluginToolTable
ref="pluginToolRef"
:plugin-id="pluginId"
:plugin-type="pluginInfo.type"
/>
</div>
</div>
</template>

View File

@@ -8,8 +8,10 @@ export interface OfflineImpactBinding {
export interface OfflineImpactCheck {
canProceed: boolean;
hasBotBindings: boolean;
hasPluginBindings: boolean;
hasWorkflowUsages: boolean;
botBindings: OfflineImpactBinding[];
pluginBindings: OfflineImpactBinding[];
workflowUsages: OfflineImpactBinding[];
message?: string;
}

View File

@@ -605,9 +605,25 @@ function handlePluginNodeUpdate(chooseId: any) {
})
.then((res) => {
pageLoading.value = false;
updatePluginNode.value(res.data);
updatePluginNode.value(decoratePluginNodeData(res.data));
});
}
function decoratePluginNodeData(data: any) {
if (!data || Number(data.pluginType || 1) !== 2) {
return data;
}
const statusText =
data.available === false
? data.reasonMessage || $t('plugin.workflowPluginUnavailable')
: $t('plugin.workflowSnapshotSynced');
return {
...data,
pluginName: data.pluginName
? `${data.pluginName} · ${statusText}`
: statusText,
pluginStatusText: statusText,
};
}
function onAsyncExecute(info: any) {
chainInfo.value = info;
}
@@ -628,7 +644,7 @@ function onAsyncExecute(info: any) {
:title="$t('menus.ai.plugin')"
width="730"
ref="pluginSelectRef"
page-url="/api/v1/plugin/page"
page-url="/api/v1/plugin/page?availableOnly=true"
:has-parent="true"
single-select
@get-data="(v) => handleChoose(nodeNames.pluginNode, v)"

View File

@@ -6,7 +6,7 @@ import type {
CardPrimaryAction,
} from '#/components/page/CardList.vue';
import { computed, markRaw, onMounted, ref } from 'vue';
import { computed, h, markRaw, onMounted, ref } from 'vue';
import { useAccess } from '@easyflow/access';
import { EasyFlowFormModal } from '@easyflow/common-ui';
@@ -360,14 +360,48 @@ async function submitOfflineAction(row: any) {
return;
}
try {
await ElMessageBox.confirm(
impactRes.data?.hasBotBindings
? buildOfflineImpactMessage(
const sections = [];
if (impactRes.data?.hasBotBindings) {
sections.push(
buildOfflineImpactMessage(
$t('aiWorkflow.offlineImpactBoundBotsIntro'),
impactRes.data.botBindings,
$t('aiWorkflow.offlineImpactBoundBotsFooter'),
)
: $t('aiWorkflow.submitOfflineApprovalConfirm'),
impactRes.data?.hasPluginBindings
? undefined
: $t('aiWorkflow.offlineImpactBoundBotsFooter'),
),
);
}
if (impactRes.data?.hasPluginBindings) {
sections.push(
buildOfflineImpactMessage(
$t('aiWorkflow.offlineImpactBoundPluginsIntro'),
impactRes.data.pluginBindings,
impactRes.data?.hasBotBindings
? undefined
: $t('aiWorkflow.offlineImpactBoundPluginsFooter'),
),
);
}
const impactMessage =
sections.length > 0
? h('div', [
...sections,
h(
'p',
{
style: 'margin-top: 12px;',
},
impactRes.data?.hasBotBindings && impactRes.data?.hasPluginBindings
? $t('aiWorkflow.offlineImpactBoundMixedFooter')
: impactRes.data?.hasPluginBindings
? $t('aiWorkflow.offlineImpactBoundPluginsFooter')
: $t('aiWorkflow.offlineImpactBoundBotsFooter'),
),
])
: $t('aiWorkflow.submitOfflineApprovalConfirm');
await ElMessageBox.confirm(
impactMessage,
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),

View File

@@ -4,11 +4,12 @@ import type { FormInstance } from 'element-plus';
import { ref } from 'vue';
import { Position } from '@element-plus/icons-vue';
import { ElButton, ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus';
import { ElButton, ElForm, ElFormItem, ElMessage } from 'element-plus';
import { api } from '#/api/request';
import ShowJson from '#/components/json/ShowJson.vue';
import { $t } from '#/locales';
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
interface Props {
workflowId: any;
@@ -47,19 +48,10 @@ function submit() {
<template>
<div>
<ElForm label-position="top" ref="singleRunForm" :model="runParams">
<ElFormItem
v-for="(item, idx) in node?.data.parameters"
:prop="item.name"
:key="idx"
:label="item.description || item.name"
:rules="[{ required: true, message: $t('message.required') }]"
>
<ElInput
v-if="item.formType === 'input' || !item.formType"
v-model="runParams[item.name]"
:placeholder="item.formPlaceholder"
<WorkflowFormItem
v-model:run-params="runParams"
:parameters="node?.data.parameters || []"
/>
</ElFormItem>
<ElFormItem>
<ElButton
type="primary"

View File

@@ -29,16 +29,64 @@ export interface WorkflowStepsProps {
nodeJson: any;
initSignal?: boolean;
pollingData?: any;
expandAll?: boolean;
}
const props = defineProps<WorkflowStepsProps>();
const emit = defineEmits(['resume']);
const nodes = ref<any[]>([]);
const nodeStatusMap = ref<Record<string, any>>({});
const isChainError = ref(false);
const activeName = ref<any>(props.expandAll ? [] : '');
const confirmParams = ref<any>({});
// 定义一个对象来存储所有的 form 实例key 为 node.key
const formRefs = ref<Record<string, FormInstance>>({});
const confirmBtnLoading = ref(false);
const chainErrMsg = ref('');
function shouldAutoExpandStatus(status: unknown) {
return [1, 5, 20, 21].includes(Number(status));
}
function isExpandedNode(nodeKey: string) {
if (Array.isArray(activeName.value)) {
return activeName.value.includes(nodeKey);
}
return activeName.value === nodeKey;
}
function hasNodePayloadChanged(previous: any, current: any) {
if (previous === current) {
return false;
}
return JSON.stringify(previous ?? null) !== JSON.stringify(current ?? null);
}
function hasNodeStateChanged(previous: any, current: any) {
if (!previous) {
return true;
}
if ((previous?.status || null) !== (current?.status || null)) {
return true;
}
if ((previous?.message || '') !== (current?.message || '')) {
return true;
}
if (hasNodePayloadChanged(previous?.result, current?.result)) {
return true;
}
return hasNodePayloadChanged(
previous?.suspendForParameters,
current?.suspendForParameters,
);
}
watch(
() => props.pollingData,
(newVal) => {
const nodes = newVal.nodes;
if (!newVal) {
return;
}
const currentNodes = newVal.nodes || {};
if (newVal.status === 21) {
isChainError.value = true;
chainErrMsg.value = newVal.message;
@@ -46,9 +94,20 @@ watch(
if (![20, 21].includes(newVal.status)) {
confirmBtnLoading.value = false;
}
for (const nodeId in nodes) {
nodeStatusMap.value[nodeId] = nodes[nodeId];
if (nodes[nodeId].status === 5) {
for (const nodeId in currentNodes) {
const previousNodeState = nodeStatusMap.value[nodeId];
const currentNodeState = currentNodes[nodeId];
const previousStatus = previousNodeState?.status;
const currentStatus = currentNodeState?.status;
if (!hasNodeStateChanged(previousNodeState, currentNodeState)) {
continue;
}
nodeStatusMap.value[nodeId] = currentNodeState;
if (
!props.expandAll &&
previousStatus !== currentStatus &&
shouldAutoExpandStatus(currentStatus)
) {
activeName.value = nodeId;
}
}
@@ -62,14 +121,17 @@ watch(
isChainError.value = false;
confirmBtnLoading.value = false;
chainErrMsg.value = '';
activeName.value = props.expandAll ? [] : '';
},
);
watch(
() => props.nodeJson,
(newVal) => {
if (newVal) {
nodes.value = [...newVal];
}
const nextNodes = Array.isArray(newVal) ? [...newVal] : [];
nodes.value = nextNodes;
activeName.value = props.expandAll
? nextNodes.map((node: any) => node.key)
: '';
},
{ immediate: true },
);
@@ -79,18 +141,12 @@ const displayNodes = computed(() => {
...nodeStatusMap.value[node.key],
}));
});
const activeName = ref('1');
const confirmParams = ref<any>({});
// 定义一个对象来存储所有的 form 实例key 为 node.key
const formRefs = ref<Record<string, FormInstance>>({});
// 动态设置 Ref 的辅助函数
const setFormRef = (el: any, key: string) => {
if (el) {
formRefs.value[key] = el as FormInstance;
}
};
const confirmBtnLoading = ref(false);
const chainErrMsg = ref('');
function getSelectMode(ops: any) {
return ops.formType || 'radio';
}
@@ -124,7 +180,11 @@ function handleConfirm(node: any) {
<div class="mb-1">
<ElAlert v-if="chainErrMsg" :title="chainErrMsg" type="error" />
</div>
<ElCollapse v-model="activeName" accordion expand-icon-position="left">
<ElCollapse
v-model="activeName"
:accordion="!props.expandAll"
expand-icon-position="left"
>
<ElCollapseItem
v-for="node in displayNodes"
:key="node.key"
@@ -165,6 +225,7 @@ function handleConfirm(node: any) {
</div>
</div>
</template>
<template v-if="isExpandedNode(node.key)">
<div v-if="node.original.type === 'confirmNode'" class="p-2.5">
<div class="mb-2 text-[16px] font-bold">
{{ node.original.data.message }}
@@ -223,6 +284,7 @@ function handleConfirm(node: any) {
<div v-else>
<ShowJson :value="node.result || node.message" />
</div>
</template>
</ElCollapseItem>
</ElCollapse>
</div>

View File

@@ -37,6 +37,7 @@
let inputEl = $state<HTMLInputElement | null>(null);
let textareaEl = $state<HTMLTextAreaElement | null>(null);
let highlightEl = $state<HTMLDivElement | null>(null);
let scrollbarWidth = $state(0);
let triggerObject: any;
let isFocused = $state(false);
let isComposing = $state(false);
@@ -49,6 +50,17 @@
}
});
$effect(() => {
localValue;
if (mode !== 'textarea') {
return;
}
requestAnimationFrame(() => {
updateScrollbarWidth();
});
});
const paramCandidates = $derived(flattenParameterCandidates(parameters));
const paramNames = $derived(paramCandidates.map((item) => item.name));
const unresolvedParamSet = $derived.by(() => {
@@ -129,9 +141,48 @@
highlightEl.scrollLeft = el.scrollLeft;
};
const updateScrollbarWidth = () => {
const el = textareaEl;
if (!el || mode !== 'textarea') {
scrollbarWidth = 0;
return;
}
const style = getComputedStyle(el);
const borderLeft = parseFloat(style.borderLeftWidth || '0');
const borderRight = parseFloat(style.borderRightWidth || '0');
const nextWidth = Math.max(0, el.offsetWidth - el.clientWidth - borderLeft - borderRight);
scrollbarWidth = Number.isFinite(nextWidth) ? nextWidth : 0;
};
const syncEditorMetrics = () => {
syncScroll();
updateScrollbarWidth();
};
$effect(() => {
if (mode !== 'textarea' || !textareaEl) {
scrollbarWidth = 0;
return;
}
const resizeObserver = typeof ResizeObserver !== 'undefined'
? new ResizeObserver(() => {
updateScrollbarWidth();
})
: null;
updateScrollbarWidth();
resizeObserver?.observe(textareaEl);
return () => {
resizeObserver?.disconnect();
};
});
const handleInput = (event: Event) => {
localValue = ((event.target as HTMLInputElement | HTMLTextAreaElement).value || '') as string;
syncScroll();
syncEditorMetrics();
emitInput(event);
};
@@ -158,7 +209,7 @@
newEditorEl.focus();
newEditorEl.setSelectionRange(result.cursor, result.cursor);
}
syncScroll();
syncEditorMetrics();
triggerObject?.hide?.();
};
@@ -310,7 +361,7 @@
editorEl.focus();
editorEl.setSelectionRange(deleteRange.start, deleteRange.start);
}
syncScroll();
syncEditorMetrics();
rest.onkeydown?.(event);
return;
}
@@ -339,7 +390,7 @@
editorEl.focus();
editorEl.setSelectionRange(tokenRange.start, tokenRange.start);
}
syncScroll();
syncEditorMetrics();
rest.onkeydown?.(event);
};
@@ -400,6 +451,7 @@
<div
class="param-token-editor-highlight {mode === 'input' ? 'single-line' : 'multi-line'}"
bind:this={highlightEl}
style={`--param-token-scrollbar-width: ${scrollbarWidth}px;`}
aria-hidden="true"
>
{@html highlightedHtml}
@@ -571,7 +623,9 @@
inset: 0;
border: 1px solid transparent;
border-radius: 5px;
padding: var(--param-token-padding-y) var(--param-token-padding-right) var(--param-token-padding-y)
padding: var(--param-token-padding-y)
calc(var(--param-token-padding-right) + var(--param-token-scrollbar-width, 0px))
var(--param-token-padding-y)
var(--param-token-padding-left);
box-sizing: border-box;
color: var(--tf-text-primary);
@@ -621,6 +675,8 @@
.param-token-textarea {
overflow: auto;
display: block;
scrollbar-gutter: stable;
}
.param-token-action {