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
+
+
+
+
+
+
+
+
+
+
+
输入报表需求
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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) {
+ }
+}