From 7894a500671399d2ba92fcfe1632773e4228739d Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 7 Jun 2026 15:03:48 +0800 Subject: [PATCH] fix(tool): emit absolute download URLs for streaming-generated files (#164) Streaming chat ran render tools on an async thread with no bound request, so download links lost their host and arrived without a domain. Resolve the host on the request thread and carry it through ChatOrigin/ToolContext; falls back to a configurable public-base-url, then a relative path. --- .env.example | 5 + .../vip/mate/agent/context/ChatOrigin.java | 39 +++++-- .../channel/ChannelChatOriginFactory.java | 3 +- .../dingtalk/DingTalkChannelAdapter.java | 2 +- .../vip/mate/channel/web/ChatController.java | 60 +++++++--- .../channel/web/SegmentSupersedeDetector.java | 2 +- .../channel/wecom/WeComChannelAdapter.java | 2 +- .../vip/mate/tool/builtin/DocxRenderTool.java | 17 ++- .../tool/builtin/HtmlImageRenderTool.java | 7 +- .../vip/mate/tool/builtin/PdfRenderTool.java | 12 +- .../vip/mate/tool/builtin/PptxRenderTool.java | 12 +- .../vip/mate/tool/builtin/SendFileTool.java | 6 +- .../vip/mate/tool/builtin/XlsxRenderTool.java | 12 +- .../tool/document/GeneratedFileCache.java | 83 ++++++++++++- .../mate/tool/document/GeneratedFileLink.java | 30 ++--- .../src/main/resources/application.yml | 7 ++ .../context/ChatOriginSenderFieldsTest.java | 4 +- .../mate/agent/context/ChatOriginTest.java | 4 +- .../RuntimeContextInjectorSenderTest.java | 7 +- .../ApprovalReplayContinuityTest.java | 3 +- .../cron/service/CronJobRunnerPromptTest.java | 3 +- ...elegateAsyncTaskOutputAttributionTest.java | 2 +- .../tool/builtin/DelegateAsyncToolTest.java | 2 +- .../tool/document/GeneratedFileUrlTest.java | 110 ++++++++++++++++++ 24 files changed, 360 insertions(+), 74 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileUrlTest.java diff --git a/.env.example b/.env.example index ce022f3a..31fc3dfe 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,11 @@ JWT_SECRET= # 若留空,服务器会允许所有 origin 并在启动日志里 WARN。生产部署务必设置。 MATECLAW_CORS_ALLOWED_ORIGINS= +# 公开访问基址(如 https://mateclaw.example.com)。用于把智能体生成文件的下载 +# 链接拼成绝对地址,便于在 Web 之外(IM 消息、复制链接、外部下载)直接打开。 +# 留空时回退到当前请求的 host,再退回相对路径。反代后部署建议显式设置。 +MATECLAW_PUBLIC_BASE_URL= + # SearXNG 会话密钥(容器内部用,留空会用开发默认值)。生产部署请设成 32+ 位随机串。 # openssl rand -hex 32 SEARXNG_SECRET= diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java index 6bc2a6c9..5e2e3a17 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java @@ -58,7 +58,15 @@ public record ChatOrigin( * vs. group conversations. Null for 1:1 chats. Distinct from * {@link #channelTarget()} (which targets cron / proactive sends). */ - @Nullable String chatId + @Nullable String chatId, + /** + * Public base URL ({@code scheme://host[:port][/contextPath]}) resolved + * from the inbound HTTP request on the request thread. Carried here so + * tools running on async/streaming threads — where no request is bound — + * can still mint absolute download links. Null for IM/cron origins, which + * have no request host; those rely on {@code mateclaw.server.public-base-url}. + */ + @Nullable String baseUrl ) { /** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */ @@ -66,7 +74,7 @@ public record ChatOrigin( /** Sentinel used by AgentService default overloads where no origin is supplied. */ public static final ChatOrigin EMPTY = - new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null); + new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null, null); // ---------------- Factories per entry point ---------------- @@ -74,9 +82,17 @@ public record ChatOrigin( @Nullable String requesterId, @Nullable Long workspaceId, @Nullable String workspaceBasePath) { + return web(conversationId, requesterId, workspaceId, workspaceBasePath, null); + } + + public static ChatOrigin web(@Nullable String conversationId, + @Nullable String requesterId, + @Nullable Long workspaceId, + @Nullable String workspaceBasePath, + @Nullable String baseUrl) { return new ChatOrigin(null, conversationId, requesterId != null ? requesterId : "", - workspaceId, workspaceBasePath, null, null, false, null, "web", null); + workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl); } public static ChatOrigin cron(@Nullable String conversationId, @@ -85,7 +101,7 @@ public record ChatOrigin( @Nullable Long channelId, @Nullable ChannelTarget target) { return new ChatOrigin(null, conversationId, "system", - workspaceId, workspaceBasePath, channelId, target, true, null, null, null); + workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null); } // ---------------- Wither-style updates ---------------- @@ -93,20 +109,27 @@ public record ChatOrigin( public ChatOrigin withAgent(@Nullable Long newAgentId) { return new ChatOrigin(newAgentId, conversationId, requesterId, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, - senderName, channelType, chatId); + senderName, channelType, chatId, baseUrl); } public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId, @Nullable String newWorkspaceBasePath) { return new ChatOrigin(agentId, conversationId, requesterId, newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin, - senderName, channelType, chatId); + senderName, channelType, chatId, baseUrl); } public ChatOrigin withConversationId(@Nullable String newConversationId) { return new ChatOrigin(agentId, newConversationId, requesterId, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, - senderName, channelType, chatId); + senderName, channelType, chatId, baseUrl); + } + + /** Carry a request-derived public base URL (see {@link #baseUrl()}). */ + public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) { + return new ChatOrigin(agentId, conversationId, requesterId, + workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, + senderName, channelType, chatId, newBaseUrl); } /** @@ -120,7 +143,7 @@ public record ChatOrigin( @Nullable String newChatId) { return new ChatOrigin(agentId, conversationId, requesterId, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, - newSenderName, newChannelType, newChatId); + newSenderName, newChannelType, newChatId, baseUrl); } // ---------------- Spring AI ToolContext interop ---------------- diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java index 1d739125..f8b166d4 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelChatOriginFactory.java @@ -43,7 +43,8 @@ public class ChannelChatOriginFactory { /* channelType */ message.getChannelType() != null ? message.getChannelType() : channel.getChannelType(), - /* chatId */ message.getChatId()); + /* chatId */ message.getChatId(), + /* baseUrl */ null); // IM origins have no request host; rely on public-base-url config } /** diff --git a/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkChannelAdapter.java index 1994c7ea..8144479e 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkChannelAdapter.java @@ -707,7 +707,7 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St } private static final java.util.regex.Pattern GENERATED_URL_PATTERN = - java.util.regex.Pattern.compile("/api/v1/files/generated/([a-zA-Z0-9-]+)"); + java.util.regex.Pattern.compile("(?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/([a-zA-Z0-9-]+)"); private static boolean isImageMime(String mimeType) { return mimeType != null && mimeType.toLowerCase().startsWith("image/"); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index 37ee1637..651c5575 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -85,6 +85,14 @@ public class ChatController { // RFC-058 PR-1: Utf8SseEmitter 显式声明 charset=UTF-8,防止中文在 Windows 中文 Chrome / 部分代理处乱码 SseEmitter emitter = new Utf8SseEmitter(10 * 60 * 1000L); + // Resolve the public base URL on THIS (request) thread. Every agent run + // below is dispatched to sseExecutor / reactive callbacks that run off + // the request thread, where the request is no longer bound and + // ServletUriComponentsBuilder would yield null. Capturing it here lets + // tool-generated download links carry an absolute host on the streaming, + // approval-replay, and queued-message paths alike. + final String requestBaseUrl = resolveRequestBaseUrl(); + // ---- 分支 A:断线重连 ---- if (Boolean.TRUE.equals(request.getReconnect())) { String reconnectUser = auth != null ? auth.getName() : "anonymous"; @@ -258,7 +266,7 @@ public class ChatController { // deny 是正常 turn 终结,用户可能在 awaiting_approval 阶段排了消息 ChatStreamTracker.CompletionResult denyCr = streamTracker.completeAndConsumeIfLast(conversationId); if (denyCr.allDone() && denyCr.queuedInput() != null) { - startQueuedMessage(conversationId, emitter, approvalEmitterDone, denyCr.queuedInput(), username); + startQueuedMessage(conversationId, emitter, approvalEmitterDone, denyCr.queuedInput(), username, requestBaseUrl); } else { completeEmitterQuietly(emitter, approvalEmitterDone); } @@ -272,7 +280,7 @@ public class ChatController { // 审批记录被另一个请求消费,但用户可能在等待期间排了消息 ChatStreamTracker.CompletionResult consumedNullCr = streamTracker.completeAndConsumeIfLast(conversationId); if (consumedNullCr.allDone() && consumedNullCr.queuedInput() != null) { - startQueuedMessage(conversationId, emitter, approvalEmitterDone, consumedNullCr.queuedInput(), username); + startQueuedMessage(conversationId, emitter, approvalEmitterDone, consumedNullCr.queuedInput(), username, requestBaseUrl); } else { completeEmitterQuietly(emitter, approvalEmitterDone); } @@ -298,6 +306,9 @@ public class ChatController { replayOrigin = vip.mate.agent.context.ChatOrigin.web( conversationId, username, workspaceId, null); } + // Carry the request-thread base URL so any file a replayed + // tool generates gets an absolute download link. + replayOrigin = replayOrigin.withBaseUrl(requestBaseUrl); Disposable disposable = agentService.chatWithReplayStream( replayAgentId, replayPrompt, conversationId, finalConsumed.getToolCallPayload(), username, replayOrigin) .doOnNext(delta -> { @@ -371,7 +382,7 @@ public class ChatController { ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); if (cr.allDone()) { if (cr.queuedInput() != null) { - startQueuedMessage(conversationId, emitter, approvalEmitterDone, cr.queuedInput(), username); + startQueuedMessage(conversationId, emitter, approvalEmitterDone, cr.queuedInput(), username, requestBaseUrl); } else { conversationService.updateStreamStatus(conversationId, "idle"); completeEmitterQuietly(emitter, approvalEmitterDone); @@ -469,7 +480,7 @@ public class ChatController { ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); if (cr.allDone()) { if (cr.queuedInput() != null) { - startQueuedMessage(conversationId, emitter, approvalEmitterDone, cr.queuedInput(), username); + startQueuedMessage(conversationId, emitter, approvalEmitterDone, cr.queuedInput(), username, requestBaseUrl); } else { conversationService.updateStreamStatus(conversationId, "idle"); completeEmitterQuietly(emitter, approvalEmitterDone); @@ -544,7 +555,8 @@ public class ChatController { // tools that need a workspace path read it from the agent (origin // is enriched with workspaceBasePath in StateGraph buildInitialState). vip.mate.agent.context.ChatOrigin webOrigin = - memoryOrigin(conversationId, username, workspaceId, request.getEndUserId()); + memoryOrigin(conversationId, username, workspaceId, request.getEndUserId()) + .withBaseUrl(requestBaseUrl); Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin) .doOnNext(delta -> { if (emitterDone.get()) return; @@ -680,7 +692,7 @@ public class ChatController { // genuinely doesn't want continuation, no message would // have been in messageQueue to begin with. if (cr.queuedInput() != null) { - startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username); + startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username, requestBaseUrl); } else { conversationService.updateStreamStatus(conversationId, "idle"); // 延迟关闭 emitter,确保最后的事件都已发送 @@ -771,7 +783,7 @@ public class ChatController { if (cr.allDone()) { if (cr.queuedInput() != null) { // 无论中断类型,都消费排队消息(修复 Disposable 不可用时队列被丢弃的 bug) - startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username); + startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username, requestBaseUrl); } else { conversationService.updateStreamStatus(conversationId, "idle"); completeEmitterQuietly(emitter, emitterDone); @@ -891,7 +903,7 @@ public class ChatController { // — just run it. Aligns with doOnComplete and the 4 other // queue-launch sites in this controller. if (cr.queuedInput() != null) { - startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username); + startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username, requestBaseUrl); } else { conversationService.updateStreamStatus(conversationId, "idle"); completeEmitterQuietly(emitter, emitterDone); @@ -1143,12 +1155,30 @@ public class ChatController { */ private vip.mate.agent.context.ChatOrigin memoryOrigin(String conversationId, String username, Long workspaceId, String endUserId) { + // Resolve the public base URL here, on the request thread, so it can ride + // the origin into async tool execution where no request is bound. Tools + // then mint absolute download links without operator config. + String baseUrl = resolveRequestBaseUrl(); if (endUserId != null && !endUserId.isBlank()) { return vip.mate.agent.context.ChatOrigin - .web(conversationId, endUserId.trim(), workspaceId, null) + .web(conversationId, endUserId.trim(), workspaceId, null, baseUrl) .withSender(null, "api", null); } - return vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null); + return vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null, baseUrl); + } + + /** + * Resolve {@code scheme://host[:port][/contextPath]} from the current request, + * honouring {@code X-Forwarded-*} when a {@code ForwardedHeaderFilter} is active. + * Returns null off the request thread (caller falls back to config / relative). + */ + private String resolveRequestBaseUrl() { + try { + return org.springframework.web.servlet.support.ServletUriComponentsBuilder + .fromCurrentContextPath().build().toUriString(); + } catch (Exception e) { + return null; + } } @lombok.Data @@ -1218,7 +1248,8 @@ public class ChatController { * 支持链式续跑:queued stream 自身完成时也通过 completeAndConsumeIfLast 检查并递归调用。 */ private void startQueuedMessage(String conversationId, SseEmitter emitter, AtomicBoolean emitterDone, - ChatStreamTracker.QueuedInput preConsumedInput, String requesterId) { + ChatStreamTracker.QueuedInput preConsumedInput, String requesterId, + String baseUrl) { if (preConsumedInput == null) { conversationService.updateStreamStatus(conversationId, "idle"); completeEmitterQuietly(emitter, emitterDone); @@ -1279,7 +1310,8 @@ public class ChatController { // a web-origin ChatOrigin so any cron job created during the queued // turn keeps a consistent (null-channel) binding. vip.mate.agent.context.ChatOrigin queuedOrigin = - vip.mate.agent.context.ChatOrigin.web(conversationId, requesterId, null, null); + vip.mate.agent.context.ChatOrigin.web(conversationId, requesterId, null, null) + .withBaseUrl(baseUrl); Disposable disposable = agentService.chatStructuredStream(agentId, queuedMessage, conversationId, requesterId, null, queuedOrigin) .doOnNext(delta -> { if (emitterDone.get()) return; @@ -1339,7 +1371,7 @@ public class ChatController { if (cr.allDone()) { if (cr.queuedInput() != null) { // 链式续跑:queued stream 期间又排了新消息 - startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), requesterId); + startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), requesterId, baseUrl); } else { conversationService.updateStreamStatus(conversationId, "idle"); sseExecutor.execute(() -> { @@ -1381,7 +1413,7 @@ public class ChatController { ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); if (cr.allDone()) { if (cr.queuedInput() != null) { - startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), requesterId); + startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), requesterId, baseUrl); } else { conversationService.updateStreamStatus(conversationId, "idle"); completeEmitterQuietly(emitter, emitterDone); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/SegmentSupersedeDetector.java b/mateclaw-server/src/main/java/vip/mate/channel/web/SegmentSupersedeDetector.java index 9e97f583..c3fe20d6 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/SegmentSupersedeDetector.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/SegmentSupersedeDetector.java @@ -14,7 +14,7 @@ final class SegmentSupersedeDetector { static final String REASON_TOOL_RESULT_REPLACED_MODEL_CLAIM = "tool_result_replaced_model_claim"; private static final Pattern GENERATED_FILE_URL = - Pattern.compile("/api/v1/files/generated/[A-Za-z0-9-]+"); + Pattern.compile("(?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/[A-Za-z0-9-]+"); private static final Pattern BYTE_COUNT = Pattern.compile("\\d+\\s*字节"); private static final Pattern REPLACEMENT_COUNT = diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java index 5243857c..82a050e5 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java @@ -1502,7 +1502,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { * each adapter rewrites the URL to a channel-native attachment. */ private static final java.util.regex.Pattern GENERATED_URL_PATTERN = - java.util.regex.Pattern.compile("/api/v1/files/generated/([a-zA-Z0-9-]+)"); + java.util.regex.Pattern.compile("(?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/([a-zA-Z0-9-]+)"); /** * Scan the agent's text for {@code /api/v1/files/generated/{id}} URLs; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java index ce3f8f90..36c4aa72 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java @@ -4,6 +4,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import vip.mate.tool.document.FilenameSanitizer; import vip.mate.tool.document.GeneratedFileCache; @@ -70,7 +72,8 @@ public class DocxRenderTool { @ToolParam(description = "Output filename without extension, e.g. 'monthly-report'") String filename, @ToolParam(description = "Page size: A4 or LETTER (default: A4)", required = false) - String pageSize) { + String pageSize, + @Nullable ToolContext ctx) { if (markdown == null || markdown.isBlank()) { return "错误:markdown 参数为空,无法生成文档。"; @@ -84,7 +87,7 @@ public class DocxRenderTool { byte[] bytes = renderer.render(markdown, size); log.info("[DocxRender] generated {} ({} bytes, {}ms)", displayName, bytes.length, System.currentTimeMillis() - t0); - return GeneratedFileLink.resultZh(bytes, displayName, DOCX_MIME, cache, "文档"); + return GeneratedFileLink.resultZh(bytes, displayName, DOCX_MIME, cache, "文档", ctx); } catch (Exception e) { log.error("[DocxRender] render failed for {}: {}", displayName, e.getMessage(), e); return "渲染失败:" + e.getMessage(); @@ -132,7 +135,8 @@ public class DocxRenderTool { @ToolParam(description = "Output filename without extension, e.g. 'monthly-report'") String filename, @ToolParam(description = "Page size: A4 or LETTER (default: A4)", required = false) - String pageSize) { + String pageSize, + @Nullable ToolContext ctx) { Resolved input; try { @@ -149,7 +153,7 @@ public class DocxRenderTool { byte[] bytes = renderer.render(input.markdown(), size); log.info("[DocxRender] generated {} ({} bytes from {} bytes md, {}ms)", displayName, bytes.length, input.totalBytes(), System.currentTimeMillis() - t0); - return GeneratedFileLink.resultEn(bytes, displayName, DOCX_MIME, cache, "Document", 1); + return GeneratedFileLink.resultEn(bytes, displayName, DOCX_MIME, cache, "Document", 1, ctx); } catch (Exception e) { log.error("[DocxRender] render failed for {} (source: {}): {}", displayName, input.sources().get(0), e.getMessage(), e); @@ -191,7 +195,8 @@ public class DocxRenderTool { @ToolParam(description = "Output filename without extension, e.g. 'quarterly-report'") String filename, @ToolParam(description = "Page size: A4 or LETTER (default: A4)", required = false) - String pageSize) { + String pageSize, + @Nullable ToolContext ctx) { Resolved input; try { @@ -210,7 +215,7 @@ public class DocxRenderTool { displayName, bytes.length, input.fileCount(), input.totalBytes(), System.currentTimeMillis() - t0); return GeneratedFileLink.resultEn(bytes, displayName, DOCX_MIME, cache, - "Document", input.fileCount()); + "Document", input.fileCount(), ctx); } catch (Exception e) { log.error("[DocxRender] render failed for {} (sources: {}): {}", displayName, input.sources(), e.getMessage(), e); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/HtmlImageRenderTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/HtmlImageRenderTool.java index 2051f2e0..55e17fbd 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/HtmlImageRenderTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/HtmlImageRenderTool.java @@ -10,6 +10,8 @@ import com.microsoft.playwright.options.WaitUntilState; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import vip.mate.tool.browser.BrowserLauncher; import vip.mate.tool.document.FilenameSanitizer; @@ -87,7 +89,8 @@ public class HtmlImageRenderTool { @ToolParam(description = "Viewport height in px (default 900, max 4096). Ignored when fullPage=true except as initial layout hint.", required = false) Integer height, @ToolParam(description = "Capture full scrollable page (default true). Set false to only capture the viewport.", required = false) - Boolean fullPage) { + Boolean fullPage, + @Nullable ToolContext ctx) { String source; try { @@ -117,7 +120,7 @@ public class HtmlImageRenderTool { log.info("[HtmlImageRender] rendered {} ({} bytes, viewport={}x{}, fullPage={})", displayName, pngBytes.length, vw, vh, full); - return GeneratedFileLink.resultZh(pngBytes, displayName, PNG_MIME, cache, "图片"); + return GeneratedFileLink.resultZh(pngBytes, displayName, PNG_MIME, cache, "图片", ctx); } private String resolveHtml(String filePath, String inlineHtml) throws Exception { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/PdfRenderTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/PdfRenderTool.java index 0c8f42b0..bbf45e13 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/PdfRenderTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/PdfRenderTool.java @@ -4,6 +4,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import vip.mate.tool.document.FilenameSanitizer; import vip.mate.tool.document.GeneratedFileCache; @@ -83,7 +85,8 @@ public class PdfRenderTool { @ToolParam(description = "Page size: A4 or LETTER (default: A4)", required = false) String pageSize, @ToolParam(description = "Engine: 'auto' (default), 'html' (force in-process), or 'libreoffice' (force soffice)", required = false) - String engine) { + String engine, + @Nullable ToolContext ctx) { if (markdown == null || markdown.isBlank()) { return "错误:markdown 参数为空,无法生成 PDF。"; @@ -97,7 +100,7 @@ public class PdfRenderTool { MarkdownPdfRenderer.Result result = renderer.render(markdown, size, eng); log.info("[PdfRender] generated {} ({} bytes via {})", displayName, result.bytes().length, result.backend()); - return GeneratedFileLink.resultZh(result.bytes(), displayName, PDF_MIME, cache, "PDF"); + return GeneratedFileLink.resultZh(result.bytes(), displayName, PDF_MIME, cache, "PDF", ctx); } catch (Exception e) { log.error("[PdfRender] render failed for {}: {}", displayName, e.getMessage(), e); return "渲染失败:" + e.getMessage(); @@ -132,7 +135,8 @@ public class PdfRenderTool { @ToolParam(description = "Page size: A4 or LETTER (default: A4)", required = false) String pageSize, @ToolParam(description = "Engine: 'auto' (default), 'html', or 'libreoffice'", required = false) - String engine) { + String engine, + @Nullable ToolContext ctx) { Resolved input; try { @@ -149,7 +153,7 @@ public class PdfRenderTool { MarkdownPdfRenderer.Result result = renderer.render(input.markdown(), size, eng); log.info("[PdfRender] generated {} ({} bytes via {} from {} bytes md)", displayName, result.bytes().length, result.backend(), input.totalBytes()); - return GeneratedFileLink.resultEn(result.bytes(), displayName, PDF_MIME, cache, "Document", 1); + return GeneratedFileLink.resultEn(result.bytes(), displayName, PDF_MIME, cache, "Document", 1, ctx); } catch (Exception e) { log.error("[PdfRender] render failed for {} (source: {}): {}", displayName, input.sources().get(0), e.getMessage(), e); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/PptxRenderTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/PptxRenderTool.java index 013c90f1..29a62bd6 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/PptxRenderTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/PptxRenderTool.java @@ -4,6 +4,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import vip.mate.tool.document.FilenameSanitizer; import vip.mate.tool.document.GeneratedFileCache; @@ -74,7 +76,8 @@ public class PptxRenderTool { @ToolParam(description = "Output filename without extension, e.g. 'pitch-deck'") String filename, @ToolParam(description = "Aspect ratio: '16:9' (default, widescreen) or '4:3' (legacy)", required = false) - String aspectRatio) { + String aspectRatio, + @Nullable ToolContext ctx) { if (markdown == null || markdown.isBlank()) { return "错误:markdown 参数为空,无法生成演示文稿。"; @@ -88,7 +91,7 @@ public class PptxRenderTool { byte[] bytes = renderer.render(markdown, ratio); log.info("[PptxRender] generated {} ({} bytes, {}ms)", displayName, bytes.length, System.currentTimeMillis() - t0); - return GeneratedFileLink.resultZh(bytes, displayName, PPTX_MIME, cache, "演示文稿"); + return GeneratedFileLink.resultZh(bytes, displayName, PPTX_MIME, cache, "演示文稿", ctx); } catch (Exception e) { log.error("[PptxRender] render failed for {}: {}", displayName, e.getMessage(), e); return "渲染失败:" + e.getMessage(); @@ -117,7 +120,8 @@ public class PptxRenderTool { @ToolParam(description = "Output filename without extension, e.g. 'pitch-deck'") String filename, @ToolParam(description = "Aspect ratio: '16:9' (default) or '4:3'", required = false) - String aspectRatio) { + String aspectRatio, + @Nullable ToolContext ctx) { Resolved input; try { @@ -135,7 +139,7 @@ public class PptxRenderTool { log.info("[PptxRender] generated {} ({} bytes from {} bytes md, {}ms)", displayName, bytes.length, input.totalBytes(), System.currentTimeMillis() - t0); - return GeneratedFileLink.resultEn(bytes, displayName, PPTX_MIME, cache, "Presentation", 1); + return GeneratedFileLink.resultEn(bytes, displayName, PPTX_MIME, cache, "Presentation", 1, ctx); } catch (Exception e) { log.error("[PptxRender] render failed for {} (source: {}): {}", displayName, input.sources().get(0), e.getMessage(), e); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java index 65e8411a..9c2d437a 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java @@ -121,7 +121,7 @@ public class SendFileTool { String displayName = (fileName != null && !fileName.isBlank()) ? fileName : path.getFileName().toString(); String mimeType = resolveMimeType(displayName); - String url = stash(bytes, displayName, mimeType); + String url = stash(bytes, displayName, mimeType, ctx); log.info("[SendFile] Sending {} ({}, {} bytes) via generated file cache", displayName, mimeType, fileSize); @@ -138,9 +138,9 @@ public class SendFileTool { } } - private String stash(byte[] bytes, String displayName, String mimeType) { + private String stash(byte[] bytes, String displayName, String mimeType, @Nullable ToolContext ctx) { String id = cache.put(bytes, displayName, mimeType); - return "/api/v1/files/generated/" + id; + return cache.downloadUrl(id, ctx); } private String resolveMimeType(String fileName) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/XlsxRenderTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/XlsxRenderTool.java index c1f728e1..803d221f 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/XlsxRenderTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/XlsxRenderTool.java @@ -4,6 +4,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import vip.mate.tool.document.FilenameSanitizer; import vip.mate.tool.document.GeneratedFileCache; @@ -66,7 +68,8 @@ public class XlsxRenderTool { @ToolParam(description = "Workbook content in Markdown format (sheets as `# Heading`, tables as `| ... |`)") String markdown, @ToolParam(description = "Output filename without extension, e.g. 'q1-sales'") - String filename) { + String filename, + @Nullable ToolContext ctx) { if (markdown == null || markdown.isBlank()) { return "错误:markdown 参数为空,无法生成工作簿。"; @@ -79,7 +82,7 @@ public class XlsxRenderTool { byte[] bytes = renderer.render(markdown); log.info("[XlsxRender] generated {} ({} bytes, {}ms)", displayName, bytes.length, System.currentTimeMillis() - t0); - return GeneratedFileLink.resultZh(bytes, displayName, XLSX_MIME, cache, "工作簿"); + return GeneratedFileLink.resultZh(bytes, displayName, XLSX_MIME, cache, "工作簿", ctx); } catch (Exception e) { log.error("[XlsxRender] render failed for {}: {}", displayName, e.getMessage(), e); return "渲染失败:" + e.getMessage(); @@ -106,7 +109,8 @@ public class XlsxRenderTool { @ToolParam(description = "Absolute or workspace-relative path to a markdown file") String filePath, @ToolParam(description = "Output filename without extension, e.g. 'quarterly-report'") - String filename) { + String filename, + @Nullable ToolContext ctx) { Resolved input; try { @@ -123,7 +127,7 @@ public class XlsxRenderTool { log.info("[XlsxRender] generated {} ({} bytes from {} bytes md, {}ms)", displayName, bytes.length, input.totalBytes(), System.currentTimeMillis() - t0); - return GeneratedFileLink.resultEn(bytes, displayName, XLSX_MIME, cache, "Workbook", 1); + return GeneratedFileLink.resultEn(bytes, displayName, XLSX_MIME, cache, "Workbook", 1, ctx); } catch (Exception e) { log.error("[XlsxRender] render failed for {} (source: {}): {}", displayName, input.sources().get(0), e.getMessage(), e); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java index e539eda4..97dffcbc 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java @@ -1,8 +1,15 @@ package vip.mate.tool.document; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.lang.Nullable; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import vip.mate.agent.context.ChatOrigin; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -41,6 +48,19 @@ public class GeneratedFileCache { /** Default on-disk location for persisted generated files. */ public static final Path DEFAULT_STORAGE_DIR = Paths.get("data", "generated-files"); + /** Path prefix under which {@link GeneratedFileController} serves files. */ + public static final String DOWNLOAD_PATH_PREFIX = "/api/v1/files/generated/"; + + /** + * Operator-configured public base URL (e.g. {@code https://mateclaw.example.com}). + * When set, download links are absolute so they remain usable outside the web + * UI — IM messages, copied links, external downloads. Empty by default; the + * resolver then falls back to the current request host, and finally to a + * relative path. + */ + @Value("${mateclaw.server.public-base-url:}") + private String publicBaseUrl; + /** How often the expired-file sweep runs (6 hours). Must be a compile-time * constant for use in {@link Scheduled#fixedDelay()}. */ private static final long CLEANUP_INTERVAL_MS = 6L * 60 * 60 * 1000; @@ -61,9 +81,15 @@ public class GeneratedFileCache { /** * URL pattern for generated files served by {@code GeneratedFileController}. * Public so channel adapters and graph nodes share a single source of truth. + * + *

The leading {@code scheme://host} is optional so the pattern matches + * both the relative {@code /api/v1/files/generated/{id}} form and the + * absolute form minted when {@code mateclaw.server.public-base-url} (or a + * resolvable request host) is in play. Matching the whole absolute URL lets + * scrubbers replace it cleanly instead of leaving a dangling host fragment. */ public static final Pattern GENERATED_URL_PATTERN = - Pattern.compile("/api/v1/files/generated/([a-zA-Z0-9-]+)"); + Pattern.compile("(?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/([a-zA-Z0-9-]+)"); /** * User-visible warning swapped in for a cache-miss URL. Identical @@ -128,6 +154,61 @@ public class GeneratedFileCache { return id; } + /** + * Build the download URL for a stored id. Prefers the configured + * {@code mateclaw.server.public-base-url}; otherwise derives the host from + * the current HTTP request if one is bound to this thread; otherwise returns + * a relative path (which the web UI resolves against its own origin). + * + *

Absolute links are what make a download survive leaving the web UI — + * a model that echoes the URL as plain text, a user copying the link, or an + * IM channel without a dedicated attachment rewriter. + */ + public String downloadUrl(String id) { + return downloadUrl(id, null); + } + + /** + * Build the download URL for a stored id, using the tool-call context to + * recover the request host when the call runs on an async/streaming thread. + */ + public String downloadUrl(String id, @Nullable ToolContext ctx) { + return resolveBase(ctx) + DOWNLOAD_PATH_PREFIX + id; + } + + /** Resolve the base URL prefix (no trailing slash), or "" for a relative link. */ + private String resolveBase(@Nullable ToolContext ctx) { + // 1. Operator-configured public URL wins — it's the canonical external + // host (correct behind a reverse proxy / for IM channels). + if (publicBaseUrl != null && !publicBaseUrl.isBlank()) { + return stripTrailingSlash(publicBaseUrl.trim()); + } + // 2. Request host captured on the controller thread and carried in the + // ChatOrigin — survives the hop to async/streaming tool threads. + if (ctx != null) { + String originBase = ChatOrigin.from(ctx).baseUrl(); + if (originBase != null && !originBase.isBlank()) { + return stripTrailingSlash(originBase.trim()); + } + } + // 3. Synchronous HTTP fallback: a request may still be bound to this thread. + try { + RequestAttributes attrs = RequestContextHolder.getRequestAttributes(); + if (attrs != null) { + // Honours X-Forwarded-* when ForwardedHeaderFilter is enabled. + return ServletUriComponentsBuilder.fromCurrentContextPath().build().toUriString(); + } + } catch (Exception e) { + log.debug("Could not derive request host for download URL: {}", e.toString()); + } + // 4. No host available (cron / IM without config) → relative path. + return ""; + } + + private static String stripTrailingSlash(String s) { + return s.endsWith("/") ? s.substring(0, s.length() - 1) : s; + } + /** * Look up an entry. Returns {@link Optional#empty()} if missing or expired. * Falls back to disk on an in-memory miss so links survive eviction and diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java index f8378cec..a57eec2f 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java @@ -1,14 +1,18 @@ package vip.mate.tool.document; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.lang.Nullable; + /** * Stash freshly-rendered bytes into the {@link GeneratedFileCache} and format * the markdown link the tool returns to the LLM. * *

Two locales are exposed because mateclaw's existing convention has the * inline render tools speak Chinese and the file-driven render tools speak - * English. Each variant carries the "do NOT prepend a host" instruction - * because some models hallucinate a placeholder domain in front of the - * relative URL when echoing it back. + * English. Each variant tells the model to echo the URL verbatim — neither + * stripping nor inventing a host — because the URL may already be absolute + * (when {@code mateclaw.server.public-base-url} is set or a request host is + * resolvable) and models otherwise tamper with it when echoing it back. */ public final class GeneratedFileLink { @@ -21,12 +25,13 @@ public final class GeneratedFileLink { * @param typeLabel "文档" / "工作簿" / "演示文稿" */ public static String resultZh(byte[] bytes, String displayName, String mimeType, - GeneratedFileCache cache, String typeLabel) { - String url = stash(bytes, displayName, mimeType, cache); + GeneratedFileCache cache, String typeLabel, + @Nullable ToolContext ctx) { + String url = stash(bytes, displayName, mimeType, cache, ctx); return typeLabel + "已生成:[" + displayName + "](" + url + ")(链接 " + GeneratedFileCache.TTL.toDays() + " 天内有效)。\n" + "重要:回答用户时**必须**使用上述 markdown 链接格式 [" + displayName + "](" + url + ")," - + "保持相对路径原样,**不要**用反引号包裹路径,也**不要**添加任何 https://、http:// 域名前缀。"; + + "保持链接地址**原样照抄**,**不要**用反引号包裹,**不要**增删任何域名或 http(s):// 前缀。"; } /** @@ -40,22 +45,21 @@ public final class GeneratedFileLink { */ public static String resultEn(byte[] bytes, String displayName, String mimeType, GeneratedFileCache cache, String typeLabel, - int sourceFileCount) { - String url = stash(bytes, displayName, mimeType, cache); + int sourceFileCount, @Nullable ToolContext ctx) { + String url = stash(bytes, displayName, mimeType, cache, ctx); String prefix = sourceFileCount > 1 ? typeLabel + " generated from " + sourceFileCount + " files" : typeLabel + " generated"; return prefix + ": [" + displayName + "](" + url + ") (link valid for " + GeneratedFileCache.TTL.toDays() + " days).\n" + "IMPORTANT: when replying to the user you **must** keep the markdown link form [" - + displayName + "](" + url + ") above. Keep the relative path verbatim — do **not** " - + "wrap it in backticks and do **not** prepend any https://, http:// or domain " - + "(the frontend resolves the current host automatically)."; + + displayName + "](" + url + ") above. Copy the URL verbatim — do **not** wrap it " + + "in backticks and do **not** add or remove any https://, http:// or domain."; } private static String stash(byte[] bytes, String displayName, String mimeType, - GeneratedFileCache cache) { + GeneratedFileCache cache, @Nullable ToolContext ctx) { String id = cache.put(bytes, displayName, mimeType); - return "/api/v1/files/generated/" + id; + return cache.downloadUrl(id, ctx); } } diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index 8f4dcd58..9c8b6255 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -124,6 +124,13 @@ springdoc: # MateClaw 自定义配置 mateclaw: + server: + # Public base URL used to build absolute download links for tool-generated + # files (e.g. https://mateclaw.example.com). Leave empty to fall back to the + # current request's host, and to a relative path when no request is bound. + # Set this when agents deliver download links to channels/clients that cannot + # resolve a relative URL (IM messages, copied links, external downloads). + public-base-url: ${MATECLAW_PUBLIC_BASE_URL:} jwt: secret: ${JWT_SECRET:MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production} expiration: 86400000 diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginSenderFieldsTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginSenderFieldsTest.java index 48c31e74..ba5b84eb 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginSenderFieldsTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginSenderFieldsTest.java @@ -41,7 +41,7 @@ class ChatOriginSenderFieldsTest { void withSenderPreservesOtherFields() { ChatOrigin original = new ChatOrigin( 7L, "conv-1", "u123", 5L, "/ws", 9L, null, false, - null, null, null); + null, null, null, null); ChatOrigin enriched = original.withSender("Alice", "wecom", "g-1"); // All non-sender fields unchanged @@ -80,7 +80,7 @@ class ChatOriginSenderFieldsTest { ChatOrigin origin = new ChatOrigin( 7L, "feishu:oc_42", "ou_xyz", 5L, "/data/ws/5", 9L, null, false, - "Alice", "feishu", "oc_42"); + "Alice", "feishu", "oc_42", null); String json = om.writeValueAsString(origin); ChatOrigin restored = om.readValue(json, ChatOrigin.class); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java index a51dd4d9..faf1c71f 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java @@ -28,7 +28,7 @@ class ChatOriginTest { void roundTripThroughToolContext_preservesAllFields() { ChannelTarget target = new ChannelTarget("user-42", "thread-abc", "bot-001"); ChatOrigin original = new ChatOrigin(7L, "wechat:42", "u123", 5L, - "/data/ws/5", 9L, target, false, null, null, null); + "/data/ws/5", 9L, target, false, null, null, null, null); ToolContext ctx = original.toToolContext(); ChatOrigin restored = ChatOrigin.from(ctx); @@ -75,7 +75,7 @@ class ChatOriginTest { void jsonSerialization_isStableAndForwardCompatible() throws Exception { ObjectMapper om = new ObjectMapper(); ChatOrigin origin = new ChatOrigin(7L, "wechat:42", "u123", 5L, - "/data/ws/5", 9L, new ChannelTarget("user-42", "thread-abc", "bot-001"), false, null, null, null); + "/data/ws/5", 9L, new ChannelTarget("user-42", "thread-abc", "bot-001"), false, null, null, null, null); String json = om.writeValueAsString(origin); ChatOrigin restored = om.readValue(json, ChatOrigin.class); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorSenderTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorSenderTest.java index e4e6c666..c4d23f2f 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorSenderTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/RuntimeContextInjectorSenderTest.java @@ -27,7 +27,8 @@ class RuntimeContextInjectorSenderTest { 9L, null, false, /* senderName */ "Alice", /* channelType */ "feishu", - /* chatId */ "oc_abc"); + /* chatId */ "oc_abc", + /* baseUrl */ null); String ctx = RuntimeContextInjector.buildContextMessage("/data/ws/5", null, origin); @@ -44,7 +45,7 @@ class RuntimeContextInjectorSenderTest { ChatOrigin origin = new ChatOrigin( 7L, "feishu:ou_xyz", "ou_xyz", 5L, "/data/ws/5", 9L, null, false, - "Alice", "feishu", null); + "Alice", "feishu", null, null); String ctx = RuntimeContextInjector.buildContextMessage("/data/ws/5", null, origin); @@ -101,7 +102,7 @@ class RuntimeContextInjectorSenderTest { void blankSenderName() { ChatOrigin origin = new ChatOrigin( 7L, null, "ou_xyz", null, null, null, null, false, - /* senderName */ " ", "feishu", null); + /* senderName */ " ", "feishu", null, null); String ctx = RuntimeContextInjector.buildContextMessage(null, null, origin); diff --git a/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java index 2411b4fe..4da63d74 100644 --- a/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java +++ b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java @@ -44,7 +44,8 @@ class ApprovalReplayContinuityTest { /* cronOrigin */ false, /* senderName */ "Alice", /* channelType */ "wecom", - /* chatId */ "group-a"); + /* chatId */ "group-a", + /* baseUrl */ null); String json = objectMapper.writeValueAsString(original); ChatOrigin restored = workflow.restoreChatOrigin(json); diff --git a/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java index 2a711fe4..38cbe5ea 100644 --- a/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java +++ b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerPromptTest.java @@ -41,7 +41,8 @@ class CronJobRunnerPromptTest { /* cronOrigin */ true, /* senderName */ null, /* channelType */ "feishu", - /* chatId */ "group-a"); + /* chatId */ "group-a", + /* baseUrl */ null); String prompt = CronJobRunner.buildCronPrompt("提醒喝水", channelOrigin); assertTrue(prompt.contains("[定时任务执行说明]")); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java index acdf2eef..c336a0ef 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncTaskOutputAttributionTest.java @@ -195,7 +195,7 @@ class DelegateAsyncTaskOutputAttributionTest { private ToolContext makeCtx(String requester, String conversationId) { ChatOrigin origin = new ChatOrigin( - 1L, conversationId, requester, null, null, null, null, false, null, null, null); + 1L, conversationId, requester, null, null, null, null, false, null, null, null, null); Map map = new HashMap<>(); map.put(ChatOrigin.CTX_KEY, origin); return new ToolContext(map); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java index 32956b8f..2faa923e 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAsyncToolTest.java @@ -388,7 +388,7 @@ class DelegateAsyncToolTest { private ToolContext makeCtx(String requester, String conversationId) { ChatOrigin origin = new ChatOrigin( - 1L, conversationId, requester, null, null, null, null, false, null, null, null); + 1L, conversationId, requester, null, null, null, null, false, null, null, null, null); Map map = new HashMap<>(); map.put(ChatOrigin.CTX_KEY, origin); return new ToolContext(map); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileUrlTest.java b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileUrlTest.java new file mode 100644 index 00000000..ff422d89 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileUrlTest.java @@ -0,0 +1,110 @@ +package vip.mate.tool.document; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.test.util.ReflectionTestUtils; + +import java.nio.file.Path; +import java.util.regex.Matcher; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Pin the download-URL builder that backs every tool-generated file link. + * + *

The link must be usable once it leaves the web UI — echoed as plain text, + * copied, or delivered to a channel without a dedicated attachment rewriter. So + * when {@code mateclaw.server.public-base-url} is set the URL is absolute, and + * the shared scrub pattern must match both the relative and absolute forms so + * downstream guards/scrubbers stay consistent. + */ +class GeneratedFileUrlTest { + + private GeneratedFileCache cache; + + @BeforeEach + void setUp(@TempDir Path tempDir) { + cache = new GeneratedFileCache(tempDir); + } + + @Test + @DisplayName("no base configured and no bound request → relative path") + void relativeWhenUnconfigured() { + // No HTTP request is bound on the test thread, so the resolver falls + // back to a relative path (the web UI resolves it against its origin). + String url = cache.downloadUrl("abc-123"); + assertEquals("/api/v1/files/generated/abc-123", url); + } + + @Test + @DisplayName("configured base-url → absolute link, trailing slash trimmed") + void absoluteWhenConfigured() { + ReflectionTestUtils.setField(cache, "publicBaseUrl", "https://mateclaw.example.com/"); + String url = cache.downloadUrl("abc-123"); + assertEquals("https://mateclaw.example.com/api/v1/files/generated/abc-123", url); + } + + @Test + @DisplayName("blank base-url is treated as unconfigured") + void blankBaseIgnored() { + ReflectionTestUtils.setField(cache, "publicBaseUrl", " "); + assertEquals("/api/v1/files/generated/abc-123", cache.downloadUrl("abc-123")); + } + + @Test + @DisplayName("ToolContext origin baseUrl → absolute link (covers async/streaming threads)") + void absoluteFromToolContext() { + // No config and no bound request, but the ChatOrigin carries a base URL + // captured on the controller thread — this is the streaming path. + ToolContext ctx = vip.mate.agent.context.ChatOrigin + .web("c1", "user", null, null, "http://host:18088") + .toToolContext(); + assertEquals("http://host:18088/api/v1/files/generated/abc-123", + cache.downloadUrl("abc-123", ctx)); + } + + @Test + @DisplayName("configured base-url overrides the ToolContext origin baseUrl") + void configWinsOverToolContext() { + ReflectionTestUtils.setField(cache, "publicBaseUrl", "https://public.example.com"); + ToolContext ctx = vip.mate.agent.context.ChatOrigin + .web("c1", "user", null, null, "http://internal:18088") + .toToolContext(); + assertEquals("https://public.example.com/api/v1/files/generated/abc-123", + cache.downloadUrl("abc-123", ctx)); + } + + @Test + @DisplayName("shared pattern matches an absolute URL and captures the bare id") + void patternMatchesAbsolute() { + Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN + .matcher("see https://mateclaw.example.com/api/v1/files/generated/xy-9 now"); + assertTrue(m.find()); + assertEquals("xy-9", m.group(1), "id group must exclude the scheme://host prefix"); + assertEquals("https://mateclaw.example.com/api/v1/files/generated/xy-9", m.group(0), + "full match must include the host so scrubbers replace the whole URL"); + } + + @Test + @DisplayName("scrub of a fake absolute URL leaves no dangling host fragment") + void scrubAbsoluteFakeLeavesNoHost() { + String text = "下载: https://mateclaw.example.com/api/v1/files/generated/" + + "00000000-0000-0000-0000-000000000000"; + String scrubbed = cache.scrubMissingReferences(text); + assertFalse(scrubbed.contains("/api/v1/files/generated/")); + assertFalse(scrubbed.contains("mateclaw.example.com"), + "the absolute URL's host must not survive a scrub; got: " + scrubbed); + assertTrue(scrubbed.contains(GeneratedFileCache.MISSING_REFERENCE_NOTICE)); + } + + @Test + @DisplayName("scrub of a live absolute URL passes through verbatim for channel rewrite") + void scrubAbsoluteLivePassesThrough() { + String id = cache.put("hi".getBytes(), "report.pdf", "application/pdf"); + String text = "下载: https://mateclaw.example.com/api/v1/files/generated/" + id; + assertEquals(text, cache.scrubMissingReferences(text)); + } +}