mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
233 lines
11 KiB
Java
233 lines
11 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.ToolInputValidationException;
|
|
import vip.mate.tool.document.FilenameSanitizer;
|
|
import vip.mate.tool.document.GeneratedFileCache;
|
|
import vip.mate.tool.document.GeneratedFileLink;
|
|
import vip.mate.tool.document.MarkdownDocxRenderer;
|
|
import vip.mate.tool.document.MarkdownInputResolver;
|
|
import vip.mate.tool.document.MarkdownInputResolver.Resolved;
|
|
import vip.mate.tool.document.MarkdownInputResolver.ResolveException;
|
|
|
|
import java.util.List;
|
|
|
|
/**
|
|
* Render a brand-new .docx from Markdown without ever forking a process.
|
|
*
|
|
* <p>The previous path forwarded these requests to {@code skills/docx} which
|
|
* runs {@code npm install docx} on first use (3-5 minutes). For "create new
|
|
* document" intents that subprocess is wholly unnecessary; this tool produces
|
|
* the bytes in the JVM, stashes them in {@link GeneratedFileCache}, and
|
|
* returns a Markdown link the user can click to download.
|
|
*
|
|
* <p>The skill workflow is still authoritative for editing existing .docx,
|
|
* tracked changes, and other XML-level operations.
|
|
*/
|
|
@Slf4j
|
|
@Component
|
|
@RequiredArgsConstructor
|
|
public class DocxRenderTool {
|
|
|
|
private static final String DOCX_MIME =
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
|
|
private final MarkdownDocxRenderer renderer;
|
|
private final GeneratedFileCache cache;
|
|
|
|
@Tool(returnDirect = true, description = """
|
|
Render a new .docx (Microsoft Word) file from Markdown text and return a
|
|
one-time download URL. Use for creating EDITABLE Word documents the user
|
|
will continue to revise — reports, memos, contracts, letters, resumes.
|
|
|
|
Supports: headings (# ## ###), bold (**text**), bullet lists (- item),
|
|
numbered lists (1. item), tables (| col | col |), plain paragraphs,
|
|
images () — SVG is rasterized
|
|
to PNG; image lines must contain only the image syntax.
|
|
|
|
For markdown bodies larger than ~5 KB, prefer renderDocxFromFile (read from
|
|
disk) — passing huge markdown as a tool argument burns LLM tokens needlessly.
|
|
|
|
Do NOT use for:
|
|
- **Anything the user asked for in PDF / .pdf format — use `renderPdf` /
|
|
`renderPdfFromFile` instead. PDF is a separate non-editable deliverable
|
|
format; don't silently substitute docx for it.**
|
|
- Spreadsheets / workbooks — use `renderXlsx` / `renderXlsxFromFile`.
|
|
- Slide decks / presentations — use `renderPptx` / `renderPptxFromFile`.
|
|
- Editing an existing .docx file (use run_skill_script with unpack/edit/pack)
|
|
- Adding tracked changes or comments (use run_skill_script)
|
|
- GB/T 9704 official documents (use writeGongwen tool, BmacClaw only)
|
|
|
|
Returns a markdown link the user can click to download the file.
|
|
The link is valid for 10 minutes.
|
|
""")
|
|
public String renderDocx(
|
|
@ToolParam(description = "Document content in Markdown format")
|
|
String markdown,
|
|
@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,
|
|
@Nullable ToolContext ctx) {
|
|
|
|
if (markdown == null || markdown.isBlank()) {
|
|
throw new ToolInputValidationException(
|
|
"markdown must not be blank; provide the document content, "
|
|
+ "or write it to a .md file and call renderDocxFromFile");
|
|
}
|
|
|
|
String displayName = FilenameSanitizer.sanitize(filename, "document", ".docx") + ".docx";
|
|
String size = resolveSize(pageSize);
|
|
|
|
try {
|
|
long t0 = System.currentTimeMillis();
|
|
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, "文档", ctx);
|
|
} catch (Exception e) {
|
|
log.error("[DocxRender] render failed for {}: {}", displayName, e.getMessage(), e);
|
|
throw new IllegalStateException("DOCX rendering failed", e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* File-based renderer — reads markdown from disk instead of taking it as a
|
|
* tool argument. Bypasses the LLM token-cost cliff: a 80 KB markdown body
|
|
* would otherwise be streamed through the chat completion as part of
|
|
* {@code renderDocx.markdown} args (≈ 20 K tokens, several minutes of
|
|
* generation just to repeat back content the LLM already wrote to disk).
|
|
* <p>
|
|
* Workflow: agent uses {@code write_file} / {@code edit_file} to assemble
|
|
* the markdown locally → calls this tool with the file path → docx is
|
|
* rendered from disk in one IO call. Token cost ≈ 50 (just the path).
|
|
*/
|
|
@Tool(returnDirect = true, description = """
|
|
Render a .docx (Microsoft Word) file from a markdown FILE on disk and return
|
|
a one-time download URL. Use this for EDITABLE Word documents only.
|
|
|
|
**If the user asked for PDF / .pdf in any wording, use `renderPdfFromFile`
|
|
instead. Do not silently substitute docx for PDF.** Same for spreadsheets
|
|
(`renderXlsxFromFile`) and slide decks (`renderPptxFromFile`).
|
|
|
|
Use this instead of `renderDocx` 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="# Report\\n...") // assemble markdown
|
|
2. renderDocxFromFile(filePath="report.md", filename="monthly-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 renderDocx (headings, bold, lists, tables,
|
|
images). Image references  are rendered when path resolves to a
|
|
readable file in the workspace. SVG sources are rasterized to PNG via Batik;
|
|
PNG/JPG/GIF/BMP are embedded directly.
|
|
""")
|
|
public String renderDocxFromFile(
|
|
@ToolParam(description = "Absolute or workspace-relative path to a markdown file")
|
|
String filePath,
|
|
@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,
|
|
@Nullable ToolContext ctx) {
|
|
|
|
Resolved input;
|
|
try {
|
|
input = MarkdownInputResolver.readSingle(filePath);
|
|
} catch (ResolveException e) {
|
|
throw new ToolInputValidationException(e.getMessage(), e);
|
|
}
|
|
|
|
String displayName = FilenameSanitizer.sanitize(filename, "document", ".docx") + ".docx";
|
|
String size = resolveSize(pageSize);
|
|
|
|
try {
|
|
long t0 = System.currentTimeMillis();
|
|
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, ctx);
|
|
} catch (Exception e) {
|
|
log.error("[DocxRender] render failed for {} (source: {}): {}",
|
|
displayName, input.sources().get(0), e.getMessage(), e);
|
|
throw new IllegalStateException("DOCX rendering failed", e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Multi-file renderer — read several markdown files in order and concatenate
|
|
* them into one docx. Lets the agent split a long report into chapters
|
|
* (cover.md, intro.md, ch1.md, ...) and render the whole thing in one call,
|
|
* so a 30-page deliverable does not need to live in a single source file.
|
|
* <p>
|
|
* Files are joined with a blank line so heading hierarchy and paragraph
|
|
* structure carry over cleanly; no extra separator markup is injected.
|
|
* Empty / missing files abort the render with a clear error so the agent
|
|
* can fix its file list before retrying.
|
|
*/
|
|
@Tool(returnDirect = true, description = """
|
|
Render a .docx by concatenating MULTIPLE markdown files in order and return a
|
|
download URL. Use when a report is split into chapters / sections, or when the
|
|
agent assembled the document piece by piece (cover, table of contents, body,
|
|
appendix) across several files.
|
|
|
|
Typical workflow:
|
|
1. write_file(path="cover.md", content="# Title\\n...")
|
|
2. write_file(path="ch1.md", content="## Chapter 1\\n...")
|
|
3. write_file(path="ch2.md", content="## Chapter 2\\n...")
|
|
4. renderDocxFromFiles(filePaths=["cover.md","ch1.md","ch2.md"],
|
|
filename="quarterly-report")
|
|
|
|
Files are read with UTF-8, joined with one blank line between them, and
|
|
rendered with the same markdown subset as renderDocx (headings, bold,
|
|
lists, tables). All paths must pass the workspace boundary check.
|
|
""")
|
|
public String renderDocxFromFiles(
|
|
@ToolParam(description = "List of markdown file paths in render order")
|
|
List<String> filePaths,
|
|
@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,
|
|
@Nullable ToolContext ctx) {
|
|
|
|
Resolved input;
|
|
try {
|
|
input = MarkdownInputResolver.readManyJoined(filePaths);
|
|
} catch (ResolveException e) {
|
|
throw new ToolInputValidationException(e.getMessage(), e);
|
|
}
|
|
|
|
String displayName = FilenameSanitizer.sanitize(filename, "document", ".docx") + ".docx";
|
|
String size = resolveSize(pageSize);
|
|
|
|
try {
|
|
long t0 = System.currentTimeMillis();
|
|
byte[] bytes = renderer.render(input.markdown(), size);
|
|
log.info("[DocxRender] generated {} ({} bytes from {} files / {} bytes md, {}ms)",
|
|
displayName, bytes.length, input.fileCount(), input.totalBytes(),
|
|
System.currentTimeMillis() - t0);
|
|
return GeneratedFileLink.resultEn(bytes, displayName, DOCX_MIME, cache,
|
|
"Document", input.fileCount(), ctx);
|
|
} catch (Exception e) {
|
|
log.error("[DocxRender] render failed for {} (sources: {}): {}",
|
|
displayName, input.sources(), e.getMessage(), e);
|
|
throw new IllegalStateException("DOCX rendering failed", e);
|
|
}
|
|
}
|
|
|
|
private static String resolveSize(String pageSize) {
|
|
return (pageSize == null || pageSize.isBlank()) ? "A4" : pageSize.trim();
|
|
}
|
|
}
|