mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
6a6652d99a
commit
7894a50067
@ -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=
|
||||
|
||||
@ -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 ----------------
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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/");
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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("[定时任务执行说明]"));
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user