mateclaw/mateclaw-server/src/main/java/vip/mate/tool/builtin/EditFileTool.java

139 lines
5.2 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package vip.mate.tool.builtin;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 内置工具:编辑文件(查找替换)
* <p>
* 通过精确字符串匹配进行查找替换。
* 支持替换首次匹配或全部匹配。
* <p>
* 安全说明:
* <ul>
* <li>编辑操作经过 ToolGuard 审批DefaultToolGuard 对 file_edit 工具默认返回 NEEDS_APPROVAL</li>
* <li>每次编辑需要用户确认</li>
* </ul>
*
* @author MateClaw Team
*/
@Slf4j
@Component
@lombok.RequiredArgsConstructor
public class EditFileTool {
private final vip.mate.i18n.I18nService i18n;
@vip.mate.tool.ConcurrencyUnsafe("in-place file edit — must not race with reads/writes on the same path")
@Tool(description = "Edit file content via find-and-replace. Finds exact match of old_text and replaces with new_text. "
+ "Returns structured JSON with filePath, replacements count. "
+ "Requires user approval. Replaces first occurrence by default; set replaceAll=true for all.")
public String edit_file(
@ToolParam(description = "Absolute or relative file path") String filePath,
@ToolParam(description = "Original text to find (exact match)") String oldText,
@ToolParam(description = "Replacement text") String newText,
@ToolParam(description = "Replace all occurrences, default false (first only)", required = false) Boolean replaceAll) {
JSONObject result = new JSONObject();
result.set("filePath", filePath);
try {
if (filePath == null || filePath.isBlank()) {
return errorResult(filePath, i18n.msg("tool.edit_file.error.path_empty"));
}
if (oldText == null || oldText.isEmpty()) {
return errorResult(filePath, i18n.msg("tool.edit_file.error.old_text_empty"));
}
if (newText == null) {
newText = "";
}
if (oldText.equals(newText)) {
return errorResult(filePath, i18n.msg("tool.edit_file.error.same_text"));
}
Path path;
try {
path = vip.mate.tool.guard.WorkspacePathGuard.validatePath(filePath);
} catch (IllegalArgumentException e) {
return errorResult(filePath, e.getMessage());
}
if (!Files.exists(path)) {
return errorResult(filePath, i18n.msg("tool.edit_file.error.not_found", path));
}
if (Files.isDirectory(path)) {
return errorResult(filePath, i18n.msg("tool.edit_file.error.is_directory", path));
}
if (!Files.isReadable(path) || !Files.isWritable(path)) {
return errorResult(filePath, i18n.msg("tool.edit_file.error.not_rw", path));
}
// 读取文件内容
String content = Files.readString(path, StandardCharsets.UTF_8);
// 检查 oldText 是否存在
if (!content.contains(oldText)) {
return errorResult(filePath, i18n.msg("tool.edit_file.error.old_not_found"));
}
// 执行替换
String newContent;
int replacements;
boolean doReplaceAll = replaceAll != null && replaceAll;
if (doReplaceAll) {
// 统计匹配次数
replacements = countOccurrences(content, oldText);
newContent = content.replace(oldText, newText);
} else {
// 只替换第一处
int idx = content.indexOf(oldText);
newContent = content.substring(0, idx) + newText + content.substring(idx + oldText.length());
replacements = 1;
}
// 写回文件
Files.writeString(path, newContent, StandardCharsets.UTF_8);
result.set("replacements", replacements);
result.set("replaceAll", doReplaceAll);
result.set("message", "Edit successful: " + replacements + " replacement(s)");
log.info("[EditFile] Edited {}: {} replacement(s)", path, replacements);
} catch (Exception e) {
log.error("[EditFile] Failed to edit file: {}", e.getMessage(), e);
return errorResult(filePath, i18n.msg("tool.edit_file.error.edit_exception", e.getMessage()));
}
return JSONUtil.toJsonPrettyStr(result);
}
private int countOccurrences(String text, String target) {
int count = 0;
int idx = 0;
while ((idx = text.indexOf(target, idx)) != -1) {
count++;
idx += target.length();
}
return count;
}
private String errorResult(String filePath, String message) {
JSONObject result = new JSONObject();
result.set("filePath", filePath);
result.set("error", true);
result.set("message", message);
return JSONUtil.toJsonPrettyStr(result);
}
}