mirror of
https://gitcode.com/ageerle/ruoyi-ai.git
synced 2026-04-11 18:47:20 +00:00
feat:恢复mcp模块 动态agent
This commit is contained in:
@@ -6,7 +6,7 @@ import dev.langchain4j.service.UserMessage;
|
||||
import dev.langchain4j.service.V;
|
||||
|
||||
|
||||
public interface ChartGenerationAgent {
|
||||
public interface ChartGenerationAgent extends Agent {
|
||||
|
||||
@SystemMessage("""
|
||||
You are a chart generation specialist. Your only task is to generate Apache ECharts
|
||||
|
||||
@@ -5,7 +5,7 @@ import dev.langchain4j.service.SystemMessage;
|
||||
import dev.langchain4j.service.UserMessage;
|
||||
import dev.langchain4j.service.V;
|
||||
|
||||
public interface McpAgent {
|
||||
public interface McpAgent extends Agent {
|
||||
|
||||
/**
|
||||
* 系统提示词:通用工具调用智能体
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.ruoyi.agent;
|
||||
|
||||
import dev.langchain4j.agentic.Agent;
|
||||
import dev.langchain4j.service.SystemMessage;
|
||||
import dev.langchain4j.service.TokenStream;
|
||||
import dev.langchain4j.service.UserMessage;
|
||||
import dev.langchain4j.service.V;
|
||||
|
||||
@@ -12,7 +11,7 @@ import dev.langchain4j.service.V;
|
||||
* and returning relevant data and analysis results.
|
||||
*
|
||||
*/
|
||||
public interface SqlAgent {
|
||||
public interface SqlAgent extends Agent {
|
||||
|
||||
@SystemMessage("""
|
||||
This agent is designed for MySQL 5.7
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package org.ruoyi.agent;
|
||||
|
||||
import dev.langchain4j.agentic.Agent;
|
||||
import dev.langchain4j.service.TokenStream;
|
||||
import dev.langchain4j.service.UserMessage;
|
||||
import dev.langchain4j.service.V;
|
||||
|
||||
public interface StreamingCreativeWriter {
|
||||
@UserMessage("""
|
||||
You are a creative writer.
|
||||
Generate a draft of a story no more than
|
||||
3 sentences long around the given topic.
|
||||
Return only the story and nothing else.
|
||||
The topic is {{topic}}.
|
||||
""")
|
||||
@Agent("Generates a story based on the given topic")
|
||||
TokenStream generateStory(@V("topic") String topic);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import dev.langchain4j.service.V;
|
||||
* A web search assistant that answers natural language questions by searching the internet
|
||||
* and returning relevant information from web pages.
|
||||
*/
|
||||
public interface WebSearchAgent {
|
||||
public interface WebSearchAgent extends Agent {
|
||||
|
||||
@SystemMessage("""
|
||||
You are a web search assistant. Answer questions by searching and retrieving web content.
|
||||
|
||||
@@ -40,7 +40,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@Component
|
||||
@DS("agent")
|
||||
public class TableSchemaManager {
|
||||
|
||||
|
||||
@Autowired(required = false)
|
||||
private DataSource agentDataSource;
|
||||
|
||||
@@ -77,7 +77,7 @@ public class TableSchemaManager {
|
||||
loadAllowedTableSchemas();
|
||||
initialized = true;
|
||||
log.info("Schema cache initialized with {} tables", schemaCache.size());
|
||||
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to initialize schema cache", e);
|
||||
}
|
||||
@@ -87,7 +87,7 @@ public class TableSchemaManager {
|
||||
/**
|
||||
* 加载所有允许的表的结构信息
|
||||
*/
|
||||
private void loadAllowedTableSchemas() throws SQLException {
|
||||
private void loadAllowedTableSchemas() {
|
||||
List<String> allowedTables = getAllowedTableNames();
|
||||
for (String tableName : allowedTables) {
|
||||
try {
|
||||
@@ -191,10 +191,7 @@ public class TableSchemaManager {
|
||||
}
|
||||
|
||||
List<String> allowedTables = getAllowedTableNames();
|
||||
return allowedTables.stream()
|
||||
.map(schemaCache::get)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
return allowedTables.stream().map(schemaCache::get).filter(Objects::nonNull).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,13 +11,14 @@ import java.util.Map;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.ruoyi.common.core.utils.SpringUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder;
|
||||
|
||||
import dev.langchain4j.agent.tool.Tool;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
|
||||
|
||||
/**
|
||||
* 执行 SQL 查询的 Tool
|
||||
@@ -25,10 +26,12 @@ import lombok.extern.slf4j.Slf4j;
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ExecuteSqlQueryTool {
|
||||
public class ExecuteSqlQueryTool implements BuiltinToolProvider {
|
||||
|
||||
@Autowired(required = false)
|
||||
private DataSource dataSource;
|
||||
// 使用延迟初始化,避免在构造函数中调用 SpringUtils.getBean()
|
||||
private DataSource getDataSource() {
|
||||
return SpringUtils.getBean(DataSource.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 SELECT SQL 查询
|
||||
@@ -52,6 +55,7 @@ public class ExecuteSqlQueryTool {
|
||||
}
|
||||
|
||||
try {
|
||||
DataSource dataSource = getDataSource();
|
||||
if (dataSource == null) {
|
||||
return "Error: Database datasource not configured";
|
||||
}
|
||||
@@ -177,4 +181,19 @@ public class ExecuteSqlQueryTool {
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getToolName() {
|
||||
return "execute_sql_query";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayName() {
|
||||
return "执行SQL查询";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "Execute a SELECT SQL query and return the results. Example: SELECT * FROM sys_user";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import java.util.List;
|
||||
|
||||
import org.ruoyi.agent.domain.TableStructure;
|
||||
import org.ruoyi.agent.manager.TableSchemaManager;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.ruoyi.common.core.utils.SpringUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import dev.langchain4j.agent.tool.Tool;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
|
||||
|
||||
/**
|
||||
* 查询数据库所有表的 Tool
|
||||
@@ -16,11 +17,13 @@ import lombok.extern.slf4j.Slf4j;
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class QueryAllTablesTool {
|
||||
public class QueryAllTablesTool implements BuiltinToolProvider {
|
||||
|
||||
// 使用延迟初始化,避免在构造函数中调用 SpringUtils.getBean()
|
||||
private TableSchemaManager getTableSchemaManager() {
|
||||
return SpringUtils.getBean(TableSchemaManager.class);
|
||||
}
|
||||
|
||||
|
||||
@Autowired
|
||||
private TableSchemaManager tableSchemaManager; // 注入管理器
|
||||
/**
|
||||
* 查询数据库中所有表
|
||||
* 返回数据库中存在的所有表的列表
|
||||
@@ -30,36 +33,49 @@ public class QueryAllTablesTool {
|
||||
@Tool("Query all tables in the database and return table names and basic information")
|
||||
public String queryAllTables() {
|
||||
try {
|
||||
// 1. 从管理器获取所有允许的表结构信息(内部已包含初始化/缓存逻辑)
|
||||
List<TableStructure> tableSchemas = tableSchemaManager.getAllowedTableSchemas();
|
||||
|
||||
if (tableSchemas == null || tableSchemas.isEmpty()) {
|
||||
return "No tables found in database or cache is empty.";
|
||||
}
|
||||
|
||||
// 2. 格式化结果
|
||||
StringBuilder result = new StringBuilder();
|
||||
result.append("Found ").append(tableSchemas.size()).append(" tables in cache:\n");
|
||||
|
||||
for (TableStructure schema : tableSchemas) {
|
||||
String tableName = schema.getTableName();
|
||||
String tableType = schema.getTableType() != null ? schema.getTableType() : "TABLE";
|
||||
String tableComment = schema.getTableComment();
|
||||
|
||||
result.append(String.format("- %s (%s) - %s\n",
|
||||
tableName,
|
||||
tableType,
|
||||
tableComment != null ? tableComment : "No comment"));
|
||||
}
|
||||
|
||||
log.info("Successfully retrieved {} tables from schema cache", tableSchemas.size());
|
||||
return result.toString();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error retrieving tables from cache", e);
|
||||
return "Error: " + e.getMessage();
|
||||
// 1. 从管理器获取所有允许的表结构信息(内部已包含初始化/缓存逻辑)
|
||||
List<TableStructure> tableSchemas = getTableSchemaManager().getAllowedTableSchemas();
|
||||
|
||||
if (tableSchemas == null || tableSchemas.isEmpty()) {
|
||||
return "No tables found in database or cache is empty.";
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 2. 格式化结果
|
||||
StringBuilder result = new StringBuilder();
|
||||
result.append("Found ").append(tableSchemas.size()).append(" tables in cache:\n");
|
||||
|
||||
for (TableStructure schema : tableSchemas) {
|
||||
String tableName = schema.getTableName();
|
||||
String tableType = schema.getTableType() != null ? schema.getTableType() : "TABLE";
|
||||
String tableComment = schema.getTableComment();
|
||||
|
||||
result.append(String.format("- %s (%s) - %s\n",
|
||||
tableName,
|
||||
tableType,
|
||||
tableComment != null ? tableComment : "No comment"));
|
||||
}
|
||||
|
||||
log.info("Successfully retrieved {} tables from schema cache", tableSchemas.size());
|
||||
return result.toString();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error retrieving tables from cache", e);
|
||||
return "Error: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getToolName() {
|
||||
return "query_all_tables";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayName() {
|
||||
return "查询所有表";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "Query all tables in the database and return table names and basic information";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,20 +6,23 @@ import java.sql.ResultSet;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.ruoyi.common.core.utils.SpringUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder;
|
||||
|
||||
import dev.langchain4j.agent.tool.Tool;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class QueryTableSchemaTool {
|
||||
public class QueryTableSchemaTool implements BuiltinToolProvider {
|
||||
|
||||
@Autowired(required = false)
|
||||
private DataSource dataSource;
|
||||
// 使用延迟初始化,避免在构造函数中调用 SpringUtils.getBean()
|
||||
private DataSource getDataSource() {
|
||||
return SpringUtils.getBean(DataSource.class);
|
||||
}
|
||||
|
||||
@Tool("Query the CREATE TABLE statement (DDL) for a specific table by table name")
|
||||
public String queryTableSchema(String tableName) {
|
||||
@@ -35,7 +38,7 @@ public class QueryTableSchemaTool {
|
||||
|
||||
String sql = "SHOW CREATE TABLE `" + tableName + "`";
|
||||
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
try (Connection connection = getDataSource().getConnection();
|
||||
PreparedStatement ps = connection.prepareStatement(sql);
|
||||
ResultSet rs = ps.executeQuery()) {
|
||||
|
||||
@@ -54,4 +57,19 @@ public class QueryTableSchemaTool {
|
||||
DynamicDataSourceContextHolder.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getToolName() {
|
||||
return "query_table_schema";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayName() {
|
||||
return "查询表结构";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "Query the CREATE TABLE statement (DDL) for a specific table by table name";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package org.ruoyi.config.mcp;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.ruoyi.domain.entity.mcp.McpTool;
|
||||
import org.ruoyi.enums.McpToolStatus;
|
||||
import org.ruoyi.mapper.mcp.McpToolMapper;
|
||||
import org.ruoyi.mcp.service.core.BuiltinToolDefinition;
|
||||
import org.ruoyi.mcp.service.core.BuiltinToolRegistry;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 系统工具初始化器
|
||||
* 在应用启动时,将系统内置工具同步到数据库
|
||||
* 这样可以统一管理所有工具,支持动态启用/禁用
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Order(999) // 确保在其他初始化器之后执行
|
||||
@RequiredArgsConstructor
|
||||
public class SystemToolInitializer implements ApplicationRunner {
|
||||
|
||||
private final McpToolMapper mcpToolMapper;
|
||||
private final BuiltinToolRegistry builtinToolRegistry;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(ApplicationArguments args) {
|
||||
log.info("开始同步系统内置工具到数据库...");
|
||||
|
||||
int addedCount = 0;
|
||||
int existingCount = 0;
|
||||
|
||||
for (BuiltinToolDefinition tool : builtinToolRegistry.getAllBuiltinTools()) {
|
||||
try {
|
||||
boolean added = syncBuiltinTool(tool);
|
||||
if (added) {
|
||||
addedCount++;
|
||||
} else {
|
||||
existingCount++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("同步内置工具失败: {}", tool.name(), e);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("系统内置工具同步完成: 新增 {} 个, 已存在 {} 个", addedCount, existingCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步单个内置工具到数据库
|
||||
*
|
||||
* @param tool 工具定义
|
||||
* @return 是否新增(true=新增, false=已存在)
|
||||
*/
|
||||
private boolean syncBuiltinTool(BuiltinToolDefinition tool) {
|
||||
// 检查是否已存在
|
||||
LambdaQueryWrapper<McpTool> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(McpTool::getName, tool.name())
|
||||
.eq(McpTool::getType, BuiltinToolRegistry.TYPE_BUILTIN);
|
||||
|
||||
McpTool existing = mcpToolMapper.selectOne(wrapper);
|
||||
|
||||
if (existing != null) {
|
||||
// 已存在,更新描述信息(保留状态不变)
|
||||
if (!tool.description().equals(existing.getDescription())) {
|
||||
existing.setDescription(tool.description());
|
||||
mcpToolMapper.updateById(existing);
|
||||
log.debug("更新内置工具描述: {}", tool.name());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 新增
|
||||
McpTool newTool = new McpTool();
|
||||
newTool.setName(tool.name());
|
||||
newTool.setDescription(tool.description());
|
||||
newTool.setType(BuiltinToolRegistry.TYPE_BUILTIN);
|
||||
newTool.setStatus(McpToolStatus.ENABLED.getValue()); // 默认启用
|
||||
newTool.setConfigJson(null); // 内置工具不需要配置
|
||||
mcpToolMapper.insert(newTool);
|
||||
|
||||
log.info("新增内置工具: {} ({})", tool.name(), tool.displayName());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package org.ruoyi.controller.mcp;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.ruoyi.common.core.domain.R;
|
||||
import org.ruoyi.common.excel.utils.ExcelUtil;
|
||||
import org.ruoyi.common.idempotent.annotation.RepeatSubmit;
|
||||
import org.ruoyi.common.log.annotation.Log;
|
||||
import org.ruoyi.common.log.enums.BusinessType;
|
||||
import org.ruoyi.common.mybatis.core.page.PageQuery;
|
||||
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
|
||||
import org.ruoyi.common.web.core.BaseController;
|
||||
import org.ruoyi.domain.bo.mcp.McpMarketBo;
|
||||
import org.ruoyi.domain.dto.mcp.McpMarketListResult;
|
||||
import org.ruoyi.domain.dto.mcp.McpMarketRefreshResult;
|
||||
import org.ruoyi.domain.dto.mcp.McpMarketToolListResult;
|
||||
import org.ruoyi.domain.vo.mcp.McpMarketVo;
|
||||
import org.ruoyi.service.mcp.IMcpMarketService;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* MCP 市场管理 Controller
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/mcp/market")
|
||||
public class McpMarketController extends BaseController {
|
||||
|
||||
private final IMcpMarketService mcpMarketService;
|
||||
|
||||
/**
|
||||
* 查询市场列表
|
||||
*/
|
||||
@SaCheckPermission("mcp:market:list")
|
||||
@GetMapping("/list")
|
||||
public TableDataInfo<McpMarketVo> list(McpMarketBo bo, PageQuery pageQuery) {
|
||||
return mcpMarketService.selectPageList(bo, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询市场列表(不分页)
|
||||
*/
|
||||
@SaCheckPermission("mcp:market:list")
|
||||
@GetMapping("/all")
|
||||
public McpMarketListResult listAll(
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String status) {
|
||||
return mcpMarketService.listMarkets(keyword, status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出 MCP 市场列表
|
||||
*/
|
||||
@SaCheckPermission("mcp:market:export")
|
||||
@Log(title = "MCP市场管理", businessType = BusinessType.EXPORT)
|
||||
@PostMapping("/export")
|
||||
public void export(McpMarketBo bo, HttpServletResponse response) {
|
||||
List<McpMarketVo> list = mcpMarketService.queryList(bo);
|
||||
ExcelUtil.exportExcel(list, "MCP市场", McpMarketVo.class, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据市场ID获取详细信息
|
||||
*
|
||||
* @param id 市场ID
|
||||
*/
|
||||
@SaCheckPermission("mcp:market:query")
|
||||
@GetMapping("/{id}")
|
||||
public R<McpMarketVo> getInfo(@PathVariable Long id) {
|
||||
return R.ok(mcpMarketService.selectById(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增市场
|
||||
*/
|
||||
@SaCheckPermission("mcp:market:add")
|
||||
@Log(title = "MCP市场管理", businessType = BusinessType.INSERT)
|
||||
@RepeatSubmit()
|
||||
@PostMapping
|
||||
public R<Void> add(@Validated @RequestBody McpMarketBo bo) {
|
||||
mcpMarketService.insert(bo);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改市场
|
||||
*/
|
||||
@SaCheckPermission("mcp:market:edit")
|
||||
@Log(title = "MCP市场管理", businessType = BusinessType.UPDATE)
|
||||
@RepeatSubmit()
|
||||
@PutMapping
|
||||
public R<Void> edit(@Validated @RequestBody McpMarketBo bo) {
|
||||
mcpMarketService.update(bo);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除市场
|
||||
*
|
||||
* @param ids 市场ID串
|
||||
*/
|
||||
@SaCheckPermission("mcp:market:remove")
|
||||
@Log(title = "MCP市场管理", businessType = BusinessType.DELETE)
|
||||
@DeleteMapping("/{ids}")
|
||||
public R<Void> remove(@PathVariable Long[] ids) {
|
||||
mcpMarketService.deleteByIds(List.of(ids));
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新市场状态
|
||||
*/
|
||||
@SaCheckPermission("mcp:market:edit")
|
||||
@Log(title = "MCP市场管理", businessType = BusinessType.UPDATE)
|
||||
@PutMapping("/{id}/status")
|
||||
public R<Void> updateStatus(@PathVariable Long id, @RequestParam String status) {
|
||||
mcpMarketService.updateStatus(id, status);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取市场工具列表(分页)
|
||||
*/
|
||||
@SaCheckPermission("mcp:market:query")
|
||||
@GetMapping("/{marketId}/tools")
|
||||
public McpMarketToolListResult getMarketTools(
|
||||
@PathVariable Long marketId,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
return mcpMarketService.getMarketTools(marketId, page, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新市场工具列表
|
||||
*/
|
||||
@SaCheckPermission("mcp:market:edit")
|
||||
@Log(title = "MCP市场管理", businessType = BusinessType.UPDATE)
|
||||
@PostMapping("/{marketId}/refresh")
|
||||
public R<McpMarketRefreshResult> refreshMarketTools(@PathVariable Long marketId) {
|
||||
return R.ok(mcpMarketService.refreshMarketTools(marketId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载单个工具到本地
|
||||
*/
|
||||
@SaCheckPermission("mcp:market:add")
|
||||
@Log(title = "MCP市场管理", businessType = BusinessType.INSERT)
|
||||
@PostMapping("/tools/{toolId}/load")
|
||||
public R<Void> loadToolToLocal(@PathVariable Long toolId) {
|
||||
mcpMarketService.loadToolToLocal(toolId);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量加载工具到本地
|
||||
*/
|
||||
@SaCheckPermission("mcp:market:add")
|
||||
@Log(title = "MCP市场管理", businessType = BusinessType.INSERT)
|
||||
@PostMapping("/tools/batch-load")
|
||||
public R<Map<String, Object>> batchLoadTools(@RequestBody List<Long> toolIds) {
|
||||
int successCount = mcpMarketService.batchLoadTools(toolIds);
|
||||
return R.ok(Map.of("successCount", successCount));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package org.ruoyi.controller.mcp;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.ruoyi.common.core.domain.R;
|
||||
import org.ruoyi.common.excel.utils.ExcelUtil;
|
||||
import org.ruoyi.common.idempotent.annotation.RepeatSubmit;
|
||||
import org.ruoyi.common.log.annotation.Log;
|
||||
import org.ruoyi.common.log.enums.BusinessType;
|
||||
import org.ruoyi.common.mybatis.core.page.PageQuery;
|
||||
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
|
||||
import org.ruoyi.common.web.core.BaseController;
|
||||
import org.ruoyi.domain.bo.mcp.McpToolBo;
|
||||
import org.ruoyi.domain.dto.mcp.McpToolListResult;
|
||||
import org.ruoyi.domain.dto.mcp.McpToolTestResult;
|
||||
import org.ruoyi.domain.vo.mcp.McpToolVo;
|
||||
import org.ruoyi.service.mcp.IMcpToolService;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MCP 工具管理 Controller
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/mcp/tool")
|
||||
public class McpToolController extends BaseController {
|
||||
|
||||
private final IMcpToolService mcpToolService;
|
||||
|
||||
/**
|
||||
* 查询 MCP 工具列表
|
||||
*/
|
||||
@SaCheckPermission("mcp:tool:list")
|
||||
@GetMapping("/list")
|
||||
public TableDataInfo<McpToolVo> list(McpToolBo bo, PageQuery pageQuery) {
|
||||
return mcpToolService.selectPageList(bo, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 MCP 工具列表(不分页)
|
||||
*/
|
||||
@SaCheckPermission("mcp:tool:list")
|
||||
@GetMapping("/all")
|
||||
public McpToolListResult listAll(
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String type,
|
||||
@RequestParam(required = false) String status) {
|
||||
return mcpToolService.listTools(keyword, type, status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出 MCP 工具列表
|
||||
*/
|
||||
@SaCheckPermission("mcp:tool:export")
|
||||
@Log(title = "MCP工具管理", businessType = BusinessType.EXPORT)
|
||||
@PostMapping("/export")
|
||||
public void export(McpToolBo bo, HttpServletResponse response) {
|
||||
List<McpToolVo> list = mcpToolService.queryList(bo);
|
||||
ExcelUtil.exportExcel(list, "MCP工具", McpToolVo.class, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据工具ID获取详细信息
|
||||
*
|
||||
* @param id 工具ID
|
||||
*/
|
||||
@SaCheckPermission("mcp:tool:query")
|
||||
@GetMapping("/{id}")
|
||||
public R<McpToolVo> getInfo(@PathVariable Long id) {
|
||||
return R.ok(mcpToolService.selectById(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增 MCP 工具
|
||||
*/
|
||||
@SaCheckPermission("mcp:tool:add")
|
||||
@Log(title = "MCP工具管理", businessType = BusinessType.INSERT)
|
||||
@RepeatSubmit()
|
||||
@PostMapping
|
||||
public R<Void> add(@Validated @RequestBody McpToolBo bo) {
|
||||
mcpToolService.insert(bo);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改 MCP 工具
|
||||
*/
|
||||
@SaCheckPermission("mcp:tool:edit")
|
||||
@Log(title = "MCP工具管理", businessType = BusinessType.UPDATE)
|
||||
@RepeatSubmit()
|
||||
@PutMapping
|
||||
public R<Void> edit(@Validated @RequestBody McpToolBo bo) {
|
||||
mcpToolService.update(bo);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 MCP 工具
|
||||
*
|
||||
* @param ids 工具ID串
|
||||
*/
|
||||
@SaCheckPermission("mcp:tool:remove")
|
||||
@Log(title = "MCP工具管理", businessType = BusinessType.DELETE)
|
||||
@DeleteMapping("/{ids}")
|
||||
public R<Void> remove(@PathVariable Long[] ids) {
|
||||
mcpToolService.deleteByIds(List.of(ids));
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新工具状态
|
||||
*/
|
||||
@SaCheckPermission("mcp:tool:edit")
|
||||
@Log(title = "MCP工具管理", businessType = BusinessType.UPDATE)
|
||||
@PutMapping("/{id}/status")
|
||||
public R<Void> updateStatus(@PathVariable Long id, @RequestParam String status) {
|
||||
mcpToolService.updateStatus(id, status);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试工具连接
|
||||
*/
|
||||
@SaCheckPermission("mcp:tool:query")
|
||||
@PostMapping("/{id}/test")
|
||||
public R<McpToolTestResult> testTool(@PathVariable Long id) {
|
||||
return R.ok(mcpToolService.testTool(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.ruoyi.domain.bo.mcp;
|
||||
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
|
||||
import org.ruoyi.domain.entity.mcp.McpMarket;
|
||||
|
||||
/**
|
||||
* MCP 市场业务对象
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@AutoMapper(target = McpMarket.class, reverseConvertGenerate = false)
|
||||
public class McpMarketBo extends BaseEntity {
|
||||
|
||||
/**
|
||||
* 市场ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 市场名称
|
||||
*/
|
||||
@NotBlank(message = "市场名称不能为空")
|
||||
@Size(min = 0, max = 200, message = "市场名称不能超过{max}个字符")
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 市场 URL
|
||||
*/
|
||||
@NotBlank(message = "市场URL不能为空")
|
||||
@Size(min = 0, max = 500, message = "市场URL不能超过{max}个字符")
|
||||
private String url;
|
||||
|
||||
/**
|
||||
* 市场描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 认证配置(JSON格式)
|
||||
*/
|
||||
private String authConfig;
|
||||
|
||||
/**
|
||||
* 状态:ENABLED-启用, DISABLED-禁用
|
||||
*/
|
||||
private String status;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package org.ruoyi.domain.bo.mcp;
|
||||
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
|
||||
import org.ruoyi.domain.entity.mcp.McpTool;
|
||||
|
||||
import java.io.Serial;
|
||||
|
||||
/**
|
||||
* MCP 工具业务对象
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@AutoMapper(target = McpTool.class, reverseConvertGenerate = false)
|
||||
public class McpToolBo extends BaseEntity {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 工具ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 工具名称
|
||||
*/
|
||||
@NotBlank(message = "工具名称不能为空")
|
||||
@Size(min = 0, max = 200, message = "工具名称不能超过{max}个字符")
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 工具描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 工具类型:LOCAL-本地, REMOTE-远程, BUILTIN-内置
|
||||
*/
|
||||
@NotBlank(message = "工具类型不能为空")
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* 状态:ENABLED-启用, DISABLED-禁用
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 配置信息(JSON格式)
|
||||
*/
|
||||
private String configJson;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.ruoyi.domain.dto.mcp;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.ruoyi.domain.entity.mcp.McpMarket;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MCP 市场列表返回结果
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class McpMarketListResult {
|
||||
|
||||
/**
|
||||
* 是否成功
|
||||
*/
|
||||
private boolean success;
|
||||
|
||||
/**
|
||||
* 市场列表
|
||||
*/
|
||||
private List<McpMarket> data;
|
||||
|
||||
/**
|
||||
* 总数
|
||||
*/
|
||||
private int total;
|
||||
|
||||
public static McpMarketListResult of(List<McpMarket> data) {
|
||||
return McpMarketListResult.builder()
|
||||
.success(true)
|
||||
.data(data)
|
||||
.total(data != null ? data.size() : 0)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.ruoyi.domain.dto.mcp;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* MCP 市场工具刷新结果
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class McpMarketRefreshResult {
|
||||
|
||||
/**
|
||||
* 是否成功
|
||||
*/
|
||||
private boolean success;
|
||||
|
||||
/**
|
||||
* 消息
|
||||
*/
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 新增工具数量
|
||||
*/
|
||||
private int addedCount;
|
||||
|
||||
/**
|
||||
* 更新工具数量
|
||||
*/
|
||||
private int updatedCount;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.ruoyi.domain.dto.mcp;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.ruoyi.domain.entity.mcp.McpMarketTool;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MCP 市场工具列表返回结果(分页)
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class McpMarketToolListResult {
|
||||
|
||||
/**
|
||||
* 是否成功
|
||||
*/
|
||||
private boolean success;
|
||||
|
||||
/**
|
||||
* 工具列表
|
||||
*/
|
||||
private List<McpMarketTool> data;
|
||||
|
||||
/**
|
||||
* 总数
|
||||
*/
|
||||
private long total;
|
||||
|
||||
/**
|
||||
* 当前页
|
||||
*/
|
||||
private int page;
|
||||
|
||||
/**
|
||||
* 每页大小
|
||||
*/
|
||||
private int size;
|
||||
|
||||
/**
|
||||
* 总页数
|
||||
*/
|
||||
private long pages;
|
||||
|
||||
public static McpMarketToolListResult of(List<McpMarketTool> data, long total, int page, int size) {
|
||||
long pages = (total + size - 1) / size;
|
||||
return McpMarketToolListResult.builder()
|
||||
.success(true)
|
||||
.data(data)
|
||||
.total(total)
|
||||
.page(page)
|
||||
.size(size)
|
||||
.pages(pages)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.ruoyi.domain.dto.mcp;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.ruoyi.domain.entity.mcp.McpTool;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MCP 工具列表返回结果
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class McpToolListResult {
|
||||
|
||||
/**
|
||||
* 是否成功
|
||||
*/
|
||||
private boolean success;
|
||||
|
||||
/**
|
||||
* 工具列表
|
||||
*/
|
||||
private List<McpTool> data;
|
||||
|
||||
/**
|
||||
* 总数
|
||||
*/
|
||||
private int total;
|
||||
|
||||
public static McpToolListResult of(List<McpTool> data) {
|
||||
return McpToolListResult.builder()
|
||||
.success(true)
|
||||
.data(data)
|
||||
.total(data != null ? data.size() : 0)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package org.ruoyi.domain.dto.mcp;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MCP 工具测试结果
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class McpToolTestResult {
|
||||
|
||||
/**
|
||||
* 是否成功
|
||||
*/
|
||||
private boolean success;
|
||||
|
||||
/**
|
||||
* 消息
|
||||
*/
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 发现的工具数量
|
||||
*/
|
||||
private Integer toolCount;
|
||||
|
||||
/**
|
||||
* 工具名称列表
|
||||
*/
|
||||
private List<String> tools;
|
||||
|
||||
public static McpToolTestResult success(String message, int toolCount, List<String> tools) {
|
||||
return McpToolTestResult.builder()
|
||||
.success(true)
|
||||
.message(message)
|
||||
.toolCount(toolCount)
|
||||
.tools(tools)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static McpToolTestResult fail(String message) {
|
||||
return McpToolTestResult.builder()
|
||||
.success(false)
|
||||
.message(message)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.ruoyi.domain.entity.mcp;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.ruoyi.common.tenant.core.TenantEntity;
|
||||
|
||||
/**
|
||||
* MCP 市场信息实体
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("mcp_market_info")
|
||||
public class McpMarket extends TenantEntity {
|
||||
|
||||
/**
|
||||
* 市场ID
|
||||
*/
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 市场名称
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 市场 URL
|
||||
*/
|
||||
private String url;
|
||||
|
||||
/**
|
||||
* 市场描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 认证配置(JSON格式)
|
||||
*/
|
||||
private String authConfig;
|
||||
|
||||
/**
|
||||
* 状态:ENABLED-启用, DISABLED-禁用
|
||||
*/
|
||||
private String status;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.ruoyi.domain.entity.mcp;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
|
||||
|
||||
/**
|
||||
* MCP 市场工具关联实体
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("mcp_market_tool")
|
||||
public class McpMarketTool extends BaseEntity {
|
||||
|
||||
/**
|
||||
* ID
|
||||
*/
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 市场 ID
|
||||
*/
|
||||
private Long marketId;
|
||||
|
||||
/**
|
||||
* 工具名称
|
||||
*/
|
||||
private String toolName;
|
||||
|
||||
/**
|
||||
* 工具描述
|
||||
*/
|
||||
private String toolDescription;
|
||||
|
||||
/**
|
||||
* 工具版本
|
||||
*/
|
||||
private String toolVersion;
|
||||
|
||||
/**
|
||||
* 工具元数据(JSON格式)
|
||||
*/
|
||||
private String toolMetadata;
|
||||
|
||||
/**
|
||||
* 是否已加载到本地
|
||||
*/
|
||||
private Boolean isLoaded;
|
||||
|
||||
/**
|
||||
* 关联的本地工具 ID
|
||||
*/
|
||||
private Long localToolId;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.ruoyi.domain.entity.mcp;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.ruoyi.common.tenant.core.TenantEntity;
|
||||
|
||||
|
||||
/**
|
||||
* MCP 工具信息实体
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("mcp_tool_info")
|
||||
public class McpTool extends TenantEntity {
|
||||
|
||||
/**
|
||||
* 工具ID
|
||||
*/
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 工具名称
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 工具描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 工具类型:LOCAL-本地, REMOTE-远程, BUILTIN-内置
|
||||
*/
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* 状态:ENABLED-启用, DISABLED-禁用
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 配置信息(JSON格式)
|
||||
* LOCAL: {"command": "npx", "args": ["-y", "@example/mcp-server"], "env": {...}}
|
||||
* REMOTE: {"baseUrl": "http://localhost:8080/mcp"}
|
||||
* BUILTIN: null
|
||||
*/
|
||||
private String configJson;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.ruoyi.domain.vo.mcp;
|
||||
|
||||
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
|
||||
import cn.idev.excel.annotation.ExcelProperty;
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import lombok.Data;
|
||||
import org.ruoyi.domain.entity.mcp.McpMarket;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* MCP 市场视图对象
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Data
|
||||
@ExcelIgnoreUnannotated
|
||||
@AutoMapper(target = McpMarket.class)
|
||||
public class McpMarketVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 市场ID
|
||||
*/
|
||||
@ExcelProperty(value = "市场ID")
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 市场名称
|
||||
*/
|
||||
@ExcelProperty(value = "市场名称")
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 市场 URL
|
||||
*/
|
||||
@ExcelProperty(value = "市场URL")
|
||||
private String url;
|
||||
|
||||
/**
|
||||
* 市场描述
|
||||
*/
|
||||
@ExcelProperty(value = "市场描述")
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 认证配置
|
||||
*/
|
||||
@ExcelProperty(value = "认证配置")
|
||||
private String authConfig;
|
||||
|
||||
/**
|
||||
* 状态
|
||||
*/
|
||||
@ExcelProperty(value = "状态")
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@ExcelProperty(value = "创建时间")
|
||||
private Date createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@ExcelProperty(value = "更新时间")
|
||||
private Date updateTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.ruoyi.domain.vo.mcp;
|
||||
|
||||
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
|
||||
import cn.idev.excel.annotation.ExcelProperty;
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import lombok.Data;
|
||||
import org.ruoyi.domain.entity.mcp.McpTool;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* MCP 工具视图对象
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Data
|
||||
@ExcelIgnoreUnannotated
|
||||
@AutoMapper(target = McpTool.class)
|
||||
public class McpToolVo implements Serializable {
|
||||
|
||||
/**
|
||||
* 工具ID
|
||||
*/
|
||||
@ExcelProperty(value = "工具ID")
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 工具名称
|
||||
*/
|
||||
@ExcelProperty(value = "工具名称")
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 工具描述
|
||||
*/
|
||||
@ExcelProperty(value = "工具描述")
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 工具类型
|
||||
*/
|
||||
@ExcelProperty(value = "工具类型")
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* 状态
|
||||
*/
|
||||
@ExcelProperty(value = "状态")
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 配置信息
|
||||
*/
|
||||
@ExcelProperty(value = "配置信息")
|
||||
private String configJson;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@ExcelProperty(value = "创建时间")
|
||||
private Date createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@ExcelProperty(value = "更新时间")
|
||||
private Date updateTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.ruoyi.enums;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* MCP 工具状态枚举
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Getter
|
||||
public enum McpToolStatus {
|
||||
|
||||
/**
|
||||
* 启用状态
|
||||
*/
|
||||
ENABLED("ENABLED", "启用"),
|
||||
|
||||
/**
|
||||
* 禁用状态
|
||||
*/
|
||||
DISABLED("DISABLED", "禁用");
|
||||
|
||||
/**
|
||||
* 状态值(存储到数据库)
|
||||
*/
|
||||
private final String value;
|
||||
|
||||
/**
|
||||
* 状态描述
|
||||
*/
|
||||
private final String description;
|
||||
|
||||
McpToolStatus(String value, String description) {
|
||||
this.value = value;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为启用状态
|
||||
*
|
||||
* @param value 状态值
|
||||
* @return 是否启用
|
||||
*/
|
||||
public static boolean isEnabled(String value) {
|
||||
return ENABLED.value.equals(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.ruoyi.mapper.mcp;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
import org.ruoyi.domain.entity.mcp.McpMarket;
|
||||
import org.ruoyi.domain.vo.mcp.McpMarketVo;
|
||||
|
||||
/**
|
||||
* MCP 市场信息 Mapper
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Mapper
|
||||
public interface McpMarketMapper extends BaseMapperPlus<McpMarket, McpMarketVo> {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.ruoyi.mapper.mcp;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
import org.ruoyi.domain.entity.mcp.McpMarketTool;
|
||||
|
||||
/**
|
||||
* MCP 市场工具关联 Mapper
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Mapper
|
||||
public interface McpMarketToolMapper extends BaseMapperPlus<McpMarketTool, Void> {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.ruoyi.mapper.mcp;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
import org.ruoyi.domain.entity.mcp.McpTool;
|
||||
import org.ruoyi.domain.vo.mcp.McpToolVo;
|
||||
|
||||
/**
|
||||
* MCP 工具信息 Mapper
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Mapper
|
||||
public interface McpToolMapper extends BaseMapperPlus<McpTool, McpToolVo> {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.ruoyi.mcp.service.core;
|
||||
|
||||
/**
|
||||
* 内置工具定义
|
||||
* 用于描述系统内置的工具信息
|
||||
*
|
||||
* @param name 工具名称(唯一标识)
|
||||
* @param displayName 显示名称
|
||||
* @param description 工具描述
|
||||
* @author ruoyi team
|
||||
*/
|
||||
public record BuiltinToolDefinition(String name, String displayName, String description) {
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.ruoyi.mcp.service.core;
|
||||
|
||||
/**
|
||||
* 内置工具提供者接口
|
||||
* 所有系统内置工具都应实现此接口,以便自动注册到 BuiltinToolRegistry
|
||||
*
|
||||
* @author ruoyi team
|
||||
*
|
||||
* <p>使用方式:
|
||||
* <pre>
|
||||
* {@code
|
||||
* @Component
|
||||
* public class MyTool implements BuiltinToolProvider {
|
||||
* @Override
|
||||
* public String getToolName() {
|
||||
* return "my_tool";
|
||||
* }
|
||||
*
|
||||
* @Override
|
||||
* public String getDisplayName() {
|
||||
* return "我的工具";
|
||||
* }
|
||||
*
|
||||
* @Override
|
||||
* public String getDescription() {
|
||||
* return "工具描述...";
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* </pre>
|
||||
*/
|
||||
public interface BuiltinToolProvider {
|
||||
|
||||
/**
|
||||
* 获取工具名称(唯一标识,用于数据库存储)
|
||||
* 建议使用 snake_case 格式,如:list_directory, edit_file
|
||||
*
|
||||
* @return 工具名称
|
||||
*/
|
||||
String getToolName();
|
||||
|
||||
/**
|
||||
* 获取工具显示名称(用于 UI 展示)
|
||||
*
|
||||
* @return 显示名称
|
||||
*/
|
||||
String getDisplayName();
|
||||
|
||||
/**
|
||||
* 获取工具描述(用于 AI 理解工具用途)
|
||||
*
|
||||
* @return 工具描述
|
||||
*/
|
||||
String getDescription();
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package org.ruoyi.mcp.service.core;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.ClassUtils;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 内置工具注册表
|
||||
* 自动发现并注册所有实现 {@link BuiltinToolProvider} 接口的工具
|
||||
*
|
||||
* <p>工具注册流程:
|
||||
* <ol>
|
||||
* <li>Spring 自动注入所有 {@link BuiltinToolProvider} 实现</li>
|
||||
* <li>{@link #init()} 方法在 Bean 初始化后自动调用</li>
|
||||
* <li>将所有工具注册到内部 Map</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>添加新工具只需:
|
||||
* <ol>
|
||||
* <li>创建一个类实现 {@link BuiltinToolProvider} 接口</li>
|
||||
* <li>添加 {@code @Component} 注解</li>
|
||||
* <li>工具会自动被发现和注册</li>
|
||||
* </ol>
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class BuiltinToolRegistry {
|
||||
|
||||
/**
|
||||
* 工具类型常量
|
||||
*/
|
||||
public static final String TYPE_BUILTIN = "BUILTIN";
|
||||
|
||||
/**
|
||||
* Spring 自动注入所有实现 BuiltinToolProvider 接口的 Bean
|
||||
* 注意:这些是 Spring 代理,不能直接用于 LangChain4j
|
||||
* 我们需要提取 Class 信息以便创建新实例
|
||||
*/
|
||||
private final List<BuiltinToolProvider> toolProviders;
|
||||
|
||||
/**
|
||||
* 内置工具类映射表 (工具名称 -> 工具类)
|
||||
* 存储 Class 对象而不是实例,以便创建不带 Spring 代理的新实例
|
||||
*/
|
||||
private final Map<String, Class<?>> registeredToolClasses = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 内置工具显示名称映射表 (工具名称 -> 显示名称)
|
||||
*/
|
||||
private final Map<String, String> displayNames = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 初始化方法,在 Bean 创建后自动调用
|
||||
* 提取工具类信息而不是存储 Spring 代理实例
|
||||
*/
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
log.info("开始注册内置工具,发现 {} 个工具提供者", toolProviders.size());
|
||||
|
||||
// 1. 注册通过 Spring 自动发现工具
|
||||
for (BuiltinToolProvider provider : toolProviders) {
|
||||
String toolName = provider.getToolName();
|
||||
|
||||
if (registeredToolClasses.containsKey(toolName)) {
|
||||
log.warn("工具名称重复: {},将覆盖原有注册", toolName);
|
||||
}
|
||||
|
||||
// 使用 ClassUtils.getUserClass 获取原始类,避免 Spring CGLIB 代理类
|
||||
Class<?> targetClass = ClassUtils.getUserClass(provider);
|
||||
registeredToolClasses.put(toolName, targetClass);
|
||||
displayNames.put(toolName, provider.getDisplayName());
|
||||
log.info("注册内置工具: {} ({}) - 原始类: {}", toolName, provider.getDisplayName(), targetClass.getName());
|
||||
}
|
||||
|
||||
log.info("内置工具注册完成,共 {} 个工具", registeredToolClasses.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具提供者(返回 Spring 代理,仅用于元数据查询)
|
||||
*
|
||||
* @param toolName 工具名称
|
||||
* @return 工具提供者,如果不存在则返回 null
|
||||
*/
|
||||
public BuiltinToolProvider getToolProvider(String toolName) {
|
||||
// 这个方法返回 Spring 代理,仅用于获取元数据
|
||||
for (BuiltinToolProvider provider : toolProviders) {
|
||||
if (provider.getToolName().equals(toolName)) {
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查工具是否已注册
|
||||
*
|
||||
* @param toolName 工具名称
|
||||
* @return 是否已注册
|
||||
*/
|
||||
public boolean hasTool(String toolName) {
|
||||
return registeredToolClasses.containsKey(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有内置工具定义
|
||||
*
|
||||
* @return 内置工具定义集合
|
||||
*/
|
||||
public Collection<BuiltinToolDefinition> getAllBuiltinTools() {
|
||||
return displayNames.entrySet().stream()
|
||||
.map(entry -> new BuiltinToolDefinition(
|
||||
entry.getKey(),
|
||||
entry.getValue(),
|
||||
"" // Description can be added later if needed
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有内置工具对象
|
||||
* 这些对象包含 @Tool 注解的方法,可直接用于 AgenticServices
|
||||
* 注意:每次调用都创建新实例,以避免 Spring CGLIB 代理问题
|
||||
*
|
||||
* @return 内置工具对象列表
|
||||
*/
|
||||
public List<Object> getAllBuiltinToolObjects() {
|
||||
List<Object> toolInstances = new java.util.ArrayList<>();
|
||||
|
||||
for (java.util.Map.Entry<String, Class<?>> entry : registeredToolClasses.entrySet()) {
|
||||
try {
|
||||
// 使用无参构造函数创建新实例,保留 @Tool 注解
|
||||
Object instance = entry.getValue().getDeclaredConstructor().newInstance();
|
||||
toolInstances.add(instance);
|
||||
log.debug("创建工具实例: {}", entry.getKey());
|
||||
} catch (Exception e) {
|
||||
log.error("创建工具实例失败: {} - {}", entry.getKey(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return toolInstances;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据工具名称获取工具对象
|
||||
* 注意:每次调用都创建新实例,以避免 Spring CGLIB 代理问题
|
||||
*
|
||||
* @param toolName 工具名称
|
||||
* @return 工具对象,如果不存在则返回 null
|
||||
*/
|
||||
public Object getBuiltinToolObject(String toolName) {
|
||||
Class<?> toolClass = registeredToolClasses.get(toolName);
|
||||
if (toolClass == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用无参构造函数创建新实例,保留 @Tool 注解
|
||||
return toolClass.getDeclaredConstructor().newInstance();
|
||||
} catch (Exception e) {
|
||||
log.error("创建工具实例失败: {} - {}", toolName, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
package org.ruoyi.mcp.service.core;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.langchain4j.mcp.McpToolProvider;
|
||||
import dev.langchain4j.mcp.client.DefaultMcpClient;
|
||||
import dev.langchain4j.mcp.client.McpClient;
|
||||
import dev.langchain4j.mcp.client.transport.McpTransport;
|
||||
import dev.langchain4j.mcp.client.transport.http.StreamableHttpMcpTransport;
|
||||
import dev.langchain4j.mcp.client.transport.stdio.StdioMcpTransport;
|
||||
import dev.langchain4j.service.tool.ToolProvider;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.ruoyi.domain.entity.mcp.McpTool;
|
||||
import org.ruoyi.enums.McpToolStatus;
|
||||
import org.ruoyi.mapper.mcp.McpToolMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* LangChain4j MCP 工具提供者服务
|
||||
* 从数据库读取 MCP 工具配置,创建 LangChain4j 的 McpToolProvider 供 Agent 使用
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class LangChain4jMcpToolProviderService {
|
||||
|
||||
/**
|
||||
* 最大失败次数,超过此次数将暂时禁用工具
|
||||
*/
|
||||
private static final int MAX_FAILURE_COUNT = 3;
|
||||
/**
|
||||
* 工具禁用时长(毫秒),默认 5 分钟
|
||||
*/
|
||||
private static final long DISABLE_DURATION = 5 * 60 * 1000;
|
||||
private final McpToolMapper mcpToolMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
/**
|
||||
* 缓存活跃的 MCP Client
|
||||
*/
|
||||
private final Map<Long, McpClient> activeClients = new ConcurrentHashMap<>();
|
||||
/**
|
||||
* 工具健康状态缓存(工具ID -> 是否健康)
|
||||
*/
|
||||
private final Map<Long, Boolean> toolHealthStatus = new ConcurrentHashMap<>();
|
||||
/**
|
||||
* 工具失败次数(工具ID -> 失败次数)
|
||||
*/
|
||||
private final Map<Long, Integer> toolFailureCount = new ConcurrentHashMap<>();
|
||||
/**
|
||||
* 工具禁用时间(工具ID -> 禁用截止时间戳)
|
||||
*/
|
||||
private final Map<Long, Long> toolDisabledUntil = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 根据工具 ID 列表获取 ToolProvider
|
||||
*
|
||||
* @param toolIds 工具 ID 列表
|
||||
* @return ToolProvider 实例
|
||||
*/
|
||||
public ToolProvider getToolProvider(List<Long> toolIds) {
|
||||
if (toolIds == null || toolIds.isEmpty()) {
|
||||
return McpToolProvider.builder().build();
|
||||
}
|
||||
|
||||
List<McpClient> clients = new ArrayList<>();
|
||||
for (Long toolId : toolIds) {
|
||||
try {
|
||||
McpClient client = getOrCreateClient(toolId);
|
||||
if (client != null) {
|
||||
clients.add(client);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to create MCP client for tool {}: {}", toolId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (clients.isEmpty()) {
|
||||
return McpToolProvider.builder().build();
|
||||
}
|
||||
|
||||
return McpToolProvider.builder()
|
||||
.mcpClients(clients)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有启用的 MCP 工具的 ToolProvider
|
||||
*
|
||||
* @return ToolProvider 实例
|
||||
*/
|
||||
public ToolProvider getAllEnabledToolsProvider() {
|
||||
List<McpTool> enabledTools = mcpToolMapper.selectList(
|
||||
new LambdaQueryWrapper<McpTool>()
|
||||
.eq(McpTool::getStatus, McpToolStatus.ENABLED.getValue())
|
||||
);
|
||||
|
||||
if (enabledTools.isEmpty()) {
|
||||
return McpToolProvider.builder().build();
|
||||
}
|
||||
|
||||
List<Long> toolIds = enabledTools.stream()
|
||||
.map(McpTool::getId)
|
||||
.toList();
|
||||
|
||||
return getToolProvider(toolIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定名称的 MCP 工具的 ToolProvider
|
||||
*
|
||||
* @param toolNames 工具名称列表
|
||||
* @return ToolProvider 实例
|
||||
*/
|
||||
public ToolProvider getToolProviderByNames(List<String> toolNames) {
|
||||
if (toolNames == null || toolNames.isEmpty()) {
|
||||
return McpToolProvider.builder().build();
|
||||
}
|
||||
|
||||
List<McpTool> tools = mcpToolMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<McpTool>()
|
||||
.in(McpTool::getName, toolNames)
|
||||
.eq(McpTool::getStatus, McpToolStatus.ENABLED.getValue())
|
||||
);
|
||||
|
||||
if (tools.isEmpty()) {
|
||||
return McpToolProvider.builder().build();
|
||||
}
|
||||
|
||||
List<Long> toolIds = tools.stream()
|
||||
.map(McpTool::getId)
|
||||
.toList();
|
||||
|
||||
return getToolProvider(toolIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建 MCP Client
|
||||
* 包含健康检查和失败重试逻辑
|
||||
*/
|
||||
private McpClient getOrCreateClient(Long toolId) {
|
||||
// 检查工具是否被禁用
|
||||
if (isToolDisabled(toolId)) {
|
||||
log.warn("Tool {} is temporarily disabled due to previous failures", toolId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 尝试从缓存获取
|
||||
McpClient cachedClient = activeClients.get(toolId);
|
||||
if (cachedClient != null && isToolHealthy(toolId)) {
|
||||
return cachedClient;
|
||||
}
|
||||
|
||||
// 创建新的客户端
|
||||
return activeClients.compute(toolId, (id, existingClient) -> {
|
||||
McpTool tool = mcpToolMapper.selectById(id);
|
||||
if (tool == null || !McpToolStatus.isEnabled(tool.getStatus())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 跳过内置工具(BUILTIN 类型)
|
||||
if ("BUILTIN".equals(tool.getType())) {
|
||||
log.debug("Skipping builtin tool: {}", tool.getName());
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
McpClient client = createMcpClient(tool);
|
||||
// 标记工具为健康状态
|
||||
markToolHealthy(id);
|
||||
log.info("Successfully created LangChain4j MCP client for tool: {}", tool.getName());
|
||||
return client;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to create MCP client for tool {}: {}", tool.getName(), e.getMessage());
|
||||
// 记录失败并可能禁用工具
|
||||
handleToolFailure(id);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查工具是否被暂时禁用
|
||||
*/
|
||||
private boolean isToolDisabled(Long toolId) {
|
||||
Long disabledUntil = toolDisabledUntil.get(toolId);
|
||||
if (disabledUntil == null) {
|
||||
return false;
|
||||
}
|
||||
if (System.currentTimeMillis() > disabledUntil) {
|
||||
// 禁用时间已过,重新启用
|
||||
toolDisabledUntil.remove(toolId);
|
||||
toolFailureCount.put(toolId, 0);
|
||||
log.info("Tool {} is re-enabled after disable period", toolId);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查工具是否健康
|
||||
*/
|
||||
private boolean isToolHealthy(Long toolId) {
|
||||
return toolHealthStatus.getOrDefault(toolId, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记工具为健康状态
|
||||
*/
|
||||
private void markToolHealthy(Long toolId) {
|
||||
toolHealthStatus.put(toolId, true);
|
||||
toolFailureCount.put(toolId, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理工具失败
|
||||
*/
|
||||
private void handleToolFailure(Long toolId) {
|
||||
int failures = toolFailureCount.getOrDefault(toolId, 0) + 1;
|
||||
toolFailureCount.put(toolId, failures);
|
||||
toolHealthStatus.put(toolId, false);
|
||||
|
||||
if (failures >= MAX_FAILURE_COUNT) {
|
||||
// 禁用工具一段时间
|
||||
long disableUntil = System.currentTimeMillis() + DISABLE_DURATION;
|
||||
toolDisabledUntil.put(toolId, disableUntil);
|
||||
log.warn("Tool {} has failed {} times, disabling until {}",
|
||||
toolId, failures, new java.util.Date(disableUntil));
|
||||
} else {
|
||||
log.warn("Tool {} has failed {} times (max: {})",
|
||||
toolId, failures, MAX_FAILURE_COUNT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动检查工具健康状态
|
||||
*
|
||||
* @param toolId 工具 ID
|
||||
* @return 工具是否健康
|
||||
*/
|
||||
public boolean checkToolHealth(Long toolId) {
|
||||
McpTool tool = mcpToolMapper.selectById(toolId);
|
||||
if (tool == null || !McpToolStatus.isEnabled(tool.getStatus())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试创建客户端来验证连接
|
||||
McpClient client = createMcpClient(tool);
|
||||
if (client != null) {
|
||||
markToolHealthy(toolId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
log.error("Health check failed for tool {}: {}", tool.getName(), e.getMessage());
|
||||
handleToolFailure(toolId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有工具的健康状态
|
||||
*
|
||||
* @return 工具 ID -> 健康状态的映射
|
||||
*/
|
||||
public Map<Long, Boolean> getAllToolsHealthStatus() {
|
||||
List<McpTool> allTools = mcpToolMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<McpTool>()
|
||||
.eq(McpTool::getStatus, McpToolStatus.ENABLED.getValue())
|
||||
);
|
||||
|
||||
Map<Long, Boolean> statusMap = new ConcurrentHashMap<>();
|
||||
for (McpTool tool : allTools) {
|
||||
boolean isHealthy = isToolHealthy(tool.getId()) && !isToolDisabled(tool.getId());
|
||||
statusMap.put(tool.getId(), isHealthy);
|
||||
}
|
||||
return statusMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据工具配置创建 MCP Client
|
||||
*/
|
||||
private McpClient createMcpClient(McpTool tool) throws Exception {
|
||||
if ("LOCAL".equals(tool.getType())) {
|
||||
return createStdioClient(tool);
|
||||
} else if ("REMOTE".equals(tool.getType())) {
|
||||
return createRemoteClient(tool);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 STDIO Client (本地命令行工具)
|
||||
*/
|
||||
private McpClient createStdioClient(McpTool tool) throws Exception {
|
||||
String configJson = tool.getConfigJson();
|
||||
if (configJson == null || configJson.isBlank()) {
|
||||
throw new IllegalArgumentException("Config JSON is required for LOCAL type tool");
|
||||
}
|
||||
|
||||
JsonNode configNode = objectMapper.readTree(configJson);
|
||||
|
||||
// 解析命令
|
||||
String command = null;
|
||||
List<String> args = new ArrayList<>();
|
||||
|
||||
if (configNode.has("command")) {
|
||||
command = configNode.get("command").asText();
|
||||
}
|
||||
|
||||
if (configNode.has("args") && configNode.get("args").isArray()) {
|
||||
for (JsonNode arg : configNode.get("args")) {
|
||||
args.add(arg.asText());
|
||||
}
|
||||
}
|
||||
|
||||
if (command == null || command.isBlank()) {
|
||||
throw new IllegalArgumentException("Command is required in config JSON");
|
||||
}
|
||||
|
||||
// 处理 Windows 系统的命令
|
||||
command = resolveCommand(command);
|
||||
|
||||
// 检查命令是否可用
|
||||
if (!isCommandAvailable(command)) {
|
||||
throw new IllegalArgumentException("Command '" + command + "' is not available on this system. Please install the required package or use a different tool.");
|
||||
}
|
||||
|
||||
// 构建完整命令列表
|
||||
List<String> fullCommand = new ArrayList<>();
|
||||
fullCommand.add(command);
|
||||
fullCommand.addAll(args);
|
||||
|
||||
log.info("Creating STDIO MCP client for tool: {}, command: {}", tool.getName(), fullCommand);
|
||||
|
||||
// 创建传输层
|
||||
McpTransport transport = StdioMcpTransport.builder()
|
||||
.command(fullCommand)
|
||||
.logEvents(true)
|
||||
.build();
|
||||
|
||||
// 创建客户端
|
||||
return new DefaultMcpClient.Builder()
|
||||
.transport(transport)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查命令是否在系统上可用
|
||||
*/
|
||||
private boolean isCommandAvailable(String command) {
|
||||
try {
|
||||
ProcessBuilder pb = new ProcessBuilder(command, "--version");
|
||||
pb.redirectErrorStream(true);
|
||||
Process process = pb.start();
|
||||
boolean finished = process.waitFor(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
if (!finished) {
|
||||
process.destroyForcibly();
|
||||
return false;
|
||||
}
|
||||
int exitCode = process.exitValue();
|
||||
// 对于某些命令,--version 可能返回非零退出码,所以我们只检查进程是否能启动
|
||||
// 如果进程能启动并退出(无论退出码是什么),我们认为命令可用
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.debug("Command '{}' is not available: {}", command, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建远程 HTTP/SSE Client
|
||||
*/
|
||||
private McpClient createRemoteClient(McpTool tool) throws Exception {
|
||||
String configJson = tool.getConfigJson();
|
||||
if (configJson == null || configJson.isBlank()) {
|
||||
throw new IllegalArgumentException("Config JSON is required for REMOTE type tool");
|
||||
}
|
||||
|
||||
JsonNode configNode = objectMapper.readTree(configJson);
|
||||
|
||||
if (!configNode.has("baseUrl")) {
|
||||
throw new IllegalArgumentException("baseUrl is required in config JSON for REMOTE type tool");
|
||||
}
|
||||
|
||||
String baseUrl = configNode.get("baseUrl").asText();
|
||||
log.info("Creating HTTP/SSE MCP client for tool: {}, baseUrl: {}", tool.getName(), baseUrl);
|
||||
|
||||
// 创建 HTTP/SSE 传输层
|
||||
McpTransport transport = StreamableHttpMcpTransport.builder()
|
||||
.url(baseUrl)
|
||||
.logRequests(true)
|
||||
.build();
|
||||
|
||||
// 创建客户端
|
||||
return new DefaultMcpClient.Builder()
|
||||
.transport(transport)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析命令,处理 Windows 系统的兼容性问题
|
||||
*/
|
||||
private String resolveCommand(String command) {
|
||||
if (command == null || command.isBlank()) {
|
||||
return command;
|
||||
}
|
||||
|
||||
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win");
|
||||
|
||||
if (isWindows) {
|
||||
String lowerCommand = command.toLowerCase();
|
||||
if (lowerCommand.equals("npx") || lowerCommand.equals("npm") ||
|
||||
lowerCommand.equals("node") || lowerCommand.equals("pnpm") ||
|
||||
lowerCommand.equals("yarn") || lowerCommand.equals("uvx") ||
|
||||
lowerCommand.equals("uv")) {
|
||||
String resolvedCommand = command + ".cmd";
|
||||
log.debug("Windows detected, resolved command: {} -> {}", command, resolvedCommand);
|
||||
return resolvedCommand;
|
||||
}
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新指定工具的客户端连接
|
||||
*/
|
||||
public void refreshClient(Long toolId) {
|
||||
closeClient(toolId);
|
||||
log.info("Refreshed MCP client for tool: {}", toolId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭指定工具的客户端连接
|
||||
*/
|
||||
private void closeClient(Long toolId) {
|
||||
McpClient client = activeClients.remove(toolId);
|
||||
if (client != null) {
|
||||
try {
|
||||
// LangChain4j McpClient 没有 close 方法,直接移除即可
|
||||
log.info("Removed MCP client for tool: {}", toolId);
|
||||
} catch (Exception e) {
|
||||
log.warn("Error closing MCP client for tool {}: {}", toolId, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用关闭时清理所有连接
|
||||
*/
|
||||
@PreDestroy
|
||||
public void cleanup() {
|
||||
log.info("Cleaning up {} MCP clients...", activeClients.size());
|
||||
activeClients.keySet().forEach(this::closeClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前活跃的客户端数量
|
||||
*/
|
||||
public int getActiveClientCount() {
|
||||
return activeClients.size();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.ruoyi.mcp.service.core;
|
||||
|
||||
import dev.langchain4j.service.tool.ToolProvider;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 统一工具提供工厂
|
||||
* 整合所有类型的MCP工具提供者,为Agent和Chat服务提供统一的工具获取入口
|
||||
*
|
||||
* <p>支持的工具类型:
|
||||
* <ul>
|
||||
* <li>BUILTIN - 内置工具(如文件操作工具)</li>
|
||||
* <li>LOCAL - 本地STDIO工具(通过命令行启动的MCP服务器)</li>
|
||||
* <li>REMOTE - 远程HTTP/SSE工具(通过网络连接的MCP服务器)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ToolProviderFactory {
|
||||
|
||||
private final BuiltinToolRegistry builtinToolRegistry;
|
||||
private final LangChain4jMcpToolProviderService langChain4jMcpToolProviderService;
|
||||
|
||||
/**
|
||||
* 获取所有已启用的MCP工具的ToolProvider
|
||||
*
|
||||
* @return ToolProvider实例
|
||||
*/
|
||||
public ToolProvider getAllEnabledMcpToolsProvider() {
|
||||
return langChain4jMcpToolProviderService.getAllEnabledToolsProvider();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 BUILTIN 工具对象
|
||||
* 这些对象包含 @Tool 注解的方法,可直接用于 AgenticServices
|
||||
*
|
||||
* @return BUILTIN 工具对象列表
|
||||
*/
|
||||
public List<Object> getAllBuiltinToolObjects() {
|
||||
return builtinToolRegistry.getAllBuiltinToolObjects();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package org.ruoyi.mcp.tools;
|
||||
|
||||
import dev.langchain4j.agent.tool.Tool;
|
||||
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 编辑文件工具
|
||||
* 支持基于diff的文件编辑
|
||||
*/
|
||||
@Component
|
||||
public class EditFileTool implements BuiltinToolProvider {
|
||||
|
||||
public static final String DESCRIPTION = "Edits a file by applying a diff. " +
|
||||
"Use this tool when you need to make specific changes to a file. " +
|
||||
"The tool will show the diff before applying changes. " +
|
||||
"Use absolute paths within the workspace directory.";
|
||||
|
||||
private final String rootDirectory;
|
||||
private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass());
|
||||
|
||||
public EditFileTool() {
|
||||
this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑文件
|
||||
*
|
||||
* @param filePath 文件绝对路径
|
||||
* @param diff 要应用的diff内容
|
||||
* @return 操作结果
|
||||
*/
|
||||
@Tool(DESCRIPTION)
|
||||
public String editFile(String filePath, String diff) {
|
||||
try {
|
||||
// 验证参数
|
||||
if (filePath == null || filePath.trim().isEmpty()) {
|
||||
return "Error: File path cannot be empty";
|
||||
}
|
||||
|
||||
if (diff == null || diff.trim().isEmpty()) {
|
||||
return "Error: Diff cannot be empty";
|
||||
}
|
||||
|
||||
Path path = Paths.get(filePath);
|
||||
|
||||
// 验证是否为绝对路径
|
||||
if (!path.isAbsolute()) {
|
||||
return "Error: File path must be absolute: " + filePath;
|
||||
}
|
||||
|
||||
// 验证是否在工作目录内
|
||||
if (!isWithinWorkspace(path)) {
|
||||
return "Error: File path must be within the workspace directory (" + rootDirectory + "): " + filePath;
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!Files.exists(path)) {
|
||||
return "Error: File not found: " + filePath;
|
||||
}
|
||||
|
||||
// 检查是否为目录
|
||||
if (Files.isDirectory(path)) {
|
||||
return "Error: Path is a directory, not a file: " + filePath;
|
||||
}
|
||||
|
||||
// 读取原始内容
|
||||
String originalContent = Files.readString(path, StandardCharsets.UTF_8);
|
||||
List<String> originalLines = Arrays.asList(originalContent.split("\n"));
|
||||
|
||||
// 应用diff
|
||||
try {
|
||||
// 这里简化处理,直接用新内容替换
|
||||
// 在实际应用中,可能需要更复杂的diff解析
|
||||
String newContent = applyDiff(originalContent, diff);
|
||||
|
||||
// 写入文件
|
||||
Files.writeString(path, newContent, StandardCharsets.UTF_8,
|
||||
StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
|
||||
String relativePath = getRelativePath(path);
|
||||
return String.format("Successfully edited file: %s", relativePath);
|
||||
|
||||
} catch (Exception e) {
|
||||
return "Error: Failed to apply diff: " + e.getMessage();
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
logger.error("Error editing file: {}", filePath, e);
|
||||
return "Error: " + e.getMessage();
|
||||
} catch (Exception e) {
|
||||
logger.error("Unexpected error editing file: {}", filePath, e);
|
||||
return "Error: Unexpected error: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化的diff应用逻辑
|
||||
* 实际应用中可能需要使用更复杂的diff解析器
|
||||
*/
|
||||
private String applyDiff(String originalContent, String diff) {
|
||||
// 这里简化处理,实际应用中需要解析diff格式
|
||||
// 目前将diff作为新内容直接替换
|
||||
// 可以考虑使用jgit等库来解析 unified diff 格式
|
||||
return diff;
|
||||
}
|
||||
|
||||
private boolean isWithinWorkspace(Path filePath) {
|
||||
try {
|
||||
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
|
||||
Path normalizedPath = filePath.normalize();
|
||||
return normalizedPath.startsWith(workspaceRoot.normalize());
|
||||
} catch (IOException e) {
|
||||
logger.warn("Could not resolve workspace path", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String getRelativePath(Path filePath) {
|
||||
try {
|
||||
Path workspaceRoot = Paths.get(rootDirectory);
|
||||
return workspaceRoot.relativize(filePath).toString();
|
||||
} catch (Exception e) {
|
||||
return filePath.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getToolName() {
|
||||
return "edit_file";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayName() {
|
||||
return "编辑文件";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return DESCRIPTION;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package org.ruoyi.mcp.tools;
|
||||
|
||||
import dev.langchain4j.agent.tool.Tool;
|
||||
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* 目录列表工具
|
||||
* 列出指定目录的文件和子目录,支持递归列表
|
||||
*/
|
||||
@Component
|
||||
public class ListDirectoryTool implements BuiltinToolProvider {
|
||||
|
||||
public static final String DESCRIPTION = "Lists files and directories in the specified path. " +
|
||||
"Supports recursive listing and filtering. " +
|
||||
"Shows file sizes, modification times, and types. " +
|
||||
"Use absolute paths within the workspace directory.";
|
||||
|
||||
private final String rootDirectory;
|
||||
private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass());
|
||||
|
||||
public ListDirectoryTool() {
|
||||
this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出目录内容
|
||||
*
|
||||
* @param filePath 目录绝对路径
|
||||
* @param recursive 是否递归列出子目录(可选,默认 false)
|
||||
* @param maxDepth 最大递归深度(可选,默认 3,范围 1-10)
|
||||
* @return 目录列表结果
|
||||
*/
|
||||
@Tool(DESCRIPTION)
|
||||
public String listDirectory(String filePath, Boolean recursive, Integer maxDepth) {
|
||||
// 创建参数对象
|
||||
ListDirectoryParams params = new ListDirectoryParams();
|
||||
params.filePath = filePath;
|
||||
params.recursive = recursive != null ? recursive : false;
|
||||
params.maxDepth = maxDepth != null ? maxDepth : 3;
|
||||
|
||||
return execute(params);
|
||||
}
|
||||
|
||||
public String execute(ListDirectoryParams params) {
|
||||
try {
|
||||
// 验证参数
|
||||
String validationError = validateParams(params);
|
||||
if (validationError != null) {
|
||||
return "Error: " + validationError;
|
||||
}
|
||||
|
||||
Path dirPath = Paths.get(params.filePath);
|
||||
|
||||
// 检查目录是否存在
|
||||
if (!Files.exists(dirPath)) {
|
||||
return "Error: Directory not found: " + params.filePath;
|
||||
}
|
||||
|
||||
// 检查是否为目录
|
||||
if (!Files.isDirectory(dirPath)) {
|
||||
return "Error: Path is not a directory: " + params.filePath;
|
||||
}
|
||||
|
||||
// 列出文件和目录
|
||||
List<FileInfo> fileInfos = listFiles(dirPath, params);
|
||||
|
||||
// 生成输出
|
||||
return formatFileList(fileInfos, params);
|
||||
|
||||
} catch (IOException e) {
|
||||
logger.error("Error listing directory: {}", params.filePath, e);
|
||||
return "Error: " + e.getMessage();
|
||||
} catch (Exception e) {
|
||||
logger.error("Unexpected error listing directory: {}", params.filePath, e);
|
||||
return "Error: Unexpected error: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private String validateParams(ListDirectoryParams params) {
|
||||
// 验证路径
|
||||
if (params.filePath == null || params.filePath.trim().isEmpty()) {
|
||||
return "Directory path cannot be empty";
|
||||
}
|
||||
|
||||
Path dirPath = Paths.get(params.filePath);
|
||||
|
||||
// 验证是否为绝对路径
|
||||
if (!dirPath.isAbsolute()) {
|
||||
return "Directory path must be absolute: " + params.filePath;
|
||||
}
|
||||
|
||||
// 验证是否在工作目录内
|
||||
if (!isWithinWorkspace(dirPath)) {
|
||||
return "Directory path must be within the workspace directory (" + rootDirectory + "): " + params.filePath;
|
||||
}
|
||||
|
||||
// 验证最大深度
|
||||
if (params.maxDepth != null && (params.maxDepth < 1 || params.maxDepth > 10)) {
|
||||
return "Max depth must be between 1 and 10";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<FileInfo> listFiles(Path dirPath, ListDirectoryParams params) throws IOException {
|
||||
List<FileInfo> fileInfos = new ArrayList<>();
|
||||
|
||||
if (params.recursive != null && params.recursive) {
|
||||
int maxDepth = params.maxDepth != null ? params.maxDepth : 3;
|
||||
listFilesRecursive(dirPath, fileInfos, 0, maxDepth, params);
|
||||
} else {
|
||||
listFilesInDirectory(dirPath, fileInfos, params);
|
||||
}
|
||||
|
||||
// 排序:目录在前,然后按名称排序
|
||||
fileInfos.sort(Comparator
|
||||
.comparing((FileInfo f) -> !f.isDirectory())
|
||||
.thenComparing(FileInfo::name));
|
||||
|
||||
return fileInfos;
|
||||
}
|
||||
|
||||
private void listFilesInDirectory(Path dirPath, List<FileInfo> fileInfos, ListDirectoryParams params) throws IOException {
|
||||
try (Stream<Path> stream = Files.list(dirPath)) {
|
||||
stream.forEach(path -> {
|
||||
try {
|
||||
FileInfo fileInfo = createFileInfo(path, dirPath);
|
||||
fileInfos.add(fileInfo);
|
||||
} catch (IOException e) {
|
||||
logger.warn("Could not get info for file: " + path, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void listFilesRecursive(Path dirPath, List<FileInfo> fileInfos, int currentDepth, int maxDepth, ListDirectoryParams params) throws IOException {
|
||||
if (currentDepth >= maxDepth) {
|
||||
return;
|
||||
}
|
||||
|
||||
try (Stream<Path> stream = Files.list(dirPath)) {
|
||||
List<Path> paths = stream.toList();
|
||||
|
||||
for (Path path : paths) {
|
||||
try {
|
||||
FileInfo fileInfo = createFileInfo(path, Paths.get(params.filePath));
|
||||
fileInfos.add(fileInfo);
|
||||
|
||||
// 如果是目录,递归列出
|
||||
if (Files.isDirectory(path)) {
|
||||
listFilesRecursive(path, fileInfos, currentDepth + 1, maxDepth, params);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
logger.warn("Could not get info for file: " + path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private FileInfo createFileInfo(Path path, Path basePath) throws IOException {
|
||||
String name = path.getFileName().toString();
|
||||
boolean isDirectory = Files.isDirectory(path);
|
||||
long size = isDirectory ? 0 : Files.size(path);
|
||||
|
||||
LocalDateTime lastModified = LocalDateTime.ofInstant(
|
||||
Files.getLastModifiedTime(path).toInstant(),
|
||||
ZoneId.systemDefault()
|
||||
);
|
||||
|
||||
String relativePath = basePath.relativize(path).toString();
|
||||
|
||||
return new FileInfo(name, relativePath, isDirectory, size, lastModified);
|
||||
}
|
||||
|
||||
private String formatFileList(List<FileInfo> fileInfos, ListDirectoryParams params) {
|
||||
if (fileInfos.isEmpty()) {
|
||||
return "Directory is empty.";
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(String.format("Directory listing for: %s\n", getRelativePath(Paths.get(params.filePath))));
|
||||
sb.append(String.format("Total items: %d\n\n", fileInfos.size()));
|
||||
|
||||
// 表头
|
||||
sb.append(String.format("%-4s %-40s %-12s %-20s %s\n",
|
||||
"Type", "Name", "Size", "Modified", "Path"));
|
||||
sb.append("-".repeat(80)).append("\n");
|
||||
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
for (FileInfo fileInfo : fileInfos) {
|
||||
String type = fileInfo.isDirectory() ? "DIR" : "FILE";
|
||||
String sizeStr = fileInfo.isDirectory() ? "-" : formatFileSize(fileInfo.size());
|
||||
String modifiedStr = fileInfo.lastModified().format(formatter);
|
||||
|
||||
sb.append(String.format("%-4s %-40s %-12s %-20s %s\n",
|
||||
type,
|
||||
truncate(fileInfo.name()),
|
||||
sizeStr,
|
||||
modifiedStr,
|
||||
fileInfo.relativePath()
|
||||
));
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String formatFileSize(long bytes) {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0);
|
||||
if (bytes < 1024 * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024));
|
||||
return String.format("%.1f GB", bytes / (1024.0 * 1024 * 1024));
|
||||
}
|
||||
|
||||
private String truncate(String str) {
|
||||
if (str.length() <= 40) {
|
||||
return str;
|
||||
}
|
||||
return str.substring(0, 40 - 3) + "...";
|
||||
}
|
||||
|
||||
private boolean isWithinWorkspace(Path dirPath) {
|
||||
try {
|
||||
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
|
||||
Path normalizedPath = dirPath.normalize();
|
||||
return normalizedPath.startsWith(workspaceRoot.normalize());
|
||||
} catch (IOException e) {
|
||||
logger.warn("Could not resolve workspace path", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String getRelativePath(Path dirPath) {
|
||||
try {
|
||||
Path workspaceRoot = Paths.get(rootDirectory);
|
||||
return workspaceRoot.relativize(dirPath).toString();
|
||||
} catch (Exception e) {
|
||||
return dirPath.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getToolName() {
|
||||
return "list_directory";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayName() {
|
||||
return "列出目录";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return DESCRIPTION;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件信息
|
||||
*/
|
||||
public record FileInfo(String name, String relativePath, boolean isDirectory, long size,
|
||||
LocalDateTime lastModified) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表目录参数
|
||||
*/
|
||||
public static class ListDirectoryParams {
|
||||
public String filePath;
|
||||
public Boolean recursive;
|
||||
public Integer maxDepth;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package org.ruoyi.mcp.tools;
|
||||
|
||||
import dev.langchain4j.agent.tool.Tool;
|
||||
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
/**
|
||||
* 读取文件工具
|
||||
* 读取指定路径的文件内容
|
||||
*/
|
||||
@Component
|
||||
public class ReadFileTool implements BuiltinToolProvider {
|
||||
|
||||
public static final String DESCRIPTION = "Reads the contents of a file. " +
|
||||
"Use absolute paths within the workspace directory. " +
|
||||
"Returns the complete file content as a string.";
|
||||
|
||||
private final String rootDirectory;
|
||||
private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass());
|
||||
|
||||
public ReadFileTool() {
|
||||
this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容
|
||||
*
|
||||
* @param filePath 文件绝对路径
|
||||
* @return 文件内容
|
||||
*/
|
||||
@Tool(DESCRIPTION)
|
||||
public String readFile(String filePath) {
|
||||
try {
|
||||
// 验证参数
|
||||
if (filePath == null || filePath.trim().isEmpty()) {
|
||||
return "Error: File path cannot be empty";
|
||||
}
|
||||
|
||||
Path path = Paths.get(filePath);
|
||||
|
||||
// 验证是否为绝对路径
|
||||
if (!path.isAbsolute()) {
|
||||
return "Error: File path must be absolute: " + filePath;
|
||||
}
|
||||
|
||||
// 验证是否在工作目录内
|
||||
if (!isWithinWorkspace(path)) {
|
||||
return "Error: File path must be within the workspace directory (" + rootDirectory + "): " + filePath;
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!Files.exists(path)) {
|
||||
return "Error: File not found: " + filePath;
|
||||
}
|
||||
|
||||
// 检查是否为目录
|
||||
if (Files.isDirectory(path)) {
|
||||
return "Error: Path is a directory, not a file: " + filePath;
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
String content = Files.readString(path, StandardCharsets.UTF_8);
|
||||
|
||||
// 获取相对路径
|
||||
String relativePath = getRelativePath(path);
|
||||
long sizeBytes = content.getBytes(StandardCharsets.UTF_8).length;
|
||||
long lineCount = content.lines().count();
|
||||
|
||||
return String.format("File: %s (%d lines, %d bytes)\n\n%s",
|
||||
relativePath, lineCount, sizeBytes, content);
|
||||
|
||||
} catch (IOException e) {
|
||||
logger.error("Error reading file: {}", filePath, e);
|
||||
return "Error: " + e.getMessage();
|
||||
} catch (Exception e) {
|
||||
logger.error("Unexpected error reading file: {}", filePath, e);
|
||||
return "Error: Unexpected error: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isWithinWorkspace(Path filePath) {
|
||||
try {
|
||||
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
|
||||
Path normalizedPath = filePath.normalize();
|
||||
return normalizedPath.startsWith(workspaceRoot.normalize());
|
||||
} catch (IOException e) {
|
||||
logger.warn("Could not resolve workspace path", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String getRelativePath(Path filePath) {
|
||||
try {
|
||||
Path workspaceRoot = Paths.get(rootDirectory);
|
||||
return workspaceRoot.relativize(filePath).toString();
|
||||
} catch (Exception e) {
|
||||
return filePath.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getToolName() {
|
||||
return "read_file";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayName() {
|
||||
return "读取文件";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return DESCRIPTION;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,41 @@
|
||||
package org.ruoyi.service.chat.impl;
|
||||
|
||||
import dev.langchain4j.agentic.AgenticServices;
|
||||
import dev.langchain4j.agentic.supervisor.SupervisorAgent;
|
||||
import dev.langchain4j.agentic.supervisor.SupervisorResponseStrategy;
|
||||
import dev.langchain4j.community.model.dashscope.QwenChatModel;
|
||||
import dev.langchain4j.data.message.ChatMessage;
|
||||
import dev.langchain4j.data.message.UserMessage;
|
||||
import dev.langchain4j.mcp.McpToolProvider;
|
||||
import dev.langchain4j.mcp.client.DefaultMcpClient;
|
||||
import dev.langchain4j.mcp.client.McpClient;
|
||||
import dev.langchain4j.mcp.client.transport.McpTransport;
|
||||
import dev.langchain4j.mcp.client.transport.stdio.StdioMcpTransport;
|
||||
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
|
||||
import dev.langchain4j.model.chat.StreamingChatModel;
|
||||
import dev.langchain4j.model.chat.response.ChatResponse;
|
||||
import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
|
||||
import dev.langchain4j.model.openai.OpenAiChatModel;
|
||||
import dev.langchain4j.service.tool.ToolProvider;
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.ruoyi.agent.ChartGenerationAgent;
|
||||
import org.ruoyi.agent.SqlAgent;
|
||||
import org.ruoyi.agent.WebSearchAgent;
|
||||
import org.ruoyi.agent.tool.ExecuteSqlQueryTool;
|
||||
import org.ruoyi.agent.tool.QueryAllTablesTool;
|
||||
import org.ruoyi.agent.tool.QueryTableSchemaTool;
|
||||
import org.ruoyi.agent.McpAgent;
|
||||
import org.ruoyi.common.chat.base.ThreadContext;
|
||||
import org.ruoyi.common.chat.domain.dto.request.ChatRequest;
|
||||
import org.ruoyi.common.chat.domain.dto.request.ReSumeRunner;
|
||||
import org.ruoyi.common.chat.domain.dto.request.WorkFlowRunner;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.common.chat.entity.chat.ChatContext;
|
||||
import org.ruoyi.common.chat.enums.RoleType;
|
||||
import org.ruoyi.common.chat.service.chat.IChatService;
|
||||
import org.ruoyi.common.chat.domain.dto.request.ChatRequest;
|
||||
import org.ruoyi.common.chat.entity.chat.ChatContext;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.common.chat.service.chatMessage.AbstractChatMessageService;
|
||||
import org.ruoyi.common.chat.service.workFlow.IWorkFlowStarterService;
|
||||
import org.ruoyi.common.core.utils.ObjectUtils;
|
||||
import org.ruoyi.common.core.utils.SpringUtils;
|
||||
import org.ruoyi.common.core.utils.StringUtils;
|
||||
import org.ruoyi.common.sse.utils.SseMessageUtils;
|
||||
import org.ruoyi.mcp.service.core.ToolProviderFactory;
|
||||
import org.ruoyi.service.chat.impl.memory.PersistentChatMemoryStore;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
@@ -213,17 +204,6 @@ public abstract class AbstractStreamingChatService extends AbstractChatMessageSe
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理指定会话的内存缓存(可选)
|
||||
* 在会话结束时调用,释放内存资源
|
||||
*
|
||||
* @param sessionId 会话ID
|
||||
*/
|
||||
public static void clearChatMemory(Object sessionId) {
|
||||
memoryCache.remove(sessionId);
|
||||
log.debug("已清理会话 {} 的内存缓存", sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行聊天(钩子方法 - 子类必须实现)
|
||||
* 注意:messages 已包含完整的历史上下文和当前消息
|
||||
@@ -232,7 +212,8 @@ public abstract class AbstractStreamingChatService extends AbstractChatMessageSe
|
||||
* @param chatRequest 聊天请求
|
||||
* @param handler 响应处理器
|
||||
*/
|
||||
protected abstract void doChat(ChatModelVo chatModelVo, ChatRequest chatRequest, List<ChatMessage> messagesWithMemory, StreamingChatResponseHandler handler);
|
||||
protected abstract void doChat(ChatModelVo chatModelVo, ChatRequest chatRequest,
|
||||
List<ChatMessage> messagesWithMemory, StreamingChatResponseHandler handler);
|
||||
|
||||
/**
|
||||
* 创建标准的响应处理器
|
||||
@@ -302,103 +283,80 @@ public abstract class AbstractStreamingChatService extends AbstractChatMessageSe
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建具体厂商的 StreamingChatModel
|
||||
* 子类必须实现此方法,返回对应厂商的模型实例
|
||||
*/
|
||||
protected abstract StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest);
|
||||
|
||||
|
||||
/**
|
||||
* 获取提供者名称(子类必须实现)
|
||||
*/
|
||||
public abstract String getProviderName();
|
||||
|
||||
protected String doAgent(String userMessage, ChatModelVo chatModelVo) {
|
||||
// 步骤1: 配置MCP传输层 - 连接到bing-cn-mcp服务器
|
||||
// 该服务提供两个工具: bing_search (必应搜索) 和 crawl_webpage (网页抓取)
|
||||
// McpTransport transport = new StdioMcpTransport.Builder()
|
||||
// .command(List.of("C:\\Program Files\\nodejs\\npx.cmd", "-y",
|
||||
// "bing-cn-mcp"
|
||||
// ))
|
||||
// .logEvents(true)
|
||||
// .build();
|
||||
|
||||
// // 步骤2: 创建MCP客户端
|
||||
// McpClient mcpClient = new DefaultMcpClient.Builder()
|
||||
// .transport(transport)
|
||||
// .build();
|
||||
|
||||
// // 步骤3: 配置工具提供者
|
||||
// ToolProvider toolProvider = McpToolProvider.builder()
|
||||
// .mcpClients(List.of(mcpClient))
|
||||
// .build();
|
||||
|
||||
|
||||
McpTransport transport1 = new StdioMcpTransport.Builder()
|
||||
.command(List.of("npx", "-y",
|
||||
"mcp-echarts"
|
||||
))
|
||||
.logEvents(true)
|
||||
.build();
|
||||
|
||||
// 步骤2: 创建MCP客户端
|
||||
McpClient mcpClient1 = new DefaultMcpClient.Builder()
|
||||
.transport(transport1)
|
||||
.build();
|
||||
|
||||
// 步骤3: 配置工具提供者
|
||||
ToolProvider toolProvider1 = McpToolProvider.builder()
|
||||
.mcpClients(List.of(mcpClient1))
|
||||
.build();
|
||||
|
||||
// 步骤4: 配置OpenAI模型
|
||||
// OpenAiChatModel PLANNER_MODEL = OpenAiChatModel.builder()
|
||||
// .baseUrl(chatModelVo.getApiHost())
|
||||
// .apiKey(chatModelVo.getApiKey())
|
||||
// .modelName(chatModelVo.getModelName())
|
||||
// .build();
|
||||
|
||||
|
||||
QwenChatModel qwenChatModel = QwenChatModel.builder()
|
||||
// .baseUrl(chatModelVo.getApiHost())
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.build();
|
||||
|
||||
SqlAgent sqlAgent = AgenticServices.agentBuilder(SqlAgent.class)
|
||||
.chatModel(
|
||||
qwenChatModel)
|
||||
.tools(
|
||||
SpringUtils.getBean(QueryAllTablesTool.class), // 必须通过 getBean 获取
|
||||
SpringUtils.getBean(QueryTableSchemaTool.class),
|
||||
SpringUtils.getBean(ExecuteSqlQueryTool.class)
|
||||
)
|
||||
.build();
|
||||
|
||||
// WebSearchAgent searchAgent = AgenticServices.agentBuilder(WebSearchAgent.class)
|
||||
// .chatModel(PLANNER_MODEL)
|
||||
// .toolProvider(toolProvider)
|
||||
// .build();
|
||||
|
||||
ChartGenerationAgent chartGenerationAgent = AgenticServices.agentBuilder(ChartGenerationAgent.class)
|
||||
.chatModel(
|
||||
qwenChatModel)
|
||||
.toolProvider(toolProvider1)
|
||||
.build();
|
||||
String res = sqlAgent.getData(userMessage);
|
||||
String res1 = chartGenerationAgent.generateChart(res);
|
||||
System.out.println(res1);
|
||||
System.out.println(res);
|
||||
SupervisorAgent supervisor = AgenticServices
|
||||
.supervisorBuilder()
|
||||
.chatModel(qwenChatModel)
|
||||
.subAgents(sqlAgent, chartGenerationAgent)
|
||||
.responseStrategy(SupervisorResponseStrategy.LAST)
|
||||
.build();
|
||||
|
||||
String invoke = supervisor.invoke(userMessage);
|
||||
System.out.println(invoke);
|
||||
return res1;
|
||||
log.info("执行Agent任务,消息: {}", userMessage);
|
||||
// 加载所有可用的 Agent,让 Supervisor 根据任务类型自动选择
|
||||
return doAgentWithAllAgents(userMessage, chatModelVo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用单一 Agent 处理所有任务
|
||||
* 不使用 Supervisor 模式,而是使用 MCP Agent 来处理所有任务
|
||||
*
|
||||
* @param userMessage 用户消息
|
||||
* @param chatModelVo 聊天模型配置
|
||||
* @return Agent 响应结果
|
||||
*/
|
||||
protected String doAgentWithAllAgents(String userMessage, ChatModelVo chatModelVo) {
|
||||
|
||||
try {
|
||||
// 1. 加载 LLM 模型
|
||||
QwenChatModel qwenChatModel = QwenChatModel.builder()
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.build();
|
||||
|
||||
// 2. 获取统一工具提供工厂
|
||||
ToolProviderFactory toolProviderFactory = SpringUtils.getBean(ToolProviderFactory.class);
|
||||
|
||||
// 3. 获取所有可用的工具
|
||||
|
||||
// 3.1 添加 BUILTIN 工具对象(包括 SQL 工具)
|
||||
List<Object> builtinTools = toolProviderFactory.getAllBuiltinToolObjects();
|
||||
|
||||
List<Object> allTools = new ArrayList<>(builtinTools);
|
||||
|
||||
log.debug("Loaded {} builtin tools (including SQL tools)", builtinTools.size());
|
||||
|
||||
log.debug("Total tools: {}", allTools.size());
|
||||
|
||||
// 4. 获取 MCP 工具提供者
|
||||
ToolProvider mcpToolProvider = toolProviderFactory.getAllEnabledMcpToolsProvider();
|
||||
|
||||
// 5. 创建 MCP Agent(包含所有工具)
|
||||
var agentBuilder = AgenticServices.agentBuilder(McpAgent.class).chatModel(qwenChatModel);
|
||||
|
||||
// 添加所有工具
|
||||
if (!allTools.isEmpty()) {
|
||||
agentBuilder.tools(allTools.toArray(new Object[0]));
|
||||
}
|
||||
|
||||
// 添加 MCP 工具
|
||||
if (mcpToolProvider != null) {
|
||||
agentBuilder.toolProvider(mcpToolProvider);
|
||||
}
|
||||
|
||||
McpAgent mcpAgent = agentBuilder.build();
|
||||
|
||||
// 6. 调用大模型LLM
|
||||
String result = mcpAgent.callMcpTool(userMessage);
|
||||
log.info("Agent 执行完成,结果长度: {}", result.length());
|
||||
return result;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Agent 模式执行失败: {}", e.getMessage(), e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建流式聊天模型
|
||||
* 子类必须实现此方法,返回对应厂商的模型实例
|
||||
*/
|
||||
protected abstract StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
package org.ruoyi.service.chat.impl.provider;
|
||||
|
||||
import dev.langchain4j.agentic.AgenticServices;
|
||||
import dev.langchain4j.agentic.supervisor.SupervisorAgent;
|
||||
import dev.langchain4j.agentic.supervisor.SupervisorResponseStrategy;
|
||||
import dev.langchain4j.community.model.dashscope.QwenChatModel;
|
||||
import dev.langchain4j.community.model.dashscope.QwenStreamingChatModel;
|
||||
import dev.langchain4j.data.message.ChatMessage;
|
||||
import dev.langchain4j.data.message.SystemMessage;
|
||||
import dev.langchain4j.data.message.UserMessage;
|
||||
import dev.langchain4j.model.chat.StreamingChatModel;
|
||||
import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
|
||||
import dev.langchain4j.service.tool.ToolProvider;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.ruoyi.agent.McpAgent;
|
||||
import org.ruoyi.common.chat.domain.dto.request.ChatRequest;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.common.core.utils.SpringUtils;
|
||||
import org.ruoyi.enums.ChatModeType;
|
||||
import org.ruoyi.mcp.service.core.ToolProviderFactory;
|
||||
import org.ruoyi.service.chat.impl.AbstractStreamingChatService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* qianWenAI服务调用
|
||||
@@ -39,9 +30,6 @@ public class QianWenChatServiceImpl extends AbstractStreamingChatService {
|
||||
// 添加文档解析的前缀字段
|
||||
private static final String UPLOAD_FILE_API_PREFIX = "fileid";
|
||||
|
||||
// 用于线程安全的锁
|
||||
private final ReentrantLock cacheLock = new ReentrantLock();
|
||||
|
||||
@Override
|
||||
protected StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo,ChatRequest chatRequest) {
|
||||
return QwenStreamingChatModel.builder()
|
||||
@@ -91,69 +79,6 @@ public class QianWenChatServiceImpl extends AbstractStreamingChatService {
|
||||
}).orElse(messagesWithMemory);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用MCP服务(智能体)
|
||||
* 使用统一的ToolProviderFactory获取所有已配置的工具(BUILTIN + MCP)
|
||||
*
|
||||
* @param userMessage 用户信息
|
||||
* @param chatModelVo 模型信息
|
||||
* @return 返回LLM信息
|
||||
*/
|
||||
protected String doAgent(String userMessage, ChatModelVo chatModelVo) {
|
||||
try {
|
||||
// 步骤1: 获取统一工具提供工厂
|
||||
ToolProviderFactory toolProviderFactory = SpringUtils.getBean(ToolProviderFactory.class);
|
||||
|
||||
// 步骤2: 获取 BUILTIN 工具对象
|
||||
List<Object> builtinTools = toolProviderFactory.getAllBuiltinToolObjects();
|
||||
|
||||
// 步骤3: 获取 MCP 工具提供者
|
||||
ToolProvider mcpToolProvider = toolProviderFactory.getAllEnabledMcpToolsProvider();
|
||||
|
||||
log.info("doAgent: BUILTIN tools count = {}, MCP tools enabled = {}",
|
||||
builtinTools.size(), mcpToolProvider != null);
|
||||
|
||||
// 步骤4: 加载LLM模型
|
||||
QwenChatModel qwenChatModel = QwenChatModel.builder()
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.build();
|
||||
|
||||
// 步骤5: 创建MCP Agent,使用所有已配置的工具
|
||||
// 使用 .tools() 传入 BUILTIN 工具对象(Java 对象,带 @Tool 注解的方法)
|
||||
// 使用 .toolProvider() 传入 MCP 工具提供者(MCP 协议工具)
|
||||
var agentBuilder = AgenticServices.agentBuilder(McpAgent.class)
|
||||
.chatModel(qwenChatModel);
|
||||
|
||||
// 添加 BUILTIN 工具(如果有)
|
||||
if (!builtinTools.isEmpty()) {
|
||||
agentBuilder.tools(builtinTools.toArray(new Object[0]));
|
||||
log.debug("Added {} BUILTIN tools to agent", builtinTools.size());
|
||||
}
|
||||
|
||||
// 添加 MCP 工具(如果有)
|
||||
if (mcpToolProvider != null) {
|
||||
agentBuilder.toolProvider(mcpToolProvider);
|
||||
log.debug("Added MCP tool provider to agent");
|
||||
}
|
||||
|
||||
McpAgent mcpAgent = agentBuilder.build();
|
||||
|
||||
// 步骤6: 创建超级智能体协调MCP Agent
|
||||
SupervisorAgent supervisor = AgenticServices
|
||||
.supervisorBuilder()
|
||||
.chatModel(qwenChatModel)
|
||||
.subAgents(mcpAgent)
|
||||
.responseStrategy(SupervisorResponseStrategy.LAST)
|
||||
.build();
|
||||
|
||||
// 步骤7: 调用大模型LLM
|
||||
return supervisor.invoke(userMessage);
|
||||
} finally {
|
||||
cacheLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProviderName() {
|
||||
return ChatModeType.QIAN_WEN.getCode();
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package org.ruoyi.service.mcp;
|
||||
|
||||
import org.ruoyi.common.mybatis.core.page.PageQuery;
|
||||
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
|
||||
import org.ruoyi.domain.bo.mcp.McpMarketBo;
|
||||
import org.ruoyi.domain.dto.mcp.McpMarketListResult;
|
||||
import org.ruoyi.domain.dto.mcp.McpMarketRefreshResult;
|
||||
import org.ruoyi.domain.dto.mcp.McpMarketToolListResult;
|
||||
import org.ruoyi.domain.vo.mcp.McpMarketVo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MCP 市场服务接口
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
public interface IMcpMarketService {
|
||||
|
||||
/**
|
||||
* 分页查询市场列表
|
||||
*
|
||||
* @param bo 查询条件
|
||||
* @param pageQuery 分页参数
|
||||
* @return 市场分页列表
|
||||
*/
|
||||
TableDataInfo<McpMarketVo> selectPageList(McpMarketBo bo, PageQuery pageQuery);
|
||||
|
||||
/**
|
||||
* 查询市场列表(不分页)
|
||||
*
|
||||
* @param keyword 关键词
|
||||
* @param status 状态
|
||||
* @return 市场列表结果
|
||||
*/
|
||||
McpMarketListResult listMarkets(String keyword, String status);
|
||||
|
||||
/**
|
||||
* 查询市场列表(用于导出)
|
||||
*
|
||||
* @param bo 查询条件
|
||||
* @return 市场列表
|
||||
*/
|
||||
List<McpMarketVo> queryList(McpMarketBo bo);
|
||||
|
||||
/**
|
||||
* 根据ID查询市场
|
||||
*
|
||||
* @param id 市场ID
|
||||
* @return 市场信息
|
||||
*/
|
||||
McpMarketVo selectById(Long id);
|
||||
|
||||
/**
|
||||
* 新增市场
|
||||
*
|
||||
* @param bo 市场信息
|
||||
* @return 新增后的市场ID
|
||||
*/
|
||||
String insert(McpMarketBo bo);
|
||||
|
||||
/**
|
||||
* 更新市场
|
||||
*
|
||||
* @param bo 市场信息
|
||||
* @return 结果
|
||||
*/
|
||||
String update(McpMarketBo bo);
|
||||
|
||||
/**
|
||||
* 删除市场
|
||||
*
|
||||
* @param ids 市场 ID 列表
|
||||
*/
|
||||
void deleteByIds(List<Long> ids);
|
||||
|
||||
/**
|
||||
* 更新市场状态
|
||||
*
|
||||
* @param id 市场 ID
|
||||
* @param status 状态
|
||||
*/
|
||||
void updateStatus(Long id, String status);
|
||||
|
||||
/**
|
||||
* 获取市场工具列表
|
||||
*
|
||||
* @param marketId 市场 ID
|
||||
* @param page 页码
|
||||
* @param size 每页大小
|
||||
* @return 工具列表结果
|
||||
*/
|
||||
McpMarketToolListResult getMarketTools(Long marketId, int page, int size);
|
||||
|
||||
/**
|
||||
* 刷新市场工具列表
|
||||
*
|
||||
* @param marketId 市场 ID
|
||||
* @return 刷新结果
|
||||
*/
|
||||
McpMarketRefreshResult refreshMarketTools(Long marketId);
|
||||
|
||||
/**
|
||||
* 加载工具到本地
|
||||
*
|
||||
* @param toolId 市场工具 ID
|
||||
*/
|
||||
void loadToolToLocal(Long toolId);
|
||||
|
||||
/**
|
||||
* 批量加载工具到本地
|
||||
*
|
||||
* @param toolIds 工具 ID 列表
|
||||
* @return 成功加载的数量
|
||||
*/
|
||||
int batchLoadTools(List<Long> toolIds);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package org.ruoyi.service.mcp;
|
||||
|
||||
import org.ruoyi.common.mybatis.core.page.PageQuery;
|
||||
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
|
||||
import org.ruoyi.domain.bo.mcp.McpToolBo;
|
||||
import org.ruoyi.domain.dto.mcp.McpToolListResult;
|
||||
import org.ruoyi.domain.dto.mcp.McpToolTestResult;
|
||||
import org.ruoyi.domain.vo.mcp.McpToolVo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MCP 工具服务接口
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
public interface IMcpToolService {
|
||||
|
||||
/**
|
||||
* 分页查询工具列表
|
||||
*
|
||||
* @param bo 查询条件
|
||||
* @param pageQuery 分页参数
|
||||
* @return 工具分页列表
|
||||
*/
|
||||
TableDataInfo<McpToolVo> selectPageList(McpToolBo bo, PageQuery pageQuery);
|
||||
|
||||
/**
|
||||
* 查询工具列表(不分页)
|
||||
*
|
||||
* @param keyword 关键词
|
||||
* @param type 类型
|
||||
* @param status 状态
|
||||
* @return 工具列表结果
|
||||
*/
|
||||
McpToolListResult listTools(String keyword, String type, String status);
|
||||
|
||||
/**
|
||||
* 查询工具列表(用于导出)
|
||||
*
|
||||
* @param bo 查询条件
|
||||
* @return 工具列表
|
||||
*/
|
||||
List<McpToolVo> queryList(McpToolBo bo);
|
||||
|
||||
/**
|
||||
* 根据ID查询工具
|
||||
*
|
||||
* @param id 工具ID
|
||||
* @return 工具信息
|
||||
*/
|
||||
McpToolVo selectById(Long id);
|
||||
|
||||
/**
|
||||
* 新增工具
|
||||
*
|
||||
* @param bo 工具信息
|
||||
* @return 新增后的工具ID
|
||||
*/
|
||||
String insert(McpToolBo bo);
|
||||
|
||||
/**
|
||||
* 更新工具
|
||||
*
|
||||
* @param bo 工具信息
|
||||
* @return 结果
|
||||
*/
|
||||
String update(McpToolBo bo);
|
||||
|
||||
/**
|
||||
* 删除工具
|
||||
*
|
||||
* @param ids 工具 ID 列表
|
||||
*/
|
||||
void deleteByIds(List<Long> ids);
|
||||
|
||||
/**
|
||||
* 更新工具状态
|
||||
*
|
||||
* @param id 工具 ID
|
||||
* @param status 状态
|
||||
*/
|
||||
void updateStatus(Long id, String status);
|
||||
|
||||
/**
|
||||
* 测试工具连接
|
||||
*
|
||||
* @param id 工具 ID
|
||||
* @return 测试结果
|
||||
*/
|
||||
McpToolTestResult testTool(Long id);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package org.ruoyi.service.mcp.impl;
|
||||
|
||||
import cn.hutool.http.HttpRequest;
|
||||
import cn.hutool.http.HttpResponse;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.ruoyi.common.core.exception.ServiceException;
|
||||
import org.ruoyi.common.core.utils.MapstructUtils;
|
||||
import org.ruoyi.common.mybatis.core.page.PageQuery;
|
||||
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
|
||||
import org.ruoyi.domain.bo.mcp.McpMarketBo;
|
||||
import org.ruoyi.domain.dto.mcp.McpMarketListResult;
|
||||
import org.ruoyi.domain.dto.mcp.McpMarketRefreshResult;
|
||||
import org.ruoyi.domain.dto.mcp.McpMarketToolListResult;
|
||||
import org.ruoyi.domain.entity.mcp.McpMarket;
|
||||
import org.ruoyi.domain.entity.mcp.McpMarketTool;
|
||||
import org.ruoyi.domain.entity.mcp.McpTool;
|
||||
import org.ruoyi.domain.vo.mcp.McpMarketVo;
|
||||
import org.ruoyi.enums.McpToolStatus;
|
||||
import org.ruoyi.mapper.mcp.McpMarketMapper;
|
||||
import org.ruoyi.mapper.mcp.McpMarketToolMapper;
|
||||
import org.ruoyi.mapper.mcp.McpToolMapper;
|
||||
import org.ruoyi.service.mcp.IMcpMarketService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* MCP 市场服务实现
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class McpMarketServiceImpl implements IMcpMarketService {
|
||||
|
||||
private final McpMarketMapper baseMapper;
|
||||
private final McpMarketToolMapper mcpMarketToolMapper;
|
||||
private final McpToolMapper mcpToolMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public TableDataInfo<McpMarketVo> selectPageList(McpMarketBo bo, PageQuery pageQuery) {
|
||||
LambdaQueryWrapper<McpMarket> wrapper = buildQueryWrapper(bo);
|
||||
Page<McpMarketVo> page = baseMapper.selectVoPage(pageQuery.build(), wrapper);
|
||||
return TableDataInfo.build(page);
|
||||
}
|
||||
|
||||
@Override
|
||||
public McpMarketListResult listMarkets(String keyword, String status) {
|
||||
LambdaQueryWrapper<McpMarket> wrapper = new LambdaQueryWrapper<>();
|
||||
|
||||
if (StringUtils.hasText(keyword)) {
|
||||
wrapper.and(w -> w.like(McpMarket::getName, keyword)
|
||||
.or()
|
||||
.like(McpMarket::getDescription, keyword));
|
||||
}
|
||||
if (StringUtils.hasText(status)) {
|
||||
wrapper.eq(McpMarket::getStatus, status);
|
||||
}
|
||||
|
||||
wrapper.orderByDesc(McpMarket::getUpdateTime);
|
||||
|
||||
List<McpMarket> list = baseMapper.selectList(wrapper);
|
||||
|
||||
return McpMarketListResult.of(list);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<McpMarketVo> queryList(McpMarketBo bo) {
|
||||
LambdaQueryWrapper<McpMarket> wrapper = buildQueryWrapper(bo);
|
||||
return baseMapper.selectVoList(wrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public McpMarketVo selectById(Long id) {
|
||||
return baseMapper.selectVoById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public String insert(McpMarketBo bo) {
|
||||
McpMarket market = MapstructUtils.convert(bo, McpMarket.class);
|
||||
if (market.getStatus() == null) {
|
||||
market.setStatus(McpToolStatus.ENABLED.getValue());
|
||||
}
|
||||
baseMapper.insert(market);
|
||||
return String.valueOf(market.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public String update(McpMarketBo bo) {
|
||||
McpMarket market = MapstructUtils.convert(bo, McpMarket.class);
|
||||
baseMapper.updateById(market);
|
||||
return String.valueOf(market.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteByIds(List<Long> ids) {
|
||||
for (Long id : ids) {
|
||||
// 先删除关联的市场工具
|
||||
LambdaQueryWrapper<McpMarketTool> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(McpMarketTool::getMarketId, id);
|
||||
mcpMarketToolMapper.delete(wrapper);
|
||||
}
|
||||
|
||||
// 删除市场
|
||||
baseMapper.deleteBatchIds(ids);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void updateStatus(Long id, String status) {
|
||||
McpMarket market = new McpMarket();
|
||||
market.setId(id);
|
||||
market.setStatus(status);
|
||||
baseMapper.updateById(market);
|
||||
}
|
||||
|
||||
@Override
|
||||
public McpMarketToolListResult getMarketTools(Long marketId, int page, int size) {
|
||||
LambdaQueryWrapper<McpMarketTool> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(McpMarketTool::getMarketId, marketId);
|
||||
wrapper.orderByDesc(McpMarketTool::getCreateTime);
|
||||
|
||||
Page<McpMarketTool> pageResult = mcpMarketToolMapper.selectPage(new Page<>(page, size), wrapper);
|
||||
|
||||
return McpMarketToolListResult.of(
|
||||
pageResult.getRecords(),
|
||||
pageResult.getTotal(),
|
||||
(int) pageResult.getCurrent(),
|
||||
(int) pageResult.getSize()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public McpMarketRefreshResult refreshMarketTools(Long marketId) {
|
||||
McpMarket market = baseMapper.selectById(marketId);
|
||||
if (market == null) {
|
||||
throw new ServiceException("市场不存在");
|
||||
}
|
||||
|
||||
int addedCount = 0;
|
||||
int updatedCount = 0;
|
||||
|
||||
try {
|
||||
// 从市场 URL 获取工具列表(使用hutool的HttpUtil)
|
||||
HttpResponse response = HttpRequest.get(market.getUrl())
|
||||
.timeout(30000) // 30秒超时
|
||||
.execute();
|
||||
String responseBody = response.body();
|
||||
JsonNode rootNode = objectMapper.readTree(responseBody);
|
||||
|
||||
// 假设响应格式为 { "data": [...] } 或直接是数组
|
||||
JsonNode toolsNode = rootNode.has("data") ? rootNode.get("data") : rootNode;
|
||||
|
||||
if (toolsNode.isArray()) {
|
||||
// 获取现有工具
|
||||
LambdaQueryWrapper<McpMarketTool> existingWrapper = new LambdaQueryWrapper<>();
|
||||
existingWrapper.eq(McpMarketTool::getMarketId, marketId);
|
||||
List<McpMarketTool> existingTools = mcpMarketToolMapper.selectList(existingWrapper);
|
||||
|
||||
// 创建现有工具的名称到ID映射
|
||||
Map<String, McpMarketTool> existingToolMap = existingTools.stream()
|
||||
.collect(Collectors.toMap(McpMarketTool::getToolName, t -> t));
|
||||
|
||||
// 处理新工具
|
||||
for (JsonNode toolNode : toolsNode) {
|
||||
String toolName = getTextValue(toolNode, "name", "title");
|
||||
McpMarketTool existingTool = existingToolMap.get(toolName);
|
||||
|
||||
if (existingTool != null) {
|
||||
// 更新现有工具
|
||||
existingTool.setToolDescription(getTextValue(toolNode, "description", "desc"));
|
||||
existingTool.setToolVersion(getTextValue(toolNode, "version"));
|
||||
existingTool.setToolMetadata(toolNode.toString());
|
||||
mcpMarketToolMapper.updateById(existingTool);
|
||||
updatedCount++;
|
||||
} else {
|
||||
// 插入新工具
|
||||
McpMarketTool tool = new McpMarketTool();
|
||||
tool.setMarketId(marketId);
|
||||
tool.setToolName(toolName);
|
||||
tool.setToolDescription(getTextValue(toolNode, "description", "desc"));
|
||||
tool.setToolVersion(getTextValue(toolNode, "version"));
|
||||
tool.setToolMetadata(toolNode.toString());
|
||||
tool.setIsLoaded(false);
|
||||
mcpMarketToolMapper.insert(tool);
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Successfully refreshed market tools for market: {}, added: {}, updated: {}",
|
||||
market.getName(), addedCount, updatedCount);
|
||||
|
||||
return McpMarketRefreshResult.builder()
|
||||
.success(true)
|
||||
.message("刷新成功")
|
||||
.addedCount(addedCount)
|
||||
.updatedCount(updatedCount)
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to refresh market tools for market {}: {}", marketId, e.getMessage());
|
||||
return McpMarketRefreshResult.builder()
|
||||
.success(false)
|
||||
.message("刷新市场工具列表失败: " + e.getMessage())
|
||||
.addedCount(0)
|
||||
.updatedCount(0)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JSON 节点获取文本值,尝试多个字段名
|
||||
*/
|
||||
private String getTextValue(JsonNode node, String... fieldNames) {
|
||||
for (String fieldName : fieldNames) {
|
||||
if (node.has(fieldName) && !node.get(fieldName).isNull()) {
|
||||
return node.get(fieldName).asText();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void loadToolToLocal(Long toolId) {
|
||||
McpMarketTool marketTool = mcpMarketToolMapper.selectById(toolId);
|
||||
if (marketTool == null) {
|
||||
throw new ServiceException("市场工具不存在");
|
||||
}
|
||||
|
||||
if (marketTool.getIsLoaded()) {
|
||||
throw new ServiceException("工具已加载到本地");
|
||||
}
|
||||
|
||||
try {
|
||||
// 解析工具元数据
|
||||
JsonNode metadata = objectMapper.readTree(marketTool.getToolMetadata());
|
||||
|
||||
// 创建本地工具
|
||||
McpTool localTool = new McpTool();
|
||||
localTool.setName(marketTool.getToolName());
|
||||
localTool.setDescription(marketTool.getToolDescription());
|
||||
|
||||
// 根据元数据判断类型
|
||||
if (metadata.has("baseUrl") || metadata.has("url")) {
|
||||
localTool.setType("REMOTE");
|
||||
String baseUrl = metadata.has("baseUrl") ? metadata.get("baseUrl").asText() :
|
||||
metadata.has("url") ? metadata.get("url").asText() : null;
|
||||
localTool.setConfigJson(objectMapper.writeValueAsString(Map.of("baseUrl", baseUrl != null ? baseUrl : "")));
|
||||
} else {
|
||||
localTool.setType("LOCAL");
|
||||
// 构建本地工具配置
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
if (metadata.has("command")) {
|
||||
config.put("command", metadata.get("command").asText());
|
||||
}
|
||||
if (metadata.has("args") && metadata.get("args").isArray()) {
|
||||
config.put("args", objectMapper.convertValue(metadata.get("args"), List.class));
|
||||
}
|
||||
if (metadata.has("env") && metadata.get("env").isObject()) {
|
||||
config.put("env", objectMapper.convertValue(metadata.get("env"), Map.class));
|
||||
}
|
||||
// 如果有 npm 包名,使用 npx 启动
|
||||
if (metadata.has("package") || metadata.has("npmPackage")) {
|
||||
String packageName = metadata.has("package") ? metadata.get("package").asText() :
|
||||
metadata.get("npmPackage").asText();
|
||||
config.put("command", "npx");
|
||||
config.put("args", List.of("-y", packageName));
|
||||
}
|
||||
localTool.setConfigJson(objectMapper.writeValueAsString(config));
|
||||
}
|
||||
|
||||
localTool.setStatus(McpToolStatus.ENABLED.getValue());
|
||||
mcpToolMapper.insert(localTool);
|
||||
|
||||
// 更新市场工具状态
|
||||
marketTool.setIsLoaded(true);
|
||||
marketTool.setLocalToolId(localTool.getId());
|
||||
mcpMarketToolMapper.updateById(marketTool);
|
||||
|
||||
log.info("Successfully loaded tool {} to local", marketTool.getToolName());
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load tool to local: {}", e.getMessage());
|
||||
throw new ServiceException("加载工具到本地失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public int batchLoadTools(List<Long> toolIds) {
|
||||
int successCount = 0;
|
||||
for (Long toolId : toolIds) {
|
||||
try {
|
||||
loadToolToLocal(toolId);
|
||||
successCount++;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to load tool {}: {}", toolId, e.getMessage());
|
||||
}
|
||||
}
|
||||
return successCount;
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<McpMarket> buildQueryWrapper(McpMarketBo bo) {
|
||||
Map<String, Object> params = bo.getParams();
|
||||
LambdaQueryWrapper<McpMarket> wrapper = Wrappers.lambdaQuery();
|
||||
wrapper.eq(StringUtils.hasText(bo.getStatus()), McpMarket::getStatus, bo.getStatus())
|
||||
.like(StringUtils.hasText(bo.getName()), McpMarket::getName, bo.getName())
|
||||
.like(StringUtils.hasText(bo.getDescription()), McpMarket::getDescription, bo.getDescription());
|
||||
return wrapper;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package org.ruoyi.service.mcp.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.ruoyi.common.core.exception.ServiceException;
|
||||
import org.ruoyi.common.core.utils.MapstructUtils;
|
||||
import org.ruoyi.common.mybatis.core.page.PageQuery;
|
||||
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
|
||||
import org.ruoyi.domain.bo.mcp.McpToolBo;
|
||||
import org.ruoyi.domain.dto.mcp.McpToolListResult;
|
||||
import org.ruoyi.domain.dto.mcp.McpToolTestResult;
|
||||
import org.ruoyi.domain.entity.mcp.McpTool;
|
||||
import org.ruoyi.domain.vo.mcp.McpToolVo;
|
||||
import org.ruoyi.enums.McpToolStatus;
|
||||
import org.ruoyi.mapper.mcp.McpToolMapper;
|
||||
import org.ruoyi.service.mcp.IMcpToolService;
|
||||
import org.ruoyi.mcp.service.core.BuiltinToolRegistry;
|
||||
import org.ruoyi.mcp.service.core.LangChain4jMcpToolProviderService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* MCP 工具服务实现
|
||||
*
|
||||
* @author ruoyi team
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class McpToolServiceImpl implements IMcpToolService {
|
||||
|
||||
private final McpToolMapper baseMapper;
|
||||
private final LangChain4jMcpToolProviderService langChain4jMcpToolProviderService;
|
||||
private final BuiltinToolRegistry builtinToolRegistry;
|
||||
|
||||
@Override
|
||||
public TableDataInfo<McpToolVo> selectPageList(McpToolBo bo, PageQuery pageQuery) {
|
||||
LambdaQueryWrapper<McpTool> wrapper = buildQueryWrapper(bo);
|
||||
Page<McpToolVo> page = baseMapper.selectVoPage(pageQuery.build(), wrapper);
|
||||
return TableDataInfo.build(page);
|
||||
}
|
||||
|
||||
@Override
|
||||
public McpToolListResult listTools(String keyword, String type, String status) {
|
||||
LambdaQueryWrapper<McpTool> wrapper = new LambdaQueryWrapper<>();
|
||||
|
||||
if (StringUtils.hasText(keyword)) {
|
||||
wrapper.and(w -> w.like(McpTool::getName, keyword)
|
||||
.or()
|
||||
.like(McpTool::getDescription, keyword));
|
||||
}
|
||||
if (StringUtils.hasText(type)) {
|
||||
wrapper.eq(McpTool::getType, type);
|
||||
}
|
||||
if (StringUtils.hasText(status)) {
|
||||
wrapper.eq(McpTool::getStatus, status);
|
||||
}
|
||||
|
||||
wrapper.orderByDesc(McpTool::getUpdateTime);
|
||||
|
||||
List<McpTool> list = baseMapper.selectList(wrapper);
|
||||
|
||||
return McpToolListResult.of(list);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<McpToolVo> queryList(McpToolBo bo) {
|
||||
LambdaQueryWrapper<McpTool> wrapper = buildQueryWrapper(bo);
|
||||
return baseMapper.selectVoList(wrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public McpToolVo selectById(Long id) {
|
||||
return baseMapper.selectVoById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public String insert(McpToolBo bo) {
|
||||
McpTool tool = MapstructUtils.convert(bo, McpTool.class);
|
||||
if (tool.getStatus() == null) {
|
||||
tool.setStatus(McpToolStatus.ENABLED.getValue());
|
||||
}
|
||||
if (tool.getType() == null) {
|
||||
tool.setType("LOCAL");
|
||||
}
|
||||
baseMapper.insert(tool);
|
||||
return String.valueOf(tool.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public String update(McpToolBo bo) {
|
||||
McpTool existingTool = baseMapper.selectById(bo.getId());
|
||||
if (existingTool != null && BuiltinToolRegistry.TYPE_BUILTIN.equals(existingTool.getType())) {
|
||||
throw new ServiceException("内置工具不允许编辑");
|
||||
}
|
||||
|
||||
McpTool tool = MapstructUtils.convert(bo, McpTool.class);
|
||||
baseMapper.updateById(tool);
|
||||
|
||||
// 如果工具正在使用中,需要刷新连接
|
||||
langChain4jMcpToolProviderService.refreshClient(bo.getId());
|
||||
|
||||
return String.valueOf(tool.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteByIds(List<Long> ids) {
|
||||
// 过滤掉内置工具
|
||||
List<Long> deletableIds = ids.stream()
|
||||
.filter(id -> {
|
||||
McpTool tool = baseMapper.selectById(id);
|
||||
return tool == null || !BuiltinToolRegistry.TYPE_BUILTIN.equals(tool.getType());
|
||||
})
|
||||
.toList();
|
||||
|
||||
if (deletableIds.isEmpty()) {
|
||||
throw new ServiceException("所选工具均为内置工具,不允许删除");
|
||||
}
|
||||
|
||||
// 刷新连接(LangChain4j会自动处理)
|
||||
deletableIds.forEach(id -> langChain4jMcpToolProviderService.refreshClient(id));
|
||||
baseMapper.deleteBatchIds(deletableIds);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void updateStatus(Long id, String status) {
|
||||
McpTool tool = new McpTool();
|
||||
tool.setId(id);
|
||||
tool.setStatus(status);
|
||||
baseMapper.updateById(tool);
|
||||
|
||||
// 刷新连接
|
||||
langChain4jMcpToolProviderService.refreshClient(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public McpToolTestResult testTool(Long id) {
|
||||
McpTool tool = baseMapper.selectById(id);
|
||||
if (tool == null) {
|
||||
return McpToolTestResult.fail("工具不存在");
|
||||
}
|
||||
|
||||
// 根据工具类型选择不同的测试逻辑
|
||||
if (BuiltinToolRegistry.TYPE_BUILTIN.equals(tool.getType())) {
|
||||
// 内置工具 - 直接验证是否在注册表中
|
||||
return testBuiltinTool(tool);
|
||||
} else {
|
||||
// MCP 工具 (LOCAL/REMOTE) - 测试连接
|
||||
return testMcpTool(tool);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试内置工具
|
||||
* 内置工具不需要网络连接,只需验证是否在注册表中
|
||||
*
|
||||
* @param tool 工具信息
|
||||
* @return 测试结果
|
||||
*/
|
||||
private McpToolTestResult testBuiltinTool(McpTool tool) {
|
||||
try {
|
||||
boolean isRegistered = builtinToolRegistry.hasTool(tool.getName());
|
||||
if (isRegistered) {
|
||||
return McpToolTestResult.success(
|
||||
String.format("内置工具 [%s] 已注册,可正常使用", tool.getName()),
|
||||
1,
|
||||
List.of(tool.getName())
|
||||
);
|
||||
} else {
|
||||
return McpToolTestResult.fail(
|
||||
String.format("内置工具 [%s] 未在注册表中找到,请检查工具名称是否正确", tool.getName())
|
||||
);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("测试内置工具失败: {} - {}", tool.getName(), e.getMessage());
|
||||
return McpToolTestResult.fail("测试失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试MCP工具连接
|
||||
*
|
||||
* @param tool 工具信息
|
||||
* @return 测试结果
|
||||
*/
|
||||
private McpToolTestResult testMcpTool(McpTool tool) {
|
||||
try {
|
||||
boolean isHealthy = langChain4jMcpToolProviderService.checkToolHealth(tool.getId());
|
||||
if (isHealthy) {
|
||||
return McpToolTestResult.success(
|
||||
String.format("MCP工具 [%s] 连接测试成功", tool.getName()),
|
||||
1,
|
||||
List.of(tool.getName())
|
||||
);
|
||||
} else {
|
||||
return McpToolTestResult.fail(
|
||||
String.format("MCP工具 [%s] 连接测试失败", tool.getName())
|
||||
);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("测试MCP工具失败: {} - {}", tool.getName(), e.getMessage());
|
||||
return McpToolTestResult.fail("测试失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<McpTool> buildQueryWrapper(McpToolBo bo) {
|
||||
Map<String, Object> params = bo.getParams();
|
||||
LambdaQueryWrapper<McpTool> wrapper = Wrappers.lambdaQuery();
|
||||
wrapper.eq(StringUtils.hasText(bo.getType()), McpTool::getType, bo.getType())
|
||||
.eq(StringUtils.hasText(bo.getStatus()), McpTool::getStatus, bo.getStatus())
|
||||
.like(StringUtils.hasText(bo.getName()), McpTool::getName, bo.getName())
|
||||
.like(StringUtils.hasText(bo.getDescription()), McpTool::getDescription, bo.getDescription());
|
||||
return wrapper;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user