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>
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user