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; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; import java.util.Base64; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Stream; /** * Store of bytes produced by tools (e.g. {@code DocxRenderTool}) and served by * {@link GeneratedFileController}. Each entry is written to disk under * {@link #DEFAULT_STORAGE_DIR} and mirrored in an in-memory map for fast reads. * *
Persistence is what makes download links durable: the bytes survive both * cache eviction and a JVM restart, so a link a user clicks minutes — or days — * after generation still resolves instead of 404ing. Entries are retained for * {@link #TTL} and a scheduled sweep removes expired files. The download URL * embeds a random {@link UUID}; web downloads additionally verify the stored * workspace owner so links cannot cross workspace boundaries. */ @Slf4j @Component public class GeneratedFileCache { /** How long a generated file remains downloadable after creation. */ public static final Duration TTL = Duration.ofDays(7); /** 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; /** Guards path resolution: only server-issued UUID-shaped ids are accepted. */ private static final Pattern ID_RE = Pattern.compile("[a-zA-Z0-9-]{1,64}"); private static final String META_SUFFIX = ".meta"; /** * Upper bound on bytes held in memory. Disk is the source of truth and * retains entries for {@link #TTL}; this map is only a hot-read cache, so * capping it keeps heap bounded regardless of how many files are produced * within the retention window. A miss simply reloads from disk. */ private static final int MAX_MEMORY_ENTRIES = 256; /** * URL pattern for generated files served by {@code GeneratedFileController}. * Public so channel adapters and graph nodes share a single source of truth. * *
The leading {@code scheme://host} is optional so the pattern matches
* both the relative {@code /api/v1/files/generated/{id}} form and the
* absolute form minted when {@code mateclaw.server.public-base-url} (or a
* resolvable request host) is in play. Matching the whole absolute URL lets
* scrubbers replace it cleanly instead of leaving a dangling host fragment.
*/
public static final Pattern GENERATED_URL_PATTERN =
Pattern.compile("(?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/([a-zA-Z0-9-]+)");
/**
* User-visible warning swapped in for a cache-miss URL. Identical
* wording to the channel-side fallback so users see one consistent
* message regardless of which surface (web, IM, etc.) renders it.
*/
public static final String MISSING_REFERENCE_NOTICE =
"⚠️ 文件未真正生成(模型未调用文档生成工具),请重新发送请求";
private final Path storageDir;
/**
* Access-ordered LRU bounded to {@link #MAX_MEMORY_ENTRIES}: the eldest
* entry is dropped from memory once the cap is exceeded (the persisted
* file stays on disk and is reloaded on the next read).
*/
private final Map 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
* JVM restarts; expired entries are removed as a side-effect.
*/
public Optional This exists to self-heal references that point at a file's logical
* name ({@code cover_xyz.png}) rather than its issued id: such a
* reference cannot be resolved by {@link #GENERATED_URL_PATTERN} (the id
* capture group stops at the first {@code _} or {@code .}), so a name-based
* fallback recovers the real, servable id instead of yielding a broken link.
*/
public Optional Cache misses are nearly always LLM hallucinations — the model
* emitted a UUID-shaped string without ever calling a render tool.
* Without this scrub, every channel that receives the answer (Web,
* Slack, DingTalk, Telegram, …) would render a clickable link that
* 404s, and IM clients save the 404 HTML body as a {@code .docx}
* which users then report as "corrupted file".
*/
public String scrubMissingReferences(String text) {
if (text == null || text.isEmpty()) return text;
Matcher m = GENERATED_URL_PATTERN.matcher(text);
if (!m.find()) return text;
StringBuilder out = new StringBuilder();
m.reset();
while (m.find()) {
String id = m.group(1);
boolean live = get(id).isPresent();
String replacement = live ? m.group(0) : MISSING_REFERENCE_NOTICE;
m.appendReplacement(out, Matcher.quoteReplacement(replacement));
}
m.appendTail(out);
return out.toString();
}
/**
* Wrap bare {@code /api/v1/files/generated/{id}} URLs whose id is live
* into a {@code [filename](url)} markdown link, so chat surfaces render
* the file name instead of the raw id URL. Models frequently echo the
* download URL as plain text ("下载链接:http://…/{uuid}") even though the
* tool result hands them a ready-made markdown link; autolink rendering
* then displays the UUID to the user.
*
* URLs already serving as a markdown link destination (directly
* preceded by {@code ](}) are left untouched, whatever their link text —
* the model may have chosen a legitimate custom label. Cache misses are
* also left untouched; {@link #scrubMissingReferences} owns that case.
*/
public String linkifyBareReferences(String text) {
if (text == null || text.isEmpty()) return text;
Matcher m = GENERATED_URL_PATTERN.matcher(text);
if (!m.find()) return text;
StringBuilder out = new StringBuilder();
m.reset();
while (m.find()) {
String replacement = m.group(0);
int s = m.start();
boolean isLinkDestination = s >= 2
&& text.charAt(s - 1) == '('
&& text.charAt(s - 2) == ']';
// Angle-bracket autolinks (