feat(file-tool): add image_bundle and svg actions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
|
||||
f'viewBox="0 0 {width} {height}">'
|
||||
f'<rect x="0" y="0" width="{width}" height="{height}" fill="{bg}" />'
|
||||
f'<text x="{text_x}" y="{text_y}" fill="{text_color}">{text}</text>'
|
||||
f"</svg>"
|
||||
)
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user