mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
139 lines
5.2 KiB
Java
139 lines
5.2 KiB
Java
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);
|
||
}
|
||
}
|