diff --git a/src/main/java/com/claudecode/tool/impl/FileEditTool.java b/src/main/java/com/claudecode/tool/impl/FileEditTool.java index afc4da9..b3a04a6 100644 --- a/src/main/java/com/claudecode/tool/impl/FileEditTool.java +++ b/src/main/java/com/claudecode/tool/impl/FileEditTool.java @@ -15,6 +15,7 @@ import java.util.Map; * 文件编辑工具 —— 对应 claude-code/src/tools/edit/EditFileTool.ts。 *

* 通过精确匹配 old_string 并替换为 new_string 来编辑文件。 + * 编辑后返回 unified diff 格式的变更摘要。 */ 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()); Files.writeString(path, newContent, StandardCharsets.UTF_8); - // 计算变更范围 + // 生成 unified diff 摘要 + String diff = generateUnifiedDiff(path.toString(), content, newContent); + + // 计算变更统计 long oldLines = oldString.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) { return "Error editing file: " + e.getMessage(); } } + /** + * 生成 unified diff 格式输出。 + * 类似 TS 版本的 getPatchForEdit(),但使用纯 Java 实现。 + */ + private String generateUnifiedDiff(String filePath, String original, String modified) { + List oldLines = original.lines().collect(java.util.stream.Collectors.toList()); + List 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 public String activityDescription(Map input) { return "✏️ Editing " + input.getOrDefault("file_path", "file"); diff --git a/src/main/java/com/claudecode/tool/impl/FileReadTool.java b/src/main/java/com/claudecode/tool/impl/FileReadTool.java index 54159bb..9ca0d9e 100644 --- a/src/main/java/com/claudecode/tool/impl/FileReadTool.java +++ b/src/main/java/com/claudecode/tool/impl/FileReadTool.java @@ -7,19 +7,28 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Base64; import java.util.Map; -import java.util.stream.Collectors; +import java.util.Set; /** * 文件读取工具 —— 对应 claude-code/src/tools/read/ReadFileTool.ts。 *

- * 读取文件内容,支持行号范围过滤。 + * 读取文件内容,支持行号范围过滤和图片读取(Base64编码)。 */ public class FileReadTool implements Tool { /** 单次读取最大行数 */ private static final int MAX_LINES = 2000; + /** 图片最大文件大小 (20MB) */ + private static final long MAX_IMAGE_SIZE = 20 * 1024 * 1024; + + /** 支持的图片扩展名 */ + private static final Set IMAGE_EXTENSIONS = Set.of( + "png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico" + ); + @Override public String name() { return "Read"; @@ -28,8 +37,10 @@ public class FileReadTool implements Tool { @Override public String description() { return """ - Read the contents of a file. Use line_start and line_end to read specific line ranges. \ - For large files, read in chunks. Supports text files only."""; + Read the contents of a file. Supports text files with optional line ranges, \ + 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 @@ -44,11 +55,11 @@ public class FileReadTool implements Tool { }, "line_start": { "type": "integer", - "description": "Starting line number (1-based, inclusive)" + "description": "Starting line number (1-based, inclusive). Only for text files." }, "line_end": { "type": "integer", - "description": "Ending line number (1-based, inclusive)" + "description": "Ending line number (1-based, inclusive). Only for text files." } }, "required": ["file_path"] @@ -72,6 +83,47 @@ public class FileReadTool implements Tool { 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 input) { try { var allLines = Files.readAllLines(path, StandardCharsets.UTF_8); int total = allLines.size(); @@ -86,7 +138,6 @@ public class FileReadTool implements Tool { end = ((Number) input.get("line_end")).intValue(); } - // 参数校验 start = Math.max(1, start); end = Math.min(total, end); @@ -94,12 +145,10 @@ public class FileReadTool implements Tool { return "Error: line_start (" + start + ") > line_end (" + end + ")"; } - // 限制行数 if (end - start + 1 > MAX_LINES) { end = start + MAX_LINES - 1; } - // 构建带行号的输出 StringBuilder sb = new StringBuilder(); for (int i = start - 1; i < end; 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 public String activityDescription(Map 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; } }