diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java new file mode 100644 index 00000000..96a2abf2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java @@ -0,0 +1,78 @@ +package vip.mate.tool.builtin; + +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Resolves a user-supplied file path against the current conversation's chat-upload + * directory ({@code data/chat-uploads/{conversationId}/}). + *

+ * Chat attachments are stored as {@code {timestamp}_{safeFilename}} where + * {@code safeFilename} replaces every non-{@code [a-zA-Z0-9._-]} character with + * {@code _}. This means a file uploaded as {@code 人人有虾.docx} is stored on disk + * as e.g. {@code 1777391026594_____.docx}. The LLM only ever sees the original + * filename in the rendered "[附件] foo.docx" prefix, so when a tool gets called + * with the original name it won't match anything on disk via direct lookup. + *

+ * This helper rescues such calls by matching basenames inside the conversation's + * upload directory. Used by both {@link ReadFileTool} and {@link DocumentExtractTool}. + */ +@Slf4j +final class ChatUploadResolver { + + static final Path CHAT_UPLOAD_ROOT = Paths.get("data", "chat-uploads"); + + private ChatUploadResolver() {} + + /** + * @return absolute path of the matched attachment, or {@code null} if no match + */ + static Path resolve(String rawPath) { + if (rawPath == null || rawPath.isBlank()) { + return null; + } + String conversationId = ToolExecutionContext.conversationId(); + if (conversationId == null || conversationId.isBlank()) { + return null; + } + Path uploadDir = CHAT_UPLOAD_ROOT.resolve(conversationId).toAbsolutePath().normalize(); + if (!Files.isDirectory(uploadDir)) { + return null; + } + + String basename; + try { + Path requested = Paths.get(rawPath).getFileName(); + basename = requested != null ? requested.toString() : null; + } catch (Exception e) { + return null; + } + if (basename == null || basename.isBlank()) { + return null; + } + + Path direct = uploadDir.resolve(basename); + if (Files.isRegularFile(direct)) { + return direct; + } + + // Stored as "{millis}_{safeFilename}" where safeFilename replaces non-ASCII + // characters with underscores; match by sanitized basename suffix. + String safeBasename = basename.replaceAll("[^a-zA-Z0-9._-]", "_"); + String suffix = "_" + safeBasename; + try (var stream = Files.list(uploadDir)) { + return stream + .filter(Files::isRegularFile) + .filter(p -> p.getFileName().toString().endsWith(suffix)) + .findFirst() + .orElse(null); + } catch (IOException e) { + log.warn("[ChatUploadResolver] Failed to scan chat-upload dir {}: {}", uploadDir, e.getMessage()); + return null; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java index ebae9a05..8587b89b 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java @@ -74,7 +74,17 @@ public class DocumentExtractTool { Path path = Paths.get(filePath).toAbsolutePath().normalize(); if (!Files.exists(path)) { - return errorResult(filePath, "文件不存在: " + path, attempts); + // The user-uploaded chat attachment is rendered to the LLM as + // "[附件] foo.docx" without its stored path, and Chinese / non-ASCII + // filenames are sanitized at upload time (see ChatController#upload), + // so the LLM-supplied path won't match anything on disk. Fall back to + // basename matching inside the conversation's chat-upload directory. + Path attachment = ChatUploadResolver.resolve(filePath); + if (attachment == null) { + return errorResult(filePath, "文件不存在: " + path, attempts); + } + log.info("[DocumentExtract] Resolved chat-upload attachment fallback: {} -> {}", filePath, attachment); + path = attachment; } // 解析文件类型 diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/FileTypeDetectorTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/FileTypeDetectorTool.java index 938c0fae..b423e6be 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/FileTypeDetectorTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/FileTypeDetectorTool.java @@ -50,7 +50,14 @@ public class FileTypeDetectorTool { Path path = Paths.get(filePath).toAbsolutePath().normalize(); if (!Files.exists(path)) { - return errorResult(filePath, "文件不存在: " + path); + // Fall back to chat-upload basename matching for filenames that were + // sanitized at upload time (e.g. Chinese characters → underscores). + Path attachment = ChatUploadResolver.resolve(filePath); + if (attachment == null) { + return errorResult(filePath, "文件不存在: " + path); + } + log.info("[FileTypeDetector] Resolved chat-upload attachment fallback: {} -> {}", filePath, attachment); + path = attachment; } if (Files.isDirectory(path)) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java index 79a25985..6c582826 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java @@ -9,12 +9,10 @@ import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; -import java.io.BufferedReader; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -40,9 +38,6 @@ public class ReadFileTool { private static final int DEFAULT_MAX_LINES = 1000; private static final int MAX_OUTPUT_BYTES = 30 * 1024; // 30KB - /** Chat attachment upload root, mirrored from ChatController.uploadRoot. */ - private static final Path CHAT_UPLOAD_ROOT = Paths.get("data", "chat-uploads"); - /** * 二进制文档扩展名集合 - 这些文件不应使用 read_file 读取 */ @@ -77,7 +72,7 @@ public class ReadFileTool { // a Linux-style path (e.g. /app/Dockerfile) for a chat-upload that // actually lives under data/chat-uploads/{conversationId}/. Retry // by basename before surfacing the boundary error. - Path attachment = resolveChatUploadAttachment(filePath); + Path attachment = ChatUploadResolver.resolve(filePath); if (attachment == null) { return errorResult(filePath, e.getMessage()); } @@ -91,7 +86,7 @@ public class ReadFileTool { // just the basename or a guessed absolute path. Fall back to // looking up the basename inside the current conversation's // chat-upload directory before reporting not-found. - Path attachment = resolveChatUploadAttachment(filePath); + Path attachment = ChatUploadResolver.resolve(filePath); if (attachment == null) { return errorResult(filePath, i18n.msg("tool.read_file.error.not_found", path)); } @@ -220,60 +215,6 @@ public class ReadFileTool { } } - /** - * Try to resolve {@code rawPath} against the current conversation's chat-upload - * directory. Uploaded files land at {@code data/chat-uploads/{conversationId}/{timestamp}_{safeFilename}} - * (see ChatController#upload), but the LLM only sees the original filename in - * the rendered message ("[附件] foo.txt"). When the LLM passes a guessed path - * that doesn't exist, this fallback rescues the call by matching basenames. - * - * @return absolute path of the matched attachment, or null if no match - */ - private Path resolveChatUploadAttachment(String rawPath) { - if (rawPath == null || rawPath.isBlank()) { - return null; - } - String conversationId = ToolExecutionContext.conversationId(); - if (conversationId == null || conversationId.isBlank()) { - return null; - } - Path uploadDir = CHAT_UPLOAD_ROOT.resolve(conversationId).toAbsolutePath().normalize(); - if (!Files.isDirectory(uploadDir)) { - return null; - } - - String basename; - try { - Path requested = Paths.get(rawPath).getFileName(); - basename = requested != null ? requested.toString() : null; - } catch (Exception e) { - return null; - } - if (basename == null || basename.isBlank()) { - return null; - } - - // 1) direct match (LLM passed the stored filename) - Path direct = uploadDir.resolve(basename); - if (Files.isRegularFile(direct)) { - return direct; - } - - // 2) timestamp-prefixed match: stored as "{millis}_{safeFilename}" - String safeBasename = basename.replaceAll("[^a-zA-Z0-9._-]", "_"); - String suffix = "_" + safeBasename; - try (var stream = Files.list(uploadDir)) { - return stream - .filter(Files::isRegularFile) - .filter(p -> p.getFileName().toString().endsWith(suffix)) - .findFirst() - .orElse(null); - } catch (IOException e) { - log.warn("[ReadFile] Failed to scan chat-upload dir {}: {}", uploadDir, e.getMessage()); - return null; - } - } - private String errorResult(String filePath, String message) { JSONObject result = new JSONObject(); result.set("filePath", filePath); diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 5eb4a5eb..81c6a927 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -452,7 +452,7 @@ public class ConversationService { switch (part.getType()) { case "text" -> appendSegment(text, part.getText()); case "thinking", "tool_call", "parse_error" -> { /* skip — frontend reads these from contentParts directly */ } - case "file" -> appendSegment(text, "[附件] " + safe(part.getFileName())); + case "file" -> appendSegment(text, renderFilePart(part)); default -> appendSegment(text, part.getText()); } } @@ -494,6 +494,23 @@ public class ConversationService { return rendered; } + /** + * Render a "file" content part for the LLM prompt. The original filename can be + * non-ASCII (Chinese, emoji, …); the upload pipeline sanitizes those characters + * to underscores when storing on disk, so the LLM-visible name and the on-disk + * name diverge. Surface the actual server-side path here so any tool the LLM + * picks (read_file / extract_document_text / detect_file_type / …) can be called + * with a path that resolves directly, instead of relying on per-tool fallbacks. + */ + private String renderFilePart(MessageContentPart part) { + String name = safe(part.getFileName()); + String path = safe(part.getPath()); + if (path.isBlank()) { + return "[附件] " + name; + } + return "[附件] " + name + "(路径: " + path + ")"; + } + private void appendSegment(StringBuilder builder, String text) { String safeText = safe(text); if (safeText.isBlank()) {