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.
This commit is contained in:
matevip 2026-06-07 15:03:48 +08:00
parent 6a6652d99a
commit 7894a50067
24 changed files with 360 additions and 74 deletions

View File

@ -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=

View File

@ -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 ----------------

View File

@ -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
}
/**

View File

@ -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/");

View File

@ -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);

View File

@ -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 =

View File

@ -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;

View File

@ -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);

View File

@ -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 {

View File

@ -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);

View File

@ -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);

View File

@ -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) {

View File

@ -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);

View File

@ -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.
*
* <p>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).
*
* <p>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

View File

@ -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.
*
* <p>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);
}
}

View File

@ -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

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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("[定时任务执行说明]"));

View File

@ -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<String, Object> map = new HashMap<>();
map.put(ChatOrigin.CTX_KEY, origin);
return new ToolContext(map);

View File

@ -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<String, Object> map = new HashMap<>();
map.put(ChatOrigin.CTX_KEY, origin);
return new ToolContext(map);

View File

@ -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.
*
* <p>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));
}
}