feat: 集成通用 Coding Agent 与 AI 报表 Agent

- 新增 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-ai@chaitin.com>
This commit is contained in:
ageerle
2026-05-03 16:03:47 +00:00
parent ec092a11c3
commit 410cb0b6f2
16 changed files with 1540 additions and 11 deletions

View File

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

View File

@@ -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<AiReportResponse> generate(@RequestBody @Valid AiReportGenerateRequest request) {
return R.ok(aiReportService.generate(request));
}
@PostMapping("/execute")
public R<AiReportResponse> execute(@RequestBody @Valid AiReportExecuteRequest request) {
return R.ok(aiReportService.execute(request));
}
@PostMapping("/refine")
public R<AiReportResponse> refine(@RequestBody @Valid AiReportRefineRequest request) {
return R.ok(aiReportService.refine(request));
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<String, TokenRecord> 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) {
}
}

View File

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

View File

@@ -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<String> 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<String> originalLines = new ArrayList<>(Arrays.asList(originalContent.split("\n", -1)));
List<String> 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<String> 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) {

View File

@@ -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<String> ALLOWED_COMMANDS = Set.of(
"mvn", "./mvnw", "npm", "pnpm", "yarn", "go", "gradle", "./gradlew", "git"
);
private static final Map<String, Set<String>> 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<String> 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<String> 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<String> parts = List.of(normalized.split("\\s+"));
List<String> 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<String> 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<String> parts) {
Set<String> 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<String> 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;
}
}

View File

@@ -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<String> steps = buildSteps(normalizedGoal, mode, risk);
List<String> 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<String> buildSteps(String goal, String mode, RiskLevel risk) {
List<String> 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<String> buildAcceptance(String mode, RiskLevel risk) {
List<String> 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
}
}

View File

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

View File

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

View File

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