mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-05 23:01:15 +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")],
|
||||
|
||||
Reference in new issue
Block a user