From 410cb0b6f28057e3bdc6a6225c6997601d88770e Mon Sep 17 00:00:00 2001 From: ageerle Date: Sun, 3 May 2026 16:03:47 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90=E9=80=9A=E7=94=A8=20?= =?UTF-8?q?Coding=20Agent=20=E4=B8=8E=20AI=20=E6=8A=A5=E8=A1=A8=20Agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Coding Agent 子代理并接入 Supervisor - 创建 file 工具(路径校验、父目录创建、覆盖控制) - run_command 受控命令工具(白名单、子命令白名单、超时、审批令牌闭环) - 升级 edit_file 为 unified diff 解析应用(上下文校验) - 新增 task_planner 任务规划工具(风险分级、审批令牌生成) - ApprovalTokenStore 审批令牌存储(TTL、scope 绑定) - 新增 AI 报表 Agent:自然语言生成 SQL 预览与 shell 查询执行 - 报表前端页面(需求描述 -> SQL 确认 -> 报表展示 -> 动态编辑) Co-authored-by: monkeycode-ai --- .monkeycode/MEMORY.md | 63 ++++ .../main/resources/static/report/index.html | 320 ++++++++++++++++++ .../java/org/ruoyi/agent/CodingAgent.java | 27 ++ .../controller/chat/AiReportController.java | 37 ++ .../dto/request/AiReportExecuteRequest.java | 20 ++ .../dto/request/AiReportGenerateRequest.java | 16 + .../dto/request/AiReportRefineRequest.java | 19 ++ .../domain/dto/response/AiReportResponse.java | 19 ++ .../ruoyi/mcp/tools/ApprovalTokenStore.java | 49 +++ .../org/ruoyi/mcp/tools/CreateFileTool.java | 116 +++++++ .../org/ruoyi/mcp/tools/EditFileTool.java | 104 +++++- .../org/ruoyi/mcp/tools/RunCommandTool.java | 260 ++++++++++++++ .../org/ruoyi/mcp/tools/TaskPlannerTool.java | 158 +++++++++ .../service/chat/impl/ChatServiceFacade.java | 24 +- .../service/report/IAiReportService.java | 15 + .../report/impl/AiReportServiceImpl.java | 304 +++++++++++++++++ 16 files changed, 1540 insertions(+), 11 deletions(-) create mode 100644 .monkeycode/MEMORY.md create mode 100644 ruoyi-admin/src/main/resources/static/report/index.html create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/CodingAgent.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/AiReportController.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/request/AiReportExecuteRequest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/request/AiReportGenerateRequest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/request/AiReportRefineRequest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/response/AiReportResponse.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ApprovalTokenStore.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/CreateFileTool.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/RunCommandTool.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/TaskPlannerTool.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/report/IAiReportService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/report/impl/AiReportServiceImpl.java diff --git a/.monkeycode/MEMORY.md b/.monkeycode/MEMORY.md new file mode 100644 index 00000000..519141d6 --- /dev/null +++ b/.monkeycode/MEMORY.md @@ -0,0 +1,63 @@ +# 用户指令记忆 + +本文件记录了用户的指令、偏好和教导,用于在未来的交互中提供参考。 + +## 格式 + +### 用户指令条目 +用户指令条目应遵循以下格式: + +[用户指令摘要] +- Date: [YYYY-MM-DD] +- Context: [提及的场景或时间] +- Instructions: + - [用户教导或指示的内容,逐行描述] + +### 项目知识条目 +Agent 在任务执行过程中发现的条目应遵循以下格式: + +[项目知识摘要] +- Date: [YYYY-MM-DD] +- Context: Agent 在执行 [具体任务描述] 时发现 +- Category: [代码结构|代码模式|代码生成|构建方法|测试方法|依赖关系|环境配置] +- Instructions: + - [具体的知识点,逐行描述] + +## 去重策略 +- 添加新条目前,检查是否存在相似或相同的指令 +- 若发现重复,跳过新条目或与已有条目合并 +- 合并时,更新上下文或日期信息 +- 这有助于避免冗余条目,保持记忆文件整洁 + +## 条目 + +[项目技术栈偏好:LangChain4j] +- Date: 2026-05-03 +- Context: 用户在讨论“集成 AI 编程能力”方案时说明 +- Category: 依赖关系 +- Instructions: + - 项目基于 LangChain4j 设计仓库级理解与自动化动作能力 + +[需求澄清:构建通用 Coding Agent] +- Date: 2026-05-03 +- Context: 用户纠正方案范围时说明 +- Category: 代码结构 +- Instructions: + - 目标是基于 LangChain4j 构建通用 coding agent,而非仅项目内问答助手 + - Agent 需要可操作文件、调用工具、跨前后端完成任务(如新建前端页面并对接现有后端) + +[内置工具自动注册机制] +- Date: 2026-05-03 +- Context: Agent 在执行 coding agent 工具扩展时发现 +- Category: 代码结构 +- Instructions: + - ruoyi-chat 模块通过 BuiltinToolProvider + @Component 自动发现并注册内置工具 + - 新增工具无需手工改注册表,BuiltinToolRegistry 会在启动时扫描并创建可供 Agent 调用的实例 + +[新增能力方向:AI 报表 Agent] +- Date: 2026-05-03 +- Context: 用户提出新的产品化需求 +- Category: 代码模式 +- Instructions: + - 用户希望通过自然语言生成报表,包含数据库查询和 HTML 报表生成 + - 用户希望在报表页面内通过提示词继续动态编辑页面 diff --git a/ruoyi-admin/src/main/resources/static/report/index.html b/ruoyi-admin/src/main/resources/static/report/index.html new file mode 100644 index 00000000..1de677c4 --- /dev/null +++ b/ruoyi-admin/src/main/resources/static/report/index.html @@ -0,0 +1,320 @@ + + + + + +AI Report Agent + + + +
+
+

AI Report Agent

+

自然语言描述需求,自动生成数据报表

+
+ +
+

Step 1

描述需求
+

Step 2

确认SQL
+

Step 3

查看报表
+
+ +
+
+

输入报表需求

+
+ + + + +
+ +
+ +
+
+
+ +
+
+

SQL 预览与确认

+
+
+
+ + + +
+
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ + + + diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/CodingAgent.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/CodingAgent.java new file mode 100644 index 00000000..2eebeb8b --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/CodingAgent.java @@ -0,0 +1,27 @@ +package org.ruoyi.agent; + +import dev.langchain4j.agentic.Agent; +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; + +/** + * 通用 Coding Agent + * 使用任务规划、文件操作与受控命令执行能力完成开发任务 + */ +public interface CodingAgent { + + @SystemMessage(""" + 你是一个通用 coding agent,负责把用户的软件开发请求落地为可执行改动。 + + 必须遵循: + 1. 先调用 task_planner 输出结构化计划与 approval token + 2. 获得用户确认后再执行 create_file、edit_file、run_command + 3. run_command 必须使用 task_planner 返回的 approval token 和 approval scope + 4. 优先最小改动,保持可验证、可回滚 + 5. 输出最终变更摘要、验证结果与下一步建议 + """) + @UserMessage("{{query}}") + @Agent("通用 Coding Agent,支持任务规划、文件操作与受控命令执行") + String execute(@V("query") String query); +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/AiReportController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/AiReportController.java new file mode 100644 index 00000000..d6a66fa7 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/AiReportController.java @@ -0,0 +1,37 @@ +package org.ruoyi.controller.chat; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.ruoyi.common.core.domain.R; +import org.ruoyi.domain.dto.request.AiReportExecuteRequest; +import org.ruoyi.domain.dto.request.AiReportGenerateRequest; +import org.ruoyi.domain.dto.request.AiReportRefineRequest; +import org.ruoyi.domain.dto.response.AiReportResponse; +import org.ruoyi.service.report.IAiReportService; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/chat/report") +public class AiReportController { + + private final IAiReportService aiReportService; + + @PostMapping("/generate") + public R generate(@RequestBody @Valid AiReportGenerateRequest request) { + return R.ok(aiReportService.generate(request)); + } + + @PostMapping("/execute") + public R execute(@RequestBody @Valid AiReportExecuteRequest request) { + return R.ok(aiReportService.execute(request)); + } + + @PostMapping("/refine") + public R refine(@RequestBody @Valid AiReportRefineRequest request) { + return R.ok(aiReportService.refine(request)); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/request/AiReportExecuteRequest.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/request/AiReportExecuteRequest.java new file mode 100644 index 00000000..ded9fb2b --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/request/AiReportExecuteRequest.java @@ -0,0 +1,20 @@ +package org.ruoyi.domain.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class AiReportExecuteRequest { + + @NotBlank(message = "模型不能为空") + private String model; + + @NotBlank(message = "报表标题不能为空") + private String title; + + @NotBlank(message = "报表摘要不能为空") + private String summary; + + @NotBlank(message = "SQL 不能为空") + private String sql; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/request/AiReportGenerateRequest.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/request/AiReportGenerateRequest.java new file mode 100644 index 00000000..483cd89a --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/request/AiReportGenerateRequest.java @@ -0,0 +1,16 @@ +package org.ruoyi.domain.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class AiReportGenerateRequest { + + @NotBlank(message = "模型不能为空") + private String model; + + @NotBlank(message = "报表需求不能为空") + private String prompt; + + private Integer maxRows; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/request/AiReportRefineRequest.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/request/AiReportRefineRequest.java new file mode 100644 index 00000000..c9124952 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/request/AiReportRefineRequest.java @@ -0,0 +1,19 @@ +package org.ruoyi.domain.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class AiReportRefineRequest { + + @NotBlank(message = "模型不能为空") + private String model; + + @NotBlank(message = "编辑提示词不能为空") + private String prompt; + + @NotBlank(message = "当前报表 HTML 不能为空") + private String html; + + private String dataContext; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/response/AiReportResponse.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/response/AiReportResponse.java new file mode 100644 index 00000000..ccf1ef8a --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/dto/response/AiReportResponse.java @@ -0,0 +1,19 @@ +package org.ruoyi.domain.dto.response; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class AiReportResponse { + + private String sql; + + private String title; + + private String summary; + + private String queryResult; + + private String html; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ApprovalTokenStore.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ApprovalTokenStore.java new file mode 100644 index 00000000..5658f2f7 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/ApprovalTokenStore.java @@ -0,0 +1,49 @@ +package org.ruoyi.mcp.tools; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 执行审批令牌存储 + * 使用内存存储短期有效令牌,用于“先计划后执行”闭环 + */ +public final class ApprovalTokenStore { + + private static final Map TOKENS = new ConcurrentHashMap<>(); + + private ApprovalTokenStore() { + } + + public static String issue(String goal, long ttlSeconds) { + cleanupExpired(); + String token = UUID.randomUUID().toString(); + long expireAt = Instant.now().getEpochSecond() + Math.max(60, ttlSeconds); + TOKENS.put(token, new TokenRecord(goal == null ? "" : goal, expireAt)); + return token; + } + + public static boolean consume(String token, String scope) { + if (token == null || token.isBlank()) { + return false; + } + TokenRecord record = TOKENS.remove(token.trim()); + if (record == null) { + return false; + } + if (record.expireAtEpochSeconds() < Instant.now().getEpochSecond()) { + return false; + } + String normalizedScope = scope == null ? "" : scope.trim(); + return record.goal().equals(normalizedScope); + } + + private static void cleanupExpired() { + long now = Instant.now().getEpochSecond(); + TOKENS.entrySet().removeIf(entry -> entry.getValue().expireAtEpochSeconds() < now); + } + + private record TokenRecord(String goal, long expireAtEpochSeconds) { + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/CreateFileTool.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/CreateFileTool.java new file mode 100644 index 00000000..5c2cd3b3 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/CreateFileTool.java @@ -0,0 +1,116 @@ +package org.ruoyi.mcp.tools; + +import dev.langchain4j.agent.tool.Tool; +import org.ruoyi.mcp.service.core.BuiltinToolProvider; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; + +/** + * 创建文件工具 + * 在工作区内创建新文件,可选择是否覆盖已有文件 + */ +@Component +public class CreateFileTool implements BuiltinToolProvider { + + public static final String DESCRIPTION = "Creates a new file with provided content. " + + "Supports creating parent directories automatically. " + + "Use absolute paths within the workspace directory. " + + "Set overwrite to true to replace existing file content."; + + private final String rootDirectory; + private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass()); + + public CreateFileTool() { + this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString(); + } + + @Tool(DESCRIPTION) + public String createFile(String filePath, String content, Boolean overwrite) { + try { + if (filePath == null || filePath.trim().isEmpty()) { + return "Error: File path cannot be empty"; + } + + Path path = Paths.get(filePath); + if (!path.isAbsolute()) { + return "Error: File path must be absolute: " + filePath; + } + + if (!isWithinWorkspace(path)) { + return "Error: File path must be within the workspace directory (" + rootDirectory + "): " + filePath; + } + + if (Files.exists(path) && Files.isDirectory(path)) { + return "Error: Path is a directory, not a file: " + filePath; + } + + boolean allowOverwrite = overwrite != null && overwrite; + if (Files.exists(path) && !allowOverwrite) { + return "Error: File already exists. Set overwrite=true to replace content: " + filePath; + } + + Path parent = path.getParent(); + if (parent != null && !Files.exists(parent)) { + Files.createDirectories(parent); + } + + String safeContent = content == null ? "" : content; + if (allowOverwrite) { + Files.writeString(path, safeContent, StandardCharsets.UTF_8, + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + } else { + Files.writeString(path, safeContent, StandardCharsets.UTF_8, + StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + } + + return "Successfully created file: " + getRelativePath(path); + } catch (IOException e) { + logger.error("Error creating file: {}", filePath, e); + return "Error: " + e.getMessage(); + } catch (Exception e) { + logger.error("Unexpected error creating file: {}", filePath, e); + return "Error: Unexpected error: " + e.getMessage(); + } + } + + private boolean isWithinWorkspace(Path filePath) { + try { + Path workspaceRoot = Paths.get(rootDirectory).toRealPath(); + Path normalizedPath = filePath.normalize(); + return normalizedPath.startsWith(workspaceRoot.normalize()); + } catch (IOException e) { + logger.warn("Could not resolve workspace path", e); + return false; + } + } + + private String getRelativePath(Path filePath) { + try { + Path workspaceRoot = Paths.get(rootDirectory); + return workspaceRoot.relativize(filePath).toString(); + } catch (Exception e) { + return filePath.toString(); + } + } + + @Override + public String getToolName() { + return "create_file"; + } + + @Override + public String getDisplayName() { + return "创建文件"; + } + + @Override + public String getDescription() { + return DESCRIPTION; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/EditFileTool.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/EditFileTool.java index 26473da2..07c1d20d 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/EditFileTool.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/EditFileTool.java @@ -11,7 +11,10 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.util.Arrays; +import java.util.ArrayList; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * 编辑文件工具 @@ -75,12 +78,9 @@ public class EditFileTool implements BuiltinToolProvider { // 读取原始内容 String originalContent = Files.readString(path, StandardCharsets.UTF_8); - List originalLines = Arrays.asList(originalContent.split("\n")); // 应用diff try { - // 这里简化处理,直接用新内容替换 - // 在实际应用中,可能需要更复杂的diff解析 String newContent = applyDiff(originalContent, diff); // 写入文件 @@ -104,14 +104,100 @@ public class EditFileTool implements BuiltinToolProvider { } /** - * 简化的diff应用逻辑 - * 实际应用中可能需要使用更复杂的diff解析器 + * 仅支持 unified diff(包含 @@ hunk 头) */ private String applyDiff(String originalContent, String diff) { - // 这里简化处理,实际应用中需要解析diff格式 - // 目前将diff作为新内容直接替换 - // 可以考虑使用jgit等库来解析 unified diff 格式 - return diff; + List originalLines = new ArrayList<>(Arrays.asList(originalContent.split("\n", -1))); + List diffLines = Arrays.asList(diff.split("\n", -1)); + + int i = 0; + while (i < diffLines.size()) { + String line = diffLines.get(i); + + if (line.startsWith("---") || line.startsWith("+++")) { + i++; + continue; + } + + if (!line.startsWith("@@")) { + i++; + continue; + } + + HunkHeader header = parseHunkHeader(line); + int targetIndex = Math.max(0, header.oldStart - 1); + i++; + + while (i < diffLines.size()) { + String hunkLine = diffLines.get(i); + if (hunkLine.startsWith("@@") || hunkLine.startsWith("---") || hunkLine.startsWith("+++")) { + break; + } + + if (hunkLine.startsWith("\\ No newline at end of file")) { + i++; + continue; + } + + if (hunkLine.isEmpty()) { + // unified diff 中空内容上下文行会表现为空字符串,视为上下文行 + ensureExpectedLine(originalLines, targetIndex, ""); + targetIndex++; + i++; + continue; + } + + char op = hunkLine.charAt(0); + String content = hunkLine.length() > 1 ? hunkLine.substring(1) : ""; + switch (op) { + case ' ': + ensureExpectedLine(originalLines, targetIndex, content); + targetIndex++; + break; + case '-': + ensureExpectedLine(originalLines, targetIndex, content); + originalLines.remove(targetIndex); + break; + case '+': + originalLines.add(targetIndex, content); + targetIndex++; + break; + default: + throw new IllegalArgumentException("Unsupported diff line: " + hunkLine); + } + i++; + } + } + + return String.join("\n", originalLines); + } + + private void ensureExpectedLine(List lines, int index, String expected) { + if (index < 0 || index >= lines.size()) { + throw new IllegalArgumentException("Diff out of range at line index: " + index); + } + String actual = lines.get(index); + if (!actual.equals(expected)) { + throw new IllegalArgumentException("Diff context mismatch at line " + (index + 1) + + ", expected: [" + expected + "], actual: [" + actual + "]"); + } + } + + private HunkHeader parseHunkHeader(String headerLine) { + Pattern p = Pattern.compile("@@ -(\\d+)(?:,(\\d+))? \\+(\\d+)(?:,(\\d+))? @@.*"); + Matcher m = p.matcher(headerLine); + if (!m.matches()) { + throw new IllegalArgumentException("Invalid unified diff hunk header: " + headerLine); + } + + int oldStart = Integer.parseInt(m.group(1)); + int oldCount = m.group(2) == null ? 1 : Integer.parseInt(m.group(2)); + int newStart = Integer.parseInt(m.group(3)); + int newCount = m.group(4) == null ? 1 : Integer.parseInt(m.group(4)); + return new HunkHeader(oldStart, oldCount, newStart, newCount); + } + + private record HunkHeader(int oldStart, int oldCount, int newStart, int newCount) { } private boolean isWithinWorkspace(Path filePath) { diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/RunCommandTool.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/RunCommandTool.java new file mode 100644 index 00000000..44228998 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/RunCommandTool.java @@ -0,0 +1,260 @@ +package org.ruoyi.mcp.tools; + +import dev.langchain4j.agent.tool.Tool; +import org.ruoyi.mcp.service.core.BuiltinToolProvider; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 受控命令执行工具 + * 仅允许执行白名单命令,禁止高风险和破坏性命令 + */ +@Component +public class RunCommandTool implements BuiltinToolProvider { + + public static final String DESCRIPTION = "Runs a safe whitelisted command in workspace. " + + "Only supports non-interactive commands for build/test/git status workflows. " + + "Blocks destructive and shell-chaining commands. " + + "Use absolute working directory inside workspace. " + + "Requires an approval token from task_planner before execution."; + + private static final int DEFAULT_TIMEOUT_SECONDS = 60; + private static final int MAX_TIMEOUT_SECONDS = 120; + private static final int MAX_OUTPUT_CHARS = 20_000; + + private static final Set ALLOWED_COMMANDS = Set.of( + "mvn", "./mvnw", "npm", "pnpm", "yarn", "go", "gradle", "./gradlew", "git" + ); + + private static final Map> ALLOWED_SUBCOMMANDS = Map.of( + "git", Set.of("status", "diff", "log", "branch", "checkout", "switch", "add", "commit", "restore"), + "mvn", Set.of("compile", "test", "package", "verify"), + "./mvnw", Set.of("compile", "test", "package", "verify"), + "npm", Set.of("run", "test", "install", "ci"), + "pnpm", Set.of("run", "test", "install"), + "yarn", Set.of("run", "test", "install"), + "go", Set.of("test", "build"), + "gradle", Set.of("test", "build"), + "./gradlew", Set.of("test", "build") + ); + + private static final Set BLOCKED_EXACT_TOKENS = Set.of( + "rm", "rmdir", "unlink", "shutdown", "reboot", "poweroff", "sudo", "su", "mkfs", "fdisk", + "mount", "umount", "iptables", "nft", "useradd", "userdel" + ); + + private static final List BLOCKED_PHRASES = List.of( + "git reset --hard", "git clean -f", "git clean -fd", "git clean -xdf" + ); + + private final String rootDirectory; + private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass()); + + public RunCommandTool() { + this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString(); + } + + @Tool(DESCRIPTION) + public String runCommand(String command, String workingDirectory, Integer timeoutSeconds, String approvalToken, + String approvalScope) { + try { + if (!ApprovalTokenStore.consume(approvalToken, approvalScope)) { + return "Error: Invalid or expired approval token. Please generate a new plan token via task_planner and confirm before execution."; + } + + if (command == null || command.trim().isEmpty()) { + return "Error: Command cannot be empty"; + } + String normalized = command.trim(); + + if (containsShellChaining(normalized)) { + return "Error: Shell chaining operators are not allowed"; + } + + String lower = normalized.toLowerCase(); + for (String blocked : BLOCKED_PHRASES) { + if (lower.contains(blocked)) { + return "Error: Command contains blocked token: " + blocked; + } + } + + List parts = List.of(normalized.split("\\s+")); + List lowerParts = parts.stream().map(String::toLowerCase).collect(Collectors.toList()); + for (String token : lowerParts) { + if (BLOCKED_EXACT_TOKENS.contains(token)) { + return "Error: Command contains blocked token: " + token; + } + } + + String binary = parts.get(0); + if (!ALLOWED_COMMANDS.contains(binary)) { + return "Error: Command is not in allowed list: " + binary; + } + String subcommandError = validateSubcommand(binary, parts); + if (subcommandError != null) { + return "Error: " + subcommandError; + } + + Path workdir = resolveWorkdir(workingDirectory); + if (!Files.exists(workdir) || !Files.isDirectory(workdir)) { + return "Error: Working directory not found: " + workdir; + } + + int timeout = timeoutSeconds == null ? DEFAULT_TIMEOUT_SECONDS : timeoutSeconds; + if (timeout < 1 || timeout > MAX_TIMEOUT_SECONDS) { + return "Error: timeoutSeconds must be between 1 and " + MAX_TIMEOUT_SECONDS; + } + + ProcessBuilder pb = new ProcessBuilder(parts); + pb.directory(workdir.toFile()); + pb.redirectErrorStream(true); + + Process process = pb.start(); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + Future outputFuture = executor.submit(() -> readProcessOutput(process, MAX_OUTPUT_CHARS)); + + boolean finished = process.waitFor(timeout, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + executor.shutdownNow(); + return "Error: Command timeout after " + timeout + " seconds"; + } + + String output = getOutputSafely(outputFuture, executor); + + int code = process.exitValue(); + String relativeWd = getRelativePath(workdir); + String summary = "Command: " + normalized + "\nWorkingDirectory: " + relativeWd + "\nExitCode: " + code + "\n\n"; + return summary + output; + } catch (IOException e) { + logger.error("Error running command: {}", command, e); + return "Error: " + e.getMessage(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return "Error: Command execution interrupted"; + } catch (Exception e) { + logger.error("Unexpected error running command: {}", command, e); + return "Error: Unexpected error: " + e.getMessage(); + } + } + + private Path resolveWorkdir(String workingDirectory) throws IOException { + Path workspaceRoot = Paths.get(rootDirectory).toRealPath(); + if (workingDirectory == null || workingDirectory.isBlank()) { + return workspaceRoot; + } + + Path path = Paths.get(workingDirectory); + Path resolved; + if (!path.isAbsolute()) { + resolved = workspaceRoot.resolve(path).normalize(); + } else { + resolved = path.normalize(); + } + + if (!resolved.startsWith(workspaceRoot)) { + throw new IOException("Working directory must be within workspace: " + workingDirectory); + } + return resolved; + } + + private boolean containsShellChaining(String command) { + return command.contains("&&") || command.contains("||") || command.contains(";") || command.contains("|"); + } + + private String validateSubcommand(String binary, List parts) { + Set allowedSubs = ALLOWED_SUBCOMMANDS.get(binary); + if (allowedSubs == null) { + return null; + } + + if (parts.size() < 2) { + return "Missing subcommand for " + binary; + } + + String sub = parts.get(1); + if (sub.startsWith("-")) { + return "Flag-only command is not allowed for " + binary; + } + + if (!allowedSubs.contains(sub)) { + return "Subcommand is not allowed for " + binary + ": " + sub; + } + + if (("npm".equals(binary) || "pnpm".equals(binary) || "yarn".equals(binary)) + && "install".equals(sub) && !parts.contains("-g") && !parts.contains("--global")) { + return "Package install must be global (-g/--global)"; + } + + return null; + } + + private String readProcessOutput(Process process, int maxChars) throws IOException { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + if (sb.length() + line.length() + 1 <= maxChars) { + sb.append(line).append('\n'); + } + } + return sb.toString(); + } + } + + private String getOutputSafely(Future outputFuture, ExecutorService executor) + throws InterruptedException, ExecutionException { + try { + return outputFuture.get(5, TimeUnit.SECONDS); + } catch (java.util.concurrent.TimeoutException e) { + return ""; + } finally { + executor.shutdown(); + executor.awaitTermination(Duration.ofSeconds(2).toSeconds(), TimeUnit.SECONDS); + } + } + + private String getRelativePath(Path filePath) { + try { + Path workspaceRoot = Paths.get(rootDirectory).toRealPath(); + return workspaceRoot.relativize(filePath.toRealPath()).toString(); + } catch (Exception e) { + return filePath.toString(); + } + } + + @Override + public String getToolName() { + return "run_command"; + } + + @Override + public String getDisplayName() { + return "执行命令"; + } + + @Override + public String getDescription() { + return DESCRIPTION; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/TaskPlannerTool.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/TaskPlannerTool.java new file mode 100644 index 00000000..bcbf42f6 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mcp/tools/TaskPlannerTool.java @@ -0,0 +1,158 @@ +package org.ruoyi.mcp.tools; + +import dev.langchain4j.agent.tool.Tool; +import org.ruoyi.mcp.service.core.BuiltinToolProvider; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * 任务规划工具 + * 将自然语言开发任务转换为可执行的结构化计划 + */ +@Component +public class TaskPlannerTool implements BuiltinToolProvider { + + private static final long DEFAULT_APPROVAL_TTL_SECONDS = 30 * 60; + + public static final String DESCRIPTION = "Creates a structured coding task plan from a natural language request. " + + "Returns objective, constraints, steps with acceptance criteria, and risk level. " + + "Use this tool before executing file or command operations."; + + @Tool(DESCRIPTION) + public String planTask(String goal, String constraints, String executionMode) { + if (goal == null || goal.trim().isEmpty()) { + return "Error: goal cannot be empty"; + } + + String normalizedGoal = goal.trim(); + String normalizedConstraints = normalize(constraints); + String mode = normalizeMode(executionMode); + RiskLevel risk = detectRisk(normalizedGoal, normalizedConstraints); + + List steps = buildSteps(normalizedGoal, mode, risk); + List acceptance = buildAcceptance(mode, risk); + + StringBuilder sb = new StringBuilder(); + sb.append("# Task Plan\n"); + sb.append("Objective: ").append(normalizedGoal).append("\n"); + sb.append("ExecutionMode: ").append(mode).append("\n"); + sb.append("RiskLevel: ").append(risk.name()).append("\n"); + if (!normalizedConstraints.isEmpty()) { + sb.append("Constraints: ").append(normalizedConstraints).append("\n"); + } + + sb.append("\nSteps:\n"); + for (int i = 0; i < steps.size(); i++) { + sb.append(i + 1).append(". ").append(steps.get(i)).append("\n"); + } + + sb.append("\nAcceptanceCriteria:\n"); + for (int i = 0; i < acceptance.size(); i++) { + sb.append(i + 1).append(". ").append(acceptance.get(i)).append("\n"); + } + + String approvalToken = ApprovalTokenStore.issue(normalizedGoal, DEFAULT_APPROVAL_TTL_SECONDS); + + sb.append("\nExecutionApproval:\n"); + sb.append("1. ApprovalToken: ").append(approvalToken).append("\n"); + sb.append("2. ApprovalScope: ").append(normalizedGoal).append("\n"); + sb.append("3. TokenTTLSeconds: ").append(DEFAULT_APPROVAL_TTL_SECONDS).append("\n"); + sb.append("4. Run command tools only after explicit user confirmation\n"); + + sb.append("\nSafetyGates:\n"); + sb.append("1. Only workspace-scoped file operations are allowed\n"); + sb.append("2. Only whitelisted commands are allowed\n"); + sb.append("3. Risky actions require explicit user confirmation\n"); + + return sb.toString(); + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } + + private String normalizeMode(String executionMode) { + String mode = normalize(executionMode).toUpperCase(); + if (!"PLAN_ONLY".equals(mode) && !"PLAN_AND_EXECUTE".equals(mode)) { + return "PLAN_ONLY"; + } + return mode; + } + + private RiskLevel detectRisk(String goal, String constraints) { + String text = (goal + " " + constraints).toLowerCase(); + if (containsAny(text, "delete", "drop", "remove", "reset --hard", "force", "rewrite history")) { + return RiskLevel.HIGH; + } + if (containsAny(text, "refactor", "multi-module", "database", "migration", "deploy", "production")) { + return RiskLevel.MEDIUM; + } + return RiskLevel.LOW; + } + + private boolean containsAny(String text, String... words) { + for (String word : words) { + if (text.contains(word)) { + return true; + } + } + return false; + } + + private List buildSteps(String goal, String mode, RiskLevel risk) { + List steps = new ArrayList<>(); + steps.add("Clarify target scope and impacted modules for: " + goal); + steps.add("Discover relevant files and APIs using list/read/search tools"); + steps.add("Design minimal change set and draft patch plan"); + steps.add("Apply file changes incrementally and keep each step reversible"); + steps.add("Run whitelisted validation commands (build/test/lint) for impacted modules only"); + if ("PLAN_AND_EXECUTE".equals(mode)) { + steps.add("Prepare summary of changes and execution logs for user review"); + } else { + steps.add("Return executable checklist and wait for execution approval"); + } + if (risk != RiskLevel.LOW) { + steps.add("Request explicit confirmation before any medium/high-risk operation"); + } + return steps; + } + + private List buildAcceptance(String mode, RiskLevel risk) { + List criteria = new ArrayList<>(); + criteria.add("Planned steps are concrete, ordered, and testable"); + criteria.add("Every file/command action is bounded within workspace and policy constraints"); + criteria.add("Validation commands and expected outputs are specified"); + if ("PLAN_AND_EXECUTE".equals(mode)) { + criteria.add("Executed changes produce a verifiable diff and command results"); + } else { + criteria.add("Plan is ready for immediate execution after user approval"); + } + if (risk == RiskLevel.HIGH) { + criteria.add("High-risk actions are isolated and require explicit user confirmation"); + } + return criteria; + } + + @Override + public String getToolName() { + return "task_planner"; + } + + @Override + public String getDisplayName() { + return "任务规划"; + } + + @Override + public String getDescription() { + return DESCRIPTION; + } + + private enum RiskLevel { + LOW, + MEDIUM, + HIGH + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java index 16e0750d..0b710656 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java @@ -29,6 +29,7 @@ import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.ruoyi.agent.ChartGenerationAgent; +import org.ruoyi.agent.CodingAgent; import org.ruoyi.agent.EchartsAgent; import org.ruoyi.agent.SkillsAgent; import org.ruoyi.agent.SqlAgent; @@ -54,6 +55,12 @@ import org.ruoyi.domain.bo.vector.QueryVectorBo; import org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo; import org.ruoyi.factory.ChatServiceFactory; import org.ruoyi.mcp.service.core.ToolProviderFactory; +import org.ruoyi.mcp.tools.CreateFileTool; +import org.ruoyi.mcp.tools.EditFileTool; +import org.ruoyi.mcp.tools.ListDirectoryTool; +import org.ruoyi.mcp.tools.ReadFileTool; +import org.ruoyi.mcp.tools.RunCommandTool; +import org.ruoyi.mcp.tools.TaskPlannerTool; import org.ruoyi.observability.*; import org.ruoyi.service.chat.AbstractChatService; import org.ruoyi.service.chat.IChatMessageService; @@ -316,11 +323,25 @@ public class ChatServiceFacade implements IChatService { .listener(new MyAgentListener()) .build(); + // 构建子 Agent 6: CodingAgent - 负责通用开发任务落地 + CodingAgent codingAgent = AgenticServices.agentBuilder(CodingAgent.class) + .chatModel(plannerModel) + .tools( + new TaskPlannerTool(), + new ListDirectoryTool(), + new ReadFileTool(), + new CreateFileTool(), + new EditFileTool(), + new RunCommandTool() + ) + .listener(new MyAgentListener()) + .build(); + // 构建监督者 Agent - 管理多个子 Agent SupervisorAgent supervisor = AgenticServices.supervisorBuilder() .chatModel(plannerModel) //.listener(new SupervisorStreamListener(null)) - .subAgents(skillsAgent,searchAgent, sqlAgent, chartGenerationAgent, echartsAgent) + .subAgents(skillsAgent,searchAgent, sqlAgent, chartGenerationAgent, echartsAgent, codingAgent) // 加入历史上下文 - 使用 ChatMemoryProvider 提供持久化的聊天内存 //.chatMemoryProvider(memoryId -> createChatMemory(chatRequest.getSessionId())) .responseStrategy(SupervisorResponseStrategy.LAST) @@ -618,4 +639,3 @@ public class ChatServiceFacade implements IChatService { }; } } - diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/report/IAiReportService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/report/IAiReportService.java new file mode 100644 index 00000000..f6833b7f --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/report/IAiReportService.java @@ -0,0 +1,15 @@ +package org.ruoyi.service.report; + +import org.ruoyi.domain.dto.request.AiReportGenerateRequest; +import org.ruoyi.domain.dto.request.AiReportExecuteRequest; +import org.ruoyi.domain.dto.request.AiReportRefineRequest; +import org.ruoyi.domain.dto.response.AiReportResponse; + +public interface IAiReportService { + + AiReportResponse generate(AiReportGenerateRequest request); + + AiReportResponse execute(AiReportExecuteRequest request); + + AiReportResponse refine(AiReportRefineRequest request); +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/report/impl/AiReportServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/report/impl/AiReportServiceImpl.java new file mode 100644 index 00000000..efbbf5be --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/report/impl/AiReportServiceImpl.java @@ -0,0 +1,304 @@ +package org.ruoyi.service.report.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.openai.OpenAiChatModel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.service.chat.IChatModelService; +import org.ruoyi.domain.dto.request.AiReportExecuteRequest; +import org.ruoyi.domain.dto.request.AiReportGenerateRequest; +import org.ruoyi.domain.dto.request.AiReportRefineRequest; +import org.ruoyi.domain.dto.response.AiReportResponse; +import org.ruoyi.service.report.IAiReportService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AiReportServiceImpl implements IAiReportService { + + private static final Pattern MYSQL_JDBC_PATTERN = + Pattern.compile("^jdbc:mysql://([^:/?]+)(?::(\\d+))?/([^?]+).*$"); + + private static final int DEFAULT_MAX_ROWS = 100; + private static final int ABSOLUTE_MAX_ROWS = 1000; + + private final IChatModelService chatModelService; + private final ObjectMapper objectMapper; + + @Value("${spring.datasource.dynamic.datasource.master.url:}") + private String jdbcUrl; + + @Value("${spring.datasource.dynamic.datasource.master.username:}") + private String dbUsername; + + @Value("${spring.datasource.dynamic.datasource.master.password:}") + private String dbPassword; + + @Override + public AiReportResponse generate(AiReportGenerateRequest request) { + ChatModel model = buildModel(request.getModel()); + int maxRows = resolveMaxRows(request.getMaxRows()); + + String tableInfo = executeShellSql("SHOW TABLES", 10); + + String sqlPlanPrompt = """ + 你是数据分析师。基于用户需求和表信息,只生成一个安全的 SELECT SQL。 + 返回 JSON,不要返回解释: + {"title":"","summary":"","sql":""} + + 约束: + 1) SQL 必须是 SELECT 开头 + 2) 严禁写入/删除/更新语句 + 3) 尽量不要 SELECT * + 4) 若用户未指定行数,默认 LIMIT %d + + 表信息(来自 shell 查询): + %s + + 用户需求: + %s + """.formatted(maxRows, tableInfo, request.getPrompt()); + + String planRaw = model.chat(sqlPlanPrompt); + JsonNode plan = parseJson(planRaw); + + String title = plan.path("title").asText("AI 报表"); + String summary = plan.path("summary").asText("根据自然语言需求自动生成"); + String sql = normalizeSql(plan.path("sql").asText("")); + validateSelectSql(sql); + + return AiReportResponse.builder() + .title(title) + .summary(summary) + .sql(sql) + .queryResult("") + .html("") + .build(); + } + + @Override + public AiReportResponse execute(AiReportExecuteRequest request) { + ChatModel model = buildModel(request.getModel()); + String sql = normalizeSql(request.getSql()); + validateSelectSql(sql); + + String queryResult = executeShellSql(sql, 30); + + String htmlPrompt = """ + 你是前端报表工程师。请生成一个完整的 HTML 页面(只输出 HTML,不要 markdown 代码块)。 + 页面要求: + 1) 展示标题、摘要、SQL、查询结果(保留原始文本) + 2) 风格专业,适合企业报表 + 3) 页面包含一个“继续编辑”输入框和按钮 + 4) 点击按钮后调用 POST /chat/report/refine + JSON: {"model":"%s","prompt":"用户输入","html":"当前页面 outerHTML","dataContext":"%s"} + 5) 收到返回后,用返回的 html 替换当前页面 + + 标题:%s + 摘要:%s + SQL:%s + 查询结果: + %s + """.formatted(request.getModel(), escapeForJson(queryResult), request.getTitle(), request.getSummary(), sql, queryResult); + + String html = stripCodeFence(model.chat(htmlPrompt)); + + return AiReportResponse.builder() + .title(request.getTitle()) + .summary(request.getSummary()) + .sql(sql) + .queryResult(queryResult) + .html(html) + .build(); + } + + @Override + public AiReportResponse refine(AiReportRefineRequest request) { + ChatModel model = buildModel(request.getModel()); + String dataContext = request.getDataContext() == null ? "" : request.getDataContext(); + + String refinePrompt = """ + 你是前端报表工程师。请基于当前 HTML 和用户要求进行修改。 + 仅输出完整 HTML,不要解释。 + + 修改要求: + %s + + 数据上下文: + %s + + 当前 HTML: + %s + """.formatted(request.getPrompt(), dataContext, request.getHtml()); + + String updatedHtml = stripCodeFence(model.chat(refinePrompt)); + + return AiReportResponse.builder() + .title("AI 报表(已编辑)") + .summary(request.getPrompt()) + .sql("") + .queryResult(dataContext) + .html(updatedHtml) + .build(); + } + + private String executeShellSql(String sql, int timeoutSeconds) { + MysqlConnectionInfo info = parseMysqlConnection(jdbcUrl); + List command = new ArrayList<>(); + command.add("mysql"); + command.add("--batch"); + command.add("--raw"); + command.add("--default-character-set=utf8mb4"); + command.add("-h"); + command.add(info.host()); + command.add("-P"); + command.add(String.valueOf(info.port())); + command.add("-u"); + command.add(dbUsername); + command.add("-D"); + command.add(info.database()); + command.add("-e"); + command.add(sql); + + ProcessBuilder pb = new ProcessBuilder(command); + pb.redirectErrorStream(true); + pb.environment().put("MYSQL_PWD", dbPassword == null ? "" : dbPassword); + + try { + Process process = pb.start(); + String output; + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append('\n'); + } + output = sb.toString().trim(); + } + + boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + throw new IllegalArgumentException("shell 查询超时"); + } + + int code = process.exitValue(); + if (code != 0) { + throw new IllegalArgumentException("shell 查询失败: " + output); + } + return output; + } catch (Exception e) { + log.error("shell 查询执行失败, sql={}", sql, e); + throw new IllegalArgumentException("shell 查询失败: " + e.getMessage()); + } + } + + private MysqlConnectionInfo parseMysqlConnection(String url) { + if (url == null || url.isBlank()) { + throw new IllegalArgumentException("未配置 MySQL JDBC URL"); + } + + Matcher matcher = MYSQL_JDBC_PATTERN.matcher(url.trim()); + if (!matcher.matches()) { + throw new IllegalArgumentException("无法解析 JDBC URL: " + url); + } + + String host = matcher.group(1); + int port = matcher.group(2) == null ? 3306 : Integer.parseInt(matcher.group(2)); + String database = matcher.group(3); + if (database.contains("/")) { + database = database.substring(0, database.indexOf('/')); + } + return new MysqlConnectionInfo(host, port, database); + } + + private ChatModel buildModel(String modelName) { + ChatModelVo modelVo = chatModelService.selectModelByName(modelName); + if (modelVo == null) { + throw new IllegalArgumentException("模型不存在: " + modelName); + } + + return OpenAiChatModel.builder() + .baseUrl(modelVo.getApiHost()) + .apiKey(modelVo.getApiKey()) + .modelName(modelVo.getModelName()) + .build(); + } + + private JsonNode parseJson(String raw) { + try { + String candidate = raw.trim(); + if (candidate.startsWith("```") && candidate.contains("{")) { + int start = candidate.indexOf('{'); + int end = candidate.lastIndexOf('}'); + candidate = candidate.substring(start, end + 1); + } + return objectMapper.readTree(candidate); + } catch (Exception e) { + log.error("解析 SQL 计划 JSON 失败: {}", raw, e); + throw new IllegalArgumentException("模型返回格式错误,未能解析 SQL 计划"); + } + } + + private String stripCodeFence(String content) { + String value = content == null ? "" : content.trim(); + if (value.startsWith("```")) { + int firstLineBreak = value.indexOf('\n'); + int lastFence = value.lastIndexOf("```"); + if (firstLineBreak > -1 && lastFence > firstLineBreak) { + return value.substring(firstLineBreak + 1, lastFence).trim(); + } + } + return value; + } + + private int resolveMaxRows(Integer maxRows) { + if (maxRows == null || maxRows < 1) { + return DEFAULT_MAX_ROWS; + } + return Math.min(maxRows, ABSOLUTE_MAX_ROWS); + } + + private String normalizeSql(String sql) { + String value = sql == null ? "" : sql.trim(); + if (value.endsWith(";")) { + value = value.substring(0, value.length() - 1).trim(); + } + return value; + } + + private void validateSelectSql(String sql) { + if (sql.isBlank()) { + throw new IllegalArgumentException("SQL 不能为空"); + } + String upperSql = sql.toUpperCase(); + if (!upperSql.startsWith("SELECT")) { + throw new IllegalArgumentException("仅允许 SELECT SQL"); + } + if (upperSql.contains(";") || upperSql.contains("UPDATE ") || upperSql.contains("DELETE ") + || upperSql.contains("INSERT ") || upperSql.contains("DROP ") || upperSql.contains("ALTER ")) { + throw new IllegalArgumentException("SQL 含有不允许的语句"); + } + } + + private String escapeForJson(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n"); + } + + private record MysqlConnectionInfo(String host, int port, String database) { + } +}