mateclaw/mateclaw-server/src/main/java/vip/mate/tool/builtin/XlsxRenderTool.java
matevip 7894a50067 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.
2026-06-07 15:03:48 +08:00

138 lines
5.8 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package vip.mate.tool.builtin;
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;
import vip.mate.tool.document.GeneratedFileLink;
import vip.mate.tool.document.MarkdownInputResolver;
import vip.mate.tool.document.MarkdownInputResolver.Resolved;
import vip.mate.tool.document.MarkdownInputResolver.ResolveException;
import vip.mate.tool.document.MarkdownXlsxRenderer;
/**
* Render a brand-new .xlsx workbook from a Markdown body, in-process via
* Apache POI. Mirrors {@link DocxRenderTool}'s shape: the LLM produces a
* Markdown body where each {@code # Heading} starts a sheet and the pipe-style
* table beneath it becomes the sheet content.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class XlsxRenderTool {
private static final String XLSX_MIME =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private final MarkdownXlsxRenderer renderer;
private final GeneratedFileCache cache;
@Tool(description = """
Render a NEW .xlsx workbook from Markdown and return a one-time download URL.
Use for creating spreadsheets: financial reports, data tables, comparison
matrices, plans, schedules.
Markdown convention:
- Each `# Sheet Name` starts a new sheet.
- The pipe-style table under the heading becomes the sheet body.
- The first table row is rendered as the header (bold, light-grey fill,
frozen). Numeric cells are auto-detected and stored as numbers so
Excel can sort / sum them; non-numeric cells stay as strings.
- Sub-headings (## / ###) and free-form prose are ignored — xlsx is
tabular and there is nowhere sensible to put them.
Example:
# Q1 Sales
| Region | Revenue | Growth |
| --- | --- | --- |
| North | 12000 | 0.15 |
| South | 8500 | 0.08 |
# Q2 Sales
| Region | Revenue |
| --- | --- |
| North | 14000 |
For markdown bodies larger than ~5 KB, prefer renderXlsxFromFile (read
from disk) — passing huge markdown as a tool argument burns LLM tokens.
Returns a markdown link the user can click to download the file.
The link is valid for 10 minutes.
""")
public String renderXlsx(
@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,
@Nullable ToolContext ctx) {
if (markdown == null || markdown.isBlank()) {
return "错误:markdown 参数为空,无法生成工作簿。";
}
String displayName = FilenameSanitizer.sanitize(filename, "workbook", ".xlsx") + ".xlsx";
try {
long t0 = System.currentTimeMillis();
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, "工作簿", ctx);
} catch (Exception e) {
log.error("[XlsxRender] render failed for {}: {}", displayName, e.getMessage(), e);
return "渲染失败:" + e.getMessage();
}
}
@Tool(description = """
Render a .xlsx workbook from a markdown FILE on disk and return a one-time download URL.
Use this instead of `renderXlsx` when the markdown body is large (>5 KB) — the
LLM does not need to repeat its own previous output as a tool argument.
Typical workflow:
1. write_file(path="report.md", content="# Q1\\n| ... |\\n...")
2. renderXlsxFromFile(filePath="report.md", filename="quarterly-report")
3. return the download link to the user
The markdown file is read with UTF-8. Path resolution honors the workspace
boundary (same rules as read_file / write_file).
Same supported markdown subset as renderXlsx (`# Heading` per sheet,
pipe-style tables; numeric cells auto-detected).
""")
public String renderXlsxFromFile(
@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,
@Nullable ToolContext ctx) {
Resolved input;
try {
input = MarkdownInputResolver.readSingle(filePath);
} catch (ResolveException e) {
return "Error: " + e.getMessage();
}
String displayName = FilenameSanitizer.sanitize(filename, "workbook", ".xlsx") + ".xlsx";
try {
long t0 = System.currentTimeMillis();
byte[] bytes = renderer.render(input.markdown());
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, ctx);
} catch (Exception e) {
log.error("[XlsxRender] render failed for {} (source: {}): {}",
displayName, input.sources().get(0), e.getMessage(), e);
return "Render failed: " + e.getMessage();
}
}
}