mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-04 14:31:13 +02:00
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:
13 files changed
+1003
-265
No files matched your search
@@ -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")],
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user