feat(agent): share Web UI compatible drawing builders between MCP and copilot

The text drawing tool was MCP-only; the copilot needs the same
capability and its area-annotation helper carried the same Web UI
compat bugs. Split into three layers with a single implementation:

- gns3server/utils/drawings.py: pure SVG builders (text, rectangle,
  ellipse) with full input validation (ValueError) — the Web UI
  format knowledge lives in core utils next to picture.py
- api_handlers.py: shared REST handlers (create_text_drawing_handler
  moved here, create_rectangle/ellipse_drawing_handler added) — the
  layer both consumers already share
- thin shells: MCP tools (drawing_create_text unchanged, plus new
  drawing_create_rectangle/_ellipse) and three LangChain tools
  (tools_v2/gns3_create_drawing.py) registered in both copilot mode
  lists

Shape builder details:

- rectangle: solid fill or "none", stroke block emitted only when a
  stroke is given, rx corner radius, dasharray validated against the
  Web UI's Qt remap table (QtDasharrayFixer rewrites those patterns)
- ellipse: bounding-box API; cx/cy derived as offsets inside the
  drawing box (the Web UI treats them as box-relative, not canvas
  coordinates); odd sizes round down and the actual size is returned

gns3_drawing_utils now delegates to the shared builders, fixing the
px-blind sizing, the ignored text-anchor/x/y centering and the
missing XML escaping in the area-annotation path.
This commit is contained in:
YueGuobin committed 2026-10-09 11:12:18 +08:00
1 parent 73cbb0387c
commit 5e8272fdd8
13 files changed
+1003 -265

No files matched your search

+100 -1
View File
@@ -45,8 +45,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
available_filters_handler,
create_ellipse_drawing_handler,
create_link_handler,
create_node_handler,
create_rectangle_drawing_handler,
create_text_drawing_handler,
delete_link_handler,
delete_node_file_handler,
delete_node_handler,
@@ -110,7 +113,6 @@ from .device_config import (
)
from .drawings import (
create_drawing_handler,
create_text_drawing_handler,
delete_drawing_handler,
get_drawing_handler,
get_drawings_handler,
@@ -1627,6 +1629,103 @@ async def drawing_create_text(
)
@mcp.tool()
async def drawing_create_rectangle(
project_id: Annotated[str, Field(description="Project name or UUID")],
x: Annotated[int, Field(description="X coordinate of the rectangle top-left corner on the canvas")],
y: Annotated[int, Field(description="Y coordinate of the rectangle top-left corner on the canvas")],
width: Annotated[int, Field(description="Rectangle width in px, 1-2000")],
height: Annotated[int, Field(description="Rectangle height in px, 1-2000")],
fill: Annotated[str, Field(description='Fill color, hex like #RRGGBB or "none" (default: #FFFFFF)')] = "#FFFFFF",
fill_opacity: Annotated[float, Field(description="Fill opacity, 0-1 (default: 1.0)")] = 1.0,
stroke: Annotated[str | None, Field(description="Border color, hex like #RRGGBB; omit for no border")] = None,
stroke_width: Annotated[int, Field(description="Border width, 1-20 (default: 1)")] = 1,
dashed: Annotated[bool, Field(description="Dashed border, uses the default 10,6 pattern (default: false)")] = False,
dasharray: Annotated[str | None, Field(description='Custom dash pattern like "4,2"; requires stroke')] = None,
rx: Annotated[int, Field(description="Corner radius, 0 to min(width, height)/2 (default: 0)")] = 0,
z: Annotated[int, Field(description="Z layer; 0 keeps shapes behind nodes and text (default: 0)")] = 0,
) -> list[dict[str, Any]]:
"""Add a rectangle to a project canvas.
The SVG is assembled server-side in the Web UI compatible format; prefer this
over hand-writing SVG for drawing_create.
- fill "none" renders an outline-only box (stroke recommended then).
- Border attributes are only emitted when stroke is given.
- dasharray must be plain "on,off" numbers; the Qt-remapped patterns
("25, 25", "5, 25", ...) are rejected because the Web UI rewrites them.
Example: semi-transparent outline box at (200, 150):
drawing_create_rectangle(project_id="demo", x=200, y=150, width=400, height=200,
fill="none", stroke="#333333", dashed=True)
"""
return await asyncio.to_thread(
_run_handler_sync,
create_rectangle_drawing_handler,
{
"project_id": project_id,
"x": x,
"y": y,
"z": z,
"width": width,
"height": height,
"fill": fill,
"fill_opacity": fill_opacity,
"stroke": stroke,
"stroke_width": stroke_width,
"dashed": dashed,
"dasharray": dasharray,
"rx": rx,
},
)
@mcp.tool()
async def drawing_create_ellipse(
project_id: Annotated[str, Field(description="Project name or UUID")],
x: Annotated[int, Field(description="X coordinate of the bounding box top-left corner on the canvas")],
y: Annotated[int, Field(description="Y coordinate of the bounding box top-left corner on the canvas")],
width: Annotated[int, Field(description="Bounding box width in px, 2-2000; equal to height gives a circle")],
height: Annotated[int, Field(description="Bounding box height in px, 2-2000")],
fill: Annotated[str, Field(description='Fill color, hex like #RRGGBB or "none" (default: #FFFFFF)')] = "#FFFFFF",
fill_opacity: Annotated[float, Field(description="Fill opacity, 0-1 (default: 1.0)")] = 1.0,
stroke: Annotated[str | None, Field(description="Border color, hex like #RRGGBB; omit for no border")] = None,
stroke_width: Annotated[int, Field(description="Border width, 1-20 (default: 1)")] = 1,
dashed: Annotated[bool, Field(description="Dashed border, uses the default 10,6 pattern (default: false)")] = False,
dasharray: Annotated[str | None, Field(description='Custom dash pattern like "4,2"; requires stroke')] = None,
z: Annotated[int, Field(description="Z layer; 0 keeps shapes behind nodes and text (default: 0)")] = 0,
) -> list[dict[str, Any]]:
"""Add an ellipse (or circle) to a project canvas.
The SVG is assembled server-side in the Web UI compatible format. cx/cy are
derived from the bounding box (the Web UI treats them as offsets inside the
drawing, not canvas coordinates); for odd width/height the drawing is 1 px
smaller than requested and the actual size is returned.
Example: circle of diameter 160 at (300, 300):
drawing_create_ellipse(project_id="demo", x=300, y=300, width=160, height=160,
fill="#5AA9DD", fill_opacity=0.8)
"""
return await asyncio.to_thread(
_run_handler_sync,
create_ellipse_drawing_handler,
{
"project_id": project_id,
"x": x,
"y": y,
"z": z,
"width": width,
"height": height,
"fill": fill,
"fill_opacity": fill_opacity,
"stroke": stroke,
"stroke_width": stroke_width,
"dashed": dashed,
"dasharray": dasharray,
},
)
@mcp.tool()
async def drawing_get(
project_id: Annotated[str, Field(description="Project name or UUID")],
-99
View File
@@ -20,11 +20,7 @@ MCP tool handlers for GNS3 drawing management.
"""
import logging
import re
from math import ceil
from typing import Any
from xml.etree import ElementTree as ET
from xml.sax.saxutils import escape
log = logging.getLogger(__name__)
@@ -73,101 +69,6 @@ def create_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
return {"message": "Drawing created", "drawing": result}
# ── Text drawing ───────────────────────────────────────────────────────────
# The Web UI renders font-size as pt (1pt = 4/3 px) and monospace glyphs advance
# ~0.6em, so one character is ~0.8 x font_size in canvas pixels. Proportional
# fonts get a wider worst-case factor so the selection box does not clip the text.
_MONOSPACE_CHAR_FACTOR = 0.8
_PROPORTIONAL_CHAR_FACTOR = 1.0
# The Web UI stacks lines at dy=1.4em, i.e. 1.4 x 4/3 ~ 1.87 px per pt of font size.
_LINE_HEIGHT_FACTOR = 1.9
_MAX_TEXT_LENGTH = 500
_COLOR_RE = re.compile(r"^#(?:[0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$")
_FONT_FAMILY_RE = re.compile(r"^[A-Za-z0-9 ,.'-]+$")
def build_text_svg(
text: str, font_size: int = 13, color: str = "#000000", bold: bool = False, font_family: str = "monospace"
) -> tuple[str, int, int, int]:
"""
Build a Web UI compatible single <text> SVG for a canvas label.
The output mirrors what the Web UI itself serializes for text drawings:
root <svg height width> with one <text> child and no x/y (the Web UI parser
ignores them and positions the text itself).
:returns: (svg, width, height, line_height) with the computed drawing size
"""
lines = text.split("\n")
factor = _MONOSPACE_CHAR_FACTOR if "monospace" in font_family.lower() else _PROPORTIONAL_CHAR_FACTOR
width = max(1, ceil(max(len(line) for line in lines) * font_size * factor))
line_height = ceil(font_size * _LINE_HEIGHT_FACTOR)
height = len(lines) * line_height
weight = "bold" if bold else "normal"
svg = (
f'<svg height="{height}" width="{width}">'
f'<text fill="{color}" fill-opacity="1.0" '
f'font-family="{font_family}" font-size="{font_size}" '
f'font-weight="{weight}">{escape(text)}</text></svg>'
)
# Fail loudly here: the controller silently keeps the previous (empty) SVG
# when handed an unparseable string of 500+ characters.
ET.fromstring(svg)
return svg, width, height, line_height
def create_text_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
text = params.get("text")
if not project_id:
return {"error": "project_id is required"}
if not text:
return {"error": "text is required"}
text = text.replace("\r\n", "\n").replace("\r", "\n").strip("\n")
if not text:
return {"error": "text is required"}
if len(text) > _MAX_TEXT_LENGTH:
return {"error": f"text is too long (max {_MAX_TEXT_LENGTH} characters)"}
if any(ord(ch) < 32 and ch != "\n" for ch in text) or "\x7f" in text:
return {"error": "text contains control characters (only newlines are allowed)"}
font_size = params.get("font_size", 13)
if isinstance(font_size, bool) or not isinstance(font_size, int) or not 1 <= font_size <= 200:
return {"error": "font_size must be an integer between 1 and 200"}
color = params.get("color", "#000000")
if not _COLOR_RE.match(color):
return {"error": "color must be a hex value like #RRGGBB"}
font_family = params.get("font_family", "monospace")
if not _FONT_FAMILY_RE.match(font_family):
return {"error": "font_family contains invalid characters (allowed: letters, digits, spaces, . ' -)"}
try:
svg, width, height, line_height = build_text_svg(
text, font_size=font_size, color=color, bold=params.get("bold", False), font_family=font_family
)
except ET.ParseError as e:
return {"error": f"generated SVG is not valid XML: {e}"}
conn = _get_connector(gns3_ctx)
data = {
"svg": svg,
"x": params.get("x", 0),
"y": params.get("y", 0),
"z": params.get("z", 1),
}
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json()
return {
"message": "Text drawing created",
"drawing": result,
"width": width,
"height": height,
"line_height": line_height,
}
def get_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
drawing_id = params.get("drawing_id")