diff --git a/README.md b/README.md index 85fd9fd2..3d39e8f3 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,181 @@ ## 🤖AI能力 + +
+ + + + + + + + + + + + + 前端 Frontend + + + + 入口层 · Controllers + + AiController + + RouterController + + AgentController + + AiRouteService · LLM路由 + + + + 助手层 · Assistants + + + + + BaseAssistant + Mfish · Sys · Storage · Oauth + Scheduler · NoCode · Workflow + Gateway · Agent (9 子类) + + + + + AgentRuntime + + Planner + + Executor + + EventBus · SSE 事件流 + + + + 能力层 · mf-ai Services + + FileParseService + 文件解析 · 新增 + + LlmModelRouter + 按租户路由模型 + + ApiToolEngine + 工具聚合引擎 + + FaultTolerant + ToolCallingManager + + + + 工具来源 · mf-common-ai · ToolProvider + + FeignToolProvider + 扫描 @FeignClient 接口 + + HttpToolProvider + 基于 OpenAPI 文档 + + + + mf-storage + 文件存储服务 + + 微服务集群 + sys/oauth/scheduler/... + + LLM 提供商 + ChatModel 流式 + + + + + + + + + + + + + + + + + 图例 + + 核心组件 / 主路径 + + 普通组件 + + 外部服务 + + 工具调用主链路 + +
+ ### 智能网关路由 通过网关助手(GatewayAssistant)实现 LLM 智能路由决策:用户用自然语言提问,LLM 自动判断意图并路由到对应领域助手,全程无需手动选择入口。 @@ -50,7 +225,7 @@ | 助手 | 路由路径 | 覆盖能力 | |------|---------|---------| -| 摸鱼小助手 | `/ai/assist/chat` | 通用问答,平台介绍 | +| 摸鱼小助手 | `/ai/agent/chat` | 通用问答,平台介绍 | | 认证中心助手 | `/ai/oauth2/assist` | 菜单、组织、角色、帐号、租户管理 | | 系统中心助手 | `/ai/sys/assist` | 字典、分类目录、日志、代码生成、数据库、数据源 | | 低代码中心助手 | `/ai/nocode/assist` | 自助大屏、自助API、组件管理、公式信息 | diff --git a/db/mf_ai.sql b/db/mf_ai.sql index 4e94c926..65151365 100644 --- a/db/mf_ai.sql +++ b/db/mf_ai.sql @@ -34,4 +34,28 @@ CREATE TABLE `ai_model_config` ( PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'AI模型配置信息' ROW_FORMAT = Dynamic; +-- ---------------------------- +-- Table structure for ai_mcp_server_config +-- ---------------------------- +DROP TABLE IF EXISTS `ai_mcp_server_config`; +CREATE TABLE `ai_mcp_server_config` ( + `id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '主键', + `server_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'MCP服务器名称', + `transport_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '传输类型: stdio/sse/streamable', + `command` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT 'stdio模式启动命令(如node/python)', + `args` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT 'stdio模式参数(JSON数组,如["server.js","--port","3000"])', + `env` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT 'stdio模式环境变量(JSON对象)', + `sse_url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '远程服务基础URL(sse/streamable通用,如https://mcp.example.com)', + `sse_endpoint` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '远程服务端点路径(sse/streamable通用,如/sse或/mcp)', + `auth_token` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '认证Token(Bearer)', + `status` tinyint NOT NULL DEFAULT 0 COMMENT '状态 0正常 1停用', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '备注', + `create_by` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '创建人', + `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间', + `update_by` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '修改人', + `update_time` datetime NULL DEFAULT NULL COMMENT '修改时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `server_name_index`(`server_name`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'MCP服务器配置表' ROW_FORMAT = Dynamic; + SET FOREIGN_KEY_CHECKS = 1; diff --git a/db/mfish_nocode.sql b/db/mfish_nocode.sql index 4797062a..d9d67a75 100644 --- a/db/mfish_nocode.sql +++ b/db/mfish_nocode.sql @@ -1986,4 +1986,28 @@ CREATE TABLE `ai_model_config` ( PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'AI模型配置信息' ROW_FORMAT = Dynamic; +-- ---------------------------- +-- Table structure for ai_mcp_server_config +-- ---------------------------- +DROP TABLE IF EXISTS `ai_mcp_server_config`; +CREATE TABLE `ai_mcp_server_config` ( + `id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '主键', + `server_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'MCP服务器名称', + `transport_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '传输类型: stdio/sse/streamable', + `command` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT 'stdio模式启动命令(如node/python)', + `args` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT 'stdio模式参数(JSON数组,如["server.js","--port","3000"])', + `env` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT 'stdio模式环境变量(JSON对象)', + `sse_url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '远程服务基础URL(sse/streamable通用,如https://mcp.example.com)', + `sse_endpoint` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '远程服务端点路径(sse/streamable通用,如/sse或/mcp)', + `auth_token` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '认证Token(Bearer)', + `status` tinyint NOT NULL DEFAULT 0 COMMENT '状态 0正常 1停用', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '备注', + `create_by` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '创建人', + `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间', + `update_by` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '修改人', + `update_time` datetime NULL DEFAULT NULL COMMENT '修改时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `server_name_index`(`server_name`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'MCP服务器配置表' ROW_FORMAT = Dynamic; + SET FOREIGN_KEY_CHECKS = 1; diff --git a/mf-api/mf-ai-api/src/main/java/cn/com/mfish/ai/api/entity/McpServerConfig.java b/mf-api/mf-ai-api/src/main/java/cn/com/mfish/ai/api/entity/McpServerConfig.java new file mode 100644 index 00000000..df0baa0a --- /dev/null +++ b/mf-api/mf-ai-api/src/main/java/cn/com/mfish/ai/api/entity/McpServerConfig.java @@ -0,0 +1,59 @@ +package cn.com.mfish.ai.api.entity; + +import cn.com.mfish.common.core.entity.BaseEntity; +import cn.idev.excel.annotation.ExcelProperty; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +/** + * @description: MCP服务器配置信息 + * @author: mfish + * @date: 2026-07-21 + * @version: V2.4.1 + */ +@Data +@TableName("ai_mcp_server_config") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "ai_mcp_server_config对象 MCP服务器配置信息") +public class McpServerConfig extends BaseEntity { + @ExcelProperty("唯一ID") + @Schema(description = "唯一ID") + @TableId(type = IdType.ASSIGN_UUID) + @Accessors(chain = true) + private String id; + @ExcelProperty("MCP服务器名称") + @Schema(description = "MCP服务器名称") + private String serverName; + @ExcelProperty("传输类型: stdio/sse/streamable") + @Schema(description = "传输类型: stdio/sse/streamable") + private String transportType; + @ExcelProperty("stdio模式启动命令") + @Schema(description = "stdio模式启动命令(如node/python)") + private String command; + @ExcelProperty("stdio模式参数(JSON数组)") + @Schema(description = "stdio模式参数(JSON数组,如[\"server.js\",\"--port\",\"3000\"])") + private String args; + @ExcelProperty("stdio模式环境变量(JSON对象)") + @Schema(description = "stdio模式环境变量(JSON对象)") + private String env; + @ExcelProperty("远程服务基础URL(sse/streamable通用)") + @Schema(description = "远程服务基础URL(sse/streamable通用,如https://mcp.example.com)") + private String sseUrl; + @ExcelProperty("远程服务端点路径(sse/streamable通用)") + @Schema(description = "远程服务端点路径(sse/streamable通用,如/sse或/mcp)") + private String sseEndpoint; + @ExcelProperty("认证Token") + @Schema(description = "认证Token(Bearer)") + private String authToken; + @ExcelProperty("状态 0正常 1停用") + @Schema(description = "状态 0正常 1停用") + private Short status; + @ExcelProperty("备注") + @Schema(description = "备注") + private String remark; +} diff --git a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/AgentRuntime.java b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/AgentRuntime.java index e2c4dc3a..3fae09da 100644 --- a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/AgentRuntime.java +++ b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/AgentRuntime.java @@ -9,9 +9,11 @@ import cn.com.mfish.common.ai.entity.AiRequest; import cn.com.mfish.common.ai.entity.ChatResponseVo; import cn.com.mfish.common.ai.entity.EventType; import cn.com.mfish.common.ai.entity.PlanStep; +import cn.com.mfish.common.ai.memory.ConversationMemory; +import cn.com.mfish.common.ai.memory.ConversationMemoryStore; +import cn.com.mfish.common.ai.memory.DocumentChunk; import cn.com.mfish.common.core.utils.AuthInfoUtils; import cn.com.mfish.common.core.utils.ServletUtils; -import cn.com.mfish.common.core.utils.StringUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestAttributes; @@ -51,30 +53,34 @@ public class AgentRuntime { private final Executor executor; private final LlmModelRouter llmModelRouter; private final FileParseService fileParseService; + private final ConversationMemoryStore memoryStore; public AgentRuntime(Planner planner, Executor executor, LlmModelRouter llmModelRouter, - FileParseService fileParseService) { + FileParseService fileParseService, ConversationMemoryStore memoryStore) { this.planner = planner; this.executor = executor; this.llmModelRouter = llmModelRouter; this.fileParseService = fileParseService; + this.memoryStore = memoryStore; } /** * 运行智能体编排 *

- * 在请求线程捕获租户上下文快照,后台启动 Plan → Execute 流程,同时返回事件流供前端订阅。 - * 编排流程在 boundedElastic 调度器上执行,不阻塞当前线程。 + * 重构后流程(接入 Memory 模块): + *

    + *
  1. 获取(或创建)sessionId 对应的 {@link ConversationMemory}
  2. + *
  3. 在请求线程捕获 TenantContext 并绑定到 Memory 的 Vars Context
  4. + *
  5. 若请求携带 fileIds:在请求线程解析文件为 DocumentChunk 列表, + * 注入到 Memory 的 Document Context
  6. + *
  7. 从 Memory 读取 Document Context 拼接文本,注入到用户 prompt 前
  8. + *
  9. 后台启动 Plan → Execute 流程
  10. + *
*

*

- * 关键点:必须在请求线程捕获 {@link TenantContext}(含 tenantId/userId/token/RequestAttributes/Exchange), - * 因为 AuthInfoUtils 和 RequestContextHolder 不支持异步线程访问,编排切到 boundedElastic 后 - * 需要用快照构建 ToolContext 和 ChatClient。 - *

- *

- * 文件解析:若请求携带 fileIds,在请求线程同步加载文件内容(Feign 调用 mf-storage), - * 拼接到用户提示词前。必须在请求线程执行,因为 Feign 的 BearerTokenInterceptor 依赖 - * RequestContextHolder 中继令牌,异步线程拿不到。 + * 关键点:必须在请求线程完成 Memory 写入(TenantContext 绑定 + 文件解析), + * 因为 AuthInfoUtils 和 Feign BearerTokenInterceptor 都依赖 RequestContextHolder, + * 切到 boundedElastic 异步线程后拿不到请求上下文。 *

*

* 所有返回的 {@link ChatResponseVo} 的 id 字段填充为 {@link AiRequest#getId()}, @@ -88,14 +94,25 @@ public class AgentRuntime { String requestId = aiRequest.getId(); String sessionId = aiRequest.getSessionId(); String prompt = aiRequest.getMessage() != null ? aiRequest.getMessage().getContent() : null; - // 用请求 id 作为事件 id,与普通聊天返回结构一致 EventBus eventBus = new EventBus(requestId); - // 在请求线程捕获租户上下文快照,供异步编排使用 - TenantContext tenantContext = captureTenantContext(); + // 获取(或创建)会话 Memory + ConversationMemory memory = memoryStore.getOrCreate(sessionId); - // 文件解析必须在请求线程执行:Feign BearerTokenInterceptor 依赖 RequestContextHolder - prompt = resolveFileContents(prompt, aiRequest.getFileIds()); + // 在请求线程捕获租户上下文并绑定到 Memory 的 Vars Context + TenantContext tenantContext = captureTenantContext(); + memory.bindTenantContext(tenantContext); + + // 文件解析 + 注入 Memory 的 Document Context(必须在请求线程执行) + List chunks = fileParseService.loadAsChunks(aiRequest.getFileIds()); + if (!chunks.isEmpty()) { + memory.addDocumentChunks(chunks); + } + + // Document Context 的拼接交由 Planner 统一处理: + // Planner.plan() 会从 Memory 读取 getSystemContext()(含 Vars + Document)拼接到 prompt 前。 + // AgentRuntime 只负责 Memory 写入(文件解析 + 租户绑定),不再拼接 DocumentContext 到 prompt, + // 避免与 Planner 重复拼接。 // 后台启动编排流程 runOrchestration(sessionId, prompt, eventBus, tenantContext); @@ -104,31 +121,6 @@ public class AgentRuntime { return eventBus.asFlux(); } - /** - * 加载文件内容并拼接到提示词前 - *

- * 若 fileIds 为空或全部加载失败,返回原始 prompt。 - * 文件内容加载失败时记录日志但不阻断流程,降级为纯文本对话。 - *

- * - * @param prompt 原始用户提示词 - * @param fileIds 文件fileKey列表 - * @return 拼接后的提示词 - */ - private String resolveFileContents(String prompt, List fileIds) { - if (fileIds == null || fileIds.isEmpty()) { - return prompt; - } - String fileContents = fileParseService.loadFileContents(fileIds); - if (StringUtils.isEmpty(fileContents)) { - log.warn("[AgentRuntime] 文件内容加载为空 fileIds={}", fileIds); - return prompt; - } - return "以下是用户上传的文件内容,请基于文件内容进行分析:\n\n" - + fileContents - + "\n用户需求:" + prompt; - } - /** * 在请求线程捕获租户上下文快照 *

diff --git a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/BaseAssistant.java b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/BaseAssistant.java index 198a1001..d9f75be2 100644 --- a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/BaseAssistant.java +++ b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/BaseAssistant.java @@ -7,6 +7,8 @@ import cn.com.mfish.common.ai.engine.ApiToolEngine; import cn.com.mfish.common.ai.entity.AiRequest; import cn.com.mfish.common.ai.entity.ChatResponseVo; import cn.com.mfish.common.ai.agent.ToolCapable; +import cn.com.mfish.common.ai.memory.ConversationMemory; +import cn.com.mfish.common.ai.memory.ConversationMemoryStore; import cn.com.mfish.common.core.constants.RPCConstants; import cn.com.mfish.common.core.utils.AuthInfoUtils; import cn.com.mfish.common.core.utils.ServletUtils; @@ -61,6 +63,19 @@ public abstract class BaseAssistant implements IClientAssistant, ToolCapable { @Autowired protected FileParseService fileParseService; + /** + * 会话记忆存储:三驾马车 Memory 模块的入口 + *

+ * 用于按 sessionId 获取 {@link ConversationMemory},将文件解析结果、租户上下文、 + * 业务变量等统一收纳到 Memory,供后续 Planner 拼接上下文。 + *

+ *

+ * 字段注入避免修改所有子类构造函数。 + *

+ */ + @Autowired + protected ConversationMemoryStore memoryStore; + public BaseAssistant(ChatMemory chatMemory, LlmModelRouter llmModelRouter, ApiToolEngine apiToolEngine) { this.llmModelRouter = llmModelRouter; this.chatMemory = chatMemory; @@ -279,25 +294,47 @@ public abstract class BaseAssistant implements IClientAssistant, ToolCapable { /** * 聊天返回id *

- * 若请求携带fileIds,会先通过FileParseService从文件服务获取文件内容, - * 将其拼接到用户提示词前,再交由子类chat(sessionId, prompt)处理。 + * 重构后流程(接入 Memory 模块): + *

    + *
  1. 获取(或创建)sessionId 对应的 {@link ConversationMemory}
  2. + *
  3. 若请求携带 fileIds:通过 FileParseService 解析为 DocumentChunk 列表, + * 注入到 Memory 的 Document Context(替代旧的字符串拼接)
  4. + *
  5. 从 Memory 读取 Document Context 拼接文本,注入到用户 prompt 前
  6. + *
  7. 交由子类 chat(sessionId, prompt) 处理(保持向后兼容)
  8. + *
+ *

+ * 关键约束:文件解析必须在请求线程执行(Feign BearerTokenInterceptor 依赖 + * RequestContextHolder),Memory 写入也在请求线程完成(保证一致性)。 * * @return 聊天信息 */ @Override public Flux chat(AiRequest aiRequest) { + String sessionId = aiRequest.getSessionId(); String prompt = aiRequest.getMessage().getContent(); + ConversationMemory memory = memoryStore.getOrCreate(sessionId); + + // 文件解析 + 注入 Memory 的 Document Context List fileIds = aiRequest.getFileIds(); if (fileIds != null && !fileIds.isEmpty()) { - String fileContents = fileParseService.loadFileContents(fileIds); - if (StringUtils.isNotEmpty(fileContents)) { - prompt = "以下是用户上传的文件内容,请基于文件内容进行分析:\n\n" - + fileContents - + "\n用户问题:" + prompt; + List chunks = fileParseService.loadAsChunks(fileIds); + if (!chunks.isEmpty()) { + memory.addDocumentChunks(chunks); } } + + // 从 Memory 读取文档上下文,拼接到 prompt 前 + // 当前使用 getDocumentContext() 全量注入;当文档较多、token 预算紧张时, + // 未来可切换为 memory.searchDocumentContext(prompt, 5) 走 RAG 检索(向量库版 Memory 覆写此方法)。 + String documentContext = memory.getDocumentContext(); + if (StringUtils.isNotEmpty(documentContext)) { + prompt = "以下是用户上传的文件内容,请基于文件内容进行分析:\n\n" + + documentContext + + "\n用户问题:" + prompt; + } + final String finalPrompt = prompt; - return chat(aiRequest.getSessionId(), finalPrompt) + return chat(sessionId, finalPrompt) .filter(resp -> "STOP".equals(Objects.requireNonNull(resp.getResult()).getMetadata().getFinishReason()) || StringUtils.isNotEmpty(resp.getResult().getOutput().getText())) .map(resp -> new ChatResponseVo().setId(aiRequest.getId()) diff --git a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/Planner.java b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/Planner.java index 77ca9049..db3f6af1 100644 --- a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/Planner.java +++ b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/Planner.java @@ -2,9 +2,13 @@ package cn.com.mfish.ai.agent; import cn.com.mfish.ai.service.LlmModelRouter; import cn.com.mfish.common.ai.agent.TenantContext; +import cn.com.mfish.common.ai.capability.ActionDefinition; +import cn.com.mfish.common.ai.capability.CapabilityEngine; import cn.com.mfish.common.ai.entity.AgentPlan; import cn.com.mfish.common.ai.entity.PlanStep; -import cn.com.mfish.common.core.constants.ServiceConstants; +import cn.com.mfish.common.ai.memory.ConversationMemory; +import cn.com.mfish.common.ai.memory.ConversationMemoryStore; +import cn.com.mfish.common.core.utils.StringUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; @@ -14,24 +18,37 @@ import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; -import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; import static org.springframework.ai.chat.memory.ChatMemory.CONVERSATION_ID; /** - * 任务规划器 + * 任务规划器(已接入 CapabilityEngine + Memory) *

- * 调用 LLM 将用户原始需求拆解为结构化的 {@link AgentPlan}: + * 重构后职责(第二阶段存量平移): *

    - *
  1. 分析用户意图
  2. - *
  3. 拆解为多个可执行的步骤
  4. - *
  5. 每步指定需要调用的微服务集合
  6. + *
  7. 从 {@link ConversationMemoryStore} 读取会话 Memory,获取系统上下文(Vars + Document)
  8. + *
  9. 从 {@link CapabilityEngine} 获取所有可用 {@link ActionDefinition},按服务分组展示给 LLM
  10. + *
  11. 将上下文 + 动作列表 + 用户需求打包发给 LLM,生成结构化 {@link AgentPlan}
  12. *
*

*

+ * 与旧版差异: + *

+ *

+ *

* 采用结构化输出(responseEntity),LLM 直接返回 JSON 反序列化为 AgentPlan。 * 必须在请求线程解析租户并构建 ChatClient(AuthInfoUtils 不支持异步)。 *

@@ -45,22 +62,36 @@ public class Planner { private final ChatMemory chatMemory; private final LlmModelRouter llmModelRouter; + private final CapabilityEngine capabilityEngine; + private final ConversationMemoryStore memoryStore; - public Planner(ChatMemory chatMemory, LlmModelRouter llmModelRouter) { + public Planner(ChatMemory chatMemory, LlmModelRouter llmModelRouter, + CapabilityEngine capabilityEngine, ConversationMemoryStore memoryStore) { this.chatMemory = chatMemory; this.llmModelRouter = llmModelRouter; + this.capabilityEngine = capabilityEngine; + this.memoryStore = memoryStore; } /** * 规划:将用户需求拆解为步骤列表 *

- * 注意:本方法会被异步调度器(boundedElastic)调用,因此 ChatClient 必须在请求线程 - * 预构建并通过闭包传入。{@link TenantContext} 中包含请求线程捕获的 tenantId, + * 重构后流程: + *

    + *
  1. 从 Memory 读取系统上下文(Vars + Document),拼接到 prompt 前
  2. + *
  3. 从 CapabilityEngine 获取所有可用 ActionDefinition,构建规划提示词
  4. + *
  5. 调用 LLM 生成结构化 AgentPlan
  6. + *
  7. 失败时降级为单步执行,聚合所有已注册服务
  8. + *
+ *

+ *

+ * 注意:本方法会被异步调度器(boundedElastic)调用,因此 ChatClient 和系统提示词 + * 必须在请求线程预构建并通过闭包传入。{@link TenantContext} 中包含请求线程捕获的 tenantId, * 用于路由到该租户的 ChatModel。 *

* * @param sessionId 会话ID - * @param prompt 用户原始需求 + * @param prompt 用户原始需求(已由 AgentRuntime 拼接 Document Context) * @param tenantContext 请求线程捕获的租户上下文 * @return 执行计划 */ @@ -70,16 +101,26 @@ public class Planner { ? tenantContext.getTenantId() : llmModelRouter.currentTenantId(); ChatClient chatClient = getChatClient(tenantId); + + // 从 Memory 读取系统上下文(Vars + Document),拼接到 prompt 前 + String systemContext = resolveSystemContext(sessionId); + + // 从 CapabilityEngine 获取动作列表,构建规划提示词 String systemPrompt = buildPlannerPrompt(); + // 拼接最终 prompt:系统上下文 + 用户需求 + String finalPrompt = StringUtils.isNotEmpty(systemContext) + ? systemContext + "\n用户需求:" + prompt + : prompt; + return Mono.fromCallable(() -> { var responseEntity = chatClient.prompt() .system(systemPrompt) - .user(prompt) + .user(finalPrompt) .advisors(a -> a.param(CONVERSATION_ID, sessionId)) .call() .responseEntity(AgentPlan.class); - AgentPlan plan = Objects.requireNonNullElseGet(responseEntity.entity(), () -> fallbackPlan(prompt)); + AgentPlan plan = Objects.requireNonNullElseGet(responseEntity.entity(), () -> fallbackPlan(finalPrompt)); plan.setOriginalPrompt(prompt); log.info("[Planner] 规划完成, 步骤数={}, summary={}", plan.getSteps() != null ? plan.getSteps().size() : 0, plan.getSummary()); @@ -88,17 +129,63 @@ public class Planner { .subscribeOn(Schedulers.boundedElastic()) .onErrorResume(ex -> { log.error("[Planner] 规划失败,降级为单步执行", ex); - return Mono.just(fallbackPlan(prompt)); + return Mono.just(fallbackPlan(finalPrompt)); }); } /** - * 构建规划师系统提示词,包含可用服务列表 + * 从 Memory 读取系统上下文(Vars Context + Document Context) + *

+ * AgentRuntime 已在请求线程将 DocumentContext 拼接到 prompt 中, + * 此处进一步补充 Vars Context(租户信息、业务变量), + * 使 Planner 能感知用户身份和业务上下文。 + *

+ */ + private String resolveSystemContext(String sessionId) { + try { + ConversationMemory memory = memoryStore.getOrCreate(sessionId); + return memory.getSystemContext(); + } catch (Exception e) { + log.warn("[Planner] 读取 Memory 系统上下文失败 sessionId={}", sessionId, e); + return ""; + } + } + + /** + * 构建规划师系统提示词,包含可用服务列表和动作摘要 + *

+ * 重构后从 {@link CapabilityEngine#getAvailableActions()} 动态获取动作列表, + * 按服务分组展示。相比旧版从 {@code ServiceConstants.MfService.values()} 硬编码枚举获取, + * 新版自动反映已注册的子引擎状态(如某服务未启动则不出现在列表中)。 + *

*/ private String buildPlannerPrompt() { - String serviceList = Arrays.stream(ServiceConstants.MfService.values()) - .map(s -> String.format(" - %s: %s", s.getValue(), s.getGatewayPrefix())) - .collect(Collectors.joining("\n")); + // 从 CapabilityEngine 获取所有动作,按 serviceId 分组 + List actions = capabilityEngine.getAvailableActions(); + Map> actionsByService = groupActionsByService(actions); + + // 构建服务列表(仅包含有动作的服务) + StringBuilder serviceList = new StringBuilder(); + for (Map.Entry> entry : actionsByService.entrySet()) { + String serviceId = entry.getKey(); + List serviceActions = entry.getValue(); + serviceList.append(String.format(" - %s: %d 个可用动作", serviceId, serviceActions.size())); + + // 展示前 3 个动作名作为示例,帮助 LLM 理解服务能力 + List sampleNames = serviceActions.stream() + .limit(3) + .map(ActionDefinition::getName) + .toList(); + if (!sampleNames.isEmpty()) { + serviceList.append("(示例: ").append(String.join(", ", sampleNames)).append(")"); + } + serviceList.append("\n"); + } + + // 如果没有动作,使用兜底提示 + if (serviceList.isEmpty()) { + serviceList.append(" (当前无可用服务,请直接回答用户问题)\n"); + } return """ 你是"摸鱼低代码"平台的任务规划师。 @@ -154,17 +241,39 @@ public class Planner { } /** - * 规划失败时的兜底:单步执行原始需求,聚合所有服务工具 + * 将动作列表按 serviceId 分组(保持注册顺序) + */ + private Map> groupActionsByService(List actions) { + Map> grouped = new LinkedHashMap<>(); + if (actions == null || actions.isEmpty()) { + return grouped; + } + for (ActionDefinition action : actions) { + String serviceId = action.getServiceId() != null ? action.getServiceId() : "unknown"; + grouped.computeIfAbsent(serviceId, k -> new java.util.ArrayList<>()).add(action); + } + return grouped; + } + + /** + * 规划失败时的兜底:单步执行原始需求,聚合所有已注册服务 + *

+ * 重构后从 {@link CapabilityEngine#getAvailableActions()} 提取所有 serviceId, + * 相比旧版从 {@code ServiceConstants.MfService.values()} 硬编码枚举获取, + * 新版仅聚合实际有动作的服务,避免向未启动的服务发送请求。 + *

*/ private AgentPlan fallbackPlan(String prompt) { - List allServices = Arrays.stream(ServiceConstants.MfService.values()) - .map(ServiceConstants.MfService::getValue) - .collect(Collectors.toList()); + Set allServiceIds = capabilityEngine.getAvailableActions().stream() + .map(ActionDefinition::getServiceId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + List serviceIdList = List.copyOf(allServiceIds); return new AgentPlan() .setOriginalPrompt(prompt) .setSummary("规划降级:直接执行") .setSteps(List.of( - new PlanStep(prompt, allServices) + new PlanStep(prompt, serviceIdList) )); } diff --git a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/controller/McpServerConfigController.java b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/controller/McpServerConfigController.java new file mode 100644 index 00000000..9a152bde --- /dev/null +++ b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/controller/McpServerConfigController.java @@ -0,0 +1,69 @@ +package cn.com.mfish.ai.controller; + +import cn.com.mfish.ai.api.entity.McpServerConfig; +import cn.com.mfish.ai.service.McpServerConfigService; +import cn.com.mfish.common.core.web.PageResult; +import cn.com.mfish.common.core.web.ReqPage; +import cn.com.mfish.common.core.web.Result; +import cn.com.mfish.common.core.enums.OperateType; +import cn.com.mfish.common.log.annotation.Log; +import cn.com.mfish.common.oauth.annotation.RequiresPermissions; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +/** + * @description: MCP服务器配置信息 + * @author: mfish + * @date: 2026-07-21 + * @version: V2.4.1 + */ +@Tag(name = "MCP服务器配置") +@RestController +@RequestMapping("/mcp/config") +@Slf4j +public class McpServerConfigController { + + @Resource + McpServerConfigService mcpServerConfigService; + + @Operation(summary = "MCP服务器配置-分页列表查询", description = "MCP服务器配置-分页列表查询") + @GetMapping + @RequiresPermissions("ai:mcp:query") + public Result> queryPageList(McpServerConfig req, ReqPage reqPage) { + return mcpServerConfigService.queryPageList(req, reqPage); + } + + @Log(title = "MCP服务器配置-新增", operateType = OperateType.INSERT) + @Operation(summary = "MCP服务器配置-新增", description = "MCP服务器配置-新增") + @PostMapping + @RequiresPermissions("ai:mcp:insert") + public Result add(@RequestBody McpServerConfig entity) { + return mcpServerConfigService.insert(entity); + } + + @Log(title = "MCP服务器配置-修改", operateType = OperateType.UPDATE) + @Operation(summary = "MCP服务器配置-修改", description = "MCP服务器配置-修改") + @PutMapping + @RequiresPermissions("ai:mcp:update") + public Result edit(@RequestBody McpServerConfig entity) { + return mcpServerConfigService.update(entity); + } + + @Log(title = "MCP服务器配置-通过id删除", operateType = OperateType.DELETE) + @Operation(summary = "MCP服务器配置-通过id删除", description = "MCP服务器配置-通过id删除") + @DeleteMapping("/{id}") + @RequiresPermissions("ai:mcp:delete") + public Result delete(@PathVariable String id) { + return mcpServerConfigService.delete(id); + } + + @Operation(summary = "MCP服务器配置-通过id查询", description = "MCP服务器配置-通过id查询") + @GetMapping("/{id}") + @RequiresPermissions("ai:mcp:query") + public Result getById(@PathVariable String id) { + return Result.ok(mcpServerConfigService.getById(id), "查询成功"); + } +} diff --git a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/mapper/McpServerConfigMapper.java b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/mapper/McpServerConfigMapper.java new file mode 100644 index 00000000..90bc9dd8 --- /dev/null +++ b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/mapper/McpServerConfigMapper.java @@ -0,0 +1,14 @@ +package cn.com.mfish.ai.mapper; + +import cn.com.mfish.ai.api.entity.McpServerConfig; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + * @description: MCP服务器配置信息 + * @author: mfish + * @date: 2026-07-21 + * @version: V2.4.1 + */ +public interface McpServerConfigMapper extends BaseMapper { + +} diff --git a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/FileParseService.java b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/FileParseService.java index bb0d7fec..0e73344c 100644 --- a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/FileParseService.java +++ b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/FileParseService.java @@ -1,5 +1,6 @@ package cn.com.mfish.ai.service; +import cn.com.mfish.common.ai.memory.DocumentChunk; import cn.com.mfish.common.core.constants.RPCConstants; import cn.com.mfish.common.core.utils.StringUtils; import cn.com.mfish.common.storage.api.entity.StorageInfo; @@ -14,9 +15,11 @@ import org.springframework.stereotype.Service; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Set; +import java.util.UUID; /** * 文件解析服务 @@ -111,6 +114,9 @@ public class FileParseService { *

* 遍历fileKey列表,逐个获取文件元数据与内容,将文本/Office文档内容拼接到StringBuilder。 * 单个文件读取失败不影响其他文件,异常会被记录日志并跳过。 + *

+ * 旧版接口,保留向后兼容。新调用方建议使用 {@link #loadAsChunks} 获取结构化的 + * {@link DocumentChunk} 列表,再注入到 Memory 模块。 * * @param fileIds 文件fileKey列表 * @return 拼接好的文件内容提示词片段;列表为空或全部失败时返回空字符串 @@ -141,6 +147,45 @@ public class FileParseService { return sb.toString(); } + /** + * 加载文件并解析为 {@link DocumentChunk} 列表 + *

+ * 与 {@link #loadFileContents} 的区别:返回结构化的文档块列表,便于 Memory 模块 + * 按 chunk 维度管理文档上下文,支持后续按相关性检索(RAG)、按 token 上限分片注入等扩展。 + *

+ *

+ * 当前实现:每个文件产出 1 个 Chunk(小文件场景),未来可扩展为按段落/页码/字符数分片。 + * Chunk 内容已截断到 {@link #MAX_CONTENT_CHARS},可直接注入 LLM 上下文。 + *

+ *

+ * 必须在请求线程调用:内部通过 Feign 调用 mf-storage,BearerTokenInterceptor 依赖 + * RequestContextHolder 中继令牌。 + *

+ * + * @param fileIds 文件fileKey列表 + * @return 文档块列表;列表为空或全部失败时返回空列表(不会返回 null) + */ + public List loadAsChunks(List fileIds) { + if (fileIds == null || fileIds.isEmpty()) { + return List.of(); + } + List chunks = new ArrayList<>(fileIds.size()); + for (String fileKey : fileIds) { + if (StringUtils.isEmpty(fileKey)) { + continue; + } + try { + DocumentChunk chunk = loadOneFileAsChunk(fileKey); + if (chunk != null && StringUtils.isNotEmpty(chunk.getContent())) { + chunks.add(chunk); + } + } catch (Exception e) { + log.warn("加载文件块失败 fileKey={} reason={}", fileKey, e.getMessage()); + } + } + return chunks; + } + /** * 加载单个文件并构建内容片段 *

@@ -176,6 +221,47 @@ public class FileParseService { return buildBinaryFileHint(fileName, fileType, storageInfo.getFileSize()); } + /** + * 加载单个文件并构建为 {@link DocumentChunk} + *

+ * 与 {@link #loadOneFile} 共享文件元数据查询和解析路径,但返回结构化 Chunk 而非拼接文本。 + * 二进制文件(无法解析内容的)返回 null 而非提示片段,避免无效 Chunk 进入 Memory。 + *

+ * + * @param fileKey 文件key + * @return 文档块;文件不存在或无法解析返回 null + */ + private DocumentChunk loadOneFileAsChunk(String fileKey) { + Result infoResult = remoteStorageService.queryByKey(RPCConstants.INNER, fileKey); + if (infoResult == null || !infoResult.isSuccess() || infoResult.getData() == null) { + log.warn("文件信息查询失败 fileKey={} msg={}", fileKey, + infoResult == null ? "result is null" : infoResult.getMsg()); + return null; + } + StorageInfo storageInfo = infoResult.getData(); + String fileName = StringUtils.isEmpty(storageInfo.getFileName()) ? fileKey : storageInfo.getFileName(); + String fileType = storageInfo.getFileType(); + + String content = null; + if (isTextFile(fileType, fileName)) { + content = readFileText(fileKey); + } else if (isOfficeDocument(fileType, fileName)) { + content = extractOfficeText(fileKey, fileName); + } + if (content == null || content.isEmpty()) { + return null; + } + String truncated = truncate(content); + return new DocumentChunk() + .setChunkId(UUID.randomUUID().toString()) + .setFileKey(fileKey) + .setFileName(fileName) + .setFileType(fileType) + .setContent(truncated) + .setChunkIndex(0) + .setLength(truncated.length()); + } + /** * 通过Feign获取文件资源并读取为UTF-8文本 * diff --git a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/McpServerConfigService.java b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/McpServerConfigService.java new file mode 100644 index 00000000..015dfea8 --- /dev/null +++ b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/McpServerConfigService.java @@ -0,0 +1,40 @@ +package cn.com.mfish.ai.service; + +import cn.com.mfish.ai.api.entity.McpServerConfig; +import cn.com.mfish.common.ai.capability.McpServerInfo; +import cn.com.mfish.common.ai.capability.McpServerConfigProvider; +import cn.com.mfish.common.core.web.PageResult; +import cn.com.mfish.common.core.web.ReqPage; +import cn.com.mfish.common.core.web.Result; +import com.baomidou.mybatisplus.extension.service.IService; + +import java.util.List; + +/** + * @description: MCP服务器配置信息 + * @author: mfish + * @date: 2026-07-21 + * @version: V2.4.1 + */ +public interface McpServerConfigService extends IService, McpServerConfigProvider { + + /** + * 分页查询 + */ + Result> queryPageList(McpServerConfig req, ReqPage reqPage); + + /** + * 新增 + */ + Result insert(McpServerConfig entity); + + /** + * 修改 + */ + Result update(McpServerConfig entity); + + /** + * 删除 + */ + Result delete(String id); +} diff --git a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/impl/McpServerConfigServiceImpl.java b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/impl/McpServerConfigServiceImpl.java new file mode 100644 index 00000000..9cf66d79 --- /dev/null +++ b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/impl/McpServerConfigServiceImpl.java @@ -0,0 +1,97 @@ +package cn.com.mfish.ai.service.impl; + +import cn.com.mfish.ai.api.entity.McpServerConfig; +import cn.com.mfish.ai.mapper.McpServerConfigMapper; +import cn.com.mfish.ai.service.McpServerConfigService; +import cn.com.mfish.common.ai.capability.McpServerInfo; +import cn.com.mfish.common.core.utils.StringUtils; +import cn.com.mfish.common.core.utils.Utils; +import cn.com.mfish.common.core.web.PageResult; +import cn.com.mfish.common.core.web.ReqPage; +import cn.com.mfish.common.core.web.Result; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.github.pagehelper.PageHelper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * @description: MCP服务器配置信息实现 + * @author: mfish + * @date: 2026-07-21 + * @version: V2.4.1 + */ +@Slf4j +@Service +public class McpServerConfigServiceImpl extends ServiceImpl + implements McpServerConfigService { + + @Override + public Result> queryPageList(McpServerConfig req, ReqPage reqPage) { + PageHelper.startPage(reqPage.getPageNum(), reqPage.getPageSize()); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (req != null) { + wrapper.like(StringUtils.isNotEmpty(req.getServerName()), McpServerConfig::getServerName, req.getServerName()); + wrapper.eq(StringUtils.isNotEmpty(req.getTransportType()), McpServerConfig::getTransportType, req.getTransportType()); + wrapper.eq(req.getStatus() != null, McpServerConfig::getStatus, req.getStatus()); + } + wrapper.orderByDesc(McpServerConfig::getCreateTime); + List list = baseMapper.selectList(wrapper); + return Result.ok(new PageResult<>(list), "查询成功"); + } + + @Override + @Transactional + public Result insert(McpServerConfig entity) { + if (StringUtils.isEmpty(entity.getId())) { + entity.setId(Utils.uuid32()); + } + entity.setCreateTime(new Date()); + baseMapper.insert(entity); + return Result.ok(entity, "新增成功"); + } + + @Override + @Transactional + public Result update(McpServerConfig entity) { + entity.setUpdateTime(new Date()); + baseMapper.updateById(entity); + return Result.ok(entity, "修改成功"); + } + + @Override + @Transactional + public Result delete(String id) { + baseMapper.deleteById(id); + return Result.ok(true, "删除成功"); + } + + /** + * 实现 {@link McpServerConfigProvider}:查询状态为"正常"的 MCP 服务器配置, + * 转换为 {@link McpServerInfo} 列表供 {@code McpCapabilityEngine} 使用 + */ + @Override + public List getActiveServerConfigs() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(McpServerConfig::getStatus, (short) 0); + List configs = baseMapper.selectList(wrapper); + List result = new ArrayList<>(); + for (McpServerConfig config : configs) { + result.add(new McpServerInfo() + .setServerName(config.getServerName()) + .setTransportType(config.getTransportType()) + .setCommand(config.getCommand()) + .setArgs(config.getArgs()) + .setEnv(config.getEnv()) + .setSseUrl(config.getSseUrl()) + .setSseEndpoint(config.getSseEndpoint()) + .setAuthToken(config.getAuthToken())); + } + return result; + } +} diff --git a/mf-common/mf-common-ai/pom.xml b/mf-common/mf-common-ai/pom.xml index 12269fa9..eff964bf 100644 --- a/mf-common/mf-common-ai/pom.xml +++ b/mf-common/mf-common-ai/pom.xml @@ -67,5 +67,16 @@ com.baomidou mybatis-plus-spring + + + org.springframework.ai + spring-ai-starter-mcp-client-webflux + \ No newline at end of file diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ActionDefinition.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ActionDefinition.java new file mode 100644 index 00000000..b6479f2e --- /dev/null +++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ActionDefinition.java @@ -0,0 +1,66 @@ +package cn.com.mfish.common.ai.capability; + +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * 动作定义(Action Definition) + *

+ * 能力引擎向 Planner 暴露的单个可执行动作的元数据,遵循 OpenAI Tool 格式。 + * 一个 {@link CapabilitySubEngine} 可暴露多个 Action,{@link CapabilityEngine} + * 将所有子引擎的 Action 合并为统一列表供 Planner 选择。 + *

+ *

+ * 与 Spring AI ToolCallback 的关系: + *

    + *
  • 对于 TOOL 引擎:ActionDefinition 由 {@code ToolCallback.getToolDefinition()} 转换而来
  • + *
  • 对于 SKILL/MCP/WORKFLOW 引擎:ActionDefinition 由各引擎自行构建
  • + *
  • Planner 可将 ActionDefinition 转换回 ToolCallback 供 ChatClient 使用, + * 也可通过 {@code CapabilityEngine.executeAction()} 直接执行
  • + *
+ *

+ * + * @author: mfish + * @date: 2026/07/21 + */ +@Data +@Accessors(chain = true) +public class ActionDefinition { + + /** + * 动作名称(全局唯一,建议加引擎前缀避免冲突,如 {@code tool.getUserList}、{@code skill.parseFile}) + */ + private String name; + + /** + * 动作描述(供 LLM 理解动作用途,决定是否选择此动作) + */ + private String description; + + /** + * 输入参数 JSON Schema(OpenAI Tool 格式,描述参数类型和结构) + *

+ * 示例: + *

{@code
+     * {
+     *   "type": "object",
+     *   "properties": {
+     *     "userId": { "type": "string", "description": "用户ID" }
+     *   },
+     *   "required": ["userId"]
+     * }
+     * }
+ *

+ */ + private String inputSchema; + + /** + * 来源引擎类型 + */ + private EngineType engineType; + + /** + * 来源服务ID(仅 TOOL 引擎有值,标识来自哪个微服务;其他引擎为 null) + */ + private String serviceId; +} diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ActionResult.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ActionResult.java new file mode 100644 index 00000000..999d851f --- /dev/null +++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ActionResult.java @@ -0,0 +1,69 @@ +package cn.com.mfish.common.ai.capability; + +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * 动作执行结果 + *

+ * 由 {@link CapabilitySubEngine#execute} 返回,封装执行状态、输出内容和错误信息。 + * 设计为不可变值对象:执行完成后构造,调用方只读。 + *

+ * + * @author: mfish + * @date: 2026/07/21 + */ +@Data +@Accessors(chain = true) +public class ActionResult { + + /** + * 是否执行成功 + */ + private boolean success; + + /** + * 执行输出(LLM 可读的文本,通常为 JSON 字符串或纯文本) + *

+ * 成功时填充结果内容,失败时可为 null。 + *

+ */ + private String output; + + /** + * 错误信息(失败时填充,成功时为 null) + */ + private String error; + + /** + * 来源引擎类型(便于 Planner 日志追踪和结果分流处理) + */ + private EngineType engineType; + + /** + * 执行耗时(毫秒) + */ + private long durationMs; + + /** + * 构建成功结果 + */ + public static ActionResult success(EngineType engineType, String output, long durationMs) { + return new ActionResult() + .setSuccess(true) + .setOutput(output) + .setEngineType(engineType) + .setDurationMs(durationMs); + } + + /** + * 构建失败结果 + */ + public static ActionResult failure(EngineType engineType, String error, long durationMs) { + return new ActionResult() + .setSuccess(false) + .setError(error) + .setEngineType(engineType) + .setDurationMs(durationMs); + } +} diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/CapabilityAutoConfiguration.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/CapabilityAutoConfiguration.java new file mode 100644 index 00000000..11a5dae0 --- /dev/null +++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/CapabilityAutoConfiguration.java @@ -0,0 +1,123 @@ +package cn.com.mfish.common.ai.capability; + +import cn.com.mfish.common.ai.engine.ApiToolEngine; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +import java.util.List; + +/** + * 能力引擎自动配置 + *

+ * 注册 {@link CapabilityEngine} 作为能力引擎门面,自动发现所有 {@link CapabilitySubEngine} 实现, + * 在所有单例 Bean 初始化完成后将子引擎注册到 CapabilityEngine。 + *

+ *

+ * 默认注册两个子引擎: + *

    + *
  • {@link ToolCapabilityEngine} — 适配 ApiToolEngine(Feign/OpenAPI 工具)
  • + *
  • {@link McpCapabilityEngine} — MCP 协议工具(条件注册,需 MCP SDK + McpServerConfigProvider)
  • + *
+ * 其他子引擎(SkillCapabilityEngine / WorkflowCapabilityEngine) + * 通过实现 CapabilitySubEngine 接口并声明为 Bean 即可自动接入。 + *

+ *

+ * 与 ApiToolAutoConfiguration 的关系: + *

    + *
  • ApiToolAutoConfiguration 负责 ApiToolEngine 的初始化(工具发现与聚合)
  • + *
  • CapabilityAutoConfiguration 负责 CapabilityEngine 的初始化(子引擎注册与动作索引构建)
  • + *
  • 两者通过 ApiToolEngine Bean 关联:ToolCapabilityEngine 依赖 ApiToolEngine
  • + *
  • 初始化顺序由 Spring 容器保证:ApiToolEngine 的 SmartInitializingSingleton 先执行, + * CapabilityEngine 的 SmartInitializingSingleton 后执行
  • + *
+ *

+ *

+ * 通过 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 注册。 + *

+ * + * @author: mfish + * @date: 2026/07/21 + */ +@Slf4j +@AutoConfiguration +public class CapabilityAutoConfiguration { + + /** + * 能力引擎门面 + */ + @Bean + @ConditionalOnMissingBean + public CapabilityEngine capabilityEngine() { + return new CapabilityEngine(); + } + + /** + * 工具能力引擎适配器(包装 ApiToolEngine 为 CapabilitySubEngine) + *

+ * 依赖 ApiToolEngine Bean,由 ApiToolAutoConfiguration 注册。 + *

+ */ + @Bean + public ToolCapabilityEngine toolCapabilityEngine(ApiToolEngine apiToolEngine) { + return new ToolCapabilityEngine(apiToolEngine); + } + + /** + * MCP 能力引擎(条件注册) + *

+ * 仅当 classpath 中存在 MCP SDK 类 且 容器中有 {@link McpServerConfigProvider} 实现时才注册。 + *

+ *

+ * 条件说明: + *

    + *
  • {@code @ConditionalOnClass} — 确保 MCP SDK 在 classpath 中(mf-common-ai 已声明依赖)
  • + *
  • {@code @ConditionalOnBean(McpServerConfigProvider.class)} — 确保业务层提供了 MCP 配置数据源 + * (由 mf-ai 模块的 McpServerConfigServiceImpl 实现)
  • + *
+ * 若条件不满足(如 mf-common-ai 独立测试或 mf-ai 模块未启动),MCP 引擎不注册,不影响其他子引擎。 + *

+ */ + @Bean + @ConditionalOnClass(name = "io.modelcontextprotocol.client.McpSyncClient") + @ConditionalOnBean(McpServerConfigProvider.class) + public McpCapabilityEngine mcpCapabilityEngine(McpServerConfigProvider configProvider) { + return new McpCapabilityEngine(configProvider); + } + + /** + * 能力引擎初始化触发器:在所有单例 Bean(含所有 CapabilitySubEngine)就绪后, + * 将子引擎注册到 CapabilityEngine 并构建动作索引 + *

+ * 放在此处而非 CapabilityEngine 内,避免 CapabilityEngine 依赖 Spring 容器回调接口,保持可测试性。 + * 与 ApiToolAutoConfiguration 的 apiToolEngineInitializer 模式一致。 + *

+ *

+ * 对于 McpCapabilityEngine,在注册前先调用 {@code refresh()} 连接所有 MCP 服务器并发现工具, + * 确保 {@code getActions()} 在注册到 CapabilityEngine 时返回完整的动作列表。 + *

+ */ + @Bean + public SmartInitializingSingleton capabilityEngineInitializer(CapabilityEngine capabilityEngine, + List subEngines) { + return () -> { + log.info("[CapabilityAutoConfiguration] 发现 {} 个 CapabilitySubEngine: {}", + subEngines.size(), + subEngines.stream().map(e -> e.getEngineType().name()).toList()); + + // MCP 引擎需在注册前初始化(连接 MCP 服务器、发现工具) + for (CapabilitySubEngine engine : subEngines) { + if (engine instanceof McpCapabilityEngine mcpEngine) { + log.info("[CapabilityAutoConfiguration] 触发 MCP 引擎初始化"); + mcpEngine.refresh(); + } + } + + capabilityEngine.registerSubEngines(subEngines); + }; + } +} diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/CapabilityEngine.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/CapabilityEngine.java new file mode 100644 index 00000000..5225c42e --- /dev/null +++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/CapabilityEngine.java @@ -0,0 +1,249 @@ +package cn.com.mfish.common.ai.capability; + +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 能力引擎门面(Capability Engine) + *

+ * 组合(Composite)所有 {@link CapabilitySubEngine} 实例,向上为 Planner 提供统一的能力发现与执行入口。 + * 屏蔽异构执行细节:Planner 无需关心动作来自 TOOL / SKILL / MCP / WORKFLOW 哪个引擎。 + *

+ *

+ * 架构定位: + *

+ *                  CapabilityEngine(门面)
+ *                       │
+ *       ┌───────────────┼───────────────┬───────────────┬───────────────┐
+ *       │               │               │               │
+ *   ToolCapability  SkillCapability  McpCapability  WorkflowCapability
+ *   (Engine)         (Engine)         (Engine)         (Engine)
+ *       │               │               │               │
+ *   ApiToolEngine    Java Skill       MCP Client      Flowable/BPMN
+ *   (Feign+HTTP)
+ * 
+ *

+ *

+ * 与现有 ApiToolEngine 的关系: + *

    + *
  • ApiToolEngine 保持不变,仍由 {@code BaseAssistant.chatWithTools()} 直接调用, + * 支撑 LLM 驱动模式(把工具交给 ChatClient)
  • + *
  • CapabilityEngine 是上层抽象,ToolCapabilityEngine 适配 ApiToolEngine 暴露其动作元数据
  • + *
  • 两条链路并存:LLM 驱动走 ChatClient.tools(),显式调用走 CapabilityEngine.executeAction()
  • + *
+ *

+ *

+ * 线程安全:子引擎注册在初始化期完成,运行期只读;动作查找通过 ConcurrentHashMap 支持并发。 + *

+ * + * @author: mfish + * @date: 2026/07/21 + */ +@Slf4j +public class CapabilityEngine { + + /** + * 已注册的子引擎列表(按注册顺序) + */ + private final List subEngines = Collections.synchronizedList(new ArrayList<>()); + + /** + * 动作名 → 子引擎 的索引(供 executeAction 快速路由) + *

+ * 初始化时构建,子引擎动态新增动作时需调用 {@link #refreshActionIndex()} 重建。 + * 同名动作按子引擎注册顺序,先注册的覆盖(与 ApiToolEngine 去重策略一致)。 + *

+ */ + private volatile Map actionIndex = new ConcurrentHashMap<>(); + + /** + * 注册子引擎 + *

+ * 在 {@link CapabilityAutoConfiguration} 初始化期调用,运行期不应动态增减。 + * 注册后自动重建动作索引。 + *

+ * + * @param subEngine 子引擎实例 + */ + public synchronized void registerSubEngine(CapabilitySubEngine subEngine) { + if (subEngine == null) { + return; + } + subEngines.add(subEngine); + log.info("[CapabilityEngine] 注册子引擎 type={} actions={}", + subEngine.getEngineType(), + subEngine.getActions() != null ? subEngine.getActions().size() : 0); + rebuildActionIndex(); + } + + /** + * 批量注册子引擎 + * + * @param engines 子引擎列表 + */ + public synchronized void registerSubEngines(List engines) { + if (engines == null || engines.isEmpty()) { + return; + } + for (CapabilitySubEngine engine : engines) { + if (engine != null) { + subEngines.add(engine); + } + } + log.info("[CapabilityEngine] 批量注册 {} 个子引擎", subEngines.size()); + rebuildActionIndex(); + } + + /** + * 获取所有子引擎的可用动作(并集) + *

+ * Planner 调用此方法获取全局动作列表,可选择: + *

    + *
  • 将动作列表注入系统提示词,供 LLM 决策
  • + *
  • 将动作列表转换为 ToolCallback 传给 ChatClient(LLM 驱动模式)
  • + *
  • 根据动作元数据自行决策后调用 {@link #executeAction}(显式调用模式)
  • + *
+ *

+ * + * @return 所有子引擎动作的并集,无子引擎时返回空列表 + */ + public List getAvailableActions() { + List all = new ArrayList<>(); + for (CapabilitySubEngine engine : subEngines) { + try { + List actions = engine.getActions(); + if (actions != null) { + all.addAll(actions); + } + } catch (Exception e) { + log.error("[CapabilityEngine] 子引擎 type={} 获取动作列表失败", + engine.getEngineType(), e); + } + } + return all; + } + + /** + * 按引擎类型过滤可用动作 + * + * @param engineType 引擎类型 + * @return 该类型引擎的动作列表 + */ + public List getAvailableActions(EngineType engineType) { + List filtered = new ArrayList<>(); + for (CapabilitySubEngine engine : subEngines) { + if (engine.getEngineType() == engineType) { + try { + List actions = engine.getActions(); + if (actions != null) { + filtered.addAll(actions); + } + } catch (Exception e) { + log.error("[CapabilityEngine] 子引擎 type={} 获取动作列表失败", engineType, e); + } + } + } + return filtered; + } + + /** + * 执行动作(统一入口) + *

+ * 按 actionName 路由到对应子引擎执行。Planner 显式调用模式走此方法。 + *

+ * + * @param actionName 动作名称(需与 ActionDefinition.name 一致) + * @param params 动作参数 + * @param ctx 执行上下文 + * @return 执行结果;动作不存在时返回 failure + */ + public ActionResult executeAction(String actionName, Map params, ExecutionContext ctx) { + if (actionName == null || actionName.isEmpty()) { + return ActionResult.failure(EngineType.TOOL, "actionName 不能为空", 0); + } + CapabilitySubEngine engine = actionIndex.get(actionName); + if (engine == null) { + return ActionResult.failure(EngineType.TOOL, + "未找到动作: " + actionName + ",可用动作: " + actionIndex.keySet(), 0); + } + long start = System.currentTimeMillis(); + try { + ActionResult result = engine.execute(actionName, params, ctx); + long elapsed = System.currentTimeMillis() - start; + // 子引擎可能未填充耗时,此处兜底 + if (result.getDurationMs() <= 0) { + result.setDurationMs(elapsed); + } + log.info("[CapabilityEngine] 执行动作 name={} engine={} success={} cost={}ms", + actionName, engine.getEngineType(), result.isSuccess(), elapsed); + return result; + } catch (Exception e) { + long elapsed = System.currentTimeMillis() - start; + log.error("[CapabilityEngine] 执行动作异常 name={} engine={}", + actionName, engine.getEngineType(), e); + return ActionResult.failure(engine.getEngineType(), + "执行异常: " + e.getMessage(), elapsed); + } + } + + /** + * 重建动作索引(子引擎动态新增动作后调用) + */ + public synchronized void refreshActionIndex() { + rebuildActionIndex(); + } + + /** + * 获取已注册的子引擎类型列表(供调试和监控) + */ + public List getRegisteredEngineTypes() { + List types = new ArrayList<>(); + for (CapabilitySubEngine engine : subEngines) { + types.add(engine.getEngineType()); + } + return types; + } + + /** + * 获取已注册的动作总数(供调试和监控) + */ + public int getActionCount() { + return actionIndex.size(); + } + + /** + * 重建动作索引:遍历所有子引擎,收集动作名 → 子引擎映射 + *

+ * 同名动作按子引擎注册顺序,先注册的保留(与 ApiToolEngine 去重策略一致)。 + *

+ */ + private void rebuildActionIndex() { + Map newIndex = new LinkedHashMap<>(); + for (CapabilitySubEngine engine : subEngines) { + try { + List actions = engine.getActions(); + if (actions == null) { + continue; + } + for (ActionDefinition action : actions) { + if (action == null || action.getName() == null) { + continue; + } + // 先注册的保留,后注册的跳过(避免同名冲突) + newIndex.putIfAbsent(action.getName(), engine); + } + } catch (Exception e) { + log.error("[CapabilityEngine] 重建索引时子引擎 type={} 获取动作失败", + engine.getEngineType(), e); + } + } + this.actionIndex = new ConcurrentHashMap<>(newIndex); + log.info("[CapabilityEngine] 动作索引重建完成,共 {} 个动作", actionIndex.size()); + } +} diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/CapabilitySubEngine.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/CapabilitySubEngine.java new file mode 100644 index 00000000..68f361be --- /dev/null +++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/CapabilitySubEngine.java @@ -0,0 +1,64 @@ +package cn.com.mfish.common.ai.capability; + +import java.util.List; +import java.util.Map; + +/** + * 能力子引擎接口 + *

+ * 屏蔽异构执行细节的统一抽象。每种能力来源(Tool / Skill / MCP / Workflow) + * 实现此接口,由 {@link CapabilityEngine} 门面组合所有子引擎实例。 + *

+ *

+ * 三种执行模式的关系: + *

    + *
  • LLM 驱动模式:Planner 将 {@link #getActions()} 的结果作为工具列表传给 ChatClient, + * 由 LLM 决定调用哪个动作、传什么参数。TOOL 引擎目前走此模式。
  • + *
  • 显式调用模式:Planner 直接通过 {@link #execute} 指定 actionName 和参数执行。 + * SKILL/MCP/WORKFLOW 引擎适合此模式。
  • + *
  • 混合模式:Planner 先用 getActions() 让 LLM 决策, + * 再用 execute() 执行 LLM 选择的动作。两种模式可共存。
  • + *
+ *

+ *

+ * 线程安全:实现类需保证 getActions() 和 execute() 线程安全, + * 因为可能被多个请求线程并发调用。 + *

+ * + * @author: mfish + * @date: 2026/07/21 + */ +public interface CapabilitySubEngine { + + /** + * 引擎类型识别 + * + * @return 引擎类型枚举(TOOL / SKILL / MCP / WORKFLOW) + */ + EngineType getEngineType(); + + /** + * 向 Planner 暴露的可用动作元数据 + *

+ * 遵循 OpenAI Tool 格式,{@link CapabilityEngine} 将所有子引擎的返回值合并为统一列表。 + * 动作名需全局唯一(建议加引擎前缀,如 {@code tool.xxx}、{@code skill.xxx})。 + *

+ * + * @return 动作定义列表,无可用动作时返回空列表 + */ + List getActions(); + + /** + * 执行动作 + *

+ * 显式调用入口:按 actionName 查找并执行对应动作。 + * 实现类需处理 actionName 不存在的情况(返回 failure 结果,不抛异常)。 + *

+ * + * @param actionName 动作名称(需与 {@link ActionDefinition#getName()} 一致) + * @param params 动作参数(key 为参数名,value 为参数值) + * @param ctx 执行上下文(租户信息、会话ID、自定义属性) + * @return 执行结果 + */ + ActionResult execute(String actionName, Map params, ExecutionContext ctx); +} diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/EngineType.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/EngineType.java new file mode 100644 index 00000000..ed66681d --- /dev/null +++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/EngineType.java @@ -0,0 +1,50 @@ +package cn.com.mfish.common.ai.capability; + +/** + * 能力子引擎类型 + *

+ * 标识 {@link CapabilitySubEngine} 的具体来源,供 Planner 决策时区分能力来源, + * 也用于日志和调试。新增引擎类型时在此枚举扩展。 + *

+ * + * @author: mfish + * @date: 2026/07/21 + */ +public enum EngineType { + + /** + * 工具引擎:基于 Feign/OpenAPI 的微服务接口工具 + *

+ * 适配现有 {@code ApiToolEngine} + {@code ToolProvider} 体系, + * 将 Spring AI {@code ToolCallback} 转换为 {@link ActionDefinition}。 + *

+ */ + TOOL, + + /** + * 技能引擎:Java 编写的预置技能(如文件解析、数据格式化、SQL 生成等) + *

+ * 与 TOOL 的区别:TOOL 是远程接口的包装,SKILL 是本地 Java 逻辑的包装。 + * SKILL 不依赖网络调用,执行延迟低、结果确定性强。 + *

+ */ + SKILL, + + /** + * MCP 引擎:Model Context Protocol 客户端工具 + *

+ * 对接外部 MCP Server,将 MCP 工具转换为统一的 ActionDefinition。 + * 当前为预留类型,待 Spring AI MCP 集成后实现。 + *

+ */ + MCP, + + /** + * 工作流引擎:基于 Flowable/BPMN 的流程编排能力 + *

+ * 将工作流的"发起流程/审批/查询任务"等操作封装为 Action, + * 供 Planner 在需要人工审批或复杂流程编排时调用。 + *

+ */ + WORKFLOW; +} diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ExecutionContext.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ExecutionContext.java new file mode 100644 index 00000000..bd3b881d --- /dev/null +++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ExecutionContext.java @@ -0,0 +1,88 @@ +package cn.com.mfish.common.ai.capability; + +import cn.com.mfish.common.ai.agent.TenantContext; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.util.HashMap; +import java.util.Map; + +/** + * 能力执行上下文 + *

+ * 封装动作执行时所需的环境信息,由调用方(Planner / Executor / BaseAssistant)构建, + * 传递给 {@link CapabilitySubEngine#execute}。 + *

+ *

+ * 与 Spring AI ToolContext 的关系: + *

    + *
  • ToolContext({@code Map})是 Spring AI 的工具执行上下文, + * 由 ChatClient 框架在调用 ToolCallback 时注入
  • + *
  • ExecutionContext 是能力引擎层的抽象,更结构化地封装租户信息和自定义属性
  • + *
  • 对于 TOOL 引擎:{@link ToolCapabilityEngine} 会从 ExecutionContext 提取信息, + * 转换为 Spring AI ToolContext 传给 ToolCallback
  • + *
  • 对于 SKILL/MCP/WORKFLOW 引擎:直接使用 ExecutionContext 的字段
  • + *
+ *

+ * + * @author: mfish + * @date: 2026/07/21 + */ +@Data +@Accessors(chain = true) +public class ExecutionContext { + + /** + * 租户上下文(包含 tenantId/userId/accessToken/请求上下文快照) + *

+ * 由请求线程捕获,供异步执行线程恢复认证上下文。 + * 可为 null(如离线测试场景),各子引擎需做空值防御。 + *

+ */ + private TenantContext tenantContext; + + /** + * 会话ID(用于日志追踪和会话级状态关联) + */ + private String sessionId; + + /** + * 自定义属性(扩展通道,子引擎可按约定存取特定 key) + *

+ * 常用 key(可选,非强制约定): + *

    + *
  • {@code requestAttributes} —— Servlet RequestAttributes
  • + *
  • {@code serverWebExchange} —— WebFlux ServerWebExchange
  • + *
  • {@code actionTraceId} —— 动作执行追踪ID
  • + *
+ *

+ */ + private Map attributes = new HashMap<>(); + + /** + * 添加自定义属性 + */ + public ExecutionContext addAttribute(String key, Object value) { + if (this.attributes == null) { + this.attributes = new HashMap<>(); + } + this.attributes.put(key, value); + return this; + } + + /** + * 获取自定义属性 + */ + public Object getAttribute(String key) { + return this.attributes != null ? this.attributes.get(key) : null; + } + + /** + * 从 TenantContext 快速构建执行上下文 + */ + public static ExecutionContext of(TenantContext tenantContext, String sessionId) { + return new ExecutionContext() + .setTenantContext(tenantContext) + .setSessionId(sessionId); + } +} diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/McpCapabilityEngine.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/McpCapabilityEngine.java new file mode 100644 index 00000000..43d8c08c --- /dev/null +++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/McpCapabilityEngine.java @@ -0,0 +1,437 @@ +package cn.com.mfish.common.ai.capability; + +import cn.com.mfish.common.core.utils.StringUtils; +import com.alibaba.fastjson2.JSON; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.ServerParameters; +import io.modelcontextprotocol.client.transport.StdioClientTransport; +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.spec.McpClientTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.springframework.ai.mcp.client.webflux.transport.WebClientStreamableHttpTransport; +import org.springframework.ai.mcp.client.webflux.transport.WebFluxSseClientTransport; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * MCP 能力引擎 + *

+ * 实现 {@link CapabilitySubEngine} 接口,对接外部 MCP (Model Context Protocol) Server, + * 将远程工具转换为统一的 {@link ActionDefinition}。 + *

+ *

+ * 架构定位: + *

+ * CapabilityEngine(门面)
+ *     │
+ *     ├── ToolCapabilityEngine   (Feign/OpenAPI)
+ *     ├── McpCapabilityEngine    (本类,MCP 协议)
+ *     ├── [SkillCapabilityEngine]  (未来扩展)
+ *     └── [WorkflowCapabilityEngine] (未来扩展)
+ * 
+ *

+ *

+ * 初始化流程: + *

    + *
  1. 从 {@link McpServerConfigProvider} 获取所有活跃的 MCP 服务器配置
  2. + *
  3. 为每个配置创建 {@link McpSyncClient}: + *
      + *
    • stdio Transport:通过 {@link StdioClientTransport} 拉起本地进程(Node.js/Python)
    • + *
    • SSE Transport:通过 {@link WebFluxSseClientTransport} 连接远程 MCP 服务(已 deprecated)
    • + *
    • Streamable HTTP Transport:通过 {@link WebClientStreamableHttpTransport} 连接远程 MCP 服务(MCP 2025-03-26 规范,推荐)
    • + *
    + *
  4. + *
  5. 调用 {@code client.initialize()} + {@code client.listTools()} 获取工具列表
  6. + *
  7. 将工具映射为 {@link ActionDefinition},动作名加 {@code mcp.{serverName}.} 前缀避免冲突
  8. + *
+ *

+ *

+ * 动作名约定:{@code mcp.{serverName}.{toolName}} + *
示例:{@code mcp.filesystem.readFile}、{@code mcp.github.searchRepos} + *

+ *

+ * 线程安全:clients 和 actions 映射使用 ConcurrentHashMap,支持并发读取。 + * 初始化在 {@link cn.com.mfish.common.ai.capability.CapabilityAutoConfiguration} 的 + * SmartInitializingSingleton 中触发。 + *

+ * + * @author: mfish + * @date: 2026/07/21 + */ +@Slf4j +@SuppressWarnings("deprecation") +public class McpCapabilityEngine implements CapabilitySubEngine { + + /** + * stdio 传输类型标识 + */ + private static final String TRANSPORT_STDIO = "stdio"; + + /** + * SSE 传输类型标识 + */ + private static final String TRANSPORT_SSE = "sse"; + + /** + * Streamable HTTP 传输类型标识(MCP 2025-03-26 规范,推荐用于新服务器) + */ + private static final String TRANSPORT_STREAMABLE = "streamable"; + + /** + * MCP 动作名前缀 + */ + private static final String MCP_ACTION_PREFIX = "mcp."; + + /** + * MCP 服务器配置提供者(由业务层注入,查询数据库) + */ + private final McpServerConfigProvider configProvider; + + /** + * MCP 客户端映射:serverName → McpSyncClient + */ + private final Map clients = new ConcurrentHashMap<>(); + + /** + * 动作映射:actionName → McpActionEntry(含 client 引用和原始 toolName) + */ + private final Map actionRegistry = new ConcurrentHashMap<>(); + + /** + * 已映射的 ActionDefinition 列表(不可变快照,getActions() 直接返回) + */ + private volatile List cachedActions = Collections.emptyList(); + + public McpCapabilityEngine(McpServerConfigProvider configProvider) { + this.configProvider = configProvider; + } + + @Override + public EngineType getEngineType() { + return EngineType.MCP; + } + + /** + * 获取所有 MCP 服务器的工具列表(并集) + *

+ * 返回的是初始化时缓存的快照,不会实时调用 MCP 服务器。 + * 如需刷新,调用 {@link #refresh()}。 + *

+ */ + @Override + public List getActions() { + return cachedActions; + } + + /** + * 执行 MCP 工具动作 + *

+ * 按 actionName 从 actionRegistry 查找对应的 McpSyncClient 和原始 toolName, + * 调用 {@code client.callTool(CallToolRequest)} 执行工具。 + *

+ */ + @Override + public ActionResult execute(String actionName, Map params, ExecutionContext ctx) { + long start = System.currentTimeMillis(); + McpActionEntry entry = actionRegistry.get(actionName); + if (entry == null) { + return ActionResult.failure(EngineType.MCP, + "未找到 MCP 动作: " + actionName, System.currentTimeMillis() - start); + } + + try { + McpSchema.CallToolRequest request = new McpSchema.CallToolRequest( + entry.toolName, params != null ? params : Collections.emptyMap()); + McpSchema.CallToolResult result = entry.client.callTool(request); + String output = extractTextContent(result); + return ActionResult.success(EngineType.MCP, output, System.currentTimeMillis() - start); + } catch (Exception e) { + log.error("[McpCapabilityEngine] 执行 MCP 工具失败 action={} tool={}", + actionName, entry.toolName, e); + return ActionResult.failure(EngineType.MCP, + "MCP 工具执行异常: " + e.getMessage(), System.currentTimeMillis() - start); + } + } + + /** + * 初始化:连接所有 MCP 服务器,发现工具,构建动作注册表 + *

+ * 由 {@link CapabilityAutoConfiguration} 的 SmartInitializingSingleton 触发。 + * 某个服务器连接失败不影响其他服务器。 + *

+ */ + public synchronized void refresh() { + if (configProvider == null) { + log.warn("[McpCapabilityEngine] 无 McpServerConfigProvider,跳过初始化"); + return; + } + + List configs; + try { + configs = configProvider.getActiveServerConfigs(); + } catch (Exception e) { + log.error("[McpCapabilityEngine] 获取 MCP 配置失败", e); + return; + } + + if (configs == null || configs.isEmpty()) { + log.info("[McpCapabilityEngine] 无活跃的 MCP 服务器配置"); + clearAll(); + return; + } + + // 清理旧的连接和动作 + clearAll(); + + List actions = new ArrayList<>(); + for (McpServerInfo config : configs) { + try { + McpSyncClient client = connectServer(config); + if (client == null) { + continue; + } + clients.put(config.getServerName(), client); + + // 列出工具并映射为 ActionDefinition + McpSchema.ListToolsResult toolsResult = client.listTools(); + if (toolsResult == null || toolsResult.tools() == null) { + continue; + } + for (McpSchema.Tool tool : toolsResult.tools()) { + String actionName = buildActionName(config.getServerName(), tool.name()); + ActionDefinition action = new ActionDefinition() + .setName(actionName) + .setDescription(tool.description()) + .setInputSchema(serializeSchema(tool.inputSchema())) + .setEngineType(EngineType.MCP) + .setServiceId(config.getServerName()); + actions.add(action); + actionRegistry.put(actionName, new McpActionEntry(client, tool.name())); + log.info("[McpCapabilityEngine] 注册 MCP 工具 server={} tool={} action={}", + config.getServerName(), tool.name(), actionName); + } + log.info("[McpCapabilityEngine] MCP 服务器 {} 连接成功,注册 {} 个工具", + config.getServerName(), toolsResult.tools().size()); + } catch (Exception e) { + log.error("[McpCapabilityEngine] MCP 服务器 {} 连接失败", config.getServerName(), e); + } + } + + this.cachedActions = Collections.unmodifiableList(actions); + log.info("[McpCapabilityEngine] 初始化完成,共 {} 个 MCP 服务器,{} 个工具", + clients.size(), actions.size()); + } + + /** + * 连接单个 MCP 服务器(支持 stdio / SSE / Streamable HTTP 三种 Transport) + */ + private McpSyncClient connectServer(McpServerInfo config) { + String transportType = config.getTransportType(); + if (TRANSPORT_STDIO.equalsIgnoreCase(transportType)) { + return connectStdio(config); + } else if (TRANSPORT_SSE.equalsIgnoreCase(transportType)) { + return connectSse(config); + } else if (TRANSPORT_STREAMABLE.equalsIgnoreCase(transportType)) { + return connectStreamable(config); + } else { + log.warn("[McpCapabilityEngine] 不支持的传输类型: {} server={}", transportType, config.getServerName()); + return null; + } + } + + /** + * stdio Transport:拉起本地进程 + */ + private McpSyncClient connectStdio(McpServerInfo config) { + if (StringUtils.isEmpty(config.getCommand())) { + log.warn("[McpCapabilityEngine] stdio 模式缺少 command 参数 server={}", config.getServerName()); + return null; + } + + ServerParameters.Builder builder = ServerParameters.builder(config.getCommand()); + + // 解析 args(JSON 数组字符串 → List) + if (StringUtils.isNotEmpty(config.getArgs())) { + try { + List argsList = JSON.parseArray(config.getArgs(), String.class); + builder.args(argsList); + } catch (Exception e) { + log.warn("[McpCapabilityEngine] 解析 args 失败 server={} args={}", + config.getServerName(), config.getArgs(), e); + } + } + + // 解析 env(JSON 对象字符串 → Map) + if (StringUtils.isNotEmpty(config.getEnv())) { + try { + Map envMap = JSON.parseObject(config.getEnv(), Map.class); + builder.env(envMap); + } catch (Exception e) { + log.warn("[McpCapabilityEngine] 解析 env 失败 server={} env={}", + config.getServerName(), config.getEnv(), e); + } + } + + // MCP SDK 2.0.0:StdioClientTransport 构造函数需要 McpJsonMapper 参数 + McpJsonMapper jsonMapper = McpJsonDefaults.getMapper(); + StdioClientTransport transport = new StdioClientTransport(builder.build(), jsonMapper); + return createAndInitialize(transport, config.getServerName()); + } + + /** + * SSE Transport:连接远程 MCP 服务 + */ + private McpSyncClient connectSse(McpServerInfo config) { + if (StringUtils.isEmpty(config.getSseUrl())) { + log.warn("[McpCapabilityEngine] SSE 模式缺少 sseUrl 参数 server={}", config.getServerName()); + return null; + } + + WebClient.Builder webClientBuilder = WebClient.builder() + .baseUrl(config.getSseUrl()); + + // 注入认证 Token + if (StringUtils.isNotEmpty(config.getAuthToken())) { + webClientBuilder.defaultHeader("Authorization", "Bearer " + config.getAuthToken()); + } + + WebFluxSseClientTransport.Builder transportBuilder = WebFluxSseClientTransport.builder(webClientBuilder); + if (StringUtils.isNotEmpty(config.getSseEndpoint())) { + transportBuilder.sseEndpoint(config.getSseEndpoint()); + } + + WebFluxSseClientTransport transport = transportBuilder.build(); + return createAndInitialize(transport, config.getServerName()); + } + + /** + * Streamable HTTP Transport:连接远程 MCP 服务(MCP 2025-03-26 规范,推荐用于新服务器) + *

+ * 与 SSE 的区别: + *

    + *
  • 单一 /mcp 端点,请求可流式响应也可普通响应
  • + *
  • 支持会话恢复(resumableStreams)
  • + *
  • 支持协议版本协商(supportedProtocolVersions)
  • + *
  • 是 MCP 2025-03-26 规范的标准 transport,SSE 已标记 deprecated
  • + *
+ *

+ *

+ * 字段复用:sseUrl 作为基础 URL,sseEndpoint 作为端点路径(通常为 /mcp)。 + *

+ */ + private McpSyncClient connectStreamable(McpServerInfo config) { + if (StringUtils.isEmpty(config.getSseUrl())) { + log.warn("[McpCapabilityEngine] streamable 模式缺少 sseUrl 参数 server={}", config.getServerName()); + return null; + } + + WebClient.Builder webClientBuilder = WebClient.builder() + .baseUrl(config.getSseUrl()); + + // 注入认证 Token + if (StringUtils.isNotEmpty(config.getAuthToken())) { + webClientBuilder.defaultHeader("Authorization", "Bearer " + config.getAuthToken()); + } + + WebClientStreamableHttpTransport.Builder transportBuilder = WebClientStreamableHttpTransport.builder(webClientBuilder); + if (StringUtils.isNotEmpty(config.getSseEndpoint())) { + transportBuilder.endpoint(config.getSseEndpoint()); + } + + WebClientStreamableHttpTransport transport = transportBuilder.build(); + return createAndInitialize(transport, config.getServerName()); + } + + /** + * 创建 McpSyncClient 并初始化连接 + */ + private McpSyncClient createAndInitialize(McpClientTransport transport, String serverName) { + McpSyncClient client = McpClient.sync(transport) + .requestTimeout(Duration.ofSeconds(60)) + .build(); + client.initialize(); + log.info("[McpCapabilityEngine] MCP 客户端已初始化 server={}", serverName); + return client; + } + + /** + * 构建 MCP 动作名:mcp.{serverName}.{toolName} + */ + private String buildActionName(String serverName, String toolName) { + return MCP_ACTION_PREFIX + serverName + "." + toolName; + } + + /** + * 从 CallToolResult 提取文本内容 + */ + private String extractTextContent(McpSchema.CallToolResult result) { + if (result == null || result.content() == null || result.content().isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (McpSchema.Content content : result.content()) { + if (content instanceof McpSchema.TextContent tc) { + sb.append(tc.text()); + } else { + // 非 TextContent 类型,序列化为 JSON + sb.append(JSON.toJSONString(content)); + } + sb.append("\n"); + } + return sb.toString().trim(); + } + + /** + * 将 MCP Tool 的 inputSchema 序列化为 JSON 字符串 + */ + private String serializeSchema(Object inputSchema) { + if (inputSchema == null) { + return "{}"; + } + try { + return JSON.toJSONString(inputSchema); + } catch (Exception e) { + log.warn("[McpCapabilityEngine] 序列化 inputSchema 失败", e); + return "{}"; + } + } + + /** + * 清理所有连接和动作注册表 + */ + private void clearAll() { + for (Map.Entry entry : clients.entrySet()) { + try { + entry.getValue().close(); + } catch (Exception e) { + log.warn("[McpCapabilityEngine] 关闭 MCP 客户端失败 server={}", entry.getKey(), e); + } + } + clients.clear(); + actionRegistry.clear(); + cachedActions = Collections.emptyList(); + } + + /** + * MCP 动作注册表条目:持有 McpSyncClient 引用和原始 toolName + */ + private static class McpActionEntry { + final McpSyncClient client; + final String toolName; + + McpActionEntry(McpSyncClient client, String toolName) { + this.client = client; + this.toolName = toolName; + } + } +} diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/McpServerConfigProvider.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/McpServerConfigProvider.java new file mode 100644 index 00000000..292d46eb --- /dev/null +++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/McpServerConfigProvider.java @@ -0,0 +1,27 @@ +package cn.com.mfish.common.ai.capability; + +import java.util.List; + +/** + * MCP 服务器配置提供者接口 + *

+ * 跨模块解耦:mf-common-ai 模块的 {@link McpCapabilityEngine} 通过此接口获取数据库中的 MCP 配置, + * 由 mf-ai 业务模块实现(查询 ai_mcp_server_config 表)。 + *

+ *

+ * 若 Spring 容器中无此接口的实现 Bean(如 mf-common-ai 独立测试场景), + * {@link McpCapabilityEngine} 不报错,返回空动作列表。 + *

+ * + * @author: mfish + * @date: 2026/07/21 + */ +public interface McpServerConfigProvider { + + /** + * 获取所有状态为"正常"的 MCP 服务器配置 + * + * @return MCP 服务器配置列表,无配置时返回空列表 + */ + List getActiveServerConfigs(); +} diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/McpServerInfo.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/McpServerInfo.java new file mode 100644 index 00000000..72764c53 --- /dev/null +++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/McpServerInfo.java @@ -0,0 +1,64 @@ +package cn.com.mfish.common.ai.capability; + +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * MCP 服务器配置信息(mf-common-ai 模块内的 DTO) + *

+ * 与 {@code McpServerConfig} 实体类解耦:mf-common-ai 不依赖 mf-ai-api 模块, + * 通过 {@link McpServerConfigProvider} 接口由业务层注入配置数据。 + *

+ * + * @author: mfish + * @date: 2026/07/21 + */ +@Data +@Accessors(chain = true) +public class McpServerInfo { + + /** + * MCP 服务器名称(唯一标识) + */ + private String serverName; + + /** + * 传输类型:stdio / sse / streamable + *
    + *
  • stdio — 拉起本地进程(Node.js/Python)
  • + *
  • sse — SSE Transport(MCP 2024-11-05 规范,已 deprecated)
  • + *
  • streamable — Streamable HTTP Transport(MCP 2025-03-26 规范,推荐)
  • + *
+ */ + private String transportType; + + /** + * stdio 模式启动命令(如 node / python) + */ + private String command; + + /** + * stdio 模式参数(JSON 数组字符串,如 ["server.js","--port","3000"]) + */ + private String args; + + /** + * stdio 模式环境变量(JSON 对象字符串) + */ + private String env; + + /** + * 远程 MCP 服务基础 URL(sse / streamable 模式通用,如 https://mcp.example.com) + */ + private String sseUrl; + + /** + * 远程 MCP 服务端点路径(sse / streamable 模式通用,如 /sse 或 /mcp) + */ + private String sseEndpoint; + + /** + * 认证 Token(Bearer) + */ + private String authToken; +} diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ToolCapabilityEngine.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ToolCapabilityEngine.java new file mode 100644 index 00000000..676a19df --- /dev/null +++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ToolCapabilityEngine.java @@ -0,0 +1,175 @@ +package cn.com.mfish.common.ai.capability; + +import cn.com.mfish.common.ai.agent.TenantContext; +import cn.com.mfish.common.ai.engine.ApiToolEngine; +import cn.com.mfish.common.core.constants.RPCConstants; +import cn.com.mfish.common.core.utils.AuthInfoUtils; +import cn.com.mfish.common.core.utils.StringUtils; +import com.alibaba.fastjson2.JSON; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.server.ServerWebExchange; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 工具能力引擎适配器 + *

+ * 将现有 {@link ApiToolEngine} 包装为 {@link CapabilitySubEngine}, + * 使 Feign/OpenAPI 工具能纳入 CapabilityEngine 的统一能力发现与执行体系。 + *

+ *

+ * 适配逻辑: + *

    + *
  • {@link #getActions()}:遍历 ApiToolEngine 所有 ToolCallback, + * 将 {@code ToolDefinition}(name/description/inputSchema)转换为 {@link ActionDefinition}
  • + *
  • {@link #execute}:按 actionName 查找 ToolCallback, + * 将 params Map 序列化为 JSON 字符串,构建 Spring AI ToolContext, + * 调用 {@code ToolCallback.call(toolInput, toolContext)}
  • + *
+ *

+ *

+ * 与 LLM 驱动模式的关系: + *

    + *
  • LLM 驱动模式:BaseAssistant 直接调用 {@code apiToolEngine.getToolCallbackProvider(serviceIds)} + * 传给 ChatClient,由 LLM 决定调用哪个工具。此模式不经过 ToolCapabilityEngine。
  • + *
  • 显式调用模式:Planner 通过 {@code capabilityEngine.executeAction(actionName, params, ctx)} + * 直接执行指定动作。此模式经过 ToolCapabilityEngine。
  • + *
  • 两种模式共享同一套 ToolCallback 实例,执行结果一致。
  • + *
+ *

+ * + * @author: mfish + * @date: 2026/07/21 + */ +@Slf4j +public class ToolCapabilityEngine implements CapabilitySubEngine { + + private final ApiToolEngine apiToolEngine; + + public ToolCapabilityEngine(ApiToolEngine apiToolEngine) { + this.apiToolEngine = apiToolEngine; + } + + @Override + public EngineType getEngineType() { + return EngineType.TOOL; + } + + /** + * 将 ApiToolEngine 中所有 ToolCallback 转换为 ActionDefinition + *

+ * 动作名直接使用 ToolCallback 的原始 name(与 LLM 驱动模式保持一致), + * 跨服务同名动作由 ApiToolEngine 去重策略保证唯一。 + * serviceId 字段标识动作来源服务,便于 Planner 按服务过滤。 + *

+ */ + @Override + public List getActions() { + List actions = new ArrayList<>(); + for (String serviceId : apiToolEngine.getAllServiceIds()) { + org.springframework.ai.tool.ToolCallbackProvider provider = + apiToolEngine.getToolCallbackProvider(serviceId); + if (provider == null) { + continue; + } + org.springframework.ai.tool.ToolCallback[] callbacks = provider.getToolCallbacks(); + if (callbacks == null) { + continue; + } + for (org.springframework.ai.tool.ToolCallback tc : callbacks) { + try { + ToolDefinition td = tc.getToolDefinition(); + actions.add(new ActionDefinition() + .setName(td.name()) + .setDescription(td.description()) + .setInputSchema(td.inputSchema()) + .setEngineType(EngineType.TOOL) + .setServiceId(serviceId)); + } catch (Exception e) { + log.warn("[ToolCapabilityEngine] 转换动作失败 serviceId={}", serviceId, e); + } + } + } + return actions; + } + + /** + * 执行工具动作 + *

+ * 通过 ApiToolEngine 查找 actionName 对应的 ToolCallback, + * 将 params 序列化为 JSON 字符串作为 toolInput, + * 从 ExecutionContext 提取租户信息构建 ToolContext, + * 调用 {@code ToolCallback.call(toolInput, toolContext)}。 + *

+ */ + @Override + public ActionResult execute(String actionName, Map params, ExecutionContext ctx) { + long start = System.currentTimeMillis(); + ToolCallback callback = apiToolEngine.findToolCallback(actionName); + if (callback == null) { + return ActionResult.failure(EngineType.TOOL, + "未找到工具动作: " + actionName, System.currentTimeMillis() - start); + } + + // 构建 Spring AI ToolContext(复用 BaseAssistant 的 context key 约定) + ToolContext toolContext = buildToolContext(ctx); + + // params 序列化为 JSON 字符串 + String toolInput = (params == null || params.isEmpty()) + ? "{}" + : JSON.toJSONString(params); + + try { + String result = callback.call(toolInput, toolContext); + return ActionResult.success(EngineType.TOOL, result, System.currentTimeMillis() - start); + } catch (Exception e) { + log.error("[ToolCapabilityEngine] 执行工具动作失败 name={} input={}", + actionName, toolInput, e); + return ActionResult.failure(EngineType.TOOL, + "工具执行异常: " + e.getMessage(), System.currentTimeMillis() - start); + } + } + + /** + * 从 ExecutionContext 构建 Spring AI ToolContext + *

+ * 复用 {@code BaseAssistant.buildToolContextFromSnapshot} 的 key 约定 + * (RPCConstants.REQ_* 常量),保证 FeignToolCallback/HttpToolCallback + * 的内部参数自动填充逻辑一致。 + *

+ */ + private ToolContext buildToolContext(ExecutionContext ctx) { + Map contextMap = new HashMap<>(); + + if (ctx != null && ctx.getTenantContext() != null) { + TenantContext tc = ctx.getTenantContext(); + contextMap.put(RPCConstants.REQ_USER_ID, tc.getUserId() != null ? tc.getUserId() : ""); + contextMap.put(RPCConstants.REQ_TENANT_ID, + tc.getTenantId() != null ? tc.getTenantId() : AuthInfoUtils.SUPER_TENANT_ID); + contextMap.put(RPCConstants.REQ_ORIGIN, RPCConstants.AI); + if (StringUtils.isNotEmpty(tc.getAccessToken())) { + contextMap.put(RPCConstants.REQ_TOKEN, tc.getAccessToken()); + } + if (tc.getRequestAttributes() instanceof RequestAttributes ra) { + contextMap.put(RPCConstants.REQ_REQUEST_ATTRIBUTES, ra); + } + if (tc.getServerWebExchange() instanceof ServerWebExchange swe) { + contextMap.put(RPCConstants.REQ_SERVER_WEB_EXCHANGE, swe); + } + } else { + // 无 TenantContext 时填充默认值,避免内部参数解析失败 + contextMap.put(RPCConstants.REQ_USER_ID, ""); + contextMap.put(RPCConstants.REQ_TENANT_ID, AuthInfoUtils.SUPER_TENANT_ID); + contextMap.put(RPCConstants.REQ_ORIGIN, RPCConstants.AI); + } + + return new ToolContext(contextMap); + } +} diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/engine/ApiToolEngine.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/engine/ApiToolEngine.java index 493d0267..3a26ec54 100644 --- a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/engine/ApiToolEngine.java +++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/engine/ApiToolEngine.java @@ -186,4 +186,53 @@ public class ApiToolEngine { public ToolCallbackProvider getToolCallbackProvider(Set serviceIds) { return ToolCallbackProvider.from(getToolCallbacks(serviceIds)); } + + /** + * 获取所有已注册的微服务ID + *

+ * 供 {@code ToolCapabilityEngine} 枚举所有服务的工具,将其暴露为 ActionDefinition。 + *

+ * + * @return 已注册的 serviceId 集合(不可变快照) + */ + public Set getAllServiceIds() { + return Set.copyOf(toolsByService.keySet()); + } + + /** + * 获取所有服务的全部工具(跨服务,同名工具按 serviceId 顺序去重) + *

+ * 供 {@code ToolCapabilityEngine} 构建全局动作列表。 + * 去重策略与 {@link #getToolCallbacks(Collection)} 一致:先注册的保留。 + *

+ * + * @return 全部工具列表(去重后的快照) + */ + public List getAllToolCallbacks() { + return getToolCallbacks(toolsByService.keySet()); + } + + /** + * 按动作名查找对应的 ToolCallback + *

+ * 供 {@code ToolCapabilityEngine.execute()} 通过 actionName 路由到具体工具。 + * 遍历所有服务,返回首个名称匹配的 ToolCallback。 + *

+ * + * @param actionName 动作名(即 ToolCallback.getToolDefinition().name()) + * @return 匹配的 ToolCallback,未找到时返回 null + */ + public ToolCallback findToolCallback(String actionName) { + if (actionName == null || actionName.isEmpty()) { + return null; + } + for (List callbacks : toolsByService.values()) { + for (ToolCallback tc : callbacks) { + if (actionName.equals(tc.getToolDefinition().name())) { + return tc; + } + } + } + return null; + } } diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/ConversationMemory.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/ConversationMemory.java new file mode 100644 index 00000000..0c15ff4a --- /dev/null +++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/ConversationMemory.java @@ -0,0 +1,268 @@ +package cn.com.mfish.common.ai.memory; + +import cn.com.mfish.common.ai.agent.TenantContext; + +import java.util.List; +import java.util.Map; + +/** + * 会话级记忆容器(Conversation Memory) + *

+ * 三驾马车驱动的 AI 架构 —— Memory 模块的核心抽象。 + * 每个 sessionId 对应一个 ConversationMemory 实例,聚合会话内 Spring AI 管不到的两类上下文: + *

+ *
    + *
  1. Vars Context(变量上下文):低代码平台运行实例中的局部变量、用户凭证(Token)、 + * 租户上下文等。统一收纳原本散落在 TenantContext / ToolContext / 业务变量中的数据, + * 通过 {@link #updateVariable} / {@link #getVariable} 读写,{@link #getVarsContext} + * 供 Planner 一次性快照。
  2. + *
  3. Document Context(文档上下文):由 FileParseService 解析后的临时文件块(Chunk)。 + * 通过 {@link #addDocumentChunks} 批量注入,{@link #getDocumentChunks} 读取, + * {@link #getDocumentContext} 直接产出 LLM 可读的拼接文本。
  4. + *
+ *

+ * 为什么不管理 Short-term Memory(Chat 历史)? + * Spring AI 的 {@code ChatMemory} + {@code MessageChatMemoryAdvisor} 已经完整覆盖 + * "对话历史存储 + 自动注入 LLM 上下文" 两个职责,本模块不再重复实现,避免双写不一致。 + * Planner / Assistant 若需要读取对话历史,直接注入 Spring AI 的 {@code ChatMemory} 即可。 + *

+ *

+ * 接口设计原则: + *

    + *
  • 会话隔离:所有方法以当前实例绑定的 sessionId 为作用域,不跨会话泄漏
  • + *
  • 两段独立:Vars 和 Documents 互不耦合,可分别清理({@link #clearVars} / {@link #clearDocuments})
  • + *
  • 上下文拼接:{@link #getSystemContext()} 一次性产出 LLM 系统提示词所需的全部上下文片段
  • + *
  • 不耦合存储:本接口只定义读写契约,底层存储由实现类决定(内存/Redis/DB)
  • + *
+ *

+ * + * @author: mfish + * @date: 2026/07/20 + */ +public interface ConversationMemory { + + /** + * 获取当前会话 ID + */ + String getSessionId(); + + // ==================== Vars Context ==================== + + /** + * 更新一个变量到 Vars Context + *

+ * 若变量已存在则覆盖。常见用法: + *

+ *
    + *
  • 低代码平台运行时写入局部变量(如 `userId` / `formId` / `businessKey`)
  • + *
  • 认证拦截器写入用户凭证(`accessToken` / `refreshToken`)
  • + *
  • 编排流程写入中间结果(如 Planner 产出的 `currentPlan` / Executor 写入的 `stepResults`)
  • + *
+ * + * @param key 变量名(建议使用命名空间前缀避免冲突,如 `sys.userId` / `biz.formId`) + * @param value 变量值(可为 null,表示移除该变量) + */ + void updateVariable(String key, Object value); + + /** + * 读取一个变量 + * + * @param key 变量名 + * @return 变量值;不存在返回 null + */ + Object getVariable(String key); + + /** + * 读取一个变量并按指定类型转换 + * + * @param key 变量名 + * @param targetType 目标类型 + * @param 泛型 + * @return 转换后的值;不存在或类型不匹配返回 null + */ + T getVariable(String key, Class targetType); + + /** + * 获取 Vars Context 的快照(只读视图) + *

+ * Planner 拼接上下文时调用,避免逐个 getVariable 的开销。 + * 返回的 Map 不可变,修改需通过 {@link #updateVariable}。 + *

+ * + * @return 变量快照(不可变 Map) + */ + Map getVarsContext(); + + /** + * 绑定租户上下文到 Vars Context + *

+ * 将 TenantContext 的 5 个字段(tenantId / userId / accessToken / requestAttributes / + * serverWebExchange)按约定的 key 写入 Vars Context,供后续工具调用恢复认证态。 + *

+ *

+ * 这是对原 AgentRuntime.captureTenantContext + BaseAssistant.buildToolContext 流程的统一收纳: + * 原本散落在两处的"捕获-传递-解包"逻辑,现在统一为 Memory 的一次 bind 调用。 + *

+ * + * @param tenantContext 租户上下文快照(null 表示清除绑定的租户信息) + */ + void bindTenantContext(TenantContext tenantContext); + + /** + * 清空 Vars Context + */ + void clearVars(); + + // ==================== Document Context ==================== + + /** + * 批量添加文档块到 Document Context + *

+ * FileParseService 解析文件后调用此方法注入。重复添加相同 chunkId 的块会被去重。 + *

+ * + * @param chunks 文档块列表(不能为 null,可为空列表) + */ + void addDocumentChunks(List chunks); + + /** + * 获取当前会话的全部文档块 + *

+ * 返回的是副本或不可变视图,外部修改不影响内部状态。 + *

+ * + * @return 文档块列表(可能为空,不会为 null) + */ + List getDocumentChunks(); + + /** + * 获取 Document Context 拼接后的文本 + *

+ * 按 chunkIndex 顺序拼接所有文档块内容,每个块带文件名分隔符。 + * Planner / Assistant 注入 LLM 上下文时调用此方法,无需自己拼接。 + *

+ *

+ * 全量注入语义:本方法返回当前会话的全部文档块内容,适合文档量较小的场景。 + * 文档量较大时(几十份 PDF / 大型代码库),建议改用 {@link #searchDocumentContext} 按相关性检索, + * 避免 LLM 上下文窗口爆炸。 + *

+ *

+ * 示例输出: + *

+ *
+     * === 文件: example.docx ===
+     * 文件内容...
+     * === 文件结束: example.docx ===
+     * 
+ * + * @return 拼接后的文本;无文档时返回空字符串 + */ + String getDocumentContext(); + + /** + * 按相关性检索文档块(向量库扩展点) + *

+ * 默认实现(内存版):忽略 query,返回全部文档块(截断到 topK)。 + * 这等价于"全量注入",保持与 {@link #getDocumentChunks()} 一致的行为。 + *

+ *

+ * 向量库版实现:将 query 文本向量化,按余弦相似度返回最相关的 topK 个 chunk。 + * 这是 RAG(检索增强生成)的核心入口,让 LLM 只看到最相关的文档片段而非全部文档。 + *

+ *

+ * 调用方建议: + *

    + *
  • 文档量小(< 5 个文件或 < 20K 字符):用 {@link #getDocumentChunks()} 全量注入
  • + *
  • 文档量大:用 {@code searchDocuments(prompt, 5)} 检索 topK 个最相关 chunk
  • + *
+ *

+ * + * @param query 检索查询文本(通常是用户 prompt 或当前任务描述);内存版可忽略此参数 + * @param topK 返回的最大 chunk 数;<= 0 表示不限制(返回全部) + * @return 检索到的文档块列表(按相关性或 chunkIndex 排序);可能为空,不会为 null + */ + default List searchDocuments(String query, int topK) { + List all = getDocumentChunks(); + if (topK <= 0 || all.size() <= topK) { + return all; + } + return new java.util.ArrayList<>(all.subList(0, topK)); + } + + /** + * 按相关性检索并拼接为 LLM 上下文文本(向量库扩展点) + *

+ * 默认实现:调用 {@link #searchDocuments(query, topK)} 检索,然后按 + * {@link #getDocumentContext()} 的格式拼接。 + *

+ *

+ * 向量库版可重写此方法以使用更高效的批量检索 API,或在拼接时附加相关性分数。 + *

+ * + * @param query 检索查询文本;内存版可忽略 + * @param topK 返回的最大 chunk 数;<= 0 表示不限制 + * @return 拼接后的文本;无文档或检索无结果时返回空字符串 + */ + default String searchDocumentContext(String query, int topK) { + List chunks = searchDocuments(query, topK); + if (chunks.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (DocumentChunk chunk : chunks) { + String fileName = chunk.getFileName() != null ? chunk.getFileName() : chunk.getFileKey(); + sb.append("=== 文件: ").append(fileName).append(" ===\n"); + sb.append(chunk.getContent() != null ? chunk.getContent() : ""); + sb.append("\n=== 文件结束: ").append(fileName).append(" ===\n\n"); + } + return sb.toString(); + } + + /** + * 清空 Document Context + */ + void clearDocuments(); + + // ==================== 上下文拼接 ==================== + + /** + * 一次性产出 LLM 系统提示词所需的全部上下文 + *

+ * Planner 在构建 prompt 时调用此方法,获取 Vars Context 和 Document Context 的拼接文本, + * 注入到 system prompt 中。Short-term Memory(Chat 历史)由 Spring AI + * MessageChatMemoryAdvisor 自动注入,不在此方法产出范围内。 + *

+ *

+ * 输出格式示例: + *

+ *
+     * 【用户上下文】
+     * 租户: tenant_001
+     * 用户: user_123
+     *
+     * 【业务变量】
+     * formId: 1001
+     * businessKey: ORDER-2026-001
+     *
+     * 【上传文件】
+     * === 文件: example.docx ===
+     * 文件内容...
+     * === 文件结束: example.docx ===
+     * 
+ * + * @return 系统上下文文本;无任何上下文时返回空字符串 + */ + String getSystemContext(); + + /** + * 清空所有记忆(Vars + Documents) + *

+ * 用于会话彻底销毁场景。会话本身的存储条目由 {@link ConversationMemoryStore} 管理。 + *

+ *

+ * 注意:此方法不清空 Spring AI 的 ChatMemory(对话历史),那部分由 Spring AI + * 自己管理,会话结束时业务方需另行调用 {@code ChatMemory.clear(sessionId)}。 + *

+ */ + void clearAll(); +} diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/ConversationMemoryStore.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/ConversationMemoryStore.java new file mode 100644 index 00000000..f84a2443 --- /dev/null +++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/ConversationMemoryStore.java @@ -0,0 +1,85 @@ +package cn.com.mfish.common.ai.memory; + +import java.util.List; + +/** + * 会话记忆存储(Conversation Memory Store) + *

+ * Memory 模块的入口,负责按 sessionId 创建、获取、销毁 {@link ConversationMemory} 实例。 + * 对外暴露类似 Map 的语义,但底层可以是内存 / Redis / 数据库。 + *

+ *

+ * 使用方式: + *

+ *
+ * // 在请求入口获取(或创建)会话 Memory
+ * ConversationMemory memory = memoryStore.getOrCreate(sessionId);
+ *
+ * // 注入上下文
+ * memory.bindTenantContext(tenantContext);
+ * memory.addDocumentChunks(chunks);
+ * memory.updateVariable("biz.formId", 1001);
+ *
+ * // Planner / Assistant 读取上下文
+ * String systemContext = memory.getSystemContext();
+ *
+ * // 会话结束后清理
+ * memoryStore.remove(sessionId);
+ * 
+ *

+ * 设计为 Spring Bean,由 {@code MemoryAutoConfiguration} 注册。 + * Controller / Assistant / Runtime 通过依赖注入获取。 + *

+ * + * @author: mfish + * @date: 2026/07/20 + */ +public interface ConversationMemoryStore { + + /** + * 获取或创建会话 Memory + *

+ * 首次访问某 sessionId 时创建新实例并缓存;后续访问返回同一实例。 + * 实例的生命周期由实现类管理(如基于 TTL 自动过期)。 + *

+ * + * @param sessionId 会话ID(不能为 null 或空) + * @return 会话 Memory 实例 + */ + ConversationMemory getOrCreate(String sessionId); + + /** + * 获取会话 Memory(不创建) + * + * @param sessionId 会话ID + * @return 会话 Memory 实例;不存在返回 null + */ + ConversationMemory get(String sessionId); + + /** + * 移除会话 Memory 并清理其内部状态 + *

+ * 触发 {@link ConversationMemory#clearAll()},并从存储中移除。 + * 用于会话主动关闭、用户登出等场景。 + *

+ * + * @param sessionId 会话ID + * @return 被移除的实例;不存在返回 null + */ + ConversationMemory remove(String sessionId); + + /** + * 获取当前活跃的会话 ID 列表 + *

+ * 主要用于监控、调试、定期清理任务。 + *

+ * + * @return 会话 ID 列表(不可变) + */ + List listSessionIds(); + + /** + * 当前存储的会话数量 + */ + int size(); +} diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/DocumentChunk.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/DocumentChunk.java new file mode 100644 index 00000000..5a0ad758 --- /dev/null +++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/DocumentChunk.java @@ -0,0 +1,79 @@ +package cn.com.mfish.common.ai.memory; + +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * 文档块(Document Chunk) + *

+ * 由 FileParseService 解析文件后产出的最小内容单元。一个文件可能产生多个 Chunk, + * 也可能只有一个 Chunk(小文件)。Memory 模块按 Chunk 维度管理文档上下文, + * 便于后续扩展:按相关性检索(RAG)、按 token 上限分片注入等。 + *

+ *

+ * 设计为不可变值对象:构造后内容不再修改,需要更新时替换整个 Chunk。 + *

+ * + * @author: mfish + * @date: 2026/07/20 + */ +@Data +@Accessors(chain = true) +public class DocumentChunk { + /** + * 块ID(建议使用 UUID 或 fileKey + 序号) + */ + private String chunkId; + /** + * 来源文件的 fileKey(Storage 服务的唯一标识) + */ + private String fileKey; + /** + * 来源文件名(用于 LLM 提示词展示) + */ + private String fileName; + /** + * 文件 MIME 类型(用于 LLM 提示词展示和后续分流处理) + */ + private String fileType; + /** + * 块文本内容(已提取为纯文本,可直接注入 LLM 上下文) + */ + private String content; + /** + * 在原文件中的序号(从 0 开始;单文件单块时为 0) + */ + private int chunkIndex; + /** + * 块字符数(便于 token 估算和上下文裁剪) + */ + private int length; + /** + * 文本向量(embedding),用于向量库相似度检索 + *

+ * 维度由 embedding model 决定(如 OpenAI text-embedding-3-small 为 1536 维)。 + *

+ *

+ * 使用约定: + *

    + *
  • 内存版 Memory:不使用此字段(始终为 null),searchDocuments 走全量返回
  • + *
  • 向量库版 Memory:由 Store 在写入时调用 embedding model 计算并存储, + * chunk 对象上的此字段可为 null(向量库内部维护真正的向量索引)
  • + *
  • 调试场景:可手动填充此字段用于离线分析
  • + *
+ *

+ *

+ * 类型为 {@code float[]} 而非 {@code List},避免装箱开销; + * Lombok {@code @Data} 会基于内容生成 equals/hashCode,正常使用无影响。 + *

+ */ + private float[] embedding; + /** + * 解析时间戳(毫秒) + */ + private long parsedAt; + + public DocumentChunk() { + this.parsedAt = System.currentTimeMillis(); + } +} diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/InMemoryConversationMemory.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/InMemoryConversationMemory.java new file mode 100644 index 00000000..10df1cae --- /dev/null +++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/InMemoryConversationMemory.java @@ -0,0 +1,279 @@ +package cn.com.mfish.common.ai.memory; + +import cn.com.mfish.common.ai.agent.TenantContext; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 基于 JVM 内存的 {@link ConversationMemory} 默认实现 + *

+ * 两段记忆的存储策略: + *

    + *
  • Vars Context:使用 {@link ConcurrentHashMap} 存储变量,支持并发读写。 + * 值为 Object,业务方自行约定类型契约。
  • + *
  • Document Context:使用 {@link List} + 同步锁保护,因为文档块通常批量写入、 + * 顺序读出。按 chunkId 去重。
  • + *
+ *

+ *

+ * 不管理 Short-term Memory:对话历史由 Spring AI 的 {@code ChatMemory} + + * {@code MessageChatMemoryAdvisor} 自动管理,本类不涉及,避免双写不一致。 + *

+ *

+ * 线程安全:本类所有方法线程安全,可被多个线程(请求线程 + 异步编排线程)并发访问。 + * 实例由 {@link InMemoryConversationMemoryStore} 按 sessionId 单例化,无需关心实例级并发。 + *

+ * + * @author: mfish + * @date: 2026/07/20 + */ +@Slf4j +public class InMemoryConversationMemory implements ConversationMemory { + + /** + * Vars Context 中租户上下文的标准 key 前缀 + */ + private static final String TENANT_KEY_PREFIX = "sys.tenant."; + /** tenantId 在 Vars Context 中的 key */ + public static final String KEY_TENANT_ID = "sys.tenant.tenantId"; + /** userId 在 Vars Context 中的 key */ + public static final String KEY_USER_ID = "sys.tenant.userId"; + /** accessToken 在 Vars Context 中的 key */ + public static final String KEY_ACCESS_TOKEN = "sys.tenant.accessToken"; + /** requestAttributes 在 Vars Context 中的 key(Servlet) */ + public static final String KEY_REQUEST_ATTRIBUTES = "sys.tenant.requestAttributes"; + /** serverWebExchange 在 Vars Context 中的 key(WebFlux) */ + public static final String KEY_SERVER_WEB_EXCHANGE = "sys.tenant.serverWebExchange"; + + private final String sessionId; + + /** Vars Context:业务变量 + 租户上下文,统一存储 */ + private final Map varsContext = new ConcurrentHashMap<>(); + + /** Document Context:文件解析块列表,按 chunkIndex 顺序维护 */ + private final List documentChunks = new ArrayList<>(); + + /** 文档块去重索引:chunkId → 是否已存在 */ + private final Map chunkIndex = new ConcurrentHashMap<>(); + + public InMemoryConversationMemory(String sessionId) { + this.sessionId = Objects.requireNonNull(sessionId, "sessionId 不能为 null"); + } + + @Override + public String getSessionId() { + return sessionId; + } + + // ==================== Vars Context ==================== + + @Override + public void updateVariable(String key, Object value) { + if (key == null || key.isEmpty()) { + return; + } + if (value == null) { + varsContext.remove(key); + } else { + varsContext.put(key, value); + } + } + + @Override + public Object getVariable(String key) { + if (key == null || key.isEmpty()) { + return null; + } + return varsContext.get(key); + } + + @Override + @SuppressWarnings("unchecked") + public T getVariable(String key, Class targetType) { + Object value = getVariable(key); + if (value == null) { + return null; + } + if (targetType.isInstance(value)) { + return (T) value; + } + log.warn("[Memory] 变量类型不匹配 key={} expected={} actual={}", + key, targetType.getName(), value.getClass().getName()); + return null; + } + + @Override + public Map getVarsContext() { + // 返回不可变快照,外部修改需通过 updateVariable + return Collections.unmodifiableMap(new LinkedHashMap<>(varsContext)); + } + + @Override + public void bindTenantContext(TenantContext tenantContext) { + if (tenantContext == null) { + // 清除绑定的租户信息 + varsContext.remove(KEY_TENANT_ID); + varsContext.remove(KEY_USER_ID); + varsContext.remove(KEY_ACCESS_TOKEN); + varsContext.remove(KEY_REQUEST_ATTRIBUTES); + varsContext.remove(KEY_SERVER_WEB_EXCHANGE); + return; + } + varsContext.put(KEY_TENANT_ID, tenantContext.getTenantId()); + varsContext.put(KEY_USER_ID, tenantContext.getUserId()); + varsContext.put(KEY_ACCESS_TOKEN, tenantContext.getAccessToken()); + if (tenantContext.getRequestAttributes() != null) { + varsContext.put(KEY_REQUEST_ATTRIBUTES, tenantContext.getRequestAttributes()); + } + if (tenantContext.getServerWebExchange() != null) { + varsContext.put(KEY_SERVER_WEB_EXCHANGE, tenantContext.getServerWebExchange()); + } + } + + @Override + public void clearVars() { + varsContext.clear(); + } + + // ==================== Document Context ==================== + + @Override + public void addDocumentChunks(List chunks) { + if (chunks == null || chunks.isEmpty()) { + return; + } + synchronized (documentChunks) { + for (DocumentChunk chunk : chunks) { + if (chunk == null || chunk.getChunkId() == null) { + continue; + } + // chunkId 去重 + if (chunkIndex.putIfAbsent(chunk.getChunkId(), Boolean.TRUE) != null) { + log.debug("[Memory] 文档块已存在,跳过 chunkId={} fileName={}", + chunk.getChunkId(), chunk.getFileName()); + continue; + } + documentChunks.add(chunk); + } + // 按 chunkIndex 字段排序,保证顺序稳定 + documentChunks.sort((a, b) -> Integer.compare(a.getChunkIndex(), b.getChunkIndex())); + } + } + + @Override + public List getDocumentChunks() { + synchronized (documentChunks) { + return new ArrayList<>(documentChunks); + } + } + + @Override + public String getDocumentContext() { + List snapshot; + synchronized (documentChunks) { + if (documentChunks.isEmpty()) { + return ""; + } + snapshot = new ArrayList<>(documentChunks); + } + StringBuilder sb = new StringBuilder(); + for (DocumentChunk chunk : snapshot) { + String fileName = chunk.getFileName() != null ? chunk.getFileName() : chunk.getFileKey(); + sb.append("=== 文件: ").append(fileName).append(" ===\n"); + sb.append(chunk.getContent() != null ? chunk.getContent() : ""); + sb.append("\n=== 文件结束: ").append(fileName).append(" ===\n\n"); + } + return sb.toString(); + } + + @Override + public void clearDocuments() { + synchronized (documentChunks) { + documentChunks.clear(); + chunkIndex.clear(); + } + } + + // ==================== 上下文拼接 ==================== + + @Override + public String getSystemContext() { + StringBuilder sb = new StringBuilder(); + + // 拼接 Vars Context(仅展示 sys.tenant.* 和非内部变量) + Map vars = getVarsContext(); + String tenantContext = buildTenantSection(vars); + if (!tenantContext.isEmpty()) { + sb.append("【用户上下文】\n").append(tenantContext).append("\n"); + } + + String bizVars = buildBizVarsSection(vars); + if (!bizVars.isEmpty()) { + sb.append("【业务变量】\n").append(bizVars).append("\n"); + } + + // 拼接 Document Context + String docContext = getDocumentContext(); + if (!docContext.isEmpty()) { + sb.append("【上传文件】\n").append(docContext); + } + + return sb.toString(); + } + + /** + * 构建租户上下文段落 + */ + private String buildTenantSection(Map vars) { + Object tenantId = vars.get(KEY_TENANT_ID); + Object userId = vars.get(KEY_USER_ID); + if (tenantId == null && userId == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + if (tenantId != null) { + sb.append("租户: ").append(tenantId).append("\n"); + } + if (userId != null) { + sb.append("用户: ").append(userId).append("\n"); + } + return sb.toString(); + } + + /** + * 构建业务变量段落(过滤掉 sys.tenant.* 内部变量和 requestAttributes/exchange 等非文本对象) + */ + private String buildBizVarsSection(Map vars) { + StringBuilder sb = new StringBuilder(); + vars.forEach((key, value) -> { + // 跳过租户上下文相关 key(已在用户上下文段落展示) + if (key.startsWith(TENANT_KEY_PREFIX)) { + return; + } + // 跳过非文本对象(RequestAttributes / ServerWebExchange 等) + if (value == null || value.getClass().getName().startsWith("org.springframework.web")) { + return; + } + // 跳过 Class 对象 + if (value instanceof Class) { + return; + } + sb.append(key).append(": ").append(value).append("\n"); + }); + return sb.toString(); + } + + @Override + public void clearAll() { + // 仅清空本模块管理的两段记忆;Spring AI 的 ChatMemory 不在此清理 + clearVars(); + clearDocuments(); + } +} diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/InMemoryConversationMemoryStore.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/InMemoryConversationMemoryStore.java new file mode 100644 index 00000000..4480d407 --- /dev/null +++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/InMemoryConversationMemoryStore.java @@ -0,0 +1,75 @@ +package cn.com.mfish.common.ai.memory; + +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.stream.Collectors; + +/** + * 基于 JVM 内存的 {@link ConversationMemoryStore} 默认实现 + *

+ * 使用 {@link ConcurrentHashMap} 按 sessionId 缓存 {@link ConversationMemory} 实例。 + * 实例首次访问时创建并缓存,后续访问返回同一实例(sessionId 级单例)。 + *

+ *

+ * 限制:进程内存储,重启丢失;不支持分布式部署下的会话漂移。 + * 后续可替换为 Redis 实现以支持多实例共享。 + *

+ *

+ * 注意:本 Store 只管理 Vars Context 和 Document Context,不管理 Spring AI 的 + * ChatMemory(对话历史)。会话结束时如需清空对话历史,业务方需另行调用 + * {@code ChatMemory.clear(sessionId)}。 + *

+ * + * @author: mfish + * @date: 2026/07/20 + */ +public class InMemoryConversationMemoryStore implements ConversationMemoryStore { + + private final ConcurrentMap store = new ConcurrentHashMap<>(); + + public InMemoryConversationMemoryStore() { + } + + @Override + public ConversationMemory getOrCreate(String sessionId) { + Objects.requireNonNull(sessionId, "sessionId 不能为 null"); + if (sessionId.isEmpty()) { + throw new IllegalArgumentException("sessionId 不能为空字符串"); + } + return store.computeIfAbsent(sessionId, InMemoryConversationMemory::new); + } + + @Override + public ConversationMemory get(String sessionId) { + if (sessionId == null || sessionId.isEmpty()) { + return null; + } + return store.get(sessionId); + } + + @Override + public ConversationMemory remove(String sessionId) { + if (sessionId == null || sessionId.isEmpty()) { + return null; + } + ConversationMemory removed = store.remove(sessionId); + if (removed != null) { + removed.clearAll(); + } + return removed; + } + + @Override + public List listSessionIds() { + return store.keySet().stream() + .sorted() + .collect(Collectors.toList()); + } + + @Override + public int size() { + return store.size(); + } +} diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/MemoryAutoConfiguration.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/MemoryAutoConfiguration.java new file mode 100644 index 00000000..06037812 --- /dev/null +++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/MemoryAutoConfiguration.java @@ -0,0 +1,33 @@ +package cn.com.mfish.common.ai.memory; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Memory 模块自动配置 + *

+ * 注册 {@link ConversationMemoryStore} 的默认实现 {@link InMemoryConversationMemoryStore}。 + *

+ *

+ * 本模块只管理 Spring AI 管不到的两类上下文:Vars Context(变量/凭证)和 + * Document Context(文件解析块)。对话历史(Short-term Memory)由 Spring AI 的 + * {@code ChatMemory} + {@code MessageChatMemoryAdvisor} 自动管理,本模块不涉及。 + *

+ *

+ * 业务方可通过自定义 {@link ConversationMemoryStore} Bean 覆盖默认实现, + * 例如替换为 Redis 版本以支持多实例部署。 + *

+ * + * @author: mfish + * @date: 2026/07/20 + */ +@Configuration +public class MemoryAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public ConversationMemoryStore conversationMemoryStore() { + return new InMemoryConversationMemoryStore(); + } +} diff --git a/mf-common/mf-common-ai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/mf-common/mf-common-ai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index f2170c30..83ecfe48 100644 --- a/mf-common/mf-common-ai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/mf-common/mf-common-ai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1 +1,3 @@ cn.com.mfish.common.ai.engine.ApiToolAutoConfiguration +cn.com.mfish.common.ai.memory.MemoryAutoConfiguration +cn.com.mfish.common.ai.capability.CapabilityAutoConfiguration