feat: FileReadTool image support + FileEditTool unified diff output

- FileReadTool: detect image files (png/jpg/gif/webp/svg/bmp/ico),
  read as Base64 with MIME type, 20MB size limit
- FileEditTool: generate unified diff after edits with 3-line context,
  showing removed (-) and added (+) lines in standard diff format

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
pull/1/head
abel533 1 month ago
parent d9b8c795b6
commit f5da3499a4
  1. 76
      src/main/java/com/claudecode/tool/impl/FileEditTool.java
  2. 110
      src/main/java/com/claudecode/tool/impl/FileReadTool.java

@ -15,6 +15,7 @@ import java.util.Map;
* 文件编辑工具 对应 claude-code/src/tools/edit/EditFileTool.ts * 文件编辑工具 对应 claude-code/src/tools/edit/EditFileTool.ts
* <p> * <p>
* 通过精确匹配 old_string 并替换为 new_string 来编辑文件 * 通过精确匹配 old_string 并替换为 new_string 来编辑文件
* 编辑后返回 unified diff 格式的变更摘要
*/ */
public class FileEditTool implements Tool { public class FileEditTool implements Tool {
@ -91,17 +92,88 @@ public class FileEditTool implements Tool {
String newContent = content.substring(0, firstIdx) + newString + content.substring(firstIdx + oldString.length()); String newContent = content.substring(0, firstIdx) + newString + content.substring(firstIdx + oldString.length());
Files.writeString(path, newContent, StandardCharsets.UTF_8); Files.writeString(path, newContent, StandardCharsets.UTF_8);
// 计算变更范围 // 生成 unified diff 摘要
String diff = generateUnifiedDiff(path.toString(), content, newContent);
// 计算变更统计
long oldLines = oldString.lines().count(); long oldLines = oldString.lines().count();
long newLines = newString.lines().count(); long newLines = newString.lines().count();
return "✅ Edited " + path + " (replaced " + oldLines + " lines with " + newLines + " lines)"; StringBuilder result = new StringBuilder();
result.append("✅ Edited ").append(path)
.append(" (replaced ").append(oldLines)
.append(" lines with ").append(newLines).append(" lines)\n\n");
result.append(diff);
return result.toString().stripTrailing();
} catch (IOException e) { } catch (IOException e) {
return "Error editing file: " + e.getMessage(); return "Error editing file: " + e.getMessage();
} }
} }
/**
* 生成 unified diff 格式输出
* 类似 TS 版本的 getPatchForEdit()但使用纯 Java 实现
*/
private String generateUnifiedDiff(String filePath, String original, String modified) {
List<String> oldLines = original.lines().collect(java.util.stream.Collectors.toList());
List<String> newLines = modified.lines().collect(java.util.stream.Collectors.toList());
// 找出变更区域
int contextLines = 3;
// 找到第一个不同的行
int firstDiff = 0;
int minLen = Math.min(oldLines.size(), newLines.size());
while (firstDiff < minLen && oldLines.get(firstDiff).equals(newLines.get(firstDiff))) {
firstDiff++;
}
// 找到最后一个不同的行(从末尾向前扫描)
int oldEnd = oldLines.size();
int newEnd = newLines.size();
while (oldEnd > firstDiff && newEnd > firstDiff
&& oldLines.get(oldEnd - 1).equals(newLines.get(newEnd - 1))) {
oldEnd--;
newEnd--;
}
// 计算包含上下文的范围
int ctxStart = Math.max(0, firstDiff - contextLines);
int oldCtxEnd = Math.min(oldLines.size(), oldEnd + contextLines);
int newCtxEnd = Math.min(newLines.size(), newEnd + contextLines);
StringBuilder sb = new StringBuilder();
sb.append("--- a/").append(filePath).append('\n');
sb.append("+++ b/").append(filePath).append('\n');
// Hunk header: @@ -oldStart,oldCount +newStart,newCount @@
int oldHunkSize = oldCtxEnd - ctxStart;
int newHunkSize = newCtxEnd - ctxStart - (oldEnd - firstDiff) + (newEnd - firstDiff);
sb.append(String.format("@@ -%d,%d +%d,%d @@%n",
ctxStart + 1, oldHunkSize, ctxStart + 1, newHunkSize));
// Context before
for (int i = ctxStart; i < firstDiff; i++) {
sb.append(' ').append(oldLines.get(i)).append('\n');
}
// Removed lines
for (int i = firstDiff; i < oldEnd; i++) {
sb.append('-').append(oldLines.get(i)).append('\n');
}
// Added lines
for (int i = firstDiff; i < newEnd; i++) {
sb.append('+').append(newLines.get(i)).append('\n');
}
// Context after
for (int i = oldEnd; i < oldCtxEnd; i++) {
sb.append(' ').append(oldLines.get(i)).append('\n');
}
return sb.toString();
}
@Override @Override
public String activityDescription(Map<String, Object> input) { public String activityDescription(Map<String, Object> input) {
return "✏ Editing " + input.getOrDefault("file_path", "file"); return "✏ Editing " + input.getOrDefault("file_path", "file");

@ -7,19 +7,28 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Base64;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.Set;
/** /**
* 文件读取工具 对应 claude-code/src/tools/read/ReadFileTool.ts * 文件读取工具 对应 claude-code/src/tools/read/ReadFileTool.ts
* <p> * <p>
* 读取文件内容支持行号范围过滤 * 读取文件内容支持行号范围过滤和图片读取Base64编码
*/ */
public class FileReadTool implements Tool { public class FileReadTool implements Tool {
/** 单次读取最大行数 */ /** 单次读取最大行数 */
private static final int MAX_LINES = 2000; private static final int MAX_LINES = 2000;
/** 图片最大文件大小 (20MB) */
private static final long MAX_IMAGE_SIZE = 20 * 1024 * 1024;
/** 支持的图片扩展名 */
private static final Set<String> IMAGE_EXTENSIONS = Set.of(
"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico"
);
@Override @Override
public String name() { public String name() {
return "Read"; return "Read";
@ -28,8 +37,10 @@ public class FileReadTool implements Tool {
@Override @Override
public String description() { public String description() {
return """ return """
Read the contents of a file. Use line_start and line_end to read specific line ranges. \ Read the contents of a file. Supports text files with optional line ranges, \
For large files, read in chunks. Supports text files only."""; and image files (png, jpg, jpeg, gif, webp, svg, bmp, ico) which are returned \
as Base64-encoded data with MIME type. For large text files, read in chunks \
using line_start and line_end.""";
} }
@Override @Override
@ -44,11 +55,11 @@ public class FileReadTool implements Tool {
}, },
"line_start": { "line_start": {
"type": "integer", "type": "integer",
"description": "Starting line number (1-based, inclusive)" "description": "Starting line number (1-based, inclusive). Only for text files."
}, },
"line_end": { "line_end": {
"type": "integer", "type": "integer",
"description": "Ending line number (1-based, inclusive)" "description": "Ending line number (1-based, inclusive). Only for text files."
} }
}, },
"required": ["file_path"] "required": ["file_path"]
@ -72,6 +83,47 @@ public class FileReadTool implements Tool {
return "Error: Not a regular file: " + path; return "Error: Not a regular file: " + path;
} }
// Check if it's an image file
String ext = getExtension(path.getFileName().toString());
if (IMAGE_EXTENSIONS.contains(ext)) {
return readImage(path, ext);
}
// Text file reading
return readText(path, input);
}
/**
* 读取图片文件返回 Base64 编码数据和 MIME 类型信息
*/
private String readImage(Path path, String ext) {
try {
long size = Files.size(path);
if (size > MAX_IMAGE_SIZE) {
return "Error: Image too large (" + formatSize(size) + "). Max: " + formatSize(MAX_IMAGE_SIZE);
}
if (size == 0) {
return "Error: Image file is empty: " + path;
}
byte[] bytes = Files.readAllBytes(path);
String base64 = Base64.getEncoder().encodeToString(bytes);
String mimeType = getMimeType(ext);
return String.format("""
{"type": "image", "file_path": "%s", "mime_type": "%s", \
"size": %d, "base64_length": %d, "base64": "%s"}""",
escapeJson(path.toString()), mimeType, size, base64.length(), base64);
} catch (IOException e) {
return "Error reading image: " + e.getMessage();
}
}
/**
* 读取文本文件支持行范围过滤
*/
private String readText(Path path, Map<String, Object> input) {
try { try {
var allLines = Files.readAllLines(path, StandardCharsets.UTF_8); var allLines = Files.readAllLines(path, StandardCharsets.UTF_8);
int total = allLines.size(); int total = allLines.size();
@ -86,7 +138,6 @@ public class FileReadTool implements Tool {
end = ((Number) input.get("line_end")).intValue(); end = ((Number) input.get("line_end")).intValue();
} }
// 参数校验
start = Math.max(1, start); start = Math.max(1, start);
end = Math.min(total, end); end = Math.min(total, end);
@ -94,12 +145,10 @@ public class FileReadTool implements Tool {
return "Error: line_start (" + start + ") > line_end (" + end + ")"; return "Error: line_start (" + start + ") > line_end (" + end + ")";
} }
// 限制行数
if (end - start + 1 > MAX_LINES) { if (end - start + 1 > MAX_LINES) {
end = start + MAX_LINES - 1; end = start + MAX_LINES - 1;
} }
// 构建带行号的输出
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
for (int i = start - 1; i < end; i++) { for (int i = start - 1; i < end; i++) {
sb.append(String.format("%4d | %s%n", i + 1, allLines.get(i))); sb.append(String.format("%4d | %s%n", i + 1, allLines.get(i)));
@ -116,8 +165,49 @@ public class FileReadTool implements Tool {
} }
} }
/**
* 获取文件扩展名小写不含点
*/
private String getExtension(String filename) {
int dot = filename.lastIndexOf('.');
if (dot < 0 || dot == filename.length() - 1) return "";
return filename.substring(dot + 1).toLowerCase();
}
/**
* 根据扩展名返回 MIME 类型
*/
private String getMimeType(String ext) {
return switch (ext) {
case "png" -> "image/png";
case "jpg", "jpeg" -> "image/jpeg";
case "gif" -> "image/gif";
case "webp" -> "image/webp";
case "svg" -> "image/svg+xml";
case "bmp" -> "image/bmp";
case "ico" -> "image/x-icon";
default -> "application/octet-stream";
};
}
private String formatSize(long bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0);
return String.format("%.1f MB", bytes / (1024.0 * 1024));
}
private String escapeJson(String s) {
if (s == null) return "";
return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n");
}
@Override @Override
public String activityDescription(Map<String, Object> input) { public String activityDescription(Map<String, Object> input) {
return "📄 Reading " + input.getOrDefault("file_path", "file"); String fp = (String) input.getOrDefault("file_path", "file");
String ext = getExtension(fp);
if (IMAGE_EXTENSIONS.contains(ext)) {
return "🖼 Reading image " + fp;
}
return "📄 Reading " + fp;
} }
} }

Loading…
Cancel
Save