diff --git a/docs/runbooks/NODE1_FILE_TOOL_SYNC_RUNBOOK.md b/docs/runbooks/NODE1_FILE_TOOL_SYNC_RUNBOOK.md index ff6a01c7..c180e21c 100644 --- a/docs/runbooks/NODE1_FILE_TOOL_SYNC_RUNBOOK.md +++ b/docs/runbooks/NODE1_FILE_TOOL_SYNC_RUNBOOK.md @@ -75,6 +75,9 @@ Implemented in actual NODE1 stack (`services/router/*` + gateway): - `image_create` - `image_edit` - `image_convert` +- `image_bundle` +- `svg_export` +- `svg_to_png` ### Standard output contract For file-producing tool calls, router now propagates: @@ -119,6 +122,8 @@ Run inside `dagi-router-node1` to validate actions deterministically: - DOCX create/update - PDF merge/split/fill - Image create/edit/convert +- Image bundle (zip) +- SVG export + SVG->PNG convert Also verify infer endpoint still works: - `POST http://127.0.0.1:9102/v1/agents/devtools/infer` @@ -136,6 +141,7 @@ Also verify infer endpoint still works: - `services/router/requirements.txt.bak_20260215_112652` - `services/router/tool_manager.py.bak_20260215_112841` - `services/router/tool_manager.py.bak_20260215_112912` +- `services/router/tool_manager.py.bak_20260215_113301` ## Rollback (NODE1) ```bash diff --git a/services/router/tool_manager.py b/services/router/tool_manager.py index 767a2699..1b834112 100644 --- a/services/router/tool_manager.py +++ b/services/router/tool_manager.py @@ -18,6 +18,8 @@ from typing import Dict, List, Any, Optional from dataclasses import dataclass from io import BytesIO, StringIO from pathlib import PurePath +import xml.etree.ElementTree as ET +from xml.sax.saxutils import escape as xml_escape from zipfile import ZIP_DEFLATED, ZipFile logger = logging.getLogger(__name__) @@ -331,7 +333,8 @@ TOOL_DEFINITIONS = [ "json_export", "yaml_export", "zip_bundle", "text_create", "text_update", "markdown_create", "markdown_update", "xml_export", "html_export", - "image_create", "image_edit", "image_convert" + "image_create", "image_edit", "image_convert", "image_bundle", + "svg_export", "svg_to_png" ], "description": "Дія file tool" }, @@ -640,6 +643,12 @@ class ToolManager: return self._file_image_edit(args) if action == "image_convert": return self._file_image_convert(args) + if action == "image_bundle": + return self._file_image_bundle(args) + if action == "svg_export": + return self._file_svg_export(args) + if action == "svg_to_png": + return self._file_svg_to_png(args) if action == "json_export": return self._file_json_export(args) if action == "yaml_export": @@ -1178,6 +1187,142 @@ class ToolManager: file_mime=self._mime_for_image_format(fmt), ) + def _file_image_bundle(self, args: Dict[str, Any]) -> ToolResult: + file_name = self._sanitize_file_name(args.get("file_name"), "images.zip", force_ext=".zip") + images = args.get("images") or args.get("entries") or [] + if not isinstance(images, list) or not images: + return ToolResult(success=False, result=None, error="images must be non-empty array") + + out = BytesIO() + with ZipFile(out, mode="w", compression=ZIP_DEFLATED) as zf: + for idx, item in enumerate(images, start=1): + if not isinstance(item, dict): + return ToolResult(success=False, result=None, error=f"images[{idx-1}] must be object") + src_b64 = item.get("file_base64") + if not src_b64: + return ToolResult(success=False, result=None, error=f"images[{idx-1}].file_base64 is required") + in_name = self._sanitize_file_name(item.get("file_name"), f"image_{idx}.bin") + zf.writestr(in_name, self._bytes_from_b64(src_b64)) + + payload = out.getvalue() + return ToolResult( + success=True, + result={"message": f"Image bundle created: {file_name}"}, + file_base64=self._b64_from_bytes(payload), + file_name=file_name, + file_mime="application/zip", + ) + + @staticmethod + def _strip_ns(tag: str) -> str: + return tag.split("}", 1)[1] if "}" in tag else tag + + @staticmethod + def _safe_int(value: Any, default: int = 0) -> int: + try: + text = str(value).strip() + if text.endswith("px"): + text = text[:-2] + return int(float(text)) + except Exception: + return default + + def _file_svg_export(self, args: Dict[str, Any]) -> ToolResult: + file_name = self._sanitize_file_name(args.get("file_name"), "image.svg", force_ext=".svg") + svg_raw = args.get("svg") + if svg_raw is not None: + payload = str(svg_raw).encode("utf-8") + return ToolResult( + success=True, + result={"message": f"SVG exported: {file_name}"}, + file_base64=self._b64_from_bytes(payload), + file_name=file_name, + file_mime="image/svg+xml", + ) + + width = max(1, int(args.get("width") or 1024)) + height = max(1, int(args.get("height") or 1024)) + bg = str(args.get("background_color") or "white") + text = xml_escape(str(args.get("text") or "")) + text_color = str(args.get("text_color") or "black") + text_x = int(args.get("text_x") or 20) + text_y = int(args.get("text_y") or 40) + + # Minimal deterministic SVG template for safe generation. + svg = ( + f'' + f'' + f'{text}' + f"" + ) + payload = svg.encode("utf-8") + return ToolResult( + success=True, + result={"message": f"SVG exported: {file_name}"}, + file_base64=self._b64_from_bytes(payload), + file_name=file_name, + file_mime="image/svg+xml", + ) + + def _file_svg_to_png(self, args: Dict[str, Any]) -> ToolResult: + from PIL import Image, ImageColor, ImageDraw + + file_name = self._sanitize_file_name(args.get("file_name"), "converted.png", force_ext=".png") + src_b64 = args.get("file_base64") + svg_text = args.get("svg") + if src_b64: + svg_text = self._bytes_from_b64(src_b64).decode("utf-8", errors="ignore") + if not svg_text: + return ToolResult(success=False, result=None, error="file_base64 or svg is required for svg_to_png") + + try: + root = ET.fromstring(svg_text) + except Exception as exc: + return ToolResult(success=False, result=None, error=f"Invalid SVG: {exc}") + + width = self._safe_int(root.attrib.get("width"), 1024) + height = self._safe_int(root.attrib.get("height"), 1024) + width = max(1, width) + height = max(1, height) + + image = Image.new("RGBA", (width, height), color="white") + draw = ImageDraw.Draw(image) + + for elem in root.iter(): + tag = self._strip_ns(elem.tag) + if tag == "rect": + x = self._safe_int(elem.attrib.get("x"), 0) + y = self._safe_int(elem.attrib.get("y"), 0) + w = self._safe_int(elem.attrib.get("width"), width) + h = self._safe_int(elem.attrib.get("height"), height) + fill = elem.attrib.get("fill", "white") + try: + color = ImageColor.getrgb(fill) + except Exception: + color = (255, 255, 255) + draw.rectangle([x, y, x + max(0, w), y + max(0, h)], fill=color) + elif tag == "text": + x = self._safe_int(elem.attrib.get("x"), 0) + y = self._safe_int(elem.attrib.get("y"), 0) + fill = elem.attrib.get("fill", "black") + try: + color = ImageColor.getrgb(fill) + except Exception: + color = (0, 0, 0) + draw.text((x, y), elem.text or "", fill=color) + + out = BytesIO() + image.convert("RGB").save(out, format="PNG") + payload = out.getvalue() + return ToolResult( + success=True, + result={"message": f"SVG converted to PNG: {file_name}"}, + file_base64=self._b64_from_bytes(payload), + file_name=file_name, + file_mime="image/png", + ) + def _file_yaml_export(self, args: Dict[str, Any]) -> ToolResult: file_name = self._sanitize_file_name(args.get("file_name"), "export.yaml", force_ext=".yaml") content = args.get("content")