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

@@ -77,7 +77,10 @@ from gns3server.agent.gns3_copilot.skills.registry import get_prompt
from gns3server.agent.gns3_copilot.tools_v2 import (
ExecuteMultipleDeviceCommands,
ExecuteMultipleDeviceConfigCommands,
GNS3CreateEllipseDrawingTool,
GNS3CreateNodeTool,
GNS3CreateRectangleDrawingTool,
GNS3CreateTextDrawingTool,
GNS3LinkTool,
GNS3PacketFilterTool,
GNS3StartNodeTool,
@@ -103,6 +106,9 @@ TEACHING_ASSISTANT_MODE_TOOLS = [
GNS3TemplateTool(), # Get GNS3 node templates
GNS3CreateNodeTool(), # Create new nodes in GNS3
GNS3LinkTool(), # Create links between nodes
GNS3CreateTextDrawingTool(), # Add text labels to the canvas
GNS3CreateRectangleDrawingTool(), # Add rectangle annotations to the canvas
GNS3CreateEllipseDrawingTool(), # Add ellipse annotations to the canvas
GNS3StartNodeTool(), # Start GNS3 nodes (returns immediately)
GNS3WaitTool(), # Wait for nodes to boot (pair with start_gns3_node)
GNS3UpdateNodeNameTool(), # Update node name
@@ -118,6 +124,9 @@ LAB_AUTOMATION_ASSISTANT_MODE_TOOLS = [
GNS3TemplateTool(), # Get GNS3 node templates
GNS3CreateNodeTool(), # Create new nodes in GNS3
GNS3LinkTool(), # Create links between nodes
GNS3CreateTextDrawingTool(), # Add text labels to the canvas
GNS3CreateRectangleDrawingTool(), # Add rectangle annotations to the canvas
GNS3CreateEllipseDrawingTool(), # Add ellipse annotations to the canvas
GNS3StartNodeTool(), # Start GNS3 nodes (returns immediately)
GNS3WaitTool(), # Wait for nodes to boot (pair with start_gns3_node)
GNS3StopNodeTool(), # Stop GNS3 nodes
@@ -55,6 +55,7 @@ from typing import Any
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
from gns3server.services import access_ticket_service
from gns3server.services.access_tickets import DEFAULT_TICKET_TTL
from gns3server.utils.drawings import build_ellipse_svg, build_rectangle_svg, build_text_svg
log = logging.getLogger(__name__)
@@ -1076,3 +1077,95 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
# action == "delete"
conn.http_call("delete", url)
return {"message": f"Marker definition '{def_name}' deleted", "project_id": project_id, "def_name": def_name}
# ── Drawing handlers ──────────────────────────────────────────────────────
# SVG assembly and validation live in gns3server.utils.drawings (Web UI
# compatible format); these handlers only carry params and do the REST call.
def create_text_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
"""Create a canvas text label; the SVG is assembled from validated params."""
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
try:
svg, width, height, line_height = build_text_svg(
params.get("text"),
font_size=params.get("font_size", 13),
color=params.get("color", "#000000"),
bold=params.get("bold", False),
font_family=params.get("font_family", "monospace"),
)
except ValueError as e:
return {"error": str(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 create_rectangle_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
"""Create a canvas rectangle; the SVG is assembled from validated params."""
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
width = params.get("width")
height = params.get("height")
if width is None or height is None:
return {"error": "width and height are required"}
try:
svg, width, height = build_rectangle_svg(
width,
height,
fill=params.get("fill", "#FFFFFF"),
fill_opacity=params.get("fill_opacity", 1.0),
stroke=params.get("stroke"),
stroke_width=params.get("stroke_width", 1),
dashed=params.get("dashed", False),
dasharray=params.get("dasharray"),
rx=params.get("rx", 0),
)
except ValueError as e:
return {"error": str(e)}
conn = _get_connector(gns3_ctx)
data = {"svg": svg, "x": params.get("x", 0), "y": params.get("y", 0), "z": params.get("z", 0)}
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json()
return {"message": "Rectangle drawing created", "drawing": result, "width": width, "height": height}
def create_ellipse_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
"""Create a canvas ellipse from a bounding box; the SVG is assembled from validated params."""
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
width = params.get("width")
height = params.get("height")
if width is None or height is None:
return {"error": "width and height are required"}
try:
svg, width, height = build_ellipse_svg(
width,
height,
fill=params.get("fill", "#FFFFFF"),
fill_opacity=params.get("fill_opacity", 1.0),
stroke=params.get("stroke"),
stroke_width=params.get("stroke_width", 1),
dashed=params.get("dashed", False),
dasharray=params.get("dasharray"),
)
except ValueError as e:
return {"error": str(e)}
conn = _get_connector(gns3_ctx)
data = {"svg": svg, "x": params.get("x", 0), "y": params.get("y", 0), "z": params.get("z", 0)}
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json()
return {"message": "Ellipse drawing created", "drawing": result, "width": width, "height": height}
@@ -40,6 +40,7 @@ Main modules:
- vpcs_tools_netmiko: VPCS device configuration tool using Netmiko
- gns3_create_node: GNS3 node creation tool
- gns3_create_link: GNS3 link creation tool
- gns3_create_drawing: GNS3 canvas drawing tools (text, rectangle, ellipse)
- gns3_start_node: GNS3 node startup tool (immediate return, no boot wait)
- gns3_wait: Wait timer tool (pair with node start to let devices boot)
- gns3_get_node_temp: GNS3 template retrieval tool
@@ -54,6 +55,11 @@ Author: Yue Guobin (岳国宾)
# Import main tool classes
from .config_tools_nornir import ExecuteMultipleDeviceConfigCommands
from .display_tools_nornir import ExecuteMultipleDeviceCommands
from .gns3_create_drawing import (
GNS3CreateEllipseDrawingTool,
GNS3CreateRectangleDrawingTool,
GNS3CreateTextDrawingTool,
)
from .gns3_create_link import GNS3LinkTool
from .gns3_create_node import GNS3CreateNodeTool
from .gns3_get_node_temp import GNS3TemplateTool
@@ -81,7 +87,10 @@ __url__ = "https://github.com/yueguobin/gns3-copilot"
__all__ = [
"ExecuteMultipleDeviceCommands",
"ExecuteMultipleDeviceConfigCommands",
"GNS3CreateEllipseDrawingTool",
"GNS3CreateNodeTool",
"GNS3CreateRectangleDrawingTool",
"GNS3CreateTextDrawingTool",
"GNS3LinkTool",
"GNS3PacketFilterTool",
"GNS3StartNodeQuickTool",
@@ -0,0 +1,162 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
#
# This file is part of GNS3-Copilot project.
#
# GNS3-Copilot is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# GNS3-Copilot is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
# for more details.
#
# You should have received a copy of the GNU General Public License
# along with GNS3-Copilot. If not, see <https://www.gnu.org/licenses/>.
#
# Copyright (C) 2025 Yue Guobin (岳国宾)
# Author: Yue Guobin (岳国宾)
#
# Project Home: https://github.com/yueguobin/gns3-copilot
#
"""
GNS3 canvas drawing tools for text, rectangle and ellipse annotations.
Thin LangChain wrappers over the shared REST handlers in
``gns3_client.api_handlers``; the Web UI compatible SVG is assembled and
validated by ``gns3server.utils.drawings`` (single implementation shared
with the MCP drawing tools).
"""
import json
import logging
from typing import Any
from langchain.tools import BaseTool
from langchain_core.callbacks import CallbackManagerForToolRun
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
build_gns3_ctx,
create_ellipse_drawing_handler,
create_rectangle_drawing_handler,
create_text_drawing_handler,
)
# Configure logging
logger = logging.getLogger(__name__)
class _GNS3CreateDrawingToolBase(BaseTool):
"""Shared plumbing: parse JSON input, build ctx, dispatch to a handler."""
def _dispatch(self, input_data: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
raise NotImplementedError
def _run(
self,
tool_input: str,
run_manager: CallbackManagerForToolRun | None = None,
) -> dict[str, Any]:
try:
input_data = json.loads(tool_input)
gns3_ctx = build_gns3_ctx()
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
return self._dispatch(input_data, gns3_ctx)
except json.JSONDecodeError as e:
logger.error("Invalid JSON input: %s", e)
return {"error": f"Invalid JSON input: {e}"}
except Exception as e:
logger.exception("Drawing creation failed")
return {"error": str(e)}
class GNS3CreateTextDrawingTool(_GNS3CreateDrawingToolBase):
"""
A LangChain tool to add a text label to the GNS3 project canvas.
**Input**:
A JSON object with project_id, x, y (top-left corner on the canvas)
and text; optional font_size (1-200, default 13), color (#RRGGBB,
default #000000), bold (default false), font_family (default
monospace) and z (default 1, keeps text above links).
Newlines in text start additional lines; XML characters are escaped
automatically; the computed width/height are returned.
**Output**:
A dictionary with the created drawing, its drawing_id and the
computed width/height/line_height, or an "error" key on failure.
"""
name: str = "create_gns3_text_drawing"
description: str = """
Adds a text label to the GNS3 project canvas.
Input: JSON with project_id, x, y (top-left corner), text and optional
font_size, color, bold, font_family, z.
Returns: the created drawing with its computed width and height.
"""
def _dispatch(self, input_data: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
return create_text_drawing_handler(input_data, gns3_ctx)
class GNS3CreateRectangleDrawingTool(_GNS3CreateDrawingToolBase):
"""
A LangChain tool to add a rectangle to the GNS3 project canvas.
**Input**:
A JSON object with project_id, x, y (top-left corner), width and
height (1-2000); optional fill (#RRGGBB or "none", default #FFFFFF),
fill_opacity (0-1, default 1.0), stroke (#RRGGBB, no border by
default), stroke_width (1-20), dashed (bool), dasharray (like "4,2",
requires stroke) and rx (corner radius, default 0). z defaults to 0
so shapes stay behind nodes and text.
**Output**:
A dictionary with the created drawing, or an "error" key on failure.
"""
name: str = "create_gns3_rectangle_drawing"
description: str = """
Adds a rectangle to the GNS3 project canvas.
Input: JSON with project_id, x, y, width, height and optional fill,
fill_opacity, stroke, stroke_width, dashed, dasharray, rx, z.
Returns: the created drawing with its width and height.
"""
def _dispatch(self, input_data: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
return create_rectangle_drawing_handler(input_data, gns3_ctx)
class GNS3CreateEllipseDrawingTool(_GNS3CreateDrawingToolBase):
"""
A LangChain tool to add an ellipse (or circle) to the GNS3 canvas.
**Input**:
A JSON object with project_id, x, y (bounding box top-left corner),
width and height (2-2000, equal values give a circle); the same
optional style fields as the rectangle tool. cx/cy are derived from
the bounding box; for odd width/height the drawing is 1 px smaller
than requested and the actual size is returned.
**Output**:
A dictionary with the created drawing, or an "error" key on failure.
"""
name: str = "create_gns3_ellipse_drawing"
description: str = """
Adds an ellipse or circle to the GNS3 project canvas.
Input: JSON with project_id, x, y, width, height (bounding box) and
optional fill, fill_opacity, stroke, stroke_width, dashed, dasharray, z.
Returns: the created drawing with its actual width and height.
"""
def _dispatch(self, input_data: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
return create_ellipse_drawing_handler(input_data, gns3_ctx)
@@ -33,6 +33,8 @@ Supports ellipse and rectangle shapes for two-node annotations.
import math
from typing import Any, Literal
from gns3server.utils.drawings import build_ellipse_svg, build_rectangle_svg, build_text_svg
# Default parameters
DEFAULT_DEVICE_WIDTH = 50
DEFAULT_DEVICE_HEIGHT = 50
@@ -144,10 +146,16 @@ def calculate_two_node_shape(
center_y = (node1_center_y + node2_center_y) / 2
color_scheme = _get_color_scheme(area_name)
text_svg = generate_text_svg(area_name, color_scheme)
text_svg_width = len(area_name) * 8 + 20
text_svg_height = DEFAULT_FONT_SIZE + 16
# Shared builder: Web UI compatible SVG plus the computed label size
# (the old len * 8 + 20 estimate ignored the Web UI's pt-based rendering).
text_svg, text_svg_width, text_svg_height, _ = build_text_svg(
area_name,
font_size=DEFAULT_FONT_SIZE,
color=color_scheme["stroke"],
bold=True,
font_family="TypeWriter",
)
if shape_type == "ellipse":
rx = distance / 2
@@ -236,8 +244,11 @@ def generate_ellipse_svg(
svg_width: int,
svg_height: int,
) -> str:
"""Generate SVG for ellipse."""
return f"""<svg width="{svg_width}" height="{svg_height}"><ellipse cx="{rx}" cy="{ry}" rx="{rx}" ry="{ry}" fill="{color_scheme["fill"]}" fill-opacity="{color_scheme["fill_opacity"]}"/></svg>"""
"""Generate SVG for ellipse (delegates to the shared Web UI compatible builder)."""
svg, _, _ = build_ellipse_svg(
rx * 2, ry * 2, fill=color_scheme["fill"], fill_opacity=color_scheme["fill_opacity"]
)
return svg
def generate_rectangle_svg(
@@ -245,16 +256,23 @@ def generate_rectangle_svg(
height: int,
color_scheme: dict[str, Any],
) -> str:
"""Generate SVG for rectangle."""
return f"""<svg width="{width}" height="{height}"><rect x="0" y="0" width="{width}" height="{height}" fill="{color_scheme["fill"]}" fill-opacity="{color_scheme["fill_opacity"]}"/></svg>"""
"""Generate SVG for rectangle (delegates to the shared Web UI compatible builder)."""
svg, _, _ = build_rectangle_svg(
width, height, fill=color_scheme["fill"], fill_opacity=color_scheme["fill_opacity"]
)
return svg
def generate_text_svg(text: str, color_scheme: dict[str, Any]) -> str:
"""Generate SVG for text label."""
text_width = len(text) * 8 + 20
text_height = DEFAULT_FONT_SIZE + 16
return f"""<svg width="{text_width}" height="{text_height}"><text font-family="TypeWriter" font-size="{DEFAULT_FONT_SIZE}.0" font-weight="bold" fill="{color_scheme["stroke"]}" text-anchor="middle" x="{text_width / 2}" y="{text_height / 2 + 4}">{text}</text></svg>"""
"""Generate SVG for text label (delegates to the shared Web UI compatible builder)."""
svg, _, _, _ = build_text_svg(
text,
font_size=DEFAULT_FONT_SIZE,
color=color_scheme["stroke"],
bold=True,
font_family="TypeWriter",
)
return svg
def _hsv_to_hex(h: int, s: int, v: int) -> str: