mirror of
https://gitcode.com/ageerle/ruoyi-ai.git
synced 2026-09-16 01:44:57 +00:00
feat: streamline workflow orchestration and chat routing
Add Zhipu web search integration, remove obsolete workflow nodes and resume handling, and separate model, agent, and workflow chat execution.
This commit is contained in:
@@ -1,16 +1,13 @@
|
||||
package org.ruoyi.workflow.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.ruoyi.common.core.domain.R;
|
||||
import org.ruoyi.workflow.dto.workflow.WfRuntimeNodeDto;
|
||||
import org.ruoyi.workflow.dto.workflow.WfRuntimeResp;
|
||||
import org.ruoyi.workflow.dto.workflow.WorkflowResumeReq;
|
||||
import org.ruoyi.workflow.service.WorkflowRuntimeService;
|
||||
import org.ruoyi.workflow.workflow.WorkflowStarter;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -24,16 +21,6 @@ public class WorkflowRuntimeController {
|
||||
@Resource
|
||||
private WorkflowRuntimeService workflowRuntimeService;
|
||||
|
||||
@Resource
|
||||
private WorkflowStarter workflowStarter;
|
||||
|
||||
@Operation(summary = "接收用户输入以继续执行剩余流程")
|
||||
@PostMapping(value = "/resume/{runtimeUuid}")
|
||||
public R resume(@PathVariable String runtimeUuid, @RequestBody WorkflowResumeReq resumeReq) {
|
||||
workflowStarter.resumeFlow(runtimeUuid, resumeReq.getFeedbackContent(), resumeReq.getSseEmitter());
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
public R<Page<WfRuntimeResp>> search(@RequestParam String wfUuid,
|
||||
@NotNull @Min(1) Integer currentPage,
|
||||
|
||||
@@ -337,7 +337,6 @@ public class AdiConstant {
|
||||
public static final String DEFAULT_INPUT_PARAM_NAME = "input";
|
||||
public static final String DEFAULT_OUTPUT_PARAM_NAME = "output";
|
||||
public static final String DEFAULT_ERROR_OUTPUT_PARAM_NAME = "error_msg";
|
||||
public static final String HUMAN_FEEDBACK_KEY = "human_feedback";
|
||||
public static final int NODE_PROCESS_STATUS_READY = 1;
|
||||
public static final int NODE_PROCESS_STATUS_DOING = 2;
|
||||
public static final int NODE_PROCESS_STATUS_SUCCESS = 3;
|
||||
@@ -347,7 +346,6 @@ public class AdiConstant {
|
||||
public static final int WORKFLOW_PROCESS_STATUS_DOING = 2;
|
||||
public static final int WORKFLOW_PROCESS_STATUS_SUCCESS = 3;
|
||||
public static final int WORKFLOW_PROCESS_STATUS_FAIL = 4;
|
||||
public static final int WORKFLOW_PROCESS_STATUS_WAITING_INPUT = 5;
|
||||
|
||||
public static final int WORKFLOW_NODE_PROCESS_TYPE_NORMAL = 1;
|
||||
public static final int WORKFLOW_NODE_PROCESS_TYPE_CONDITIONAL = 2;
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
package org.ruoyi.workflow.dto.workflow;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
@Data
|
||||
public class WorkflowResumeReq {
|
||||
private String feedbackContent;
|
||||
private SseEmitter sseEmitter;
|
||||
}
|
||||
@@ -55,8 +55,13 @@ public class SSEEmitterHelper {
|
||||
} else {
|
||||
sseEmitter.send(msg);
|
||||
}
|
||||
} catch (IllegalStateException ise) {
|
||||
// SSE连接已关闭(用户刷新页面、关闭标签页或重新提交)
|
||||
log.warn("SSE emitter already completed for event [{}], ignoring", name);
|
||||
COMPLETED_SSE.put(sseEmitter, Boolean.TRUE);
|
||||
} catch (IOException ioException) {
|
||||
log.error("stream onNext error", ioException);
|
||||
COMPLETED_SSE.put(sseEmitter, Boolean.TRUE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import org.ruoyi.common.chat.domain.dto.request.ChatRequest;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.common.chat.enums.RoleType;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.ruoyi.common.core.exception.ServiceException;
|
||||
import org.ruoyi.common.core.service.ConfigService;
|
||||
import org.ruoyi.common.core.utils.SpringUtils;
|
||||
import org.ruoyi.common.core.utils.StringUtils;
|
||||
@@ -12,6 +11,7 @@ import org.ruoyi.workflow.entity.WorkflowNode;
|
||||
import org.ruoyi.workflow.helper.SSEEmitterHelper;
|
||||
import org.ruoyi.workflow.workflow.WfState;
|
||||
import org.ruoyi.workflow.workflow.WorkflowUtil;
|
||||
import org.ruoyi.workflow.workflow.node.enmus.NodeMessageTemplateEnum;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
/**
|
||||
@@ -38,17 +38,20 @@ public class WorkflowMessageUtil {
|
||||
|
||||
|
||||
/**
|
||||
* 获取节点的响应模板
|
||||
* 获取节点的响应模板 <br/>
|
||||
* 优先读取 sys_config 配置(可在系统管理-配置管理中自定义),
|
||||
* 未配置时回退到枚举内置默认模板, 模板仅为展示文案, 缺失不应中断工作流执行
|
||||
* @param configKey 参数Key
|
||||
* @return 返回模板样式
|
||||
*/
|
||||
public static String getNodeMessageTemplate(String configKey){
|
||||
ConfigService configService = SpringUtil.getBean(ConfigService.class);
|
||||
String configValue = configService.getConfigValue(configKey);
|
||||
if (StringUtils.isEmpty(configValue)) {
|
||||
throw new ServiceException("请先配置该节点的响应模板");
|
||||
if (StringUtils.isNotEmpty(configValue)) {
|
||||
return configValue;
|
||||
}
|
||||
return configValue;
|
||||
log.warn("sys_config 未配置节点响应模板 [{}], 已回退使用内置默认模板", configKey);
|
||||
return NodeMessageTemplateEnum.getDefaultTemplate(configKey);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package org.ruoyi.workflow.workflow;
|
||||
|
||||
import org.apache.commons.collections4.map.PassiveExpiringMap;
|
||||
|
||||
/**
|
||||
* 已中断正在等待用户输入的流程 <br/>
|
||||
* TODO 需要考虑项目多节点部署的情况
|
||||
*/
|
||||
public class InterruptedFlow {
|
||||
|
||||
/**
|
||||
* 10分钟超时
|
||||
*/
|
||||
private static final PassiveExpiringMap.ExpirationPolicy<String, WorkflowEngine> ep = new PassiveExpiringMap.ConstantTimeToLiveExpirationPolicy<>(60 * 1000 * 10);
|
||||
public static PassiveExpiringMap<String, WorkflowEngine> RUNTIME_TO_GRAPH = new PassiveExpiringMap<>(ep);
|
||||
|
||||
}
|
||||
@@ -16,24 +16,14 @@ public enum WfComponentNameEnum {
|
||||
|
||||
TONGYI_WANX("Tongyiwanx"),
|
||||
|
||||
DOCUMENT_EXTRACTOR("DocumentExtractor"),
|
||||
|
||||
KEYWORD_EXTRACTOR("KeywordExtractor"),
|
||||
|
||||
FAQ_EXTRACTOR("FaqExtractor"),
|
||||
|
||||
KNOWLEDGE_RETRIEVER("KnowledgeRetrieval"),
|
||||
|
||||
SWITCHER("Switcher"),
|
||||
|
||||
CLASSIFIER("Classifier"),
|
||||
|
||||
TEMPLATE("Template"),
|
||||
|
||||
GOOGLE_SEARCH("Google"),
|
||||
|
||||
HUMAN_FEEDBACK("HumanFeedback"),
|
||||
|
||||
MAIL_SEND("MailSend"),
|
||||
|
||||
HTTP_REQUEST("HttpRequest");
|
||||
|
||||
@@ -4,15 +4,14 @@ import org.ruoyi.workflow.entity.WorkflowComponent;
|
||||
import org.ruoyi.workflow.entity.WorkflowNode;
|
||||
import org.ruoyi.workflow.workflow.node.AbstractWfNode;
|
||||
import org.ruoyi.workflow.workflow.node.EndNode;
|
||||
import org.ruoyi.workflow.workflow.node.humanFeedBack.HumanFeedbackNode;
|
||||
import org.ruoyi.workflow.workflow.node.answer.LLMAnswerNode;
|
||||
import org.ruoyi.workflow.workflow.node.httpRequest.HttpRequestNode;
|
||||
import org.ruoyi.workflow.workflow.node.image.ImageNode;
|
||||
import org.ruoyi.workflow.workflow.node.keywordExtractor.KeywordExtractorNode;
|
||||
import org.ruoyi.workflow.workflow.node.knowledgeRetrieval.KnowledgeRetrievalNode;
|
||||
import org.ruoyi.workflow.workflow.node.mailSend.MailSendNode;
|
||||
import org.ruoyi.workflow.workflow.node.start.StartNode;
|
||||
import org.ruoyi.workflow.workflow.node.switcher.SwitcherNode;
|
||||
import org.ruoyi.workflow.workflow.node.googleSearch.GoogleSearchNode;
|
||||
|
||||
public class WfNodeFactory {
|
||||
public static AbstractWfNode create(WorkflowComponent wfComponent, WorkflowNode nodeDefinition,
|
||||
@@ -21,14 +20,13 @@ public class WfNodeFactory {
|
||||
switch (WfComponentNameEnum.getByName(wfComponent.getName())) {
|
||||
case START -> wfNode = new StartNode(wfComponent, nodeDefinition, wfState, nodeState);
|
||||
case LLM_ANSWER -> wfNode = new LLMAnswerNode(wfComponent, nodeDefinition, wfState, nodeState);
|
||||
case KEYWORD_EXTRACTOR -> wfNode = new KeywordExtractorNode(wfComponent, nodeDefinition, wfState, nodeState);
|
||||
case TONGYI_WANX -> wfNode = new ImageNode(wfComponent, nodeDefinition, wfState, nodeState);
|
||||
case KNOWLEDGE_RETRIEVER -> wfNode = new KnowledgeRetrievalNode(wfComponent, nodeDefinition, wfState, nodeState);
|
||||
case END -> wfNode = new EndNode(wfComponent, nodeDefinition, wfState, nodeState);
|
||||
case MAIL_SEND -> wfNode = new MailSendNode(wfComponent, nodeDefinition, wfState, nodeState);
|
||||
case HTTP_REQUEST -> wfNode = new HttpRequestNode(wfComponent, nodeDefinition, wfState, nodeState);
|
||||
case SWITCHER -> wfNode = new SwitcherNode(wfComponent, nodeDefinition, wfState, nodeState);
|
||||
case HUMAN_FEEDBACK -> wfNode = new HumanFeedbackNode(wfComponent, nodeDefinition, wfState, nodeState);
|
||||
case GOOGLE_SEARCH -> wfNode = new GoogleSearchNode(wfComponent, nodeDefinition, wfState, nodeState);
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,11 +55,6 @@ public class WfState {
|
||||
private List<NodeIOData> output = new ArrayList<>();
|
||||
private Integer processStatus = WORKFLOW_PROCESS_STATUS_READY;
|
||||
|
||||
/**
|
||||
* 人机交互节点
|
||||
*/
|
||||
private Set<String> interruptNodes = new HashSet<>();
|
||||
|
||||
public WfState(User user, List<NodeIOData> input, String uuid, Long userId, String tokenValue, SseEmitter sseEmitter, Long sessionId) {
|
||||
this.input = input;
|
||||
this.user = user;
|
||||
@@ -133,8 +128,4 @@ public class WfState {
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public void addInterruptNode(String nodeUuid) {
|
||||
this.interruptNodes.add(nodeUuid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,82 +108,39 @@ public class WorkflowEngine {
|
||||
|
||||
MemorySaver saver = new MemorySaver();
|
||||
CompileConfig compileConfig = CompileConfig.builder().checkpointSaver(saver)
|
||||
.interruptBefore(wfState.getInterruptNodes().toArray(String[]::new))
|
||||
.build();
|
||||
app = mainStateGraph.compile(compileConfig);
|
||||
RunnableConfig invokeConfig = RunnableConfig.builder().build();
|
||||
exe(invokeConfig, false);
|
||||
exe(invokeConfig);
|
||||
} catch (Exception e) {
|
||||
errorWhenExe(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void exe(RunnableConfig invokeConfig, boolean resume) {
|
||||
private void exe(RunnableConfig invokeConfig) {
|
||||
//不使用langgraph4j state的update相关方法,无需传入input
|
||||
AsyncGenerator<NodeOutput<WfNodeState>> outputs = app.stream(resume ? null : Map.of(), invokeConfig);
|
||||
AsyncGenerator<NodeOutput<WfNodeState>> outputs = app.stream(Map.of(), invokeConfig);
|
||||
streamingResult(wfState, outputs, sseEmitter);
|
||||
|
||||
StateSnapshot<WfNodeState> stateSnapshot = app.getState(invokeConfig);
|
||||
String nextNode = stateSnapshot.config().nextNode().orElse("");
|
||||
//还有下个节点,表示进入中断状态,等待用户输入后继续执<E7BBAD>?
|
||||
if (StringUtils.isNotBlank(nextNode) && !nextNode.equalsIgnoreCase(END)) {
|
||||
// 获取提示模板
|
||||
String nodeMessageTemplate = WorkflowMessageUtil.getNodeMessageTemplate(NodeMessageTemplateEnum.HUMAN_FEED_BACK.getValue());
|
||||
// 获取人机交互提示信息
|
||||
String intTip = nodeMessageTemplate + WorkflowUtil.getHumanFeedbackTip(nextNode, wfNodes);
|
||||
//将等待输入信息[事件与提示词]发送到到客户端
|
||||
SSEEmitterHelper.parseAndSendPartialMsg(sseEmitter, "[NODE_WAIT_FEEDBACK_BY_" + nextNode + "]", intTip);
|
||||
// 保存提示信息到Chat信息记录中(对话使用)
|
||||
WorkflowMessageUtil.saveWorkflowMessage(wfState, intTip);
|
||||
InterruptedFlow.RUNTIME_TO_GRAPH.put(wfState.getUuid(), this);
|
||||
//更新状<E696B0>?
|
||||
wfState.setProcessStatus(WORKFLOW_PROCESS_STATUS_WAITING_INPUT);
|
||||
workflowRuntimeService.updateOutput(wfRuntimeResp.getId(), wfState);
|
||||
} else {
|
||||
WorkflowRuntime updatedRuntime = workflowRuntimeService.updateOutput(wfRuntimeResp.getId(), wfState);
|
||||
// 保存成功会话信息
|
||||
wfNodes.stream().filter(item -> stateSnapshot.node().equals(item.getUuid()))
|
||||
.findFirst().ifPresent(wfNode -> {
|
||||
// 获取节点模板提示词信息
|
||||
String nodeMessageTemplate = WorkflowMessageUtil.getNodeMessageTemplate(NodeMessageTemplateEnum.END.getValue());
|
||||
// 发送SSE消息驱动事件和保存会话
|
||||
WorkflowMessageUtil.notifyAndStoreMessage(wfState, sseEmitter, wfNode, nodeMessageTemplate);
|
||||
});
|
||||
// 发送结束消息
|
||||
sseEmitterHelper.sendComplete(user.getId(), sseEmitter, updatedRuntime.getOutput());
|
||||
// 发送驱动消息事件
|
||||
InterruptedFlow.RUNTIME_TO_GRAPH.remove(wfState.getUuid());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 中断流程等待用户输入时,会进行暂停状态,用户输入后调用本方法执行流程剩余部分
|
||||
*
|
||||
* @param userInput 用户输入
|
||||
*/
|
||||
public void resume(String userInput) {
|
||||
RunnableConfig invokeConfig = RunnableConfig.builder().build();
|
||||
try {
|
||||
app.updateState(invokeConfig, Map.of(HUMAN_FEEDBACK_KEY, userInput), null);
|
||||
exe(invokeConfig, true);
|
||||
} catch (Exception e) {
|
||||
errorWhenExe(e);
|
||||
} finally {
|
||||
//有可能多次接收人机交互,待整个流程完全执行后才能删除
|
||||
if (wfState.getProcessStatus() != WORKFLOW_PROCESS_STATUS_WAITING_INPUT) {
|
||||
InterruptedFlow.RUNTIME_TO_GRAPH.remove(wfState.getUuid());
|
||||
}
|
||||
}
|
||||
wfState.setProcessStatus(WORKFLOW_PROCESS_STATUS_SUCCESS);
|
||||
WorkflowRuntime updatedRuntime = workflowRuntimeService.updateOutput(wfRuntimeResp.getId(), wfState);
|
||||
wfNodes.stream().filter(item -> stateSnapshot.node().equals(item.getUuid()))
|
||||
.findFirst().ifPresent(wfNode -> {
|
||||
String nodeMessageTemplate = WorkflowMessageUtil.getNodeMessageTemplate(NodeMessageTemplateEnum.END.getValue());
|
||||
WorkflowMessageUtil.notifyAndStoreMessage(wfState, sseEmitter, wfNode, nodeMessageTemplate);
|
||||
});
|
||||
sseEmitterHelper.sendComplete(user.getId(), sseEmitter, updatedRuntime.getOutput());
|
||||
}
|
||||
|
||||
private void errorWhenExe(Exception e) {
|
||||
log.error("error", e);
|
||||
String nodeMessageTemplate = WorkflowMessageUtil.getNodeMessageTemplate(NodeMessageTemplateEnum.EXCEPTION.getValue());
|
||||
String errorMsg = e.getMessage();
|
||||
if (errorMsg.contains("parallel node doesn't support conditional branch")) {
|
||||
if (errorMsg != null && errorMsg.contains("parallel node doesn't support conditional branch")) {
|
||||
errorMsg = "并行节点中不能包含条件分<EFBFBD>?";
|
||||
}
|
||||
errorMsg = nodeMessageTemplate + errorMsg;
|
||||
errorMsg = nodeMessageTemplate + (errorMsg != null ? errorMsg : e.getClass().getSimpleName());
|
||||
// 保存会话信息且发送驱动消息事件
|
||||
WorkflowMessageUtil.saveWorkflowMessage(wfState, errorMsg);
|
||||
sseEmitterHelper.sendErrorAndComplete(user.getId(), sseEmitter, errorMsg);
|
||||
@@ -267,6 +224,10 @@ public class WorkflowEngine {
|
||||
log.info("node:{},chunk:{}", node, chunk);
|
||||
SSEEmitterHelper.parseAndSendPartialMsg(sseEmitter, "[NODE_CHUNK_" + node + "]", chunk);
|
||||
} else {
|
||||
// __END__ 是 langgraph4j 的终止伪节点, 无对应业务节点状态, 跳过
|
||||
if (END.equals(out.node())) {
|
||||
continue;
|
||||
}
|
||||
AbstractWfNode abstractWfNode = wfState.getCompletedNodes().stream()
|
||||
.filter(item -> item.getNode().getUuid().endsWith(out.node())).findFirst().orElse(null);
|
||||
if (null != abstractWfNode) {
|
||||
|
||||
@@ -19,7 +19,6 @@ import static org.bsc.langgraph4j.StateGraph.END;
|
||||
import static org.bsc.langgraph4j.StateGraph.START;
|
||||
import static org.bsc.langgraph4j.action.AsyncEdgeAction.edge_async;
|
||||
import static org.bsc.langgraph4j.action.AsyncNodeAction.node_async;
|
||||
import static org.ruoyi.workflow.workflow.WfComponentNameEnum.HUMAN_FEEDBACK;
|
||||
|
||||
/**
|
||||
* 负责构建工作流运行所依赖的状态图<E68081>?
|
||||
@@ -27,7 +26,6 @@ import static org.ruoyi.workflow.workflow.WfComponentNameEnum.HUMAN_FEEDBACK;
|
||||
@Slf4j
|
||||
public class WorkflowGraphBuilder {
|
||||
|
||||
private final Map<Long, WorkflowComponent> componentIndex;
|
||||
private final Map<String, WorkflowNode> nodeIndex;
|
||||
private final Map<String, List<WorkflowEdge>> edgesBySource;
|
||||
private final Map<String, List<WorkflowEdge>> edgesByTarget;
|
||||
@@ -46,8 +44,6 @@ public class WorkflowGraphBuilder {
|
||||
List<WorkflowEdge> edges,
|
||||
WorkflowNodeRunner nodeRunner,
|
||||
WfState wfState) {
|
||||
this.componentIndex = components.stream()
|
||||
.collect(Collectors.toMap(WorkflowComponent::getId, Function.identity(), (origin, ignore) -> origin));
|
||||
this.nodeIndex = nodes.stream()
|
||||
.collect(Collectors.toMap(WorkflowNode::getUuid, Function.identity(), (origin, ignore) -> origin));
|
||||
this.edgesBySource = edges.stream().collect(Collectors.groupingBy(WorkflowEdge::getSourceNodeUuid));
|
||||
@@ -217,14 +213,6 @@ public class WorkflowGraphBuilder {
|
||||
WorkflowNode wfNode = getNodeByUuid(stateGraphNodeUuid);
|
||||
stateGraph.addNode(stateGraphNodeUuid, node_async(state -> nodeRunner.run(wfNode, state)));
|
||||
stateGraphList.add(stateGraph);
|
||||
|
||||
WorkflowComponent component = componentIndex.get(wfNode.getWorkflowComponentId());
|
||||
if (component == null) {
|
||||
throw new BaseException(ErrorEnum.A_PARAMS_ERROR.getInfo());
|
||||
}
|
||||
if (HUMAN_FEEDBACK.getName().equals(component.getName())) {
|
||||
wfState.addInterruptNode(stateGraphNodeUuid);
|
||||
}
|
||||
}
|
||||
|
||||
private void addEdgeToStateGraph(StateGraph<WfNodeState> stateGraph, String source, String target) throws GraphStateException {
|
||||
|
||||
@@ -6,9 +6,9 @@ import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.ruoyi.common.chat.entity.User;
|
||||
import org.ruoyi.common.chat.service.workFlow.IWorkFlowStarterService;
|
||||
import org.ruoyi.common.core.exception.base.BaseException;
|
||||
import org.ruoyi.common.satoken.utils.LoginHelper;
|
||||
import org.ruoyi.common.sse.core.SseEmitterManager;
|
||||
import org.ruoyi.common.tenant.helper.TenantHelper;
|
||||
import org.ruoyi.workflow.entity.*;
|
||||
import org.ruoyi.workflow.helper.SSEEmitterHelper;
|
||||
import org.ruoyi.workflow.service.*;
|
||||
@@ -58,6 +58,8 @@ public class WorkflowStarter implements IWorkFlowStarterService {
|
||||
Long userId = LoginHelper.getUserId();
|
||||
// 获取登录Token(仅透传给 WfState,工作流 SSE 通过 emitter 直发,不串台)
|
||||
String tokenValue = StpUtil.getTokenValue();
|
||||
// 获取当前租户ID(@Async 线程不继承请求线程的租户上下文,需显式透传)
|
||||
String tenantId = TenantHelper.getTenantId();
|
||||
// 根据会话ID连接SSE对象(每会话一个连接,避免同用户多会话串台)
|
||||
SseEmitter sseEmitter = sseEmitterManager.connect(String.valueOf(sessionId));
|
||||
if (!sseEmitterHelper.checkOrComplete(user, sseEmitter)) {
|
||||
@@ -71,41 +73,33 @@ public class WorkflowStarter implements IWorkFlowStarterService {
|
||||
sseEmitterHelper.sendErrorAndComplete(user.getId(), sseEmitter, A_WF_DISABLED.getInfo());
|
||||
return sseEmitter;
|
||||
}
|
||||
self.asyncRun(user, workflow, userInputs, sseEmitter, userId, tokenValue, sessionId);
|
||||
self.asyncRun(user, workflow, userInputs, sseEmitter, userId, tokenValue, sessionId, tenantId);
|
||||
return sseEmitter;
|
||||
}
|
||||
|
||||
@Async
|
||||
public void asyncRun(User user, Workflow workflow, List<ObjectNode> userInputs, SseEmitter sseEmitter, Long userId, String tokenValue, Long sessionId) {
|
||||
log.info("WorkflowEngine run,userId:{},workflowUuid:{},userInputs:{}", user.getId(), workflow.getUuid(), userInputs);
|
||||
List<WorkflowComponent> components = workflowComponentService.getAllEnable();
|
||||
List<WorkflowNode> nodes = workflowNodeService.lambdaQuery()
|
||||
.eq(WorkflowNode::getWorkflowId, workflow.getId())
|
||||
.eq(WorkflowNode::getIsDeleted, false)
|
||||
.list();
|
||||
List<WorkflowEdge> edges = workflowEdgeService.lambdaQuery()
|
||||
.eq(WorkflowEdge::getWorkflowId, workflow.getId())
|
||||
.eq(WorkflowEdge::getIsDeleted, false)
|
||||
.list();
|
||||
WorkflowEngine workflowEngine = new WorkflowEngine(workflow,
|
||||
sseEmitterHelper, components, nodes, edges,
|
||||
workflowRuntimeService, workflowRuntimeNodeService);
|
||||
workflowEngine.run(user, userInputs, sseEmitter, userId, tokenValue, sessionId);
|
||||
}
|
||||
|
||||
@Async
|
||||
public void resumeFlow(String runtimeUuid, String userInput, SseEmitter sseEmitter) {
|
||||
WorkflowEngine workflowEngine = InterruptedFlow.RUNTIME_TO_GRAPH.get(runtimeUuid);
|
||||
if (null == workflowEngine) {
|
||||
log.error("工作流恢复执行时失败,runtime:{}", runtimeUuid);
|
||||
throw new BaseException(A_WF_RESUME_FAIL.getInfo());
|
||||
public void asyncRun(User user, Workflow workflow, List<ObjectNode> userInputs, SseEmitter sseEmitter, Long userId, String tokenValue, Long sessionId, String tenantId) {
|
||||
// @Async 线程不继承请求线程的租户上下文, 显式设置, 避免租户缓存/隔离逻辑异常
|
||||
if (tenantId != null) {
|
||||
TenantHelper.setDynamic(tenantId);
|
||||
}
|
||||
// 如果SSE连接对象不为空传入该对象(Chat调用工作流对话使用)
|
||||
if (null != sseEmitter){
|
||||
workflowEngine.setSseEmitter(sseEmitter);
|
||||
// 为了让每个节点都可以发送模板消息 保持SSE对象一致(以防出现向已关闭的SSE对象发送消息)
|
||||
workflowEngine.getWfState().setSseEmitter(sseEmitter);
|
||||
try {
|
||||
log.info("WorkflowEngine run,userId:{},workflowUuid:{},userInputs:{}", user.getId(), workflow.getUuid(), userInputs);
|
||||
List<WorkflowComponent> components = workflowComponentService.getAllEnable();
|
||||
List<WorkflowNode> nodes = workflowNodeService.lambdaQuery()
|
||||
.eq(WorkflowNode::getWorkflowId, workflow.getId())
|
||||
.eq(WorkflowNode::getIsDeleted, false)
|
||||
.list();
|
||||
List<WorkflowEdge> edges = workflowEdgeService.lambdaQuery()
|
||||
.eq(WorkflowEdge::getWorkflowId, workflow.getId())
|
||||
.eq(WorkflowEdge::getIsDeleted, false)
|
||||
.list();
|
||||
WorkflowEngine workflowEngine = new WorkflowEngine(workflow,
|
||||
sseEmitterHelper, components, nodes, edges,
|
||||
workflowRuntimeService, workflowRuntimeNodeService);
|
||||
workflowEngine.run(user, userInputs, sseEmitter, userId, tokenValue, sessionId);
|
||||
} finally {
|
||||
TenantHelper.clearDynamic();
|
||||
}
|
||||
workflowEngine.resume(userInput);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.ruoyi.workflow.workflow;
|
||||
|
||||
import cn.hutool.core.collection.CollStreamUtil;
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import dev.langchain4j.data.message.ChatMessage;
|
||||
import dev.langchain4j.data.message.UserMessage;
|
||||
import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
|
||||
@@ -20,7 +19,6 @@ import org.ruoyi.common.chat.factory.ImageServiceFactory;
|
||||
import org.ruoyi.workflow.base.NodeInputConfigTypeHandler;
|
||||
import org.ruoyi.workflow.entity.WorkflowNode;
|
||||
import org.ruoyi.workflow.enums.WfIODataTypeEnum;
|
||||
import org.ruoyi.workflow.util.JsonUtil;
|
||||
import org.ruoyi.workflow.workflow.data.NodeIOData;
|
||||
import org.ruoyi.workflow.workflow.data.NodeIODataContent;
|
||||
import org.ruoyi.workflow.workflow.def.WfNodeParamRef;
|
||||
@@ -88,22 +86,6 @@ public class WorkflowUtil{
|
||||
return result;
|
||||
}
|
||||
|
||||
public static String getHumanFeedbackTip(String nodeUuid, List<WorkflowNode> wfNodes) {
|
||||
WorkflowNode wfNode = wfNodes.stream()
|
||||
.filter(item -> item.getUuid().equals(nodeUuid))
|
||||
.findFirst().orElse(null);
|
||||
if (null == wfNode) {
|
||||
return "";
|
||||
}
|
||||
String wfNodeNodeConfig = wfNode.getNodeConfig();
|
||||
if (StrUtil.isBlank(wfNodeNodeConfig)) {
|
||||
return "";
|
||||
}
|
||||
Map<String, Object> map = JsonUtil.toMap(wfNodeNodeConfig);
|
||||
Object tip = map.getOrDefault("tip", "");
|
||||
return String.valueOf(tip);
|
||||
}
|
||||
|
||||
public void streamingInvokeLLM(WfState wfState, WfNodeState state, WorkflowNode node, String modelName,
|
||||
String prompt, String nodeMessageTemplate) {
|
||||
log.info("stream invoke, modelName: {}", modelName);
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
package org.ruoyi.workflow.workflow.node.classifier;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class ClassifierNodeConfig {
|
||||
private List categories = new ArrayList<>();
|
||||
@JsonProperty("model_platform")
|
||||
private String modelPlatform;
|
||||
@JsonProperty("model_name")
|
||||
private String modelName;
|
||||
}
|
||||
@@ -3,23 +3,44 @@ package org.ruoyi.workflow.workflow.node.enmus;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 节点消息模板ConfigKey枚举
|
||||
* 节点消息模板ConfigKey枚举 <br/>
|
||||
* 模板优先从 sys_config 读取(可在系统管理-配置管理中自定义), 未配置时回退到 defaultTemplate 内置默认值
|
||||
*/
|
||||
@Getter
|
||||
public enum NodeMessageTemplateEnum {
|
||||
HTTP_REQUEST("node.httpRequest.template"),
|
||||
MAIL_SEND("node.mailsend.template"),
|
||||
IMAGE("node.image.template"),
|
||||
HUMAN_FEED_BACK("node.humanFeedback.template"),
|
||||
SWITCH("node.switch.template"),
|
||||
LLM_RESPONSE("node.llmAnswer.template"),
|
||||
KEYWORD_EXTRACTOR("node.keywordExtractor.template"),
|
||||
EXCEPTION("node.exception.template"),
|
||||
END("node.end.template");
|
||||
HTTP_REQUEST("node.httpRequest.template", "✅ HTTP请求节点:结束响应 - "),
|
||||
MAIL_SEND("node.mailsend.template", "📧 发送邮箱节点:结束响应 - "),
|
||||
IMAGE("node.image.template", "🎨 文生图节点:结束响应 - 图片URL: "),
|
||||
SWITCH("node.switch.template", "🔀 条件分支节点:触发 -> 跳转到节点 "),
|
||||
LLM_RESPONSE("node.llmAnswer.template", "🤖 LLM 节点 生成回答:"),
|
||||
GOOGLE_SEARCH("node.googleSearch.template", "🔍 网络搜索节点处理完成:"),
|
||||
EXCEPTION("node.exception.template", "🛑 工作流发生异常:"),
|
||||
END("node.end.template", "🔚 流程已执行完毕,如果您有其他需求,请随时重新发起请求。");
|
||||
|
||||
private final String value;
|
||||
|
||||
NodeMessageTemplateEnum(String value) {
|
||||
/**
|
||||
* 内置默认模板, sys_config 未配置对应键时使用
|
||||
*/
|
||||
private final String defaultTemplate;
|
||||
|
||||
NodeMessageTemplateEnum(String value, String defaultTemplate) {
|
||||
this.value = value;
|
||||
this.defaultTemplate = defaultTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 configKey 获取内置默认模板, 未知键返回空串
|
||||
*
|
||||
* @param configKey sys_config 配置键
|
||||
* @return 内置默认模板
|
||||
*/
|
||||
public static String getDefaultTemplate(String configKey) {
|
||||
for (NodeMessageTemplateEnum item : values()) {
|
||||
if (item.value.equals(configKey)) {
|
||||
return item.defaultTemplate;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package org.ruoyi.workflow.workflow.node.googleSearch;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.ruoyi.workflow.entity.WorkflowComponent;
|
||||
import org.ruoyi.workflow.entity.WorkflowNode;
|
||||
import org.ruoyi.workflow.util.JsonUtil;
|
||||
import org.ruoyi.workflow.util.SpringUtil;
|
||||
import org.ruoyi.workflow.workflow.NodeProcessResult;
|
||||
import org.ruoyi.workflow.workflow.WfNodeState;
|
||||
import org.ruoyi.workflow.workflow.WfState;
|
||||
import org.ruoyi.workflow.workflow.WorkflowUtil;
|
||||
import org.ruoyi.workflow.workflow.data.NodeIOData;
|
||||
import org.ruoyi.workflow.workflow.node.AbstractWfNode;
|
||||
import org.ruoyi.workflow.workflow.node.enmus.NodeMessageTemplateEnum;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.ruoyi.workflow.cosntant.AdiConstant.WorkflowConstant.DEFAULT_OUTPUT_PARAM_NAME;
|
||||
|
||||
/**
|
||||
* 【扩展节点】网络搜索
|
||||
* 通过智谱 Web Search API 返回适合大模型消费的结构化网页结果。
|
||||
*/
|
||||
@Slf4j
|
||||
public class GoogleSearchNode extends AbstractWfNode {
|
||||
|
||||
public GoogleSearchNode(WorkflowComponent wfComponent, WorkflowNode nodeDef, WfState wfState, WfNodeState nodeState) {
|
||||
super(wfComponent, nodeDef, wfState, nodeState);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理搜索请求
|
||||
* nodeConfig 格式:
|
||||
* {
|
||||
* "query": "搜索关键词",
|
||||
* "search_engine": "search_std",
|
||||
* "result_count": 10,
|
||||
* "search_domain_filter": "",
|
||||
* "search_recency_filter": "noLimit",
|
||||
* "content_size": "medium",
|
||||
* "include_image": false
|
||||
* }
|
||||
*
|
||||
* @return 搜索结果
|
||||
*/
|
||||
@Override
|
||||
public NodeProcessResult onProcess() {
|
||||
GoogleSearchNodeConfig config = checkAndGetConfig(GoogleSearchNodeConfig.class);
|
||||
|
||||
// 获取搜索关键词
|
||||
String searchQuery = WorkflowUtil.renderTemplate(config.getQuery(), state.getInputs());
|
||||
if (StringUtils.isBlank(searchQuery)) {
|
||||
searchQuery = getFirstInputText();
|
||||
}
|
||||
|
||||
if (StringUtils.isBlank(searchQuery)) {
|
||||
throw new IllegalArgumentException("未提供搜索关键词");
|
||||
}
|
||||
searchQuery = searchQuery.trim();
|
||||
if (searchQuery.length() > 70) {
|
||||
throw new IllegalArgumentException("搜索关键词不能超过 70 个字符");
|
||||
}
|
||||
|
||||
log.info("Web search node processing, engine: {}, result_count: {}",
|
||||
config.getSearchEngine(), config.getResultCount());
|
||||
|
||||
String nodeMessageTemplate = getNodeMessageTemplate(NodeMessageTemplateEnum.GOOGLE_SEARCH.getValue());
|
||||
notifyAndStoreMessage(wfState, nodeMessageTemplate);
|
||||
|
||||
ZhipuWebSearchClient searchClient = SpringUtil.getBean(ZhipuWebSearchClient.class);
|
||||
ZhipuWebSearchClient.SearchResponse response = searchClient.search(
|
||||
searchQuery,
|
||||
config,
|
||||
UUID.randomUUID().toString()
|
||||
);
|
||||
String searchResult = JsonUtil.toJson(response);
|
||||
if (searchResult == null) {
|
||||
throw new IllegalStateException("搜索结果序列化失败");
|
||||
}
|
||||
|
||||
log.info("Web search completed, result count: {}", response.count());
|
||||
notifyAndStoreMessage(wfState, nodeMessageTemplate + "返回 " + response.count() + " 条结果");
|
||||
|
||||
List<NodeIOData> outputs = List.of(
|
||||
NodeIOData.createByText(DEFAULT_OUTPUT_PARAM_NAME, "智谱网络搜索结果", searchResult)
|
||||
);
|
||||
return NodeProcessResult.builder().content(outputs).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.ruoyi.workflow.workflow.node.googleSearch;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class GoogleSearchNodeConfig {
|
||||
/**
|
||||
* 搜索查询关键词
|
||||
*/
|
||||
private String query;
|
||||
|
||||
@JsonProperty("search_engine")
|
||||
@Pattern(
|
||||
regexp = "search_std|search_pro|search_pro_sogou|search_pro_quark",
|
||||
message = "搜索引擎参数无效"
|
||||
)
|
||||
private String searchEngine = "search_std";
|
||||
|
||||
@JsonProperty("result_count")
|
||||
@Min(value = 1, message = "搜索结果数量不能小于 1")
|
||||
@Max(value = 50, message = "搜索结果数量不能大于 50")
|
||||
private Integer resultCount = 10;
|
||||
|
||||
@JsonProperty("search_domain_filter")
|
||||
private String searchDomainFilter;
|
||||
|
||||
@JsonProperty("search_recency_filter")
|
||||
@Pattern(
|
||||
regexp = "oneDay|oneWeek|oneMonth|oneYear|noLimit",
|
||||
message = "搜索时间范围参数无效"
|
||||
)
|
||||
private String searchRecencyFilter = "noLimit";
|
||||
|
||||
@JsonProperty("content_size")
|
||||
@Pattern(regexp = "medium|high", message = "网页摘要长度参数无效")
|
||||
private String contentSize = "medium";
|
||||
|
||||
@JsonProperty("include_image")
|
||||
private Boolean includeImage = false;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package org.ruoyi.workflow.workflow.node.googleSearch;
|
||||
|
||||
import ai.z.openapi.ZhipuAiClient;
|
||||
import ai.z.openapi.service.web_search.WebSearchRequest;
|
||||
import ai.z.openapi.service.web_search.WebSearchResp;
|
||||
import ai.z.openapi.service.web_search.WebSearchResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo;
|
||||
import org.ruoyi.common.chat.service.chat.IChatModelService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 智谱 Web Search 官方 SDK 适配器。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ZhipuWebSearchClient {
|
||||
|
||||
private static final String ZHIPU_PROVIDER_CODE = "zhipu";
|
||||
private static final String DEFAULT_BASE_URL = "https://open.bigmodel.cn/api/paas/v4/";
|
||||
|
||||
private final ZhipuWebSearchProperties properties;
|
||||
private final IChatModelService chatModelService;
|
||||
|
||||
public SearchResponse search(String query, GoogleSearchNodeConfig config, String requestId) {
|
||||
Credential credential = resolveCredential();
|
||||
ZhipuAiClient client = createClient(credential);
|
||||
try {
|
||||
WebSearchRequest request = WebSearchRequest.builder()
|
||||
.searchQuery(query)
|
||||
.searchEngine(config.getSearchEngine())
|
||||
.count(config.getResultCount())
|
||||
.searchDomainFilter(blankToNull(config.getSearchDomainFilter()))
|
||||
.searchRecencyFilter(config.getSearchRecencyFilter())
|
||||
.contentSize(config.getContentSize())
|
||||
.includeImage(config.getIncludeImage())
|
||||
.requestId(requestId)
|
||||
.build();
|
||||
|
||||
WebSearchResponse response = client.webSearch().createWebSearch(request);
|
||||
if (response == null || !response.isSuccess() || response.getData() == null) {
|
||||
String message = response == null ? "接口未返回响应" : StringUtils.defaultIfBlank(response.getMsg(), "未知错误");
|
||||
throw new IllegalStateException("智谱 Web Search 调用失败:" + message);
|
||||
}
|
||||
|
||||
List<SearchResult> results = response.getData().getWebSearchResp() == null
|
||||
? List.of()
|
||||
: response.getData().getWebSearchResp().stream()
|
||||
.map(this::toSearchResult)
|
||||
.toList();
|
||||
|
||||
return new SearchResponse(
|
||||
query,
|
||||
config.getSearchEngine(),
|
||||
response.getData().getRequestId(),
|
||||
results.size(),
|
||||
results
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
private ZhipuAiClient createClient(Credential credential) {
|
||||
int connectTimeout = positiveOrDefault(properties.getConnectTimeout(), 10);
|
||||
int readTimeout = positiveOrDefault(properties.getReadTimeout(), 30);
|
||||
return ZhipuAiClient.builder()
|
||||
.ofZHIPU()
|
||||
.apiKey(credential.apiKey())
|
||||
.baseUrl(credential.baseUrl())
|
||||
.networkConfig(connectTimeout, readTimeout, readTimeout, readTimeout, TimeUnit.SECONDS)
|
||||
.enableTokenCache()
|
||||
.build();
|
||||
}
|
||||
|
||||
private Credential resolveCredential() {
|
||||
if (isUsableApiKey(properties.getApiKey())) {
|
||||
return new Credential(normalizeBaseUrl(properties.getBaseUrl()), properties.getApiKey().trim());
|
||||
}
|
||||
|
||||
ChatModelBo query = new ChatModelBo();
|
||||
query.setProviderCode(ZHIPU_PROVIDER_CODE);
|
||||
return chatModelService.queryList(query).stream()
|
||||
.filter(model -> isUsableApiKey(model.getApiKey()))
|
||||
.findFirst()
|
||||
.map(model -> new Credential(normalizeBaseUrl(model.getApiHost()), model.getApiKey().trim()))
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"未配置智谱 API Key,请设置环境变量 ZAI_API_KEY,或在模型管理中配置 zhipu 厂商密钥"
|
||||
));
|
||||
}
|
||||
|
||||
private boolean isUsableApiKey(String apiKey) {
|
||||
return StringUtils.isNotBlank(apiKey)
|
||||
&& !"sk_xx".equalsIgnoreCase(apiKey.trim())
|
||||
&& !"your_api_key".equalsIgnoreCase(apiKey.trim());
|
||||
}
|
||||
|
||||
private String normalizeBaseUrl(String baseUrl) {
|
||||
String normalized = StringUtils.defaultIfBlank(baseUrl, DEFAULT_BASE_URL).trim();
|
||||
normalized = StringUtils.removeEnd(normalized, "/");
|
||||
if (!normalized.endsWith("/api/paas/v4")) {
|
||||
normalized += "/api/paas/v4";
|
||||
}
|
||||
return normalized + "/";
|
||||
}
|
||||
|
||||
private int positiveOrDefault(Integer value, int defaultValue) {
|
||||
return value != null && value > 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
private String blankToNull(String value) {
|
||||
return StringUtils.isBlank(value) ? null : value.trim();
|
||||
}
|
||||
|
||||
private SearchResult toSearchResult(WebSearchResp result) {
|
||||
return new SearchResult(
|
||||
result.getTitle(),
|
||||
result.getContent(),
|
||||
result.getLink(),
|
||||
result.getMedia(),
|
||||
result.getIcon(),
|
||||
result.getRefer(),
|
||||
result.getPublishDate(),
|
||||
result.getImages()
|
||||
);
|
||||
}
|
||||
|
||||
private record Credential(String baseUrl, String apiKey) {
|
||||
}
|
||||
|
||||
public record SearchResponse(
|
||||
String query,
|
||||
String searchEngine,
|
||||
String requestId,
|
||||
int count,
|
||||
List<SearchResult> results
|
||||
) {
|
||||
}
|
||||
|
||||
public record SearchResult(
|
||||
String title,
|
||||
String content,
|
||||
String link,
|
||||
String media,
|
||||
String icon,
|
||||
String refer,
|
||||
String publishDate,
|
||||
List<String> images
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.ruoyi.workflow.workflow.node.googleSearch;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 智谱 Web Search 配置。
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "workflow.web-search.zhipu")
|
||||
public class ZhipuWebSearchProperties {
|
||||
|
||||
/**
|
||||
* 智谱国内开放平台 API 根地址。
|
||||
*/
|
||||
private String baseUrl = "https://open.bigmodel.cn/api/paas/v4/";
|
||||
|
||||
/**
|
||||
* 智谱 API Key。建议通过环境变量 ZAI_API_KEY 注入。
|
||||
*/
|
||||
private String apiKey;
|
||||
|
||||
/**
|
||||
* 连接超时秒数。
|
||||
*/
|
||||
private Integer connectTimeout = 10;
|
||||
|
||||
/**
|
||||
* 读取超时秒数。
|
||||
*/
|
||||
private Integer readTimeout = 30;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package org.ruoyi.workflow.workflow.node.humanFeedBack;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.ruoyi.workflow.entity.WorkflowComponent;
|
||||
import org.ruoyi.workflow.entity.WorkflowNode;
|
||||
import org.ruoyi.workflow.workflow.NodeProcessResult;
|
||||
import org.ruoyi.workflow.workflow.WfNodeState;
|
||||
import org.ruoyi.workflow.workflow.WfState;
|
||||
import org.ruoyi.workflow.workflow.WorkflowUtil;
|
||||
import org.ruoyi.workflow.workflow.data.NodeIOData;
|
||||
import org.ruoyi.workflow.workflow.node.AbstractWfNode;
|
||||
|
||||
import static org.ruoyi.workflow.cosntant.AdiConstant.WorkflowConstant.*;
|
||||
|
||||
/**
|
||||
* 人机交互节点实现类
|
||||
*/
|
||||
@Slf4j
|
||||
public class HumanFeedbackNode extends AbstractWfNode {
|
||||
|
||||
public HumanFeedbackNode(WorkflowComponent component, WorkflowNode nodeDefinition, WfState wfState, WfNodeState nodeState) {
|
||||
super(component, nodeDefinition, wfState, nodeState);
|
||||
}
|
||||
|
||||
// 人机交互节点的处理逻辑
|
||||
@Override
|
||||
public NodeProcessResult onProcess() {
|
||||
log.info("Processing HumanFeedback node: {}", node.getTitle());
|
||||
// 从状态中获取用户输入数据
|
||||
Object humanFeedbackState = state.data().get(HUMAN_FEEDBACK_KEY);
|
||||
if (null != humanFeedbackState) {
|
||||
String userInput = humanFeedbackState.toString();
|
||||
if (StringUtils.isNotBlank(userInput)) {
|
||||
// 用户已提供输入,将用户输入添加到节点输入和输出中
|
||||
NodeIOData feedbackData = NodeIOData.createByText("output", "default", userInput);
|
||||
// 添加到输出列表,这样后续节点可以使用
|
||||
state.getOutputs().add(feedbackData);
|
||||
// 设置为成功状态
|
||||
state.setProcessStatus(NODE_PROCESS_STATUS_SUCCESS);
|
||||
log.info("Human feedback processed for node: {}, content: {}", node.getTitle(), userInput);
|
||||
} else {
|
||||
// 用户输入为空,设置等待状态
|
||||
state.setProcessStatus(NODE_PROCESS_STATUS_DOING);
|
||||
log.info("Human feedback is empty for node: {}", node.getTitle());
|
||||
}
|
||||
} else {
|
||||
// 没有用户输入,这可能是正常情况(等待用户输入)
|
||||
// 但为了确保流程可以继续,我们仍然标记为成功
|
||||
state.setProcessStatus(NODE_PROCESS_STATUS_SUCCESS);
|
||||
log.info("No human feedback found for node: {}, continuing workflow", node.getTitle());
|
||||
}
|
||||
return new NodeProcessResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
package org.ruoyi.workflow.workflow.node.keywordExtractor;
|
||||
|
||||
import dev.langchain4j.data.message.SystemMessage;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.ruoyi.workflow.entity.WorkflowComponent;
|
||||
import org.ruoyi.workflow.entity.WorkflowNode;
|
||||
import org.ruoyi.workflow.util.SpringUtil;
|
||||
import org.ruoyi.workflow.util.WorkflowMessageUtil;
|
||||
import org.ruoyi.workflow.workflow.NodeProcessResult;
|
||||
import org.ruoyi.workflow.workflow.WfNodeState;
|
||||
import org.ruoyi.workflow.workflow.WfState;
|
||||
import org.ruoyi.workflow.workflow.WorkflowUtil;
|
||||
import org.ruoyi.workflow.workflow.data.NodeIOData;
|
||||
import org.ruoyi.workflow.workflow.node.AbstractWfNode;
|
||||
import org.ruoyi.workflow.workflow.node.enmus.NodeMessageTemplateEnum;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.ruoyi.workflow.cosntant.AdiConstant.WorkflowConstant.DEFAULT_OUTPUT_PARAM_NAME;
|
||||
|
||||
/**
|
||||
* 【节点】关键词提取节点
|
||||
* 使用 LLM 从文本中提取关键词
|
||||
*/
|
||||
@Slf4j
|
||||
public class KeywordExtractorNode extends AbstractWfNode {
|
||||
|
||||
public KeywordExtractorNode(WorkflowComponent wfComponent, WorkflowNode nodeDef, WfState wfState, WfNodeState nodeState) {
|
||||
super(wfComponent, nodeDef, wfState, nodeState);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理关键词提取
|
||||
* nodeConfig 格式:
|
||||
* {
|
||||
* "model_name": "deepseek-chat",
|
||||
* "category": "llm",
|
||||
* "top_n": 5,
|
||||
* "prompt": "额外的提示词"
|
||||
* }
|
||||
*
|
||||
* @return 提取的关键词列表
|
||||
*/
|
||||
@Override
|
||||
public NodeProcessResult onProcess() {
|
||||
KeywordExtractorNodeConfig config = checkAndGetConfig(KeywordExtractorNodeConfig.class);
|
||||
|
||||
// 获取输入文本
|
||||
String inputText = getFirstInputText();
|
||||
if (StringUtils.isBlank(inputText)) {
|
||||
log.warn("Keyword extractor node has no input text, node: {}", state.getUuid());
|
||||
// 返回空结果
|
||||
List<NodeIOData> outputs = new ArrayList<>();
|
||||
outputs.add(NodeIOData.createByText(DEFAULT_OUTPUT_PARAM_NAME, "", ""));
|
||||
return NodeProcessResult.builder().content(outputs).build();
|
||||
}
|
||||
|
||||
log.info("Keyword extractor node config: {}", config);
|
||||
log.info("Input text length: {}", inputText.length());
|
||||
|
||||
// 构建提示词
|
||||
String prompt = buildPrompt(config, inputText);
|
||||
log.info("Keyword extraction prompt: {}", prompt);
|
||||
|
||||
// 调用 LLM 进行关键词提取
|
||||
WorkflowUtil workflowUtil = SpringUtil.getBean(WorkflowUtil.class);
|
||||
String modelName = config.getModelName();
|
||||
// 获取节点模板提示词信息
|
||||
String nodeMessageTemplate = WorkflowMessageUtil.getNodeMessageTemplate(NodeMessageTemplateEnum.KEYWORD_EXTRACTOR.getValue());
|
||||
// 发送SSE事件消息
|
||||
WorkflowMessageUtil.sendEmitterMessage(wfState.getSseEmitter(), node, nodeMessageTemplate);
|
||||
// 使用流式调用
|
||||
workflowUtil.streamingInvokeLLM(wfState, state, node, modelName, prompt, nodeMessageTemplate);
|
||||
return new NodeProcessResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建关键词提取的提示词
|
||||
*/
|
||||
private String buildPrompt(KeywordExtractorNodeConfig config, String inputText) {
|
||||
StringBuilder promptBuilder = new StringBuilder();
|
||||
|
||||
// 基础提示词
|
||||
promptBuilder.append("请从以下文本中提取 ").append(config.getTopN()).append(" 个最重要的关键词。\n\n");
|
||||
|
||||
// 添加自定义提示词(如果有)
|
||||
if (StringUtils.isNotBlank(config.getPrompt())) {
|
||||
promptBuilder.append(config.getPrompt()).append("\n\n");
|
||||
}
|
||||
|
||||
// 输出格式要求
|
||||
promptBuilder.append("要求:\n");
|
||||
promptBuilder.append("1. 只返回关键词,每个关键词用逗号分隔\n");
|
||||
promptBuilder.append("2. 关键词应该是名词或名词短语\n");
|
||||
promptBuilder.append("3. 按重要性从高到低排序\n");
|
||||
promptBuilder.append("4. 不要添加任何解释或额外的文字\n\n");
|
||||
|
||||
// 原始文本
|
||||
promptBuilder.append("文本内容:\n");
|
||||
promptBuilder.append(inputText);
|
||||
|
||||
return promptBuilder.toString();
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package org.ruoyi.workflow.workflow.node.keywordExtractor;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 关键词提取节点配置
|
||||
*/
|
||||
@EqualsAndHashCode
|
||||
@Data
|
||||
public class KeywordExtractorNodeConfig {
|
||||
|
||||
/**
|
||||
* 模型分类(如:llm, embedding 等)
|
||||
*/
|
||||
private String category;
|
||||
|
||||
/**
|
||||
* 模型名称
|
||||
*/
|
||||
@NotNull
|
||||
@JsonProperty("model_name")
|
||||
private String modelName;
|
||||
|
||||
/**
|
||||
* 提取的关键词数量
|
||||
*/
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
@JsonProperty("top_n")
|
||||
private Integer topN = 5;
|
||||
|
||||
/**
|
||||
* 提示词(可选)
|
||||
* 用于指导关键词提取的额外说明
|
||||
*/
|
||||
private String prompt;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.ruoyi.workflow.entity.WorkflowComponent;
|
||||
import org.ruoyi.workflow.entity.WorkflowNode;
|
||||
import org.ruoyi.workflow.util.JsonUtil;
|
||||
import org.ruoyi.workflow.workflow.NodeProcessResult;
|
||||
import org.ruoyi.workflow.workflow.WfNodeState;
|
||||
import org.ruoyi.workflow.workflow.WfState;
|
||||
@@ -40,15 +41,25 @@ public class MailSendNode extends AbstractWfNode {
|
||||
String input = getDataFromInput(inputs);
|
||||
// 判断是否为JSON格式(LLM输出转换 由LLM生成格式)
|
||||
if (StringUtils.isNotBlank(input) && isJson(input)) {
|
||||
// 使用Jackson解析和合并配置
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
JsonNode inputJson = objectMapper.readTree(input);
|
||||
// 将config转换为JsonNode
|
||||
JsonNode configJson = objectMapper.valueToTree(config);
|
||||
// 合并两个JSON节点
|
||||
JsonNode mergedJson = objectMapper.readerForUpdating(configJson).readValue(inputJson);
|
||||
// 转换回config对象
|
||||
config = objectMapper.treeToValue(mergedJson, MailSendNodeConfig.class);
|
||||
try {
|
||||
// 使用统一的 JsonUtil 进行解析
|
||||
JsonNode inputJson = JsonUtil.toJsonNode(input);
|
||||
if (inputJson != null) {
|
||||
// 使用 JsonUtil 内部的 ObjectMapper 进行合并
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
// 将config转换为JsonNode
|
||||
JsonNode configJson = objectMapper.valueToTree(config);
|
||||
// 合并两个JSON节点
|
||||
JsonNode mergedJson = objectMapper.readerForUpdating(configJson).readValue(inputJson);
|
||||
// 转换回config对象
|
||||
config = objectMapper.treeToValue(mergedJson, MailSendNodeConfig.class);
|
||||
} else {
|
||||
log.warn("输入 JSON 解析结果为 null,使用原始配置");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("合并邮件配置失败,使用原始配置: {}", e.getMessage(), e);
|
||||
// 继续使用原始 config,不中断流程
|
||||
}
|
||||
}
|
||||
|
||||
// 安全获取模板(使用 defaultString 避免 null)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.ruoyi.workflow.workflow.node.switcher;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.ObjectUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
@@ -9,6 +8,7 @@ import org.ruoyi.common.core.utils.SpringUtils;
|
||||
import org.ruoyi.workflow.entity.WorkflowComponent;
|
||||
import org.ruoyi.workflow.entity.WorkflowNode;
|
||||
import org.ruoyi.workflow.service.WorkflowNodeService;
|
||||
import org.ruoyi.workflow.util.JsonUtil;
|
||||
import org.ruoyi.workflow.workflow.NodeProcessResult;
|
||||
import org.ruoyi.workflow.workflow.WfNodeState;
|
||||
import org.ruoyi.workflow.workflow.WfState;
|
||||
@@ -18,8 +18,6 @@ import org.ruoyi.workflow.workflow.node.enmus.NodeMessageTemplateEnum;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 条件分支节点
|
||||
@@ -339,9 +337,12 @@ public class SwitcherNode extends AbstractWfNode {
|
||||
log.info("节点 '{}' 的输入配置: {}", nodeUuid, inputConfig);
|
||||
if (StringUtils.isNotBlank(inputConfig)){
|
||||
try {
|
||||
// 使用Jackson解析输入配置
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
JsonNode configJson = objectMapper.readTree(inputConfig);
|
||||
// 使用统一的 JsonUtil 而不是每次创建新的 ObjectMapper
|
||||
JsonNode configJson = JsonUtil.toJsonNode(inputConfig);
|
||||
if (configJson == null) {
|
||||
log.warn("节点 '{}' 的输入配置 JSON 解析结果为 null", nodeUuid);
|
||||
return result;
|
||||
}
|
||||
// 获取 user_inputs 数组
|
||||
JsonNode userInputs = configJson.get("user_inputs");
|
||||
if (userInputs != null && userInputs.isArray()) {
|
||||
@@ -357,7 +358,8 @@ public class SwitcherNode extends AbstractWfNode {
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("解析节点输入配置失败: {}", nodeUuid, e);
|
||||
log.error("解析节点 '{}' 输入配置失败,参数名: {}, 配置内容: {}", nodeUuid, paramName, inputConfig, e);
|
||||
// 不抛出异常,返回默认结果,避免中断整个流程
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- =============================================
|
||||
-- 流程编排搜索节点配置脚本
|
||||
-- =============================================
|
||||
-- 说明:本脚本用于添加搜索节点的系统配置
|
||||
-- 执行前请确保 sys_config 表存在
|
||||
-- =============================================
|
||||
|
||||
-- 搜索节点模板配置
|
||||
INSERT INTO sys_config (config_name, config_key, config_value, config_type, remark, create_by, create_time, update_by, update_time)
|
||||
VALUES ('搜索节点模板', 'node.googleSearch.template', '正在搜索相关内容...', 'Y', '搜索节点的响应模板,用于网络搜索功能', 'admin', NOW(), 'admin', NOW())
|
||||
ON DUPLICATE KEY UPDATE config_value = '正在搜索相关内容...', update_time = NOW();
|
||||
|
||||
-- =============================================
|
||||
-- 验证配置是否添加成功
|
||||
-- =============================================
|
||||
SELECT config_id, config_name, config_key, config_value, config_type, remark
|
||||
FROM sys_config
|
||||
WHERE config_key IN (
|
||||
'node.googleSearch.template'
|
||||
)
|
||||
ORDER BY config_id;
|
||||
Reference in New Issue
Block a user