mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
485 lines
22 KiB
Java
485 lines
22 KiB
Java
package vip.mate.tool.builtin;
|
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
import org.springframework.ai.chat.model.ToolContext;
|
|
import org.springframework.ai.tool.annotation.Tool;
|
|
import org.springframework.ai.tool.annotation.ToolParam;
|
|
import org.springframework.lang.Nullable;
|
|
import org.springframework.stereotype.Component;
|
|
import vip.mate.tool.ConcurrencyUnsafe;
|
|
import vip.mate.tool.document.FilenameSanitizer;
|
|
import vip.mate.tool.document.GeneratedFileCache;
|
|
import vip.mate.tool.document.GeneratedFileLink;
|
|
import vip.mate.tool.guard.WorkspacePathGuard;
|
|
|
|
import java.io.IOException;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
import java.nio.file.StandardCopyOption;
|
|
import java.util.ArrayList;
|
|
import java.util.Comparator;
|
|
import java.util.List;
|
|
import java.util.Locale;
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
import java.util.concurrent.TimeUnit;
|
|
|
|
/**
|
|
* Optional structured adapter for iOfficeAI/OfficeCLI (issue #583).
|
|
*
|
|
* <p>The adapter deliberately exposes a narrow operation vocabulary instead of
|
|
* accepting an arbitrary command line. Every input is copied into a private
|
|
* scratch directory before OfficeCLI sees it, so even mutating operations are
|
|
* copy-on-write and can never overwrite the user's source document. Generated
|
|
* bytes are immediately moved into {@link GeneratedFileCache}; scratch files
|
|
* are removed at the end of the call.</p>
|
|
*/
|
|
@Slf4j
|
|
@Component
|
|
public class OfficeCliTool {
|
|
|
|
private static final Set<String> OFFICE_EXTENSIONS = Set.of("docx", "xlsx", "pptx");
|
|
private static final Set<String> INSPECT_MODES = Set.of(
|
|
"outline", "stats", "issues", "text", "annotated");
|
|
private static final Map<String, String> RENDER_EXTENSIONS = Map.of(
|
|
"html", "html",
|
|
"screenshot", "png",
|
|
"svg", "svg",
|
|
"pdf", "pdf");
|
|
private static final int DEFAULT_TIMEOUT_SECONDS = 90;
|
|
private static final int MAX_TIMEOUT_SECONDS = 300;
|
|
private static final int MAX_OUTPUT_BYTES = 50_000;
|
|
private static final long MAX_INPUT_BYTES = 50L * 1024 * 1024;
|
|
private static final long MAX_ARTIFACT_BYTES = 20L * 1024 * 1024;
|
|
|
|
private final GeneratedFileCache generatedFileCache;
|
|
private final ObjectMapper objectMapper;
|
|
private final String executable;
|
|
|
|
@Autowired
|
|
public OfficeCliTool(GeneratedFileCache generatedFileCache, ObjectMapper objectMapper) {
|
|
this(generatedFileCache, objectMapper, "officecli");
|
|
}
|
|
|
|
/** Test seam for a fake executable; production deliberately resolves from PATH. */
|
|
OfficeCliTool(GeneratedFileCache generatedFileCache,
|
|
ObjectMapper objectMapper,
|
|
String executable) {
|
|
this.generatedFileCache = generatedFileCache;
|
|
this.objectMapper = objectMapper;
|
|
this.executable = executable == null || executable.isBlank() ? "officecli" : executable.trim();
|
|
}
|
|
|
|
@ConcurrencyUnsafe("OfficeCLI starts native processes and may use document-level locks")
|
|
@Tool(description = """
|
|
Use the optional iOfficeAI/OfficeCLI engine to inspect, validate, batch-edit,
|
|
merge, or render an existing .docx, .xlsx, or .pptx file. This complements
|
|
the built-in Markdown renderers: use those for simple new files, and use this
|
|
tool for existing templates, complex structure, validation, or visual QA.
|
|
|
|
Actions:
|
|
- inspect: read structure/content. mode is outline|stats|issues|text|annotated.
|
|
- validate: run OpenXML validation.
|
|
- batch: apply an OfficeCLI batch JSON array to a COPY of the input. payload is required.
|
|
- merge: replace template placeholders using a JSON object. payload is required.
|
|
- render: render to html|screenshot|svg|pdf. mode is required.
|
|
|
|
Mutating actions NEVER overwrite the source. The result is returned as a
|
|
generated-file download link. OfficeCLI must be installed on the MateClaw
|
|
server; missing installations return a setup error.
|
|
""")
|
|
public String office_document(
|
|
@ToolParam(description = "inspect | validate | batch | merge | render") String action,
|
|
@ToolParam(description = "Workspace path or uploaded attachment name for a .docx/.xlsx/.pptx file") String filePath,
|
|
@ToolParam(description = "Inspect/render mode; omitted for validate/batch/merge", required = false) String mode,
|
|
@ToolParam(description = "JSON array for batch or JSON object for merge", required = false) String payload,
|
|
@ToolParam(description = "Optional result filename; extension is normalized", required = false) String outputFilename,
|
|
@ToolParam(description = "Timeout in seconds, default 90, maximum 300", required = false) Integer timeoutSeconds,
|
|
@Nullable ToolContext ctx) {
|
|
|
|
String normalizedAction = normalize(action);
|
|
if (!Set.of("inspect", "validate", "batch", "merge", "render").contains(normalizedAction)) {
|
|
return error("Unsupported action: " + action);
|
|
}
|
|
|
|
Path source;
|
|
try {
|
|
source = resolveInput(filePath, ctx);
|
|
} catch (Exception e) {
|
|
return error(e.getMessage());
|
|
}
|
|
|
|
String inputExtension = extension(source.getFileName().toString());
|
|
if (!OFFICE_EXTENSIONS.contains(inputExtension)) {
|
|
return error("OfficeCLI supports .docx, .xlsx, and .pptx inputs only");
|
|
}
|
|
if (!Files.isRegularFile(source)) {
|
|
return error("Office input not found or not a regular file: " + filePath);
|
|
}
|
|
try {
|
|
if (Files.size(source) > MAX_INPUT_BYTES) {
|
|
return error("Office input exceeds the 50 MB limit");
|
|
}
|
|
} catch (IOException e) {
|
|
return error("Cannot inspect Office input size: " + e.getMessage());
|
|
}
|
|
|
|
int timeout = timeoutSeconds == null || timeoutSeconds <= 0
|
|
? DEFAULT_TIMEOUT_SECONDS
|
|
: Math.min(timeoutSeconds, MAX_TIMEOUT_SECONDS);
|
|
|
|
Path scratch = null;
|
|
try {
|
|
scratch = Files.createTempDirectory("mc_officecli_");
|
|
Path scratchInput = scratch.resolve("input." + inputExtension);
|
|
Files.copy(source, scratchInput, StandardCopyOption.REPLACE_EXISTING);
|
|
|
|
return switch (normalizedAction) {
|
|
case "inspect" -> inspect(scratchInput, mode, timeout);
|
|
case "validate" -> validate(scratchInput, timeout);
|
|
case "batch" -> batch(scratchInput, inputExtension, payload, outputFilename, timeout, ctx);
|
|
case "merge" -> merge(scratchInput, inputExtension, payload, outputFilename, timeout, ctx);
|
|
case "render" -> render(scratchInput, mode, outputFilename, timeout, ctx);
|
|
default -> error("Unsupported action: " + action);
|
|
};
|
|
} catch (Exception e) {
|
|
log.warn("[OfficeCLI] action={} failed: {}", normalizedAction, e.getMessage());
|
|
return error("OfficeCLI execution failed: " + e.getMessage());
|
|
} finally {
|
|
deleteTreeQuietly(scratch);
|
|
}
|
|
}
|
|
|
|
private String inspect(Path input, String mode, int timeout) throws IOException, InterruptedException {
|
|
String inspectMode = normalize(mode);
|
|
if (!INSPECT_MODES.contains(inspectMode)) {
|
|
return error("inspect mode must be one of: " + String.join(", ", INSPECT_MODES));
|
|
}
|
|
ProcessResult result = run(input.getParent(), timeout,
|
|
List.of("view", input.toString(), inspectMode, "--json"));
|
|
return processOnlyResult("inspect", result);
|
|
}
|
|
|
|
private String validate(Path input, int timeout) throws IOException, InterruptedException {
|
|
ProcessResult result = run(input.getParent(), timeout,
|
|
List.of("validate", input.toString(), "--json"));
|
|
return processOnlyResult("validate", result);
|
|
}
|
|
|
|
private String batch(Path input, String extension, String payload, String outputFilename,
|
|
int timeout, @Nullable ToolContext ctx) throws IOException, InterruptedException {
|
|
JsonNode commands = parsePayload(payload, true);
|
|
if (commands == null) {
|
|
return error("batch payload must be a non-empty JSON array");
|
|
}
|
|
String unsafeReason = validateBatchCommands(commands);
|
|
if (unsafeReason != null) {
|
|
return error(unsafeReason);
|
|
}
|
|
String displayName = outputName(outputFilename, "officecli-edited", extension);
|
|
Path output = input.getParent().resolve("result." + extension);
|
|
Files.copy(input, output, StandardCopyOption.REPLACE_EXISTING);
|
|
|
|
ProcessResult result = run(input.getParent(), timeout,
|
|
List.of("batch", output.toString(), "--commands", commands.toString(), "--json"));
|
|
return generatedResult("batch", result, output, displayName, mimeFor(extension), ctx);
|
|
}
|
|
|
|
private String merge(Path input, String extension, String payload, String outputFilename,
|
|
int timeout, @Nullable ToolContext ctx) throws IOException, InterruptedException {
|
|
JsonNode data = parsePayload(payload, false);
|
|
if (data == null) {
|
|
return error("merge payload must be a non-empty JSON object");
|
|
}
|
|
String displayName = outputName(outputFilename, "officecli-merged", extension);
|
|
Path output = input.getParent().resolve("result." + extension);
|
|
|
|
ProcessResult result = run(input.getParent(), timeout,
|
|
List.of("merge", input.toString(), output.toString(), "--data", data.toString(), "--json"));
|
|
return generatedResult("merge", result, output, displayName, mimeFor(extension), ctx);
|
|
}
|
|
|
|
private String render(Path input, String mode, String outputFilename,
|
|
int timeout, @Nullable ToolContext ctx) throws IOException, InterruptedException {
|
|
String renderMode = normalize(mode);
|
|
String extension = RENDER_EXTENSIONS.get(renderMode);
|
|
if (extension == null) {
|
|
return error("render mode must be one of: html, screenshot, svg, pdf");
|
|
}
|
|
String displayName = outputName(outputFilename, "officecli-preview", extension);
|
|
Path output = input.getParent().resolve("rendered." + extension);
|
|
|
|
// html/svg are streamed to stdout by OfficeCLI; screenshot/pdf accept -o.
|
|
// Keep artifact bytes separate from the bounded diagnostic capture.
|
|
ProcessResult result = Set.of("html", "svg").contains(renderMode)
|
|
? run(input.getParent(), timeout,
|
|
List.of("view", input.toString(), renderMode), output)
|
|
: run(input.getParent(), timeout,
|
|
List.of("view", input.toString(), renderMode, "-o", output.toString()));
|
|
return generatedResult("render", result, output, displayName, mimeFor(extension), ctx);
|
|
}
|
|
|
|
private JsonNode parsePayload(String payload, boolean array) {
|
|
if (payload == null || payload.isBlank()) return null;
|
|
try {
|
|
JsonNode node = objectMapper.readTree(payload);
|
|
if (array ? node.isArray() && !node.isEmpty() : node.isObject() && !node.isEmpty()) {
|
|
return node;
|
|
}
|
|
} catch (Exception ignore) {
|
|
// A concise validation error is returned by the caller.
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private String generatedResult(String action, ProcessResult result, Path output,
|
|
String displayName, String mime, @Nullable ToolContext ctx) throws IOException {
|
|
if (result.exitCode() != 0 || result.timedOut()) {
|
|
return processOnlyResult(action, result);
|
|
}
|
|
if (!Files.isRegularFile(output) || Files.size(output) == 0) {
|
|
return error("OfficeCLI completed without producing the expected output file");
|
|
}
|
|
if (Files.size(output) > MAX_ARTIFACT_BYTES) {
|
|
return error("OfficeCLI output exceeds the 20 MB delivery limit");
|
|
}
|
|
String link = GeneratedFileLink.resultEn(
|
|
Files.readAllBytes(output), displayName, mime, generatedFileCache, "Office file", 1, ctx);
|
|
ObjectNode json = baseResult(action, result);
|
|
json.put("generatedFile", link);
|
|
return pretty(json);
|
|
}
|
|
|
|
private String processOnlyResult(String action, ProcessResult result) {
|
|
return pretty(baseResult(action, result));
|
|
}
|
|
|
|
private ObjectNode baseResult(String action, ProcessResult result) {
|
|
ObjectNode json = objectMapper.createObjectNode();
|
|
json.put("success", result.exitCode() == 0 && !result.timedOut());
|
|
json.put("action", action);
|
|
json.put("exitCode", result.exitCode());
|
|
json.put("stdout", result.stdout());
|
|
json.put("stderr", result.stderr());
|
|
json.put("timedOut", result.timedOut());
|
|
if (result.setupMissing()) {
|
|
json.put("setupRequired", true);
|
|
json.put("message", "OfficeCLI is not installed on the MateClaw server PATH");
|
|
}
|
|
return json;
|
|
}
|
|
|
|
private ProcessResult run(Path workingDir, int timeoutSeconds, List<String> args)
|
|
throws IOException, InterruptedException {
|
|
return run(workingDir, timeoutSeconds, args, null);
|
|
}
|
|
|
|
private ProcessResult run(Path workingDir, int timeoutSeconds, List<String> args,
|
|
@Nullable Path stdoutArtifact)
|
|
throws IOException, InterruptedException {
|
|
List<String> command = new ArrayList<>(args.size() + 1);
|
|
command.add(executable);
|
|
command.addAll(args);
|
|
|
|
Path stdoutFile = stdoutArtifact == null
|
|
? Files.createTempFile(workingDir, "stdout-", ".log")
|
|
: stdoutArtifact;
|
|
Path stderrFile = Files.createTempFile(workingDir, "stderr-", ".log");
|
|
Process process = null;
|
|
try {
|
|
ProcessBuilder pb = new ProcessBuilder(command);
|
|
pb.directory(workingDir.toFile());
|
|
pb.redirectOutput(stdoutFile.toFile());
|
|
pb.redirectError(stderrFile.toFile());
|
|
pb.environment().put("OFFICECLI_SKIP_UPDATE", "1");
|
|
pb.environment().put("OFFICECLI_NO_AUTO_RESIDENT", "1");
|
|
pb.environment().keySet().removeIf(key -> {
|
|
String upper = key.toUpperCase(Locale.ROOT);
|
|
return upper.contains("KEY") || upper.contains("SECRET") || upper.contains("TOKEN")
|
|
|| upper.contains("PASSWORD") || upper.contains("CREDENTIAL");
|
|
});
|
|
|
|
try {
|
|
process = pb.start();
|
|
} catch (IOException e) {
|
|
if (isMissingExecutable(e)) {
|
|
return new ProcessResult(-1, "", e.getMessage(), false, true);
|
|
}
|
|
throw e;
|
|
}
|
|
|
|
boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
|
|
if (!finished) {
|
|
killProcessTree(process);
|
|
}
|
|
int exitCode = finished ? process.exitValue() : -1;
|
|
return new ProcessResult(exitCode,
|
|
stdoutArtifact == null ? readTruncated(stdoutFile) : "",
|
|
readTruncated(stderrFile),
|
|
!finished,
|
|
false);
|
|
} catch (InterruptedException e) {
|
|
if (process != null && process.isAlive()) killProcessTree(process);
|
|
Thread.currentThread().interrupt();
|
|
throw e;
|
|
} finally {
|
|
if (stdoutArtifact == null) Files.deleteIfExists(stdoutFile);
|
|
Files.deleteIfExists(stderrFile);
|
|
}
|
|
}
|
|
|
|
private Path resolveInput(String filePath, @Nullable ToolContext ctx) {
|
|
if (filePath == null || filePath.isBlank()) {
|
|
throw new IllegalArgumentException("filePath is required");
|
|
}
|
|
try {
|
|
Path path = WorkspacePathGuard.validatePath(filePath, ctx);
|
|
if (Files.exists(path)) return path;
|
|
} catch (IllegalArgumentException boundary) {
|
|
Path attachment = ChatUploadResolver.resolve(filePath);
|
|
if (attachment != null) return attachment;
|
|
throw boundary;
|
|
}
|
|
Path attachment = ChatUploadResolver.resolve(filePath);
|
|
if (attachment != null) return attachment;
|
|
throw new IllegalArgumentException("Office input not found: " + filePath);
|
|
}
|
|
|
|
private String outputName(String requested, String fallback, String extension) {
|
|
String base = FilenameSanitizer.sanitize(requested, fallback, "." + extension);
|
|
return base + "." + extension;
|
|
}
|
|
|
|
/**
|
|
* Keep the first-draft batch surface structural. Raw XML and importer verbs
|
|
* can make OfficeCLI read arbitrary host files through relationship/media
|
|
* properties even though the document itself lives in scratch space.
|
|
*/
|
|
@Nullable
|
|
private String validateBatchCommands(JsonNode commands) {
|
|
Set<String> allowed = Set.of("add", "set", "remove", "move", "swap", "validate");
|
|
for (JsonNode command : commands) {
|
|
if (!command.isObject()) {
|
|
return "Each batch item must be a JSON object";
|
|
}
|
|
String verb = normalize(command.path("command").asText(command.path("op").asText()));
|
|
if (!allowed.contains(verb)) {
|
|
return "Unsupported batch command in the safe adapter: " + verb;
|
|
}
|
|
String type = normalize(command.path("type").asText());
|
|
if (Set.of("image", "picture", "video", "audio", "ole", "embeddedobject").contains(type)) {
|
|
return "External media/OLE batch operations are not supported by the safe adapter";
|
|
}
|
|
String unsafeValue = findUnsafeHostPath(command);
|
|
if (unsafeValue != null) {
|
|
return "Batch payload contains a host filesystem reference that is not allowed: " + unsafeValue;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@Nullable
|
|
private String findUnsafeHostPath(JsonNode node) {
|
|
if (node.isTextual()) {
|
|
String value = node.asText().trim();
|
|
String lower = value.toLowerCase(Locale.ROOT);
|
|
if (lower.startsWith("file://") || lower.startsWith("~/") || lower.startsWith("~\\")
|
|
|| value.matches("^[A-Za-z]:[\\\\/].*")) {
|
|
return value;
|
|
}
|
|
if (value.matches(".*(^|[/\\\\])\\.\\.([/\\\\]|$).*")
|
|
|| value.matches("^/(etc|var|tmp|home|users|root|opt|proc|sys|dev)(/.*)?$")) {
|
|
return value;
|
|
}
|
|
return null;
|
|
}
|
|
if (node.isContainerNode()) {
|
|
for (JsonNode child : node) {
|
|
String unsafe = findUnsafeHostPath(child);
|
|
if (unsafe != null) return unsafe;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static String extension(String filename) {
|
|
int dot = filename.lastIndexOf('.');
|
|
return dot < 0 ? "" : filename.substring(dot + 1).toLowerCase(Locale.ROOT);
|
|
}
|
|
|
|
private static String normalize(String value) {
|
|
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
|
}
|
|
|
|
private static String mimeFor(String extension) {
|
|
return switch (extension) {
|
|
case "docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
case "xlsx" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
case "pptx" -> "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
|
case "html" -> "text/html";
|
|
case "png" -> "image/png";
|
|
case "svg" -> "image/svg+xml";
|
|
case "pdf" -> "application/pdf";
|
|
default -> "application/octet-stream";
|
|
};
|
|
}
|
|
|
|
private static boolean isMissingExecutable(IOException e) {
|
|
String message = e.getMessage();
|
|
return message != null && (message.contains("No such file") || message.contains("CreateProcess error=2"));
|
|
}
|
|
|
|
private String readTruncated(Path path) throws IOException {
|
|
byte[] bytes = Files.readAllBytes(path);
|
|
if (bytes.length <= MAX_OUTPUT_BYTES) return new String(bytes, StandardCharsets.UTF_8);
|
|
return new String(bytes, 0, MAX_OUTPUT_BYTES, StandardCharsets.UTF_8)
|
|
+ "\n... [output truncated]";
|
|
}
|
|
|
|
private static void killProcessTree(Process process) {
|
|
process.descendants().forEach(handle -> {
|
|
try { handle.destroyForcibly(); } catch (Exception ignore) { }
|
|
});
|
|
process.destroyForcibly();
|
|
try {
|
|
process.waitFor(5, TimeUnit.SECONDS);
|
|
} catch (InterruptedException e) {
|
|
Thread.currentThread().interrupt();
|
|
}
|
|
}
|
|
|
|
private void deleteTreeQuietly(@Nullable Path root) {
|
|
if (root == null || !Files.exists(root)) return;
|
|
try (var paths = Files.walk(root)) {
|
|
paths.sorted(Comparator.reverseOrder()).forEach(path -> {
|
|
try { Files.deleteIfExists(path); } catch (IOException ignore) { }
|
|
});
|
|
} catch (IOException e) {
|
|
log.debug("[OfficeCLI] failed to delete scratch directory {}: {}", root, e.getMessage());
|
|
}
|
|
}
|
|
|
|
private String error(String message) {
|
|
ObjectNode json = objectMapper.createObjectNode();
|
|
json.put("success", false);
|
|
json.put("error", message == null ? "Unknown OfficeCLI error" : message);
|
|
return pretty(json);
|
|
}
|
|
|
|
private String pretty(JsonNode json) {
|
|
try {
|
|
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(json);
|
|
} catch (Exception e) {
|
|
return json.toString();
|
|
}
|
|
}
|
|
|
|
private record ProcessResult(int exitCode, String stdout, String stderr,
|
|
boolean timedOut, boolean setupMissing) { }
|
|
}
|