mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 14:11:49 +08:00
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.
138 lines
5.8 KiB
Java
138 lines
5.8 KiB
Java
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();
|
||
}
|
||
}
|
||
}
|