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")],