feat: 商道调用封装、流式闲聊

This commit is contained in:
stream 2025-09-29 16:07:59 +08:00
parent 9f324941d3
commit ac37baf54b
17 changed files with 700 additions and 39 deletions

View File

@ -116,7 +116,13 @@ xss:
api: api:
app-id: d0epfibvo3em6c4iul40 app-id: d0epfibvo3em6c4iul40
app-key: d0eqt1kqek3vg7g4ofi0 app-key: d0eqt1kqek3vg7g4ofi0
speech-to-text-url: https://aiagentplatform.cmft.com/api/proxy/api/v1/run_app_workflow work-flow-url: https://aiagentplatform.cmft.com/api/proxy/api/v1/run_app_workflow
query-result-url: https://aiagentplatform.cmft.com/api/proxy/api/v1/query_run_app_process query-result-url: https://aiagentplatform.cmft.com/api/proxy/api/v1/query_run_app_process
bge-vl: http://192.168.1.8:5000/search_similar bge-vl: http://192.168.1.8:5000/search_similar
generate_advanced_audio: http://192.168.0.19:8000/generate_advanced_audio generate_advanced_audio: http://192.168.0.102:9003/generate_advanced_audio
# 智能体应用id和秘钥
# ActionEnum 的枚举作为 key
agents:
INTENT_RECOGNITION: # 意图识别
app-id: d3c98sp0gdon0fcf6l00
app-key: d3c98vp0gdon0fcf6n20

View File

@ -1,9 +1,12 @@
package com.cmvr.llm.config; package com.cmvr.llm.config;
import com.cmvr.common.exception.GlobalException;
import lombok.Data; import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.Map;
/** /**
* API配置类 * API配置类
*/ */
@ -22,10 +25,10 @@ public class APIProperties {
*/ */
private String appKey; private String appKey;
/** 执行语音转文本工作流接口地址 */ /** 商道启动工作流地址 */
private String speechToTextUrl; private String workFlowUrl;
/** 查询语音转文本结果接口地址 */ /** 商道工作流结果接口地址 */
private String queryResultUrl; private String queryResultUrl;
private String bgeVl; private String bgeVl;
@ -34,4 +37,18 @@ public class APIProperties {
* 文本合成语音 * 文本合成语音
*/ */
private String generateAdvancedAudio; private String generateAdvancedAudio;
/** 多应用配置key=ActionEnum) */
private Map<String, AgentConfig> agents;
/**
* 根据 ActionEnum 名称获取 app 配置
*/
public AgentConfig getAgentConfig(String actionName) {
AgentConfig config = agents.get(actionName);
if (config == null) {
throw new GlobalException("未找到功能 {} 的 智能体应用配置", actionName);
}
return config;
}
} }

View File

@ -0,0 +1,18 @@
package com.cmvr.llm.config;
import lombok.Data;
/**
* 智能体应用配置
*/
@Data
public class AgentConfig {
/**
* 应用id
*/
private String appId;
/**
* 应用秘钥
*/
private String appKey;
}

View File

@ -0,0 +1,11 @@
package com.cmvr.llm.service;
import com.alibaba.fastjson2.JSONObject;
public interface LLMIntentRecognitionService {
/**
* 意图识别
*/
public JSONObject intentRecognition(String action,String text);
}

View File

@ -0,0 +1,248 @@
package com.cmvr.llm.service;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.cmvr.common.exception.GlobalException;
import com.cmvr.common.utils.http.CallAPIUtil;
import com.cmvr.llm.config.APIProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
/**
* 商道大模型工作流调用服务
* 流程
* 1. 调用工作流启动接口获取 runId
* 2. 根据 runId 轮询查询接口
* 3. 解析结果
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WorkflowInvokeService {
private final APIProperties apiProperties;
/**
* 用户ID可以作为全局常量
*/
private static final String USER_ID = "18888888888";
/**
* 默认最大重试次数
*/
private static final int DEFAULT_MAX_RETRY = 40;
/**
* 默认轮询间隔毫秒
*/
private static final int DEFAULT_INTERVAL_MS = 1000;
/**
* 一站式执行工作流带自定义重试参数
*
* @param appId 智能体应用ID
* @param appKey 智能体应用秘钥
* @param inputParams 输入参数业务参数例如图片描述等
* @param maxRetry 最大重试次数
* @param intervalMs 每次轮询间隔毫秒
* @return 最终结果 message
*/
public JSONObject executeWorkflow(String appId, String appKey,
Map<String, String> inputParams,
int maxRetry, long intervalMs) {
// 1. 启动工作流获取 runId
String runId = startWorkflow(appId, appKey, inputParams);
// 2. 轮询获取结果
String responseBody = pollResult(runId, appId, appKey, maxRetry, intervalMs);
// 3. 解析
return parseResponse(responseBody);
}
/**
* 一站式执行工作流使用默认重试参数
*
* @param appId 智能体应用ID
* @param appKey 智能体应用秘钥
* @param inputParams 输入参数业务参数例如图片描述等
* @return 最终结果 message
*/
public JSONObject executeWorkflow(String appId, String appKey, Map<String, String> inputParams) {
return executeWorkflow(appId, appKey, inputParams, DEFAULT_MAX_RETRY, DEFAULT_INTERVAL_MS);
}
// ------------------- 核心流程 -------------------
/**
* 启动工作流返回 runId
*
* @param appId 智能体应用ID
* @param appKey 智能体应用秘钥
* @param inputParams 输入参数
* @return 工作流运行ID-runId
*/
private String startWorkflow(String appId, String appKey, Map<String, String> inputParams) {
Map<String, String> headers = buildHeaders(appKey);
Map<String, String> body = buildRequestBody(appKey, appId, inputParams);
String responseBody = CallAPIUtil.doPostJson(apiProperties.getWorkFlowUrl(), headers, body);
JSONObject jsonObject = JSON.parseObject(responseBody);
String runId = jsonObject.getString("runId");
if (StrUtil.isEmpty(runId)) {
throw new GlobalException("未返回 runId响应{}", responseBody);
}
return runId;
}
/**
* 轮询查询接口直到获取到 end 节点
*
* @param runId 工作流运行ID
* @param appId 智能体应用ID
* @param appKey 智能体应用秘钥
* @param maxRetry 最大重试次数
* @param intervalMs 轮询间隔毫秒
* @return 最终响应 JSON 字符串
*/
private String pollResult(String runId, String appId, String appKey, int maxRetry, long intervalMs) {
Map<String, String> headers = buildHeaders(appKey);
Map<String, String> body = buildBaseBody(appKey, appId);
body.put("RunID", runId);
int failCount = 0;
while (true) {
String responseBody = CallAPIUtil.doPostJson(apiProperties.getQueryResultUrl(), headers, body);
JSONObject root = JSON.parseObject(responseBody);
String status = root.getString("status");
if ("success".equalsIgnoreCase(status)) {
// 校验并返回最终结果
parseResponse(responseBody);
return responseBody;
} else if ("processing".equalsIgnoreCase(status)) {
// 继续轮询
failCount++;
if (failCount > maxRetry) {
throw new GlobalException("轮询超时,达到最大重试次数,最后响应:{}", responseBody);
}
try {
Thread.sleep(intervalMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new GlobalException("轮询被中断");
}
} else {
// 其他状态直接抛异常不再重试
throw new GlobalException("工作流执行失败status={},响应:{}", status, responseBody);
}
}
}
// ------------------- 结果解析 -------------------
/**
* 从响应 JSON 中找到 nodeType=end status=success 的节点
*/
private JSONObject findEndNode(String responseBody) {
JSONObject root = JSON.parseObject(responseBody);
JSONObject nodes = root.getJSONObject("nodes");
if (nodes == null) {
throw new GlobalException("响应中 nodes 为空:{}", responseBody);
}
for (Map.Entry<String, Object> entry : nodes.entrySet()) {
JSONObject node = (JSONObject) entry.getValue();
if ("end".equalsIgnoreCase(node.getString("nodeType"))
&& "success".equalsIgnoreCase(node.getString("status"))) {
return node;
}
}
throw new GlobalException("未找到 nodeType=end 且 status=success 的节点:{}", nodes);
}
/**
* 解析 end 节点的 output 内容
*
* @param endNode end 节点
* @return output JSON
*/
private JSONObject parseOutput(JSONObject endNode) {
String outputStr = endNode.getString("output");
if (StrUtil.isEmpty(outputStr)) {
throw new GlobalException("end 节点 output 为空:{}", endNode);
}
try {
return JSON.parseObject(outputStr);
} catch (Exception e) {
throw new GlobalException("output 解析失败:{}", outputStr);
}
}
/**
* 接卸
*
* @param responseBody 响应 JSON 字符串
* @return message
*/
private JSONObject parseResponse(String responseBody) {
JSONObject endNode = findEndNode(responseBody);
JSONObject output = parseOutput(endNode);
JSONObject nestedOutput = output.getJSONObject("output");
if (ObjUtil.isEmpty(nestedOutput)) {
throw new GlobalException("end node output 为空!");
}
return nestedOutput;
}
// ------------------- 请求体拼装 -------------------
/**
* 构造请求头
*
* @param appKey 应用秘钥
* @return headers
*/
private Map<String, String> buildHeaders(String appKey) {
Map<String, String> headers = new HashMap<>();
headers.put("Apikey", appKey);
return headers;
}
/**
* 构造启动工作流的请求体
*
* @param appKey 应用秘钥
* @param appId 应用ID
* @param inputParams 输入参数
* @return body
*/
private Map<String, String> buildRequestBody(String appKey, String appId, Map<String, String> inputParams) {
Map<String, String> body = buildBaseBody(appKey, appId);
body.put("InputData", JSON.toJSONString(inputParams));
return body;
}
/**
* 构造通用请求体
*
* @param appKey 应用秘钥
* @param appId 应用ID
* @return body
*/
private Map<String, String> buildBaseBody(String appKey, String appId) {
Map<String, String> body = new HashMap<>();
body.put("AppKey", appKey);
body.put("AppID", appId);
body.put("UserID", USER_ID);
return body;
}
}

View File

@ -0,0 +1,28 @@
package com.cmvr.llm.service.impl;
import cn.hutool.core.map.MapUtil;
import com.alibaba.fastjson2.JSONObject;
import com.cmvr.llm.config.APIProperties;
import com.cmvr.llm.config.AgentConfig;
import com.cmvr.llm.service.LLMIntentRecognitionService;
import com.cmvr.llm.service.WorkflowInvokeService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class LLMIntentRecognitionServiceImpl implements LLMIntentRecognitionService {
private final APIProperties apiProperties;
private final WorkflowInvokeService workflowInvokeService;
@Override
public JSONObject intentRecognition(String action, String text) {
AgentConfig acg = apiProperties.getAgentConfig(action);
Map<String, String> inputParams = MapUtil.of("text", text);
// return new JSONObject().fluentPut("type", "4").fluentPut("message", "\n\n建议优先选择成都大熊猫繁育研究基地成华区该基地拥有顶流熊猫\\\"花花\\\"及完善的参观体系。门票需提前7天在\\\"成都熊猫基地电子票\\\"公众号预约全票55元/人建议选择清晨7:30前抵达南门入园当前9月凉爽适宜户外观赏。重点参观点为幼年大熊猫别墅花花所在地、月亮/太阳产房活跃时段8:30-10:30。若需避开人流可前往都江堰熊猫谷门票51元该区域植被覆盖率高游客密度较低适合深度观赏熊猫自然行为。交通建议乘坐地铁3号线至熊猫大道站换乘景区直通车运营时间8:00-17:00。需注意园区禁止投喂建议携带轻便饮食及防滑鞋具。");
return workflowInvokeService.executeWorkflow(acg.getAppId(), acg.getAppKey(), inputParams);
}
}

View File

@ -53,7 +53,7 @@ public class LLMTouchServiceImpl implements LLMTouchService {
body.put("InputData", JSON.toJSONString(params)); body.put("InputData", JSON.toJSONString(params));
body.put("UserID", "18888888888"); body.put("UserID", "18888888888");
String res1 = CallAPIUtil.doPostJson(apiProperties.getSpeechToTextUrl(), headers, body); String res1 = CallAPIUtil.doPostJson(apiProperties.getWorkFlowUrl(), headers, body);
JSONObject resObject1 = JSON.parseObject(res1); JSONObject resObject1 = JSON.parseObject(res1);
if (resObject1.containsKey("runId")) { if (resObject1.containsKey("runId")) {
String processId = resObject1.getString("runId"); String processId = resObject1.getString("runId");

View File

@ -31,7 +31,7 @@ public class LargeModelRemoteServiceImpl implements LargeModelRemoteService {
body.put("InputData", JSON.toJSONString(callAgentVO)); body.put("InputData", JSON.toJSONString(callAgentVO));
body.put("UserID", "18888888888"); body.put("UserID", "18888888888");
String res1 = CallAPIUtil.doPostJson(apiProperties.getSpeechToTextUrl(), headers, body); String res1 = CallAPIUtil.doPostJson(apiProperties.getWorkFlowUrl(), headers, body);
JSONObject resObject1 = JSON.parseObject(res1); JSONObject resObject1 = JSON.parseObject(res1);
if (resObject1.containsKey("runId")) { if (resObject1.containsKey("runId")) {

View File

@ -164,7 +164,7 @@ public class ShowIntelligentCockpitServiceImpl implements ShowIntelligentCockpit
body.put("InputData", JSON.toJSONString(callAgentAdvancedVO)); body.put("InputData", JSON.toJSONString(callAgentAdvancedVO));
body.put("UserID", "18888888888"); body.put("UserID", "18888888888");
String res = CallAPIUtil.doPostJson(apiProperties.getSpeechToTextUrl(), headers, body); String res = CallAPIUtil.doPostJson(apiProperties.getWorkFlowUrl(), headers, body);
JSONObject resObject = JSON.parseObject(res); JSONObject resObject = JSON.parseObject(res);
if (resObject.containsKey("runId")) { if (resObject.containsKey("runId")) {
@ -243,7 +243,7 @@ public class ShowIntelligentCockpitServiceImpl implements ShowIntelligentCockpit
body.put("InputData", JSON.toJSONString(params)); body.put("InputData", JSON.toJSONString(params));
body.put("UserID", "18888888888"); body.put("UserID", "18888888888");
String res1 = CallAPIUtil.doPostJson(apiProperties.getSpeechToTextUrl(), headers, body); String res1 = CallAPIUtil.doPostJson(apiProperties.getWorkFlowUrl(), headers, body);
JSONObject resObject1 = JSON.parseObject(res1); JSONObject resObject1 = JSON.parseObject(res1);
if (resObject1.containsKey("runId")) { if (resObject1.containsKey("runId")) {
String processId = resObject1.getString("runId"); String processId = resObject1.getString("runId");

View File

@ -46,6 +46,7 @@ public enum ActionEnum {
// 大模型行为 // 大模型行为
// ---------------获取触控坐标--------------- // ---------------获取触控坐标---------------
TOUCH_COORDINATES("LLM", "TOUCH_COORDINATES", "获取触控坐标"), TOUCH_COORDINATES("LLM", "TOUCH_COORDINATES", "获取触控坐标"),
INTENT_RECOGNITION("LLM", "INTENT_RECOGNITION", "意图识别"),
GENERATE_ADVANCED_AUDIO("LLM", "GENERATE_ADVANCED_AUDIO", "tts语音合成"), GENERATE_ADVANCED_AUDIO("LLM", "GENERATE_ADVANCED_AUDIO", "tts语音合成"),
// 语音交互 // 语音交互

View File

@ -229,9 +229,18 @@ public class FlowGraph {
List<FlowEdge> outEdges = successors.getOrDefault(nodeId, Collections.emptyList()); List<FlowEdge> outEdges = successors.getOrDefault(nodeId, Collections.emptyList());
for (FlowEdge edge : new ArrayList<>(outEdges)) { for (FlowEdge edge : new ArrayList<>(outEdges)) {
String targetNodeId = edge.getTo(); String targetNodeId = edge.getTo();
// 先删边
removeEdge(edge); removeEdge(edge);
removePathFrom(targetNodeId);
// 只有目标节点没有其他前驱时才递归
List<FlowEdge> predecessorsList = predecessors.getOrDefault(targetNodeId, Collections.emptyList());
if (predecessorsList.isEmpty()) {
removePathFrom(targetNodeId);
}
} }
// 最后判断是否孤立再删节点
removeNodeIfIsolated(nodeId); removeNodeIfIsolated(nodeId);
} finally { } finally {
lock.writeLock().unlock(); lock.writeLock().unlock();

View File

@ -125,7 +125,7 @@ public class FlowItemExecutor {
// 分支节点处理 // 分支节点处理
if (node.getNodeType() == NodeTypeEnum.BRANCH) { if (node.getNodeType() == NodeTypeEnum.BRANCH) {
postHandleBranchNode(graph, node, startNodeId, startInputDefs, postHandleBranchNode(taskInstHolder.getContext(rootMessage.getInstId()), graph, node, startNodeId, startInputDefs,
rootMessage, onFinished, iterations, nodeId, message.getInputParams(), message); rootMessage, onFinished, iterations, nodeId, message.getInputParams(), message);
return; return;
} }
@ -158,7 +158,7 @@ public class FlowItemExecutor {
@NotNull @NotNull
private static TaskNodeExecuteMessage buildTaskNodeExecuteMsg(FlowGraph graph, FlowNodeWrapper node, private static TaskNodeExecuteMessage buildTaskNodeExecuteMsg(FlowGraph graph, FlowNodeWrapper node,
TaskNodeExecuteMessage rootMessage, String nodeId, TaskNodeExecuteMessage rootMessage, String nodeId,
List<Integer> iterations) { List<Integer> iterations) {
TaskNodeExecuteMessage message = new TaskNodeExecuteMessage(); TaskNodeExecuteMessage message = new TaskNodeExecuteMessage();
BeanUtil.copyProperties(rootMessage, message); BeanUtil.copyProperties(rootMessage, message);
message.setNodeId(nodeId); message.setNodeId(nodeId);
@ -175,10 +175,10 @@ public class FlowItemExecutor {
return message; return message;
} }
private void postHandleBranchNode(FlowGraph graph, FlowNodeWrapper node, String startNodeId, private void postHandleBranchNode(TaskContext taskContext, FlowGraph graph, FlowNodeWrapper node, String startNodeId,
List<FlowParamDef> startInputDefs, TaskNodeExecuteMessage rootMessage, Runnable onFinished, List<FlowParamDef> startInputDefs, TaskNodeExecuteMessage rootMessage, Runnable onFinished,
List<Integer> iterations, String nodeId, JSONObject inputParams, TaskNodeExecuteMessage message) { List<Integer> iterations, String nodeId, JSONObject inputParams, TaskNodeExecuteMessage message) {
String matchedAnchorId = FlowBranchEvaluator.evaluate(nodeId, node.getBranchConditions(), inputParams); String matchedAnchorId = FlowBranchEvaluator.evaluate(taskContext, iterations,nodeId, node.getBranchConditions(), inputParams);
FlowEdge matchedEdge = graph.getEdgeByFromAnchorId(matchedAnchorId); FlowEdge matchedEdge = graph.getEdgeByFromAnchorId(matchedAnchorId);
if (matchedEdge == null) { if (matchedEdge == null) {
@ -190,8 +190,14 @@ public class FlowItemExecutor {
List<FlowEdge> allOutEdges = new ArrayList<>(graph.getSuccessors().getOrDefault(nodeId, Collections.emptyList())); List<FlowEdge> allOutEdges = new ArrayList<>(graph.getSuccessors().getOrDefault(nodeId, Collections.emptyList()));
for (FlowEdge edge : allOutEdges) { for (FlowEdge edge : allOutEdges) {
if (!edge.equals(matchedEdge)) { if (!edge.equals(matchedEdge)) {
graph.removePathFrom(edge.getTo()); // 先删边
graph.removeEdge(edge); graph.removeEdge(edge);
// 再尝试删子路径只有当目标节点没有其他前驱时才删
List<FlowEdge> predecessorsList = graph.getPredecessors().getOrDefault(edge.getTo(), Collections.emptyList());
if (predecessorsList.isEmpty()) {
graph.removePathFrom(edge.getTo());
}
} }
} }
@ -206,7 +212,7 @@ public class FlowItemExecutor {
} }
private void registerThread(FlowGraph graph, FlowNodeWrapper node, String startNodeId, List<FlowParamDef> startInputDefs, private void registerThread(FlowGraph graph, FlowNodeWrapper node, String startNodeId, List<FlowParamDef> startInputDefs,
TaskNodeExecuteMessage rootMessage, Runnable onFinished, List<Integer> iterations, String instId, String itemId, String nodeId) { TaskNodeExecuteMessage rootMessage, Runnable onFinished, List<Integer> iterations, String instId, String itemId, String nodeId) {
TaskNodeExecuteContext nodeExecuteContext = new TaskNodeExecuteContext(); TaskNodeExecuteContext nodeExecuteContext = new TaskNodeExecuteContext();
nodeExecuteContext.setThread(Thread.currentThread()); nodeExecuteContext.setThread(Thread.currentThread());
nodeExecuteContext.setGraph(graph); nodeExecuteContext.setGraph(graph);

View File

@ -1,9 +1,13 @@
package com.cmvr.test.flow.runtime.engine.support; package com.cmvr.test.flow.runtime.engine.support;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjUtil;
import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.JSONObject;
import com.cmvr.test.enums.BranchConditionOpEnum; import com.cmvr.test.enums.BranchConditionOpEnum;
import com.cmvr.test.flow.builder.FlowBranchConditionGroup; import com.cmvr.test.flow.builder.FlowBranchConditionGroup;
import com.cmvr.test.flow.context.TaskContext;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
@ -19,7 +23,9 @@ public class FlowBranchEvaluator {
* 遍历所有条件组返回第一个满足条件的分支ID * 遍历所有条件组返回第一个满足条件的分支ID
* 如果都不满足返回默认的 else 出口 * 如果都不满足返回默认的 else 出口
*/ */
public static String evaluate(String nodeId, List<FlowBranchConditionGroup> groups, JSONObject inputParams) { public static String evaluate(TaskContext taskContext, List<Integer> iterations, String nodeId, List<FlowBranchConditionGroup> groups, JSONObject inputParams) {
// 先合并上游输出参数
mergeParams(taskContext,iterations, inputParams, groups);
for (FlowBranchConditionGroup group : groups) { for (FlowBranchConditionGroup group : groups) {
if (matchGroup(group, inputParams)) { if (matchGroup(group, inputParams)) {
return group.getId(); return group.getId();
@ -28,6 +34,34 @@ public class FlowBranchEvaluator {
return nodeId + "_else"; return nodeId + "_else";
} }
/**
* 获取分支节点引用节点的输出参数并合并到当前计算参数列表中
*/
private static void mergeParams(TaskContext taskContext, List<Integer> iterations, JSONObject inputParams, List<FlowBranchConditionGroup> groups) {
if (CollUtil.isEmpty(taskContext.getNodeOutputs())) {
return;
}
HashSet<String> quoteIds = new HashSet<>();
for (FlowBranchConditionGroup group : groups) {
List<FlowBranchConditionGroup.ConditionExpression> expressions = group.getExpressions();
for (FlowBranchConditionGroup.ConditionExpression expression : expressions) {
if (expression.getNameType().equalsIgnoreCase("quote")) {
List<String> nameQuotePath = expression.getNameQuotePath();
if (CollUtil.isNotEmpty(nameQuotePath) && nameQuotePath.get(0).length() == 32) {
quoteIds.add(nameQuotePath.get(0));
}
}
}
}
for (String quoteId : quoteIds) {
JSONObject nodeOutput = taskContext.getNodeOutput(quoteId, iterations);
if (ObjUtil.isNotEmpty(nodeOutput)) {
// 合并进inputParams
inputParams.putAll(nodeOutput);
}
}
}
/** /**
* 匹配某个条件组是否成立 * 匹配某个条件组是否成立
*/ */
@ -109,7 +143,7 @@ public class FlowBranchEvaluator {
} }
Object current = context; Object current = context;
for (int i = 1; i < path.size(); i++) { for (int i = 2; i < path.size(); i++) {
if (current instanceof JSONObject) { if (current instanceof JSONObject) {
current = ((JSONObject) current).get(path.get(i)); current = ((JSONObject) current).get(path.get(i));
} else if (current instanceof Map) { } else if (current instanceof Map) {

View File

@ -12,6 +12,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.function.Function; import java.util.function.Function;
@Slf4j @Slf4j
@ -42,7 +43,10 @@ public class FlowExceptionInterceptor implements FlowMsgPreInterceptor {
ObjUtil.isEmpty(message.getInputParams()) ? null : message.getInputParams().toJSONString(), ObjUtil.isEmpty(message.getInputParams()) ? null : message.getInputParams().toJSONString(),
"执行异常:" + StrUtil.sub(ExceptionUtil.getMessage(e), 0, 480) "执行异常:" + StrUtil.sub(ExceptionUtil.getMessage(e), 0, 480)
); );
throw new GlobalException("任务执行失败:" + e.getMessage()); log.error("节点执行异常instId={}, nodeId={}, action={}",
message.getInstId(), message.getNodeId(), message.getAction(), e);
throw new GlobalException("任务执行失败", e);
} }
} }
} }

View File

@ -0,0 +1,34 @@
package com.cmvr.test.flow.runtime.operator.llm;
import com.alibaba.fastjson2.JSONObject;
import com.cmvr.llm.service.LLMIntentRecognitionService;
import com.cmvr.test.enums.ActionEnum;
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class LLMIntentRecogOperateService implements LLMOperateService {
private final LLMIntentRecognitionService intentRecognitionService;
@Override
public boolean supports(ActionEnum action) {
return action.name().equals("INTENT_RECOGNITION");
}
@Override
public TaskNodeExecuteResult execute(TaskNodeExecuteMessage message) {
ActionEnum action = message.getAction();
JSONObject inputParams = message.getInputParams();
String text = inputParams.getString("text");
JSONObject output = intentRecognitionService.intentRecognition(action.name(), text);
return TaskNodeExecuteResult.success(output);
}
}

View File

@ -10,14 +10,30 @@ import com.cmvr.llm.config.APIProperties;
import com.cmvr.test.enums.ActionEnum; import com.cmvr.test.enums.ActionEnum;
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage; import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult; import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
import com.cmvr.test.util.TextSplitUtil;
import lombok.Data;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.DataLine;
import javax.sound.sampled.SourceDataLine;
import javax.sound.sampled.UnsupportedAudioFileException;
import java.io.File; import java.io.File;
import java.io.IOException;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@ -25,6 +41,16 @@ public class LLMTTSOperateService implements LLMOperateService {
private final APIProperties apiProperties; private final APIProperties apiProperties;
/**
* instId -> 音频队列
*/
private final Map<String, BlockingQueue<AudioSegment>> audioQueues = new ConcurrentHashMap<>();
/**
* instId -> 播放线程
*/
private final Map<String, Thread> playerThreads = new ConcurrentHashMap<>();
@Override @Override
public boolean supports(ActionEnum action) { public boolean supports(ActionEnum action) {
return action.name().equals("GENERATE_ADVANCED_AUDIO"); return action.name().equals("GENERATE_ADVANCED_AUDIO");
@ -32,20 +58,83 @@ public class LLMTTSOperateService implements LLMOperateService {
@Override @Override
public TaskNodeExecuteResult execute(TaskNodeExecuteMessage message) { public TaskNodeExecuteResult execute(TaskNodeExecuteMessage message) {
String instId = message.getInstId();
JSONObject inputParams = message.getInputParams(); JSONObject inputParams = message.getInputParams();
String text = inputParams.getString("text"); String text = inputParams.getString("text");
JSONObject output = new JSONObject(); // 1. 切分
List<String> slices = TextSplitUtil.splitText(text);
if (slices.isEmpty()) {
throw new GlobalException("输入文本为空,无法合成语音");
}
// 1. 构建请求体 // 2. 确保队列和线程存在
BlockingQueue<AudioSegment> queue = audioQueues.computeIfAbsent(instId, k -> new LinkedBlockingQueue<>());
startPlayerThread(instId, queue);
// 3. 逐个生成并放入队列
CountDownLatch latch = new CountDownLatch(1);
int index = 1;
for (String slice : slices) {
long start = System.currentTimeMillis();
File audioFile = getAudioFile(slice, index);
long end = System.currentTimeMillis();
long playMs = getAudioDurationMs(audioFile);
// 最后一段带 latch
AudioSegment segment = new AudioSegment(audioFile.getAbsolutePath(), playMs, end - start,
(index == slices.size()) ? latch : null);
queue.offer(segment);
log.info("[{}] 音频入队: {}", instId, segment.getPath());
index++;
}
// 4. 等待最后一段播完
try {
latch.await();
log.info("[{}] 最后一段音频播放完成", instId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new GlobalException("等待音频播放时被中断");
}
// 5. 节点才算完成
JSONObject output = new JSONObject();
output.put("segments", slices.size());
return TaskNodeExecuteResult.success(output);
}
/**
* 启动播放线程 instId 单独维护
*/
private void startPlayerThread(String instId, BlockingQueue<AudioSegment> queue) {
playerThreads.computeIfAbsent(instId, k -> {
Thread thread = new Thread(() -> {
try {
while (true) {
AudioSegment segment = queue.take(); // 阻塞等待
playAudio(segment);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.info("[{}] 播放线程被中断,退出", instId);
}
}, "AudioPlayer-" + instId);
thread.start();
return thread;
});
}
@NotNull
private File getAudioFile(String text, int index) {
Map<String, Object> body = new HashMap<>(); Map<String, Object> body = new HashMap<>();
body.put("text", text); body.put("text", text);
body.put("dialect", "普通话"); body.put("dialect", "普通话");
body.put("timbre", ""); body.put("timbre", "标准男");
body.put("language", "中文"); body.put("language", "中文");
body.put("tone", "中性"); body.put("tone", "中性");
// 2. 发送请求到语音合成服务
HttpResponse response = HttpRequest.post(apiProperties.getGenerateAdvancedAudio()) HttpResponse response = HttpRequest.post(apiProperties.getGenerateAdvancedAudio())
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.body(JSON.toJSONString(body)) .body(JSON.toJSONString(body))
@ -55,28 +144,80 @@ public class LLMTTSOperateService implements LLMOperateService {
throw new GlobalException("音频生成失败,状态码: " + response.getStatus()); throw new GlobalException("音频生成失败,状态码: " + response.getStatus());
} }
// 3. 获取音频数据
byte[] audioData = response.bodyBytes(); byte[] audioData = response.bodyBytes();
String filename = System.currentTimeMillis() + "_" + index + ".wav";
// 4. 生成本地文件路径
String filename = System.currentTimeMillis() + "_" + text + ".wav";
String saveDir = "C:\\Users\\11158\\Desktop\\iot"; String saveDir = "C:\\Users\\11158\\Desktop\\iot";
File dir = new File(saveDir); File dir = new File(saveDir);
if (!dir.exists() && !dir.mkdirs()) { if (!dir.exists() && !dir.mkdirs()) {
throw new GlobalException("音频保存目录创建失败: " + saveDir); throw new GlobalException("音频保存目录创建失败: " + saveDir);
} }
File audioFile = FileUtil.writeBytes(audioData, new File(dir, filename)); return FileUtil.writeBytes(audioData, new File(dir, filename));
log.info("音频生成成功,保存路径: {}", audioFile.getAbsolutePath());
// 5. 预留上传功能
// String fileUrl = fileUploadService.upload(audioFile);
// 先返回本地路径
output.put("localPath", audioFile.getAbsolutePath());
// output.put("url", fileUrl); // 上传后再返回 url
return TaskNodeExecuteResult.success(output);
} }
private long getAudioDurationMs(File file) {
try (AudioInputStream audioInputStream = AudioSystem.getAudioInputStream(file)) {
AudioFormat format = audioInputStream.getFormat();
long frames = audioInputStream.getFrameLength();
double durationInSeconds = (frames + 0.0) / format.getFrameRate();
return (long) (durationInSeconds * 1000);
} catch (UnsupportedAudioFileException | IOException e) {
throw new GlobalException("获取音频时长失败: {}", e.getMessage());
}
}
/**
* 播放一个音频文件阻塞直到播完
*/
private void playAudio(AudioSegment segment) {
String filePath = segment.getPath();
try (AudioInputStream sourceStream = AudioSystem.getAudioInputStream(new File(filePath))) {
AudioFormat baseFormat = sourceStream.getFormat();
AudioFormat targetFormat = new AudioFormat(
AudioFormat.Encoding.PCM_SIGNED,
baseFormat.getSampleRate(),
16,
baseFormat.getChannels(),
baseFormat.getChannels() * 2,
baseFormat.getSampleRate(),
false
);
AudioInputStream pcmStream = AudioSystem.getAudioInputStream(targetFormat, sourceStream);
DataLine.Info info = new DataLine.Info(SourceDataLine.class, targetFormat);
try (SourceDataLine line = (SourceDataLine) AudioSystem.getLine(info)) {
line.open(targetFormat);
line.start();
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = pcmStream.read(buffer, 0, buffer.length)) != -1) {
line.write(buffer, 0, bytesRead);
}
line.drain();
line.stop();
}
log.info("播放完成: {}", filePath);
// 如果有 latch释放
if (segment.getLatch() != null) {
segment.getLatch().countDown();
}
} catch (Exception e) {
throw new GlobalException("播放失败: ", e);
}
}
@Data
public static class AudioSegment {
private final String path;
private final long playMs;
private final long generateMs;
private final CountDownLatch latch; // 仅最后一段有
}
} }

View File

@ -0,0 +1,104 @@
package com.cmvr.test.util;
import cn.hutool.core.util.StrUtil;
import java.util.ArrayList;
import java.util.List;
public class TextSplitUtil {
private static final int DEFAULT_MAX_LENGTH = 20;
public static List<String> splitText(String text) {
return splitText(text, DEFAULT_MAX_LENGTH);
}
public static List<String> splitText(String text, int maxLength) {
List<String> result = new ArrayList<>();
if (StrUtil.isEmpty(text)) {
return result;
}
// 0. 清洗
text = cleanText(text);
// 1. 先按句号/问号/感叹号切
String[] sentences = text.split("(?<=[。!?])");
for (int i = 0; i < sentences.length; i++) {
String sentence = sentences[i].trim();
if (StrUtil.isEmpty(sentence)) {
continue;
}
// 特殊规则第一句单独处理
if (i == 0 && sentence.length() > 15) {
int cut = Math.min(sentence.length(), 15); // 最多15
if (cut < 10) cut = 10; // 保底至少10
result.add(sentence.substring(0, cut));
// 剩余部分继续走正常分割
String remain = sentence.substring(cut).trim();
if (StrUtil.isNotEmpty(remain)) {
if (remain.length() > maxLength) {
result.addAll(splitByComma(remain, maxLength));
} else {
result.add(remain);
}
}
continue;
}
// 2. 如果太长按逗号/分号切
if (sentence.length() > maxLength) {
result.addAll(splitByComma(sentence, maxLength));
} else {
result.add(sentence);
}
}
return result;
}
/**
* 先按逗号/分号切再兜底按长度切
*/
private static List<String> splitByComma(String text, int maxLength) {
List<String> result = new ArrayList<>();
String[] parts = text.split("(?<=[])"); // 保留逗号/分号
for (String part : parts) {
part = part.trim();
if (StrUtil.isEmpty(part)) {
continue;
}
if (part.length() > maxLength) {
result.addAll(splitByLength(part, maxLength));
} else {
result.add(part);
}
}
return result;
}
/**
* 长句按长度强制切分兜底
*/
private static List<String> splitByLength(String text, int maxLength) {
List<String> result = new ArrayList<>();
for (int i = 0; i < text.length(); i += maxLength) {
int end = Math.min(text.length(), i + maxLength);
result.add(text.substring(i, end).trim());
}
return result;
}
private static String cleanText(String text) {
if (StrUtil.isEmpty(text)) {
return text;
}
String cleaned = text;
cleaned = cleaned.replaceAll("[\\r\\n\\t]+", " "); // 换行制表
cleaned = cleaned.replaceAll(" +", " "); // 多余空格
cleaned = cleaned.replaceAll("[-=*]{3,}", " "); // 分隔符
return cleaned.trim();
}
}