v3.9.0 里程碑版本发布

This commit is contained in:
JEECG
2025-11-26 11:14:47 +08:00
parent 1a923596db
commit 9571e0b169
272 changed files with 6596 additions and 105398 deletions

View File

@ -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>

View File

@ -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";
}

View File

@ -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
*/

View File

@ -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);
}
/**
* 发送消息到客户端
*

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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";
}

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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;
}

View File

@ -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类型ssesse类型stdio标准类型
*/
@Excel(name = "mcp类型ssesse类型stdio标准类型", width = 15)
@Schema(description = "mcp类型ssesse类型stdio标准类型")
private java.lang.String type;
/**
* 服务端点SSE类型为URLstdio类型为命令
*/
@Excel(name = "服务端点SSE类型为URLstdio类型为命令", width = 15)
@Schema(description = "服务端点SSE类型为URLstdio类型为命令")
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;
}

View File

@ -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);

View File

@ -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);
}
}
}

View File

@ -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> {
}

View File

@ -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>

View File

@ -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);
}

View File

@ -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("保存成功");
}
}