mirror of
https://github.com/jeecgboot/JeecgBoot.git
synced 2026-01-04 04:45:28 +08:00
v3.9.0 里程碑版本发布
This commit is contained in:
@ -6,7 +6,7 @@
|
||||
<parent>
|
||||
<groupId>org.jeecgframework.boot3</groupId>
|
||||
<artifactId>jeecg-boot-module</artifactId>
|
||||
<version>3.8.3</version>
|
||||
<version>3.9.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>jeecg-boot-module-airag</artifactId>
|
||||
@ -31,6 +31,8 @@
|
||||
</repositories>
|
||||
|
||||
<properties>
|
||||
<kotlin.version>2.2.0</kotlin.version>
|
||||
<liteflow.version>2.15.0</liteflow.version>
|
||||
<apache-tika.version>2.9.1</apache-tika.version>
|
||||
</properties>
|
||||
|
||||
@ -73,7 +75,7 @@
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework.boot3</groupId>
|
||||
<artifactId>jeecg-aiflow</artifactId>
|
||||
<version>3.8.3.1</version>
|
||||
<version>3.9.0</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>commons-io</groupId>
|
||||
@ -100,6 +102,7 @@
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<!-- end 这两个依赖太多每个包50M左右,如果你发布需要使用,请把<scope>provided</scope>删掉 -->
|
||||
|
||||
<!-- aiflow 脚本依赖 -->
|
||||
<dependency>
|
||||
<groupId>com.yomahub</groupId>
|
||||
@ -107,6 +110,12 @@
|
||||
<version>${liteflow.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.yomahub</groupId>
|
||||
<artifactId>liteflow-script-python</artifactId>
|
||||
<version>${liteflow.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.yomahub</groupId>
|
||||
<artifactId>liteflow-script-kotlin</artifactId>
|
||||
@ -132,7 +141,7 @@
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<!-- aiflow 脚本依赖 -->
|
||||
|
||||
|
||||
<!-- langChain4j model support -->
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
@ -174,6 +183,10 @@
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-anthropic</artifactId>
|
||||
</dependency>
|
||||
<!-- langChain4j vextor support -->
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework</groupId>
|
||||
|
||||
@ -38,4 +38,10 @@ public class AiAppConsts {
|
||||
*/
|
||||
public static final String APP_TYPE_CHAT_FLOW = "chatFLow";
|
||||
|
||||
/**
|
||||
* 应用元数据:流程输入参数
|
||||
* for [issues/8545]新建AI应用的时候只能选择没有自定义参数的AI流程
|
||||
*/
|
||||
public static final String APP_METADATA_FLOW_INPUTS = "flowInputs";
|
||||
|
||||
}
|
||||
|
||||
@ -167,6 +167,12 @@ public class AiragApp implements Serializable {
|
||||
@Schema(description = "元数据")
|
||||
private java.lang.String metadata;
|
||||
|
||||
/**
|
||||
* 插件 [{pluginId: '123213', pluginName: 'xxxx', category: 'mcp'}]
|
||||
*/
|
||||
@Schema(description = "插件")
|
||||
private java.lang.String plugins;
|
||||
|
||||
/**
|
||||
* 知识库ids
|
||||
*/
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
package org.jeecg.modules.airag.app.service.impl;
|
||||
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import dev.langchain4j.agent.tool.ToolExecutionRequest;
|
||||
import dev.langchain4j.data.image.Image;
|
||||
import dev.langchain4j.data.message.*;
|
||||
import dev.langchain4j.model.output.FinishReason;
|
||||
import dev.langchain4j.service.TokenStream;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.exception.JeecgBootBizTipException;
|
||||
@ -23,16 +26,19 @@ import org.jeecg.modules.airag.common.consts.AiragConsts;
|
||||
import org.jeecg.modules.airag.common.handler.AIChatParams;
|
||||
import org.jeecg.modules.airag.common.handler.IAIChatHandler;
|
||||
import org.jeecg.modules.airag.common.utils.AiragLocalCache;
|
||||
import org.jeecg.modules.airag.common.vo.LlmPlugin;
|
||||
import org.jeecg.modules.airag.common.vo.MessageHistory;
|
||||
import org.jeecg.modules.airag.common.vo.event.EventData;
|
||||
import org.jeecg.modules.airag.common.vo.event.EventFlowData;
|
||||
import org.jeecg.modules.airag.common.vo.event.EventMessageData;
|
||||
import org.jeecg.modules.airag.flow.consts.FlowConsts;
|
||||
import org.jeecg.modules.airag.flow.entity.AiragFlow;
|
||||
import org.jeecg.modules.airag.flow.service.IAiragFlowService;
|
||||
import org.jeecg.modules.airag.flow.vo.api.FlowRunParams;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragModel;
|
||||
import org.jeecg.modules.airag.llm.handler.AIChatHandler;
|
||||
import org.jeecg.modules.airag.llm.handler.JeecgToolsProvider;
|
||||
import org.jeecg.modules.airag.llm.mapper.AiragModelMapper;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.BoundValueOperations;
|
||||
@ -40,7 +46,6 @@ import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
@ -78,6 +83,9 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
@Autowired
|
||||
JeecgToolsProvider jeecgToolsProvider;
|
||||
|
||||
@Autowired
|
||||
AiragModelMapper airagModelMapper;
|
||||
|
||||
/**
|
||||
* 重新接收消息
|
||||
*/
|
||||
@ -102,6 +110,12 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
if (oConvertUtils.isEmpty(chatConversation.getTitle())) {
|
||||
chatConversation.setTitle(userMessage.length() > 5 ? userMessage.substring(0, 5) : userMessage);
|
||||
}
|
||||
//update-begin---author:chenrui ---date:20251106 for:[issues/8545]新建AI应用的时候只能选择没有自定义参数的AI流程------------
|
||||
// 保存工作流入参配置(如果有)
|
||||
if (oConvertUtils.isObjectNotEmpty(chatSendParams.getFlowInputs())) {
|
||||
chatConversation.setFlowInputs(chatSendParams.getFlowInputs());
|
||||
}
|
||||
//update-end---author:chenrui ---date:20251106 for:[issues/8545]新建AI应用的时候只能选择没有自定义参数的AI流程------------
|
||||
// 发送消息
|
||||
return doChat(chatConversation, topicId, chatSendParams);
|
||||
}
|
||||
@ -117,6 +131,12 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
AiragApp app = appDebugParams.getApp();
|
||||
app.setId("__DEBUG_APP");
|
||||
ChatConversation chatConversation = getOrCreateChatConversation(app, topicId);
|
||||
//update-begin---author:chenrui ---date:20251106 for:[issues/8545]新建AI应用的时候只能选择没有自定义参数的AI流程------------
|
||||
// 保存工作流入参配置(如果有)
|
||||
if (oConvertUtils.isObjectNotEmpty(appDebugParams.getFlowInputs())) {
|
||||
chatConversation.setFlowInputs(appDebugParams.getFlowInputs());
|
||||
}
|
||||
//update-end---author:chenrui ---date:20251106 for:[issues/8545]新建AI应用的时候只能选择没有自定义参数的AI流程------------
|
||||
// 发送消息
|
||||
SseEmitter emitter = doChat(chatConversation, topicId, appDebugParams);
|
||||
//保存会话
|
||||
@ -237,7 +257,33 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
if (oConvertUtils.isObjectEmpty(chatConversation)) {
|
||||
return Result.ok(Collections.emptyList());
|
||||
}
|
||||
return Result.ok(chatConversation.getMessages());
|
||||
//update-begin---author:chenrui ---date:20251106 for:[issues/8545]新建AI应用的时候只能选择没有自定义参数的AI流程------------
|
||||
// 返回消息列表和会话设置信息
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
// 过滤掉工具调用相关的消息(前端不需要展示)
|
||||
List<MessageHistory> messages = chatConversation.getMessages();
|
||||
if (oConvertUtils.isObjectNotEmpty(messages)) {
|
||||
messages = messages.stream()
|
||||
.filter(msg -> !AiragConsts.MESSAGE_ROLE_TOOL.equals(msg.getRole()))
|
||||
.map(msg -> {
|
||||
// 克隆消息对象,移除工具执行请求信息(前端不需要)
|
||||
MessageHistory displayMsg = MessageHistory.builder()
|
||||
.conversationId(msg.getConversationId())
|
||||
.topicId(msg.getTopicId())
|
||||
.role(msg.getRole())
|
||||
.content(msg.getContent())
|
||||
.images(msg.getImages())
|
||||
.datetime(msg.getDatetime())
|
||||
.build();
|
||||
// 不设置toolExecutionRequests和toolExecutionResult
|
||||
return displayMsg;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
result.put("messages", messages);
|
||||
result.put("flowInputs", chatConversation.getFlowInputs());
|
||||
return Result.ok(result);
|
||||
//update-end---author:chenrui ---date:20251106 for:[issues/8545]新建AI应用的时候只能选择没有自定义参数的AI流程------------
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -258,6 +304,51 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
@Override
|
||||
public Result<?> initChat(String appId) {
|
||||
AiragApp app = airagAppMapper.getByIdIgnoreTenant(appId);
|
||||
//update-begin---author:chenrui ---date:20251106 for:[issues/8545]新建AI应用的时候只能选择没有自定义参数的AI流程------------
|
||||
if(AiAppConsts.APP_TYPE_CHAT_FLOW.equalsIgnoreCase(app.getType())) {
|
||||
AiragFlow flow = airagFlowService.getById(app.getFlowId());
|
||||
String flowMetadata = flow.getMetadata();
|
||||
if(oConvertUtils.isNotEmpty(flowMetadata)) {
|
||||
JSONObject flowMetadataJson = JSONObject.parseObject(flowMetadata);
|
||||
JSONArray flowMetadataInputs = flowMetadataJson.getJSONArray(FlowConsts.FLOW_METADATA_INPUTS);
|
||||
if(oConvertUtils.isObjectNotEmpty(flowMetadataInputs)) {
|
||||
String appMetadataStr = app.getMetadata();
|
||||
JSONObject appMetadataJson;
|
||||
if(oConvertUtils.isEmpty(appMetadataStr)){
|
||||
appMetadataJson = new JSONObject();
|
||||
} else {
|
||||
appMetadataJson = JSONObject.parseObject(appMetadataStr);
|
||||
}
|
||||
appMetadataJson.put(AiAppConsts.APP_METADATA_FLOW_INPUTS, flowMetadataInputs);
|
||||
app.setMetadata(appMetadataJson.toJSONString());
|
||||
}
|
||||
}
|
||||
}
|
||||
//update-end---author:chenrui ---date:20251106 for:[issues/8545]新建AI应用的时候只能选择没有自定义参数的AI流程------------
|
||||
|
||||
//update-begin---author:chenrui ---date:202501XX for:在initChat接口中返回模型供应商信息,避免前端多次调用模型查询接口------------
|
||||
// 如果应用有模型ID,查询模型信息并将供应商、类型、名称等信息添加到metadata中
|
||||
if (oConvertUtils.isNotEmpty(app.getModelId())) {
|
||||
AiragModel model = airagModelMapper.getByIdIgnoreTenant(app.getModelId());
|
||||
if (model != null) {
|
||||
String appMetadataStr = app.getMetadata();
|
||||
JSONObject appMetadataJson;
|
||||
if(oConvertUtils.isEmpty(appMetadataStr)){
|
||||
appMetadataJson = new JSONObject();
|
||||
} else {
|
||||
appMetadataJson = JSONObject.parseObject(appMetadataStr);
|
||||
}
|
||||
// 将模型信息添加到metadata中
|
||||
JSONObject modelInfo = new JSONObject();
|
||||
modelInfo.put("provider", model.getProvider());
|
||||
modelInfo.put("modelType", model.getModelType());
|
||||
modelInfo.put("modelName", model.getModelName());
|
||||
appMetadataJson.put("modelInfo", modelInfo);
|
||||
app.setMetadata(appMetadataJson.toJSONString());
|
||||
}
|
||||
}
|
||||
//update-end---author:chenrui ---date:202501XX for:在initChat接口中返回模型供应商信息,避免前端多次调用模型查询接口------------
|
||||
|
||||
return Result.ok(app);
|
||||
}
|
||||
|
||||
@ -541,7 +632,30 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
chatMessage = UserMessage.from(contents);
|
||||
break;
|
||||
case AiragConsts.MESSAGE_ROLE_AI:
|
||||
chatMessage = new AiMessage(history.getContent());
|
||||
// 重建AI消息,包括工具执行请求
|
||||
if (oConvertUtils.isObjectNotEmpty(history.getToolExecutionRequests())) {
|
||||
// 有工具执行请求,重建带工具调用的AiMessage
|
||||
List<ToolExecutionRequest> toolRequests = history.getToolExecutionRequests().stream()
|
||||
.map(toolReq -> ToolExecutionRequest.builder()
|
||||
.id(toolReq.getId())
|
||||
.name(toolReq.getName())
|
||||
.arguments(toolReq.getArguments())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
chatMessage = AiMessage.from(history.getContent(), toolRequests);
|
||||
} else {
|
||||
chatMessage = new AiMessage(history.getContent());
|
||||
}
|
||||
break;
|
||||
case AiragConsts.MESSAGE_ROLE_TOOL:
|
||||
// 重建工具执行结果消息
|
||||
// 需要重建ToolExecutionRequest,第一个参数是request对象,第二个参数是result字符串
|
||||
ToolExecutionRequest recreatedRequest = ToolExecutionRequest.builder()
|
||||
.id(history.getContent()) // content字段存储的是工具执行的id
|
||||
.name("unknown") // 工具名称在重建时不重要,因为主要用于AI理解结果
|
||||
.arguments("{}")
|
||||
.build();
|
||||
chatMessage = ToolExecutionResultMessage.from(recreatedRequest, history.getToolExecutionResult());
|
||||
break;
|
||||
}
|
||||
if (null == chatMessage) {
|
||||
@ -599,7 +713,26 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
historyMessage.setImages(images);
|
||||
} else if (message.type().equals(ChatMessageType.AI)) {
|
||||
historyMessage.setRole(AiragConsts.MESSAGE_ROLE_AI);
|
||||
historyMessage.setContent(((AiMessage) message).text());
|
||||
AiMessage aiMessage = (AiMessage) message;
|
||||
historyMessage.setContent(aiMessage.text());
|
||||
// 处理工具执行请求
|
||||
if (oConvertUtils.isObjectNotEmpty(aiMessage.toolExecutionRequests())) {
|
||||
List<MessageHistory.ToolExecutionRequestHistory> toolRequests = new ArrayList<>();
|
||||
for (ToolExecutionRequest request : aiMessage.toolExecutionRequests()) {
|
||||
toolRequests.add(MessageHistory.ToolExecutionRequestHistory.from(
|
||||
request.id(),
|
||||
request.name(),
|
||||
request.arguments()
|
||||
));
|
||||
}
|
||||
historyMessage.setToolExecutionRequests(toolRequests);
|
||||
}
|
||||
} else if (message.type().equals(ChatMessageType.TOOL_EXECUTION_RESULT)) {
|
||||
// 工具执行结果消息
|
||||
historyMessage.setRole(AiragConsts.MESSAGE_ROLE_TOOL);
|
||||
ToolExecutionResultMessage toolMessage = (ToolExecutionResultMessage) message;
|
||||
historyMessage.setContent(toolMessage.id());
|
||||
historyMessage.setToolExecutionResult(toolMessage.text());
|
||||
}
|
||||
histories.add(historyMessage);
|
||||
chatConversation.setMessages(histories);
|
||||
@ -648,11 +781,15 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
sendWithFlow(requestId, aiApp.getFlowId(), chatConversation, topicId, messages, sendParams);
|
||||
} else {
|
||||
// AI应用-聊天助手(ChatAssistant):从应用信息组装模型和提示词
|
||||
sendWithAppChat(requestId, messages, chatConversation, topicId);
|
||||
sendWithAppChat(requestId, messages, chatConversation, topicId, sendParams);
|
||||
}
|
||||
} else {
|
||||
// 发消息
|
||||
sendWithDefault(requestId, chatConversation, topicId, null, messages, null);
|
||||
AIChatParams aiChatParams = new AIChatParams();
|
||||
if (oConvertUtils.isObjectNotEmpty(sendParams.getEnableSearch())) {
|
||||
aiChatParams.setEnableSearch(sendParams.getEnableSearch());
|
||||
}
|
||||
sendWithDefault(requestId, chatConversation, topicId, null, messages, aiChatParams);
|
||||
}
|
||||
// 发送就绪消息
|
||||
EventData eventRequestId = new EventData(requestId, null, EventData.EVENT_INIT_REQUEST_ID, chatConversation.getId(), topicId);
|
||||
@ -698,6 +835,14 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
flowInputParams.put(FlowConsts.FLOW_INPUT_PARAM_HISTORY, histories);
|
||||
flowInputParams.put(FlowConsts.FLOW_INPUT_PARAM_QUESTION, sendParams.getContent());
|
||||
flowInputParams.put(FlowConsts.FLOW_INPUT_PARAM_IMAGES, sendParams.getImages());
|
||||
|
||||
//update-begin---author:chenrui ---date:20251106 for:[issues/8545]新建AI应用的时候只能选择没有自定义参数的AI流程------------
|
||||
// 添加工作流的额外参数(从conversation的flowInputs中读取)
|
||||
if (oConvertUtils.isObjectNotEmpty(chatConversation.getFlowInputs())) {
|
||||
flowInputParams.putAll(chatConversation.getFlowInputs());
|
||||
}
|
||||
//update-end---author:chenrui ---date:20251106 for:[issues/8545]新建AI应用的时候只能选择没有自定义参数的AI流程------------
|
||||
|
||||
flowRunParams.setInputParams(flowInputParams);
|
||||
HttpServletRequest httpRequest = SpringContextUtils.getHttpServletRequest();
|
||||
flowRunParams.setHttpRequest(httpRequest);
|
||||
@ -762,11 +907,12 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
* @param messages
|
||||
* @param chatConversation
|
||||
* @param topicId
|
||||
* @param sendParams
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/2/28 10:41
|
||||
*/
|
||||
private void sendWithAppChat(String requestId, List<ChatMessage> messages, ChatConversation chatConversation, String topicId) {
|
||||
private void sendWithAppChat(String requestId, List<ChatMessage> messages, ChatConversation chatConversation, String topicId, ChatSendParams sendParams) {
|
||||
AiragApp aiApp = chatConversation.getApp();
|
||||
String modelId = aiApp.getModelId();
|
||||
AssertUtils.assertNotEmpty("请先选择模型", modelId);
|
||||
@ -799,6 +945,31 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AI应用插件(支持MCP和自定义插件)
|
||||
String plugins = aiApp.getPlugins();
|
||||
if (oConvertUtils.isNotEmpty(plugins)) {
|
||||
List<String> pluginIds = new ArrayList<>();
|
||||
JSONArray pluginArray = JSONArray.parseArray(plugins);
|
||||
pluginArray.stream().filter(Objects::nonNull)
|
||||
.map(o -> JSONObject.parseObject(o.toString(), LlmPlugin.class))
|
||||
.forEach(plugin -> {
|
||||
// 支持MCP和插件类型
|
||||
if (plugin.getCategory().equals(AiragConsts.PLUGIN_CATEGORY_MCP)
|
||||
|| plugin.getCategory().equals(AiragConsts.PLUGIN_CATEGORY_PLUGIN)) {
|
||||
pluginIds.add(plugin.getPluginId());
|
||||
}
|
||||
});
|
||||
if (oConvertUtils.isNotEmpty(pluginIds)) {
|
||||
aiChatParams.setPluginIds(pluginIds);
|
||||
}
|
||||
}
|
||||
|
||||
// 设置网络搜索参数(如果前端传递了)
|
||||
if (sendParams != null && oConvertUtils.isObjectNotEmpty(sendParams.getEnableSearch())) {
|
||||
aiChatParams.setEnableSearch(sendParams.getEnableSearch());
|
||||
}
|
||||
|
||||
// 打印流程耗时日志
|
||||
printChatDuration(requestId, "构造应用自定义参数完成");
|
||||
// 发消息
|
||||
@ -828,6 +999,8 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
}
|
||||
aiChatParams.setKnowIds(chatConversation.getApp().getKnowIds());
|
||||
aiChatParams.setMaxMsgNumber(oConvertUtils.getInt(chatConversation.getApp().getMsgNum(), 5));
|
||||
aiChatParams.setCurrentHttpRequest(SpringContextUtils.getHttpServletRequest());
|
||||
aiChatParams.setReturnThinking(true);
|
||||
HttpServletRequest httpRequest = SpringContextUtils.getHttpServletRequest();
|
||||
TokenStream chatStream;
|
||||
try {
|
||||
@ -861,20 +1034,13 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
AtomicBoolean isThinking = new AtomicBoolean(false);
|
||||
// ai聊天响应逻辑
|
||||
chatStream.onPartialResponse((String resMessage) -> {
|
||||
// 兼容推理模型
|
||||
if ("<think>".equals(resMessage)) {
|
||||
isThinking.set(true);
|
||||
resMessage = "> ";
|
||||
}
|
||||
if ("</think>".equals(resMessage)) {
|
||||
//update-begin---author:wangshuai---date:2025-11-07---for:[issues/8506]/[issues/8260]/[issues/8166]新增推理模型的支持---
|
||||
if(isThinking.get()){
|
||||
//思考过程结束
|
||||
this.sendThinkEnd(requestId, chatConversation, topicId);
|
||||
isThinking.set(false);
|
||||
resMessage = "\n\n";
|
||||
}
|
||||
if (isThinking.get()) {
|
||||
if (null != resMessage && resMessage.contains("\n")) {
|
||||
resMessage = "\n> ";
|
||||
}
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-11-07---for:[issues/8506]/[issues/8260]/[issues/8166]新增推理模型的支持---
|
||||
EventData eventData = new EventData(requestId, null, EventData.EVENT_MESSAGE, chatConversation.getId(), topicId);
|
||||
EventMessageData messageEventData = EventMessageData.builder().message(resMessage).build();
|
||||
eventData.setData(messageEventData);
|
||||
@ -886,6 +1052,48 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
return;
|
||||
}
|
||||
sendMessage2Client(emitter, eventData);
|
||||
}).onToolExecuted((toolExecution) -> {
|
||||
// 打印工具执行结果
|
||||
log.debug("[AI应用]工具执行结果: toolName={}, toolId={}, result={}",
|
||||
toolExecution.request().name(),
|
||||
toolExecution.request().id(),
|
||||
toolExecution.result());
|
||||
// 将工具执行结果存储到消息历史中
|
||||
ToolExecutionResultMessage toolResultMessage = ToolExecutionResultMessage.from(
|
||||
toolExecution.request(),
|
||||
toolExecution.result()
|
||||
);
|
||||
appendMessage(messages, toolResultMessage, chatConversation, topicId);
|
||||
}).onIntermediateResponse((chatResponse) -> {
|
||||
// 中间响应:包含tool_calls的AI消息
|
||||
AiMessage aiMessage = chatResponse.aiMessage();
|
||||
if (aiMessage != null && oConvertUtils.isObjectNotEmpty(aiMessage.toolExecutionRequests())) {
|
||||
// 保存包含工具调用请求的AI消息
|
||||
log.debug("[AI应用]保存包含工具调用的AI消息: toolCallsCount={}", aiMessage.toolExecutionRequests().size());
|
||||
appendMessage(messages, aiMessage, chatConversation, topicId);
|
||||
}
|
||||
}).onPartialThinking((partialThinking) -> {
|
||||
try {
|
||||
if (oConvertUtils.isEmpty(partialThinking)) {
|
||||
return;
|
||||
}
|
||||
isThinking.set(true);
|
||||
String text = partialThinking.text();
|
||||
// 构造事件数据(EVENT_THINKING 以便前端统一处理)
|
||||
EventData thinkingEvent = new EventData(requestId, null, EventData.EVENT_THINKING, chatConversation.getId(), topicId);
|
||||
thinkingEvent.setData(EventMessageData.builder().message(text).build());
|
||||
thinkingEvent.setRequestId(requestId);
|
||||
// 获取当前缓存的 emitter
|
||||
SseEmitter emitter = AiragLocalCache.get(AiragConsts.CACHE_TYPE_SSE, requestId);
|
||||
if (null == emitter) {
|
||||
log.warn("[AI应用]思考过程发送失败,SSE 已关闭: {}", requestId);
|
||||
return;
|
||||
}
|
||||
// 发送给客户端并缓存历史
|
||||
sendMessage2Client(emitter, thinkingEvent);
|
||||
} catch (Exception e) {
|
||||
log.error("发送思考过程异常", e);
|
||||
}
|
||||
}).onCompleteResponse((responseMessage) -> {
|
||||
// 打印流程耗时日志
|
||||
printChatDuration(requestId, "LLM输出消息完成");
|
||||
@ -907,9 +1115,6 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
// 保存会话
|
||||
saveChatConversation(chatConversation, false, httpRequest);
|
||||
closeSSE(emitter, eventData);
|
||||
} else if (FinishReason.TOOL_EXECUTION.equals(finishReason)) {
|
||||
// 需要执行工具
|
||||
// TODO author: chenrui for: date:2025/3/7
|
||||
} else if (FinishReason.LENGTH.equals(finishReason)) {
|
||||
// 上下文长度超过限制
|
||||
log.error("调用模型异常:上下文长度超过限制:{}", responseMessage.tokenUsage());
|
||||
@ -966,6 +1171,26 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
}).start();
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送思考过程结束
|
||||
*
|
||||
* @param requestId
|
||||
* @param chatConversation
|
||||
* @param topicId
|
||||
*/
|
||||
private void sendThinkEnd(String requestId, ChatConversation chatConversation, String topicId) {
|
||||
EventData eventData = new EventData(requestId, null, EventData.EVENT_THINKING_END, chatConversation.getId(), topicId);
|
||||
EventMessageData messageEventData = EventMessageData.builder().message("").build();
|
||||
eventData.setData(messageEventData);
|
||||
eventData.setRequestId(requestId);
|
||||
SseEmitter emitter = AiragLocalCache.get(AiragConsts.CACHE_TYPE_SSE, requestId);
|
||||
if (null == emitter) {
|
||||
log.warn("[AI应用]接收LLM返回会话已关闭");
|
||||
return;
|
||||
}
|
||||
sendMessage2Client(emitter, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息到客户端
|
||||
*
|
||||
|
||||
@ -6,6 +6,7 @@ import org.jeecg.modules.airag.common.vo.MessageHistory;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @Description: 聊天会话
|
||||
@ -39,4 +40,11 @@ public class ChatConversation {
|
||||
* 创建时间
|
||||
*/
|
||||
private Date createTime;
|
||||
|
||||
/**
|
||||
* 流程入参配置(工作流的额外参数设置)
|
||||
* key: 参数field, value: 参数值
|
||||
* for [issues/8545]新建AI应用的时候只能选择没有自定义参数的AI流程
|
||||
*/
|
||||
private Map<String, Object> flowInputs;
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @Description: 发送消息的入参
|
||||
@ -46,4 +47,16 @@ public class ChatSendParams {
|
||||
*/
|
||||
private List<String> images;
|
||||
|
||||
/**
|
||||
* 工作流额外入参配置
|
||||
* key: 参数field, value: 参数值
|
||||
* for [issues/8545]新建AI应用的时候只能选择没有自定义参数的AI流程
|
||||
*/
|
||||
private Map<String, Object> flowInputs;
|
||||
|
||||
/**
|
||||
* 是否开启网络搜索(仅千问模型支持)
|
||||
*/
|
||||
private Boolean enableSearch;
|
||||
|
||||
}
|
||||
|
||||
@ -80,4 +80,9 @@ public class LLMConsts {
|
||||
*/
|
||||
public static final String KNOWLEDGE_DOC_METADATA_SOURCES_PATH = "sourcesPath";
|
||||
|
||||
/**
|
||||
* DEEPSEEK推理模型
|
||||
*/
|
||||
public static final String DEEPSEEK_REASONER = "deepseek-reasoner";
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,191 @@
|
||||
package org.jeecg.modules.airag.llm.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.system.base.controller.JeecgController;
|
||||
import org.jeecg.common.system.query.QueryGenerator;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragMcp;
|
||||
import org.jeecg.modules.airag.llm.service.IAiragMcpService;
|
||||
import org.jeecg.modules.airag.llm.dto.SaveToolsDTO;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
|
||||
/**
|
||||
* @Description: MCP
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-10-20
|
||||
* @Version: V1.0
|
||||
*/
|
||||
@Tag(name = "MCP")
|
||||
@RestController("airagMcpController")
|
||||
@RequestMapping("/airag/airagMcp")
|
||||
@Slf4j
|
||||
public class AiragMcpController extends JeecgController<AiragMcp, IAiragMcpService> {
|
||||
@Autowired
|
||||
private IAiragMcpService airagMcpService;
|
||||
|
||||
/**
|
||||
* 分页列表查询
|
||||
*
|
||||
* @param airagMcp
|
||||
* @param pageNo
|
||||
* @param pageSize
|
||||
* @param req
|
||||
* @return
|
||||
*/
|
||||
@Operation(summary = "MCP-分页列表查询")
|
||||
@GetMapping(value = "/list")
|
||||
public Result<IPage<AiragMcp>> queryPageList(AiragMcp airagMcp,
|
||||
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
|
||||
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
|
||||
HttpServletRequest req) {
|
||||
|
||||
QueryWrapper<AiragMcp> queryWrapper = QueryGenerator.initQueryWrapper(airagMcp, req.getParameterMap());
|
||||
Page<AiragMcp> page = new Page<AiragMcp>(pageNo, pageSize);
|
||||
IPage<AiragMcp> pageList = airagMcpService.page(page, queryWrapper);
|
||||
return Result.OK(pageList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存
|
||||
*
|
||||
* @param airagMcp
|
||||
* @return
|
||||
*/
|
||||
@Operation(summary = "MCP-保存")
|
||||
@PostMapping(value = "/save")
|
||||
public Result<String> save(@RequestBody AiragMcp airagMcp) {
|
||||
return airagMcpService.edit(airagMcp);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 保存并同步
|
||||
*
|
||||
* @param airagMcp
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/10/21 10:54
|
||||
*/
|
||||
@Operation(summary = "MCP-保存并同步")
|
||||
@PostMapping(value = "/saveAndSync")
|
||||
public Result<?> saveAndSync(@RequestBody AiragMcp airagMcp) {
|
||||
Result<String> saveResult = airagMcpService.edit(airagMcp);
|
||||
if (!saveResult.isSuccess()) {
|
||||
return saveResult;
|
||||
}
|
||||
String id = airagMcp.getId();
|
||||
if (id == null || id.trim().isEmpty()) {
|
||||
return Result.error("保存失败");
|
||||
}
|
||||
return airagMcpService.sync(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步MCP信息
|
||||
*
|
||||
* @param id
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/10/20 20:09
|
||||
*/
|
||||
@Operation(summary = "MCP-同步MCP信息")
|
||||
@PostMapping(value = "/sync/{id}")
|
||||
public Result<?> sync(@PathVariable(name = "id", required = true) String id) {
|
||||
return airagMcpService.sync(id);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 启用/禁用MCP信息
|
||||
*
|
||||
* @param action 启用:enable,禁用:disable
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/10/20 20:13
|
||||
*/
|
||||
@Operation(summary = "MCP-启用/禁用MCP信息")
|
||||
@PostMapping(value = "/status/{id}/{action}")
|
||||
public Result<?> toggleStatus(@PathVariable(name = "id",required = true) String id,
|
||||
@PathVariable(name = "action", required = true) String action) {
|
||||
return airagMcpService.toggleStatus(id,action);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存插件工具
|
||||
* for [QQYUN-12453]【AI】支持插件
|
||||
* @param dto 包含插件ID和工具列表JSON字符串的DTO
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/10/30
|
||||
*/
|
||||
@Operation(summary = "MCP-保存插件工具")
|
||||
@PostMapping(value = "/saveTools")
|
||||
public Result<String> saveTools(@RequestBody SaveToolsDTO dto) {
|
||||
return airagMcpService.saveTools(dto.getId(), dto.getTools());
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过id删除
|
||||
*
|
||||
* @param id
|
||||
* @return
|
||||
*/
|
||||
@Operation(summary = "MCP-通过id删除")
|
||||
@DeleteMapping(value = "/delete")
|
||||
public Result<String> delete(@RequestParam(name = "id", required = true) String id) {
|
||||
airagMcpService.removeById(id);
|
||||
return Result.OK("删除成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过id查询
|
||||
*
|
||||
* @param id
|
||||
* @return
|
||||
*/
|
||||
@Operation(summary = "MCP-通过id查询")
|
||||
@GetMapping(value = "/queryById")
|
||||
public Result<AiragMcp> queryById(@RequestParam(name = "id", required = true) String id) {
|
||||
AiragMcp airagMcp = airagMcpService.getById(id);
|
||||
if (airagMcp == null) {
|
||||
return Result.error("未找到对应数据");
|
||||
}
|
||||
return Result.OK(airagMcp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出excel
|
||||
*
|
||||
* @param request
|
||||
* @param airagMcp
|
||||
*/
|
||||
// @RequiresPermissions("llm:airag_mcp:exportXls")
|
||||
@RequestMapping(value = "/exportXls")
|
||||
public ModelAndView exportXls(HttpServletRequest request, AiragMcp airagMcp) {
|
||||
return super.exportXls(request, airagMcp, AiragMcp.class, "MCP");
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过excel导入数据
|
||||
*
|
||||
* @param request
|
||||
* @param response
|
||||
* @return
|
||||
*/
|
||||
// @RequiresPermissions("llm:airag_mcp:importExcel")
|
||||
@RequestMapping(value = "/importExcel", method = RequestMethod.POST)
|
||||
public Result<?> importExcel(HttpServletRequest request, HttpServletResponse response) {
|
||||
return super.importExcel(request, response, AiragMcp.class);
|
||||
}
|
||||
|
||||
}
|
||||
@ -81,8 +81,9 @@ public class AiragModelController extends JeecgController<AiragModel, IAiragMode
|
||||
// 默认未激活
|
||||
if(oConvertUtils.isObjectEmpty(airagModel.getActivateFlag())){
|
||||
airagModel.setActivateFlag(0);
|
||||
} else {
|
||||
airagModel.setActivateFlag(1);
|
||||
}
|
||||
airagModel.setActivateFlag(0);
|
||||
airagModelService.save(airagModel);
|
||||
return Result.OK("添加成功!");
|
||||
}
|
||||
@ -178,7 +179,7 @@ public class AiragModelController extends JeecgController<AiragModel, IAiragMode
|
||||
}
|
||||
}catch (Exception e){
|
||||
log.error("测试模型连接失败", e);
|
||||
return Result.error("测试模型连接失败:" + e.getMessage());
|
||||
return Result.error("测试模型连接失败,请检查模型配置是否正确!");
|
||||
}
|
||||
// 测试成功激活数据
|
||||
airagModel.setActivateFlag(1);
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
package org.jeecg.modules.airag.llm.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 保存插件工具DTO
|
||||
* fro [QQYUN-12453]【AI】支持插件
|
||||
* @author chenrui
|
||||
* @date 2025/10/30
|
||||
*/
|
||||
@Data
|
||||
public class SaveToolsDTO {
|
||||
/**
|
||||
* 插件ID
|
||||
*/
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* 工具列表JSON字符串
|
||||
*/
|
||||
private String tools;
|
||||
}
|
||||
|
||||
@ -0,0 +1,138 @@
|
||||
package org.jeecg.modules.airag.llm.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.jeecgframework.poi.excel.annotation.Excel;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* @Description: MCP
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-10-20
|
||||
* @Version: V1.0
|
||||
*/
|
||||
@Data
|
||||
@TableName("airag_mcp")
|
||||
@Accessors(chain = true)
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Schema(description = "MCP")
|
||||
public class AiragMcp implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* id
|
||||
*/
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
@Schema(description = "id")
|
||||
private java.lang.String id;
|
||||
/**
|
||||
* 应用图标
|
||||
*/
|
||||
@Excel(name = "应用图标", width = 15)
|
||||
@Schema(description = "应用图标")
|
||||
private java.lang.String icon;
|
||||
/**
|
||||
* 名称
|
||||
*/
|
||||
@Excel(name = "名称", width = 15)
|
||||
@Schema(description = "名称")
|
||||
private java.lang.String name;
|
||||
/**
|
||||
* 描述
|
||||
*/
|
||||
@Excel(name = "描述", width = 15)
|
||||
@Schema(description = "描述")
|
||||
private java.lang.String descr;
|
||||
/**
|
||||
* 类型(plugin=插件,mcp=MCP)
|
||||
* for [QQYUN-12453]【AI】支持插件
|
||||
*/
|
||||
@Excel(name = "类型(plugin=插件,mcp=MCP)", width = 15)
|
||||
@Schema(description = "类型(plugin=插件,mcp=MCP)")
|
||||
private java.lang.String category;
|
||||
/**
|
||||
* mcp类型(sse:sse类型;stdio:标准类型)
|
||||
*/
|
||||
@Excel(name = "mcp类型(sse:sse类型;stdio:标准类型)", width = 15)
|
||||
@Schema(description = "mcp类型(sse:sse类型;stdio:标准类型)")
|
||||
private java.lang.String type;
|
||||
/**
|
||||
* 服务端点(SSE类型为URL,stdio类型为命令)
|
||||
*/
|
||||
@Excel(name = "服务端点(SSE类型为URL,stdio类型为命令)", width = 15)
|
||||
@Schema(description = "服务端点(SSE类型为URL,stdio类型为命令)")
|
||||
private java.lang.String endpoint;
|
||||
/**
|
||||
* 请求头(sse类型)、环境变量(stdio类型)
|
||||
*/
|
||||
@Excel(name = "请求头(sse类型)、环境变量(stdio类型)", width = 15)
|
||||
@Schema(description = "请求头(sse类型)、环境变量(stdio类型)")
|
||||
private java.lang.String headers;
|
||||
/**
|
||||
* 工具列表
|
||||
*/
|
||||
@Excel(name = "工具列表", width = 15)
|
||||
@Schema(description = "工具列表")
|
||||
private java.lang.String tools;
|
||||
/**
|
||||
* 状态(enable=启用、disable=禁用)
|
||||
*/
|
||||
@Excel(name = "状态(enable=启用、disable=禁用)", width = 15)
|
||||
@Schema(description = "状态(enable=启用、disable=禁用)")
|
||||
private java.lang.String status;
|
||||
/**
|
||||
* 是否同步
|
||||
*/
|
||||
@Excel(name = "是否同步", width = 15)
|
||||
@Schema(description = "是否同步")
|
||||
private java.lang.Integer synced;
|
||||
/**
|
||||
* 元数据
|
||||
*/
|
||||
@Excel(name = "元数据", width = 15)
|
||||
@Schema(description = "元数据")
|
||||
private java.lang.String metadata;
|
||||
/**
|
||||
* 创建人
|
||||
*/
|
||||
@Schema(description = "创建人")
|
||||
private java.lang.String createBy;
|
||||
/**
|
||||
* 创建日期
|
||||
*/
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Schema(description = "创建日期")
|
||||
private java.util.Date createTime;
|
||||
/**
|
||||
* 更新人
|
||||
*/
|
||||
@Schema(description = "更新人")
|
||||
private java.lang.String updateBy;
|
||||
/**
|
||||
* 更新日期
|
||||
*/
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Schema(description = "更新日期")
|
||||
private java.util.Date updateTime;
|
||||
/**
|
||||
* 所属部门
|
||||
*/
|
||||
@Schema(description = "所属部门")
|
||||
private java.lang.String sysOrgCode;
|
||||
/**
|
||||
* 租户id
|
||||
*/
|
||||
@Excel(name = "租户id", width = 15)
|
||||
@Schema(description = "租户id")
|
||||
private java.lang.String tenantId;
|
||||
}
|
||||
@ -1,9 +1,12 @@
|
||||
package org.jeecg.modules.airag.llm.handler;
|
||||
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import dev.langchain4j.agent.tool.ToolSpecification;
|
||||
import dev.langchain4j.data.message.*;
|
||||
import dev.langchain4j.mcp.McpToolProvider;
|
||||
import dev.langchain4j.rag.query.router.QueryRouter;
|
||||
import dev.langchain4j.service.TokenStream;
|
||||
import dev.langchain4j.service.tool.ToolExecutor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.ai.handler.LLMHandler;
|
||||
import org.jeecg.common.exception.JeecgBootException;
|
||||
@ -12,7 +15,9 @@ import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.modules.airag.common.handler.AIChatParams;
|
||||
import org.jeecg.modules.airag.common.handler.IAIChatHandler;
|
||||
import org.jeecg.modules.airag.llm.consts.LLMConsts;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragMcp;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragModel;
|
||||
import org.jeecg.modules.airag.llm.mapper.AiragMcpMapper;
|
||||
import org.jeecg.modules.airag.llm.mapper.AiragModelMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@ -25,6 +30,7 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 大模型聊天工具类
|
||||
@ -39,6 +45,9 @@ public class AIChatHandler implements IAIChatHandler {
|
||||
@Autowired
|
||||
AiragModelMapper airagModelMapper;
|
||||
|
||||
@Autowired
|
||||
AiragMcpMapper airagMcpMapper;
|
||||
|
||||
@Autowired
|
||||
EmbeddingHandler embeddingHandler;
|
||||
|
||||
@ -105,12 +114,22 @@ public class AIChatHandler implements IAIChatHandler {
|
||||
// langchain4j 异常友好提示
|
||||
String errMsg = "调用大模型接口失败,详情请查看后台日志。";
|
||||
if (oConvertUtils.isNotEmpty(e.getMessage())) {
|
||||
String exceptionMsg = e.getMessage();
|
||||
|
||||
// 检查是否是工具调用消息序列不完整的异常
|
||||
if (exceptionMsg.contains("messages with role 'tool' must be a response to a preceeding message with 'tool_calls'")) {
|
||||
errMsg = "消息序列不完整,可能是因为历史消息数量设置过小导致工具调用上下文丢失。建议增加历史消息数量后重试。";
|
||||
log.error("AI模型调用异常: 工具调用消息序列不完整,建议增加历史消息数量。异常详情: {}", exceptionMsg, e);
|
||||
throw new JeecgBootException(errMsg);
|
||||
}
|
||||
|
||||
// 根据常见异常关键字做细致翻译
|
||||
for (Map.Entry<String, String> entry : MODEL_ERROR_MAP.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
String value = entry.getValue();
|
||||
if (errMsg.contains(key)) {
|
||||
errMsg = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -247,6 +266,9 @@ public class AIChatHandler implements IAIChatHandler {
|
||||
if (oConvertUtils.isObjectEmpty(params.getTimeout())) {
|
||||
params.setTimeout(modelParams.getInteger("timeout"));
|
||||
}
|
||||
if (oConvertUtils.isObjectEmpty(params.getEnableSearch())) {
|
||||
params.setEnableSearch(modelParams.getBoolean("enableSearch"));
|
||||
}
|
||||
}
|
||||
|
||||
// RAG
|
||||
@ -266,9 +288,77 @@ public class AIChatHandler implements IAIChatHandler {
|
||||
params.setTimeout(60);
|
||||
}
|
||||
|
||||
//deepseek-reasoner 推理模型不支持插件tool
|
||||
String modelName = airagModel.getModelName();
|
||||
if(!LLMConsts.DEEPSEEK_REASONER.equals(modelName)){
|
||||
// 插件/MCP处理
|
||||
buildPlugins(params);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造插件和MCP工具
|
||||
* for [QQYUN-12453]【AI】支持插件
|
||||
* @param params
|
||||
* @author chenrui
|
||||
* @date 2025/10/31 14:04
|
||||
*/
|
||||
private void buildPlugins(AIChatParams params) {
|
||||
List<String> pluginIds = params.getPluginIds();
|
||||
|
||||
if(oConvertUtils.isObjectNotEmpty(pluginIds)){
|
||||
List<McpToolProvider> mcpToolProviders = new ArrayList<>();
|
||||
Map<ToolSpecification, ToolExecutor> pluginTools = new HashMap<>();
|
||||
|
||||
for (String pluginId : pluginIds.stream().distinct().collect(Collectors.toList())) {
|
||||
AiragMcp airagMcp = airagMcpMapper.selectById(pluginId);
|
||||
if (airagMcp == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String category = airagMcp.getCategory();
|
||||
if (oConvertUtils.isEmpty(category)) {
|
||||
// 兼容旧数据:如果没有category字段,默认为mcp
|
||||
category = "mcp";
|
||||
}
|
||||
|
||||
if ("mcp".equalsIgnoreCase(category)) {
|
||||
// MCP类型:构建McpToolProvider
|
||||
McpToolProvider mcpToolProvider = buildMcpToolProvider(
|
||||
airagMcp.getName(),
|
||||
airagMcp.getType(),
|
||||
airagMcp.getEndpoint(),
|
||||
airagMcp.getHeaders()
|
||||
);
|
||||
if (mcpToolProvider != null) {
|
||||
mcpToolProviders.add(mcpToolProvider);
|
||||
}
|
||||
} else if ("plugin".equalsIgnoreCase(category)) {
|
||||
// 插件类型:构建ToolSpecification和ToolExecutor
|
||||
Map<ToolSpecification, ToolExecutor> tools = PluginToolBuilder.buildTools(airagMcp, params.getCurrentHttpRequest());
|
||||
if (tools != null && !tools.isEmpty()) {
|
||||
pluginTools.putAll(tools);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 设置MCP工具提供者
|
||||
if (!mcpToolProviders.isEmpty()) {
|
||||
params.setMcpToolProviders(mcpToolProviders);
|
||||
}
|
||||
|
||||
// 设置插件工具
|
||||
if (!pluginTools.isEmpty()) {
|
||||
if (params.getTools() == null) {
|
||||
params.setTools(new HashMap<>());
|
||||
}
|
||||
params.getTools().putAll(pluginTools);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserMessage buildUserMessage(String content, List<String> images) {
|
||||
AssertUtils.assertNotEmpty("请输入消息内容", content);
|
||||
|
||||
@ -0,0 +1,540 @@
|
||||
package org.jeecg.modules.airag.llm.handler;
|
||||
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import dev.langchain4j.agent.tool.ToolSpecification;
|
||||
import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
|
||||
import dev.langchain4j.service.tool.ToolExecutor;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.jeecg.common.util.CommonUtils;
|
||||
import org.jeecg.common.util.RestUtil;
|
||||
import org.jeecg.common.util.TokenUtils;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragMcp;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 插件工具构建器
|
||||
* 根据插件配置构建ToolSpecification和ToolExecutor
|
||||
* for [QQYUN-12453]【AI】支持插件
|
||||
*
|
||||
* @author chenrui
|
||||
* @date 2025/10/30
|
||||
*/
|
||||
@Slf4j
|
||||
public class PluginToolBuilder {
|
||||
|
||||
/**
|
||||
* 从插件配置构建工具Map
|
||||
*
|
||||
* @param airagMcp 插件配置
|
||||
* @return Map<ToolSpecification, ToolExecutor>
|
||||
*/
|
||||
public static Map<ToolSpecification, ToolExecutor> buildTools(AiragMcp airagMcp, HttpServletRequest currentHttpRequest) {
|
||||
Map<ToolSpecification, ToolExecutor> tools = new HashMap<>();
|
||||
if (airagMcp == null || oConvertUtils.isEmpty(airagMcp.getTools())) {
|
||||
return tools;
|
||||
}
|
||||
|
||||
try {
|
||||
JSONArray toolsArray = JSONArray.parseArray(airagMcp.getTools());
|
||||
if (toolsArray == null || toolsArray.isEmpty()) {
|
||||
return tools;
|
||||
}
|
||||
|
||||
String baseUrl = airagMcp.getEndpoint();
|
||||
// 如果baseUrl为空,使用当前系统地址
|
||||
if (oConvertUtils.isEmpty(baseUrl)) {
|
||||
if (currentHttpRequest != null) {
|
||||
baseUrl = CommonUtils.getBaseUrl(currentHttpRequest);
|
||||
log.info("插件[{}]的BaseURL为空,使用系统地址: {}", airagMcp.getName(), baseUrl);
|
||||
} else {
|
||||
log.warn("插件[{}]的BaseURL为空且无法获取系统地址,跳过工具构建", airagMcp.getName());
|
||||
return tools;
|
||||
}
|
||||
}
|
||||
|
||||
// 解析headers
|
||||
Map<String, String> headersMap = parseHeaders(airagMcp.getHeaders());
|
||||
|
||||
// 解析并应用授权配置(从metadata中读取)
|
||||
applyAuthConfig(headersMap, airagMcp.getMetadata(), currentHttpRequest);
|
||||
|
||||
for (int i = 0; i < toolsArray.size(); i++) {
|
||||
JSONObject toolConfig = toolsArray.getJSONObject(i);
|
||||
if (toolConfig == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
ToolSpecification spec = buildToolSpecification(toolConfig);
|
||||
ToolExecutor executor = buildToolExecutor(toolConfig, baseUrl, headersMap);
|
||||
if (spec != null && executor != null) {
|
||||
tools.put(spec, executor);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("构建插件工具失败,工具配置: {}", toolConfig.toJSONString(), e);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("解析插件工具配置失败,插件: {}", airagMcp.getName(), e);
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建ToolSpecification
|
||||
*/
|
||||
private static ToolSpecification buildToolSpecification(JSONObject toolConfig) {
|
||||
String name = toolConfig.getString("name");
|
||||
String description = toolConfig.getString("description");
|
||||
|
||||
if (oConvertUtils.isEmpty(name) || oConvertUtils.isEmpty(description)) {
|
||||
log.warn("工具配置缺少name或description字段");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 构建完整的描述信息(包含响应参数配置)
|
||||
StringBuilder fullDescription = new StringBuilder(description);
|
||||
|
||||
// 解析响应参数并拼接到描述中
|
||||
JSONArray responses = toolConfig.getJSONArray("responses");
|
||||
if (responses != null && !responses.isEmpty()) {
|
||||
fullDescription.append("\n\n返回值说明:");
|
||||
for (int i = 0; i < responses.size(); i++) {
|
||||
JSONObject responseParam = responses.getJSONObject(i);
|
||||
if (responseParam == null) {
|
||||
continue;
|
||||
}
|
||||
String paramName = responseParam.getString("name");
|
||||
String paramDesc = responseParam.getString("description");
|
||||
String paramType = responseParam.getString("type");
|
||||
|
||||
if (oConvertUtils.isEmpty(paramName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
fullDescription.append("\n- ").append(paramName);
|
||||
if (oConvertUtils.isNotEmpty(paramType)) {
|
||||
fullDescription.append(" (").append(paramType).append(")");
|
||||
}
|
||||
if (oConvertUtils.isNotEmpty(paramDesc)) {
|
||||
fullDescription.append(": ").append(paramDesc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
JsonObjectSchema.Builder schemaBuilder = JsonObjectSchema.builder();
|
||||
|
||||
// 解析请求参数
|
||||
JSONArray parameters = toolConfig.getJSONArray("parameters");
|
||||
if (parameters != null && !parameters.isEmpty()) {
|
||||
List<String> requiredParams = new ArrayList<>();
|
||||
for (int i = 0; i < parameters.size(); i++) {
|
||||
JSONObject param = parameters.getJSONObject(i);
|
||||
if (param == null) {
|
||||
continue;
|
||||
}
|
||||
String paramName = param.getString("name");
|
||||
String paramDesc = param.getString("description");
|
||||
String paramType = param.getString("type");
|
||||
|
||||
if (oConvertUtils.isEmpty(paramName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 根据参数类型添加属性
|
||||
if ("String".equalsIgnoreCase(paramType) || "string".equalsIgnoreCase(paramType)) {
|
||||
schemaBuilder.addStringProperty(paramName, paramDesc != null ? paramDesc : "");
|
||||
} else if ("Number".equalsIgnoreCase(paramType) || "number".equalsIgnoreCase(paramType)
|
||||
|| "Integer".equalsIgnoreCase(paramType) || "integer".equalsIgnoreCase(paramType)) {
|
||||
schemaBuilder.addNumberProperty(paramName, paramDesc != null ? paramDesc : "");
|
||||
} else if ("Boolean".equalsIgnoreCase(paramType) || "boolean".equalsIgnoreCase(paramType)) {
|
||||
schemaBuilder.addBooleanProperty(paramName, paramDesc != null ? paramDesc : "");
|
||||
} else {
|
||||
// 默认作为String处理
|
||||
schemaBuilder.addStringProperty(paramName, paramDesc != null ? paramDesc : "");
|
||||
}
|
||||
|
||||
// 检查是否必须
|
||||
Boolean required = param.getBooleanValue("required");
|
||||
if (required != null && required) {
|
||||
requiredParams.add(paramName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!requiredParams.isEmpty()) {
|
||||
schemaBuilder.required(requiredParams.toArray(new String[0]));
|
||||
}
|
||||
}
|
||||
|
||||
return ToolSpecification.builder()
|
||||
.name(name)
|
||||
.description(fullDescription.toString())
|
||||
.parameters(schemaBuilder.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建ToolExecutor
|
||||
*/
|
||||
private static ToolExecutor buildToolExecutor(JSONObject toolConfig, String baseUrl, Map<String, String> defaultHeaders) {
|
||||
String path = toolConfig.getString("path");
|
||||
String method = toolConfig.getString("method");
|
||||
JSONArray parameters = toolConfig.getJSONArray("parameters");
|
||||
|
||||
if (oConvertUtils.isEmpty(path) || oConvertUtils.isEmpty(method)) {
|
||||
log.warn("工具配置缺少path或method字段");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (toolExecutionRequest, memoryId) -> {
|
||||
try {
|
||||
// 解析AI传入的参数
|
||||
JSONObject args = JSONObject.parseObject(toolExecutionRequest.arguments());
|
||||
|
||||
// 构建完整URL
|
||||
String url = buildUrl(baseUrl, path, parameters, args);
|
||||
|
||||
// 构建请求方法
|
||||
HttpMethod httpMethod = parseHttpMethod(method);
|
||||
|
||||
// 构建请求头
|
||||
HttpHeaders httpHeaders = buildHttpHeaders(parameters, args, defaultHeaders);
|
||||
|
||||
// 构建请求参数
|
||||
JSONObject urlVariables = buildUrlVariables(parameters, args);
|
||||
Object body = buildRequestBody(parameters, args, httpHeaders);
|
||||
|
||||
// 发送HTTP请求
|
||||
ResponseEntity<String> response = RestUtil.request(url, httpMethod, httpHeaders, urlVariables, body, String.class);
|
||||
|
||||
// 直接返回原始响应字符串,不进行解析
|
||||
return response.getBody() != null ? response.getBody() : "";
|
||||
} catch (HttpClientErrorException e) {
|
||||
log.error("插件工具HTTP请求失败: {}", e.getMessage(), e);
|
||||
return "请求失败: " + e.getStatusCode() + " - " + e.getResponseBodyAsString();
|
||||
} catch (Exception e) {
|
||||
log.error("插件工具执行失败: {}", e.getMessage(), e);
|
||||
return "工具执行失败: " + e.getMessage();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建完整URL(处理Path参数)
|
||||
*/
|
||||
private static String buildUrl(String baseUrl, String path, JSONArray parameters, JSONObject args) {
|
||||
String fullPath = path;
|
||||
if (!path.startsWith("/")) {
|
||||
fullPath = "/" + path;
|
||||
}
|
||||
// 拼接URL时防止出现双斜杠
|
||||
if (baseUrl.endsWith("/") && fullPath.startsWith("/")) {
|
||||
fullPath = fullPath.substring(1);
|
||||
}
|
||||
String url = baseUrl + fullPath;
|
||||
|
||||
// 替换Path参数
|
||||
if (parameters != null && args != null) {
|
||||
for (int i = 0; i < parameters.size(); i++) {
|
||||
JSONObject param = parameters.getJSONObject(i);
|
||||
if (param == null) {
|
||||
continue;
|
||||
}
|
||||
String paramName = param.getString("name");
|
||||
String paramLocation = param.getString("location");
|
||||
|
||||
if (!"Path".equalsIgnoreCase(paramLocation)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Object value = args.get(paramName);
|
||||
if (value != null) {
|
||||
url = url.replace("{" + paramName + "}", value.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建请求头
|
||||
*/
|
||||
private static HttpHeaders buildHttpHeaders(JSONArray parameters, JSONObject args, Map<String, String> defaultHeaders) {
|
||||
HttpHeaders httpHeaders = new HttpHeaders();
|
||||
|
||||
// 添加默认请求头
|
||||
if (defaultHeaders != null) {
|
||||
defaultHeaders.forEach(httpHeaders::set);
|
||||
}
|
||||
|
||||
// 添加Header类型的参数
|
||||
if (parameters != null && args != null) {
|
||||
for (int i = 0; i < parameters.size(); i++) {
|
||||
JSONObject param = parameters.getJSONObject(i);
|
||||
if (param == null) {
|
||||
continue;
|
||||
}
|
||||
String paramName = param.getString("name");
|
||||
String paramLocation = param.getString("location");
|
||||
|
||||
if (!"Header".equalsIgnoreCase(paramLocation)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Object value = args.get(paramName);
|
||||
if (value != null) {
|
||||
httpHeaders.set(paramName, value.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果请求体不为空且没有设置Content-Type,默认设置为application/json
|
||||
if (!httpHeaders.containsKey(HttpHeaders.CONTENT_TYPE)) {
|
||||
httpHeaders.setContentType(MediaType.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
return httpHeaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建URL查询参数
|
||||
*/
|
||||
private static JSONObject buildUrlVariables(JSONArray parameters, JSONObject args) {
|
||||
JSONObject urlVariables = new JSONObject();
|
||||
|
||||
if (parameters == null || args == null) {
|
||||
return urlVariables;
|
||||
}
|
||||
|
||||
for (int i = 0; i < parameters.size(); i++) {
|
||||
JSONObject param = parameters.getJSONObject(i);
|
||||
if (param == null) {
|
||||
continue;
|
||||
}
|
||||
String paramName = param.getString("name");
|
||||
String paramLocation = param.getString("location");
|
||||
|
||||
String location = paramLocation != null ? paramLocation : "";
|
||||
// 显式指定Query类型,或者未指定类型(默认作为Query)
|
||||
boolean isQueryParam = "Query".equalsIgnoreCase(location);
|
||||
boolean isOtherType = "Body".equalsIgnoreCase(location) || "Form-Data".equalsIgnoreCase(location)
|
||||
|| "Header".equalsIgnoreCase(location) || "Path".equalsIgnoreCase(location);
|
||||
|
||||
if (isQueryParam || !isOtherType) {
|
||||
Object value = args.get(paramName);
|
||||
if (value != null) {
|
||||
urlVariables.put(paramName, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return urlVariables.isEmpty() ? null : urlVariables;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建请求体
|
||||
*/
|
||||
private static Object buildRequestBody(JSONArray parameters, JSONObject args, HttpHeaders httpHeaders) {
|
||||
if (parameters == null || args == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
boolean hasBody = false;
|
||||
boolean hasFormData = false;
|
||||
|
||||
// 检查是否有Body或Form-Data类型的参数
|
||||
for (int i = 0; i < parameters.size(); i++) {
|
||||
JSONObject param = parameters.getJSONObject(i);
|
||||
if (param == null) {
|
||||
continue;
|
||||
}
|
||||
String paramLocation = param.getString("location");
|
||||
|
||||
if ("Body".equalsIgnoreCase(paramLocation)) {
|
||||
hasBody = true;
|
||||
} else if ("Form-Data".equalsIgnoreCase(paramLocation)) {
|
||||
hasFormData = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Body和Form-Data互斥
|
||||
if (hasBody && hasFormData) {
|
||||
log.warn("工具配置同时包含Body和Form-Data类型参数,优先使用Body");
|
||||
hasFormData = false;
|
||||
}
|
||||
|
||||
if (hasBody) {
|
||||
// Body类型:构建JSON对象
|
||||
JSONObject body = new JSONObject();
|
||||
for (int i = 0; i < parameters.size(); i++) {
|
||||
JSONObject param = parameters.getJSONObject(i);
|
||||
if (param == null) {
|
||||
continue;
|
||||
}
|
||||
String paramName = param.getString("name");
|
||||
String paramLocation = param.getString("location");
|
||||
|
||||
if (!"Body".equalsIgnoreCase(paramLocation) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Object value = args.get(paramName);
|
||||
if (value != null) {
|
||||
body.put(paramName, value);
|
||||
} else {
|
||||
// 检查是否有默认值
|
||||
String defaultValue = param.getString("defaultValue");
|
||||
if (oConvertUtils.isNotEmpty(defaultValue)) {
|
||||
body.put(paramName, defaultValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
httpHeaders.setContentType(MediaType.APPLICATION_JSON);
|
||||
return body.isEmpty() ? null : body;
|
||||
} else if (hasFormData) {
|
||||
// Form-Data类型:构建JSON对象(RestUtil会处理)
|
||||
JSONObject formData = new JSONObject();
|
||||
for (int i = 0; i < parameters.size(); i++) {
|
||||
JSONObject param = parameters.getJSONObject(i);
|
||||
if (param == null) {
|
||||
continue;
|
||||
}
|
||||
String paramName = param.getString("name");
|
||||
String paramLocation = param.getString("location");
|
||||
|
||||
if (!"Form-Data".equalsIgnoreCase(paramLocation)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Object value = args.get(paramName);
|
||||
if (value != null) {
|
||||
formData.put(paramName, value);
|
||||
} else {
|
||||
String defaultValue = param.getString("defaultValue");
|
||||
if (oConvertUtils.isNotEmpty(defaultValue)) {
|
||||
formData.put(paramName, defaultValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
httpHeaders.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
return formData.isEmpty() ? null : formData;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析HTTP方法
|
||||
*/
|
||||
private static HttpMethod parseHttpMethod(String method) {
|
||||
try {
|
||||
return HttpMethod.valueOf(method.toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("无效的HTTP方法: {},使用默认GET", method);
|
||||
return HttpMethod.GET;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 解析headers JSON字符串为Map
|
||||
*/
|
||||
private static Map<String, String> parseHeaders(String headersStr) {
|
||||
Map<String, String> headersMap = new HashMap<>();
|
||||
if (oConvertUtils.isEmpty(headersStr)) {
|
||||
return headersMap;
|
||||
}
|
||||
|
||||
try {
|
||||
JSONObject headersJson = JSONObject.parseObject(headersStr);
|
||||
if (headersJson != null) {
|
||||
headersJson.forEach((key, value) -> {
|
||||
if (value != null) {
|
||||
headersMap.put(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("解析headers失败: {}", headersStr);
|
||||
}
|
||||
|
||||
return headersMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用授权配置到headers
|
||||
* 从metadata中读取授权配置,如果是Token授权,添加到headers中
|
||||
* 如果授权类型为token但没有设置token值,则从TokenUtils获取当前请求的token
|
||||
*
|
||||
* @param headersMap 请求头Map
|
||||
* @param metadataStr 元数据JSON字符串
|
||||
*/
|
||||
private static void applyAuthConfig(Map<String, String> headersMap, String metadataStr, HttpServletRequest currentHttpRequest) {
|
||||
if (oConvertUtils.isEmpty(metadataStr)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
JSONObject metadata = JSONObject.parseObject(metadataStr);
|
||||
if (metadata == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String authType = metadata.getString("authType");
|
||||
if (oConvertUtils.isEmpty(authType) || !"token".equalsIgnoreCase(authType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Token授权方式:从metadata中获取token配置并添加到headers
|
||||
String tokenParamName = metadata.getString("tokenParamName");
|
||||
String tokenParamValue = metadata.getString("tokenParamValue");
|
||||
|
||||
// 如果token参数名存在,但token值未设置,尝试从TokenUtils获取当前请求的token
|
||||
if (oConvertUtils.isNotEmpty(tokenParamName) && oConvertUtils.isEmpty(tokenParamValue)) {
|
||||
try {
|
||||
// 注意:TokenUtils需要获取当前线程的request,所以必须在同步调用中使用
|
||||
String currentToken = TokenUtils.getTokenByRequest();
|
||||
if(oConvertUtils.isEmpty(currentToken) && currentHttpRequest != null) {
|
||||
currentToken = TokenUtils.getTokenByRequest(currentHttpRequest);
|
||||
}
|
||||
if (oConvertUtils.isNotEmpty(currentToken)) {
|
||||
tokenParamValue = currentToken;
|
||||
log.debug("从TokenUtils获取Token并添加到请求头: {} = {}", tokenParamName,
|
||||
currentToken.length() > 10 ? currentToken.substring(0, 10) + "..." : currentToken);
|
||||
} else {
|
||||
log.warn("Token授权配置中tokenParamValue为空,且无法从TokenUtils获取当前请求的token");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("从TokenUtils获取token失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (oConvertUtils.isNotEmpty(tokenParamName) && oConvertUtils.isNotEmpty(tokenParamValue)) {
|
||||
// 如果headers中已存在同名header,优先使用metadata中的配置(覆盖)
|
||||
headersMap.put(tokenParamName, tokenParamValue);
|
||||
// 日志中只显示token的前几个字符,避免泄露完整token
|
||||
String tokenPreview = tokenParamValue.length() > 10
|
||||
? tokenParamValue.substring(0, 10) + "..."
|
||||
: tokenParamValue;
|
||||
log.debug("添加Token授权到请求头: {} = {}", tokenParamName, tokenPreview);
|
||||
} else {
|
||||
log.warn("Token授权配置不完整: tokenParamName={}, tokenParamValue={}", tokenParamName, tokenParamValue != null ? "***" : null);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("解析授权配置失败: {}", metadataStr, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
package org.jeecg.modules.airag.llm.mapper;
|
||||
|
||||
import org.jeecg.modules.airag.llm.entity.AiragMcp;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
|
||||
/**
|
||||
* @Description: MCP
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-10-20
|
||||
* @Version: V1.0
|
||||
*/
|
||||
public interface AiragMcpMapper extends BaseMapper<AiragMcp> {
|
||||
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="org.jeecg.modules.airag.llm.mapper.AiragMcpMapper">
|
||||
|
||||
</mapper>
|
||||
@ -0,0 +1,32 @@
|
||||
package org.jeecg.modules.airag.llm.service;
|
||||
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragMcp;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
|
||||
/**
|
||||
* @Description: MCP
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-10-20
|
||||
* @Version: V1.0
|
||||
*/
|
||||
public interface IAiragMcpService extends IService<AiragMcp> {
|
||||
|
||||
Result<String> edit(AiragMcp airagMcp);
|
||||
|
||||
Result<?> sync(String id);
|
||||
|
||||
|
||||
Result<?> toggleStatus(String id, String action);
|
||||
|
||||
/**
|
||||
* 保存插件工具(仅更新tools字段)
|
||||
* for [QQYUN-12453]【AI】支持插件
|
||||
* @param id 插件ID
|
||||
* @param tools 工具列表JSON字符串
|
||||
* @return 操作结果
|
||||
* @author chenrui
|
||||
* @date 2025/10/30
|
||||
*/
|
||||
Result<String> saveTools(String id, String tools);
|
||||
}
|
||||
@ -0,0 +1,356 @@
|
||||
package org.jeecg.modules.airag.llm.service.impl;
|
||||
|
||||
import com.alibaba.fastjson.JSONException;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.langchain4j.agent.tool.ToolSpecification;
|
||||
import dev.langchain4j.mcp.client.DefaultMcpClient;
|
||||
import dev.langchain4j.mcp.client.McpClient;
|
||||
import dev.langchain4j.mcp.client.transport.http.HttpMcpTransport;
|
||||
import dev.langchain4j.model.chat.request.json.*;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.constant.CommonConstant;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragMcp;
|
||||
import org.jeecg.modules.airag.llm.mapper.AiragMcpMapper;
|
||||
import org.jeecg.modules.airag.llm.service.IAiragMcpService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* @Description: MCP
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-10-20
|
||||
* @Version: V1.0
|
||||
*/
|
||||
@Service("airagMcpServiceImpl")
|
||||
@Slf4j
|
||||
public class AiragMcpServiceImpl extends ServiceImpl<AiragMcpMapper, AiragMcp> implements IAiragMcpService {
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper; // 使用全局配置的 Jackson ObjectMapper
|
||||
|
||||
/**
|
||||
* 新增或编辑Mcpserver
|
||||
*
|
||||
* @param airagMcp MCP对象
|
||||
* @return 返回保存后的MCP对象
|
||||
* @author chenrui
|
||||
* @date 2025/10/21
|
||||
*/
|
||||
@Override
|
||||
public Result<String> edit(AiragMcp airagMcp) {
|
||||
// 校验必填项
|
||||
if (airagMcp.getName() == null || airagMcp.getName().trim().isEmpty()) {
|
||||
return Result.error("名称不能为空");
|
||||
}
|
||||
//update-begin---author:chenrui ---date:20251031 for:[QQYUN-12453]【AI】支持插件------------
|
||||
// 设置默认category
|
||||
if (oConvertUtils.isEmpty(airagMcp.getCategory())) {
|
||||
airagMcp.setCategory("mcp");
|
||||
}
|
||||
// 对于MCP类型,需要校验type和endpoint
|
||||
if ("mcp".equalsIgnoreCase(airagMcp.getCategory())) {
|
||||
if (airagMcp.getType() == null || airagMcp.getType().trim().isEmpty()) {
|
||||
return Result.error("MCP类型不能为空");
|
||||
}
|
||||
if (airagMcp.getEndpoint() == null || airagMcp.getEndpoint().trim().isEmpty()) {
|
||||
return Result.error("服务端点不能为空");
|
||||
}
|
||||
} else if ("plugin".equalsIgnoreCase(airagMcp.getCategory())) {
|
||||
// 对于插件类型,BaseURL可选,不填时使用当前系统地址
|
||||
// 不再校验endpoint是否为空
|
||||
} else {
|
||||
// 未知类型,默认为MCP并校验
|
||||
if (airagMcp.getEndpoint() == null || airagMcp.getEndpoint().trim().isEmpty()) {
|
||||
return Result.error("服务端点不能为空");
|
||||
}
|
||||
}
|
||||
//update-end---author:chenrui ---date:20251031 for:[QQYUN-12453]【AI】支持插件------------
|
||||
|
||||
if (airagMcp.getId() == null || airagMcp.getId().trim().isEmpty()) {
|
||||
// 设置默认值
|
||||
airagMcp.setStatus("enable");
|
||||
//update-begin---author:chenrui ---date:20251031 for:[QQYUN-12453]【AI】支持插件------------
|
||||
// 只有MCP类型才设置synced字段,插件类型不需要同步默认为已同步
|
||||
if ("mcp".equalsIgnoreCase(airagMcp.getCategory())) {
|
||||
airagMcp.setSynced(CommonConstant.STATUS_0_INT);
|
||||
} else {
|
||||
airagMcp.setSynced(CommonConstant.STATUS_1_INT);
|
||||
}
|
||||
//update-end---author:chenrui ---date:20251031 for:[QQYUN-12453]【AI】支持插件------------
|
||||
// 新增
|
||||
this.save(airagMcp);
|
||||
} else {
|
||||
// 编辑
|
||||
this.updateById(airagMcp);
|
||||
}
|
||||
return Result.OK("保存成功");
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步mcp的工具列表
|
||||
*
|
||||
* @param id mcp主键
|
||||
* @return 工具列表
|
||||
* @author chenrui
|
||||
* @date 2025/10/21
|
||||
*/
|
||||
@Override
|
||||
public Result<?> sync(String id) {
|
||||
AiragMcp mcp = this.getById(id);
|
||||
if (mcp == null) {
|
||||
return Result.error("未找到对应的MCP对象");
|
||||
}
|
||||
//update-begin---author:chenrui ---date:20251031 for:[QQYUN-12453]【AI】支持插件------------
|
||||
// 只有MCP类型才支持同步,插件类型不支持
|
||||
String category = mcp.getCategory();
|
||||
if (oConvertUtils.isEmpty(category)) {
|
||||
category = "mcp"; // 兼容旧数据
|
||||
}
|
||||
if (!"mcp".equalsIgnoreCase(category)) {
|
||||
return Result.error("只有MCP类型才支持同步操作");
|
||||
}
|
||||
//update-end---author:chenrui ---date:20251031 for:[QQYUN-12453]【AI】支持插件------------
|
||||
String type = mcp.getType();
|
||||
String endpoint = mcp.getEndpoint();
|
||||
Map<String, String> headers = null;
|
||||
if (oConvertUtils.isNotEmpty(mcp.getHeaders())) {
|
||||
try {
|
||||
headers = JSONObject.parseObject(mcp.getHeaders(), Map.class);
|
||||
} catch (JSONException e) {
|
||||
headers = null;
|
||||
}
|
||||
}
|
||||
if (type == null || endpoint == null) {
|
||||
return Result.error("MCP类型或端点为空");
|
||||
}
|
||||
McpClient mcpClient = null;
|
||||
try {
|
||||
if ("sse".equalsIgnoreCase(type)) {
|
||||
HttpMcpTransport.Builder builder = new HttpMcpTransport.Builder()
|
||||
.sseUrl(endpoint)
|
||||
.logRequests(true)
|
||||
.logResponses(true);
|
||||
mcpClient = new DefaultMcpClient.Builder().transport(builder.build()).build();
|
||||
} else if ("stdio".equalsIgnoreCase(type)) {
|
||||
// stdio 类型:endpoint 可能是一个命令行,需要拆分为命令列表
|
||||
// List<String> cmdParts = Arrays.asList(endpoint.trim().split("\\s+"));
|
||||
// StdioMcpTransport.Builder builder = new StdioMcpTransport.Builder()
|
||||
// .command(cmdParts)
|
||||
// .environment(headers);
|
||||
// mcpClient = new DefaultMcpClient.Builder().transport(builder.build()).build();
|
||||
return Result.error("不支持的MCP类型:" + type);
|
||||
} else {
|
||||
return Result.error("不支持的MCP类型:" + type);
|
||||
}
|
||||
List<ToolSpecification> toolSpecifications = mcpClient.listTools();
|
||||
// 先尝试直接使用 ObjectMapper 序列化,若结果为 {} 则回退到反射 Map
|
||||
List<Map<String, Object>> specMaps = toolSpecifications.stream()
|
||||
.map(spec -> {
|
||||
try {
|
||||
String raw = objectMapper.writeValueAsString(spec);
|
||||
if (raw != null && raw.length() > 2) {
|
||||
// 直接反序列化成 Map,保留 Jackson 认出的字段
|
||||
return objectMapper.readValue(raw, new TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
}
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
return convertToolSpec(spec);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
String jsonList;
|
||||
try {
|
||||
jsonList = objectMapper.writeValueAsString(specMaps);
|
||||
} catch (JsonProcessingException e) {
|
||||
jsonList = JSONObject.toJSONString(specMaps);
|
||||
}
|
||||
String firstJson = specMaps.isEmpty() ? "null" : safeWriteJson(specMaps.get(0));
|
||||
log.info("MCP工具列表 id={}, size={}, first={}", id, toolSpecifications.size(), firstJson);
|
||||
mcp.setTools(jsonList);
|
||||
mcp.setSynced(1);
|
||||
|
||||
Map<String,Object> metadata = new HashMap<>();
|
||||
metadata.put("tool_count", toolSpecifications.size());
|
||||
mcp.setMetadata(objectMapper.writeValueAsString(metadata));
|
||||
this.updateById(mcp);
|
||||
return Result.OK(specMaps);
|
||||
} catch (Exception e) {
|
||||
String message = e.getMessage();
|
||||
if (e instanceof IllegalArgumentException) {
|
||||
message = ",MCP客户端参数错误";
|
||||
}
|
||||
log.error("同步MCP工具失败 id={}, error={}", id, message, e);
|
||||
return Result.error("同步失败" + message);
|
||||
} finally {
|
||||
if (mcpClient != null) {
|
||||
try {
|
||||
Method closeMethod = mcpClient.getClass().getMethod("close");
|
||||
closeMethod.invoke(mcpClient);
|
||||
} catch (NoSuchMethodException ignore) {
|
||||
} catch (Exception ex) {
|
||||
log.warn("关闭MCP客户端失败 id={}, error={}", id, ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 安全序列化单个对象为 JSON 字符串
|
||||
private String safeWriteJson(Object obj) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(obj);
|
||||
} catch (Exception e) {
|
||||
return String.valueOf(obj);
|
||||
}
|
||||
}
|
||||
|
||||
// 反射将 ToolSpecification 转成 Map,兼容 record/私有字段/仅 Jackson 注解场景 -> 改为直接调用访问器
|
||||
private Map<String, Object> convertToolSpec(ToolSpecification spec) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
if (spec == null) {
|
||||
return map;
|
||||
}
|
||||
map.put("name", spec.name());
|
||||
map.put("description", spec.description());
|
||||
try {
|
||||
Object params = spec.parameters();
|
||||
if (params != null) {
|
||||
JsonObjectSchema obj = (JsonObjectSchema) params;
|
||||
List<Map<String, Object>> fields = new ArrayList<>();
|
||||
if (obj.properties() != null) {
|
||||
obj.properties().forEach((fieldName, fieldSchema) -> {
|
||||
Map<String, Object> fieldMap = new LinkedHashMap<>();
|
||||
fieldMap.put("name", fieldName);
|
||||
fieldMap.put("description", extractDescription(fieldSchema));
|
||||
// 若需要标记必填
|
||||
if (obj.required() != null && obj.required().contains(fieldName)) {
|
||||
fieldMap.put("required", true);
|
||||
}
|
||||
fields.add(fieldMap);
|
||||
});
|
||||
}
|
||||
map.put("parameters", fields);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// 提取各类型 schema 的描述
|
||||
private String extractDescription(Object schema) {
|
||||
if (schema == null) return null;
|
||||
try {
|
||||
if (schema instanceof JsonStringSchema) return ((JsonStringSchema) schema).description();
|
||||
if (schema instanceof JsonNumberSchema) return ((JsonNumberSchema) schema).description();
|
||||
if (schema instanceof JsonBooleanSchema) return ((JsonBooleanSchema) schema).description();
|
||||
if (schema instanceof JsonArraySchema) return ((JsonArraySchema) schema).description();
|
||||
if (schema instanceof JsonEnumSchema) return ((JsonEnumSchema) schema).description();
|
||||
if (schema instanceof JsonObjectSchema) return ((JsonObjectSchema) schema).description();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return schema.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改状态
|
||||
*
|
||||
* @param id MCP主键
|
||||
* @param action 操作(enable/disable)
|
||||
* @return 操作结果
|
||||
* @author chenrui
|
||||
* @date 2025/10/21 11:00
|
||||
*/
|
||||
@Override
|
||||
public Result<?> toggleStatus(String id, String action) {
|
||||
if (oConvertUtils.isEmpty(id)) {
|
||||
return Result.error("id不能为空");
|
||||
}
|
||||
if (oConvertUtils.isEmpty(action)) {
|
||||
return Result.error("action不能为空");
|
||||
}
|
||||
String normalized = action.toLowerCase();
|
||||
if (!"enable".equals(normalized) && !"disable".equals(normalized)) {
|
||||
return Result.error("action只能为enable或disable");
|
||||
}
|
||||
AiragMcp mcp = this.getById(id);
|
||||
if (mcp == null) {
|
||||
return Result.error("未找到对应的MCP服务");
|
||||
}
|
||||
if (normalized.equalsIgnoreCase(mcp.getStatus())) {
|
||||
return Result.OK("操作成功");
|
||||
}
|
||||
mcp.setStatus(normalized);
|
||||
this.updateById(mcp);
|
||||
return Result.OK("操作成功");
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存插件工具(仅更新tools字段)
|
||||
* for [QQYUN-12453]【AI】支持插件
|
||||
* @param id 插件ID
|
||||
* @param tools 工具列表JSON字符串
|
||||
* @return 操作结果
|
||||
* @author chenrui
|
||||
* @date 2025/10/30
|
||||
*/
|
||||
@Override
|
||||
public Result<String> saveTools(String id, String tools) {
|
||||
if (oConvertUtils.isEmpty(id)) {
|
||||
return Result.error("插件ID不能为空");
|
||||
}
|
||||
AiragMcp mcp = this.getById(id);
|
||||
if (mcp == null) {
|
||||
return Result.error("未找到对应的插件");
|
||||
}
|
||||
// 验证是否为插件类型
|
||||
String category = mcp.getCategory();
|
||||
if (oConvertUtils.isEmpty(category)) {
|
||||
category = "mcp"; // 兼容旧数据
|
||||
}
|
||||
if (!"plugin".equalsIgnoreCase(category)) {
|
||||
return Result.error("只有插件类型才能保存工具");
|
||||
}
|
||||
// 更新tools字段
|
||||
mcp.setTools(tools);
|
||||
|
||||
// 更新metadata中的tool_count
|
||||
try {
|
||||
com.alibaba.fastjson.JSONArray toolsArray = com.alibaba.fastjson.JSONArray.parseArray(tools);
|
||||
int toolCount = toolsArray != null ? toolsArray.size() : 0;
|
||||
|
||||
// 解析现有metadata
|
||||
JSONObject metadata = new JSONObject();
|
||||
if (oConvertUtils.isNotEmpty(mcp.getMetadata())) {
|
||||
try {
|
||||
JSONObject metadataJson = JSONObject.parseObject(mcp.getMetadata());
|
||||
if (metadataJson != null) {
|
||||
metadata.putAll(metadataJson);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("解析metadata失败,将重新创建: {}", mcp.getMetadata());
|
||||
}
|
||||
}
|
||||
|
||||
// 更新tool_count
|
||||
metadata.put("tool_count", toolCount);
|
||||
|
||||
// 保存metadata
|
||||
mcp.setMetadata(metadata.toJSONString());
|
||||
} catch (Exception e) {
|
||||
log.warn("更新工具数量失败: {}", e.getMessage());
|
||||
// 即使更新tool_count失败,也不影响保存tools
|
||||
}
|
||||
|
||||
this.updateById(mcp);
|
||||
return Result.OK("保存成功");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user