mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 04:18:17 +08:00
fix(chat): surface stored path for uploaded attachments
Chat attachments with non-ASCII filenames (e.g. Chinese) get sanitized at upload time — `人人有虾.docx` is stored as `1777391026594_____.docx`. Tools then receive only the original filename via '[Attachment] foo.docx' and fail with 'file not found'. - renderMessageContent now appends the actual server-side path so any tool the LLM picks (read_file / extract_document_text / detect_file_type) gets a path that resolves directly. - New ChatUploadResolver helper performs basename-suffix matching inside the conversation's chat-upload directory; ReadFileTool, DocumentExtractTool and FileTypeDetectorTool fall through to it when the literal path does not exist (defense in depth for cases where the LLM ignores the path hint). Refs https://github.com/matevip/mateclaw/issues/29
This commit is contained in:
parent
ae820f0f94
commit
709a0db200
@ -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}/}).
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
* <p>
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -74,7 +74,17 @@ public class DocumentExtractTool {
|
|||||||
Path path = Paths.get(filePath).toAbsolutePath().normalize();
|
Path path = Paths.get(filePath).toAbsolutePath().normalize();
|
||||||
|
|
||||||
if (!Files.exists(path)) {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析文件类型
|
// 解析文件类型
|
||||||
|
|||||||
@ -50,7 +50,14 @@ public class FileTypeDetectorTool {
|
|||||||
Path path = Paths.get(filePath).toAbsolutePath().normalize();
|
Path path = Paths.get(filePath).toAbsolutePath().normalize();
|
||||||
|
|
||||||
if (!Files.exists(path)) {
|
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)) {
|
if (Files.isDirectory(path)) {
|
||||||
|
|||||||
@ -9,12 +9,10 @@ import org.springframework.ai.tool.annotation.ToolParam;
|
|||||||
import org.springframework.lang.Nullable;
|
import org.springframework.lang.Nullable;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
|
||||||
import java.io.IOException;
|
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.nio.file.Paths;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@ -40,9 +38,6 @@ public class ReadFileTool {
|
|||||||
private static final int DEFAULT_MAX_LINES = 1000;
|
private static final int DEFAULT_MAX_LINES = 1000;
|
||||||
private static final int MAX_OUTPUT_BYTES = 30 * 1024; // 30KB
|
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 读取
|
* 二进制文档扩展名集合 - 这些文件不应使用 read_file 读取
|
||||||
*/
|
*/
|
||||||
@ -77,7 +72,7 @@ public class ReadFileTool {
|
|||||||
// a Linux-style path (e.g. /app/Dockerfile) for a chat-upload that
|
// a Linux-style path (e.g. /app/Dockerfile) for a chat-upload that
|
||||||
// actually lives under data/chat-uploads/{conversationId}/. Retry
|
// actually lives under data/chat-uploads/{conversationId}/. Retry
|
||||||
// by basename before surfacing the boundary error.
|
// by basename before surfacing the boundary error.
|
||||||
Path attachment = resolveChatUploadAttachment(filePath);
|
Path attachment = ChatUploadResolver.resolve(filePath);
|
||||||
if (attachment == null) {
|
if (attachment == null) {
|
||||||
return errorResult(filePath, e.getMessage());
|
return errorResult(filePath, e.getMessage());
|
||||||
}
|
}
|
||||||
@ -91,7 +86,7 @@ public class ReadFileTool {
|
|||||||
// just the basename or a guessed absolute path. Fall back to
|
// just the basename or a guessed absolute path. Fall back to
|
||||||
// looking up the basename inside the current conversation's
|
// looking up the basename inside the current conversation's
|
||||||
// chat-upload directory before reporting not-found.
|
// chat-upload directory before reporting not-found.
|
||||||
Path attachment = resolveChatUploadAttachment(filePath);
|
Path attachment = ChatUploadResolver.resolve(filePath);
|
||||||
if (attachment == null) {
|
if (attachment == null) {
|
||||||
return errorResult(filePath, i18n.msg("tool.read_file.error.not_found", path));
|
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) {
|
private String errorResult(String filePath, String message) {
|
||||||
JSONObject result = new JSONObject();
|
JSONObject result = new JSONObject();
|
||||||
result.set("filePath", filePath);
|
result.set("filePath", filePath);
|
||||||
|
|||||||
@ -452,7 +452,7 @@ public class ConversationService {
|
|||||||
switch (part.getType()) {
|
switch (part.getType()) {
|
||||||
case "text" -> appendSegment(text, part.getText());
|
case "text" -> appendSegment(text, part.getText());
|
||||||
case "thinking", "tool_call", "parse_error" -> { /* skip — frontend reads these from contentParts directly */ }
|
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());
|
default -> appendSegment(text, part.getText());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -494,6 +494,23 @@ public class ConversationService {
|
|||||||
return rendered;
|
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) {
|
private void appendSegment(StringBuilder builder, String text) {
|
||||||
String safeText = safe(text);
|
String safeText = safe(text);
|
||||||
if (safeText.isBlank()) {
|
if (safeText.isBlank()) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user