mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 05:51:15 +02:00
feat(agent): add gns3-copilot AI assistant integration module
Integrate the gns3-copilot AI assistant module to provide intelligent automation and interaction capabilities for GNS3 network emulation. Key components: - AI agent framework with LLM integration (supports Qwen vision model) - GNS3 client library for project topology management - Extensive prompt templates for various network operation scenarios - Tool library for node creation, linking, configuration, and management - Support for English level assessment (A1-C2) and specialized personas - Network drawing and topology visualization tools - Linux device automation via Nornir/Telnetlib - Window controller for UI interaction Features: - Multi-modal AI agent with vision capabilities - Automated network topology deployment and configuration - Interactive node and drawing management - File-based project operations (read, write, list) - Specialized prompts for different scenarios and skill levels - Comprehensive tool set for network device management
This commit is contained in:
1 parent
7a2d15cb64
commit
46b262a02c
62 files changed
+18114
No files matched your search
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
FlowNet-Lab Public Model Package
|
||||
|
||||
This package provides reusable public models and utilities for GNS3 network automation tasks.
|
||||
It contains common functionality that can be shared across different tools and modules.
|
||||
|
||||
Main modules:
|
||||
- get_gns3_device_port: Device port information retrieval from GNS3 topology
|
||||
- parse_tool_content: Tool execution result parsing and formatting utilities
|
||||
|
||||
Author: Guobin Yue
|
||||
"""
|
||||
|
||||
# Import main utility functions
|
||||
from .app_config import (
|
||||
DEFAULT_CONFIG,
|
||||
get_all_config,
|
||||
get_config,
|
||||
init_config,
|
||||
reset_config,
|
||||
set_config,
|
||||
)
|
||||
from .get_gns3_device_port import get_device_ports_from_topology
|
||||
from .parse_tool_content import format_tool_response, parse_tool_content
|
||||
|
||||
# Dynamic version management
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
__version__ = version("gns3-copilot")
|
||||
except Exception:
|
||||
__version__ = "unknown"
|
||||
|
||||
__author__ = "Guobin Yue"
|
||||
__description__ = "AI-powered network automation assistant for GNS3"
|
||||
__url__ = "https://github.com/yueguobin/gns3-copilot"
|
||||
|
||||
|
||||
# Export main utility functions
|
||||
__all__ = [
|
||||
"DEFAULT_CONFIG",
|
||||
"get_config",
|
||||
"set_config",
|
||||
"get_all_config",
|
||||
"init_config",
|
||||
"reset_config",
|
||||
"get_device_ports_from_topology",
|
||||
"parse_tool_content",
|
||||
"format_tool_response",
|
||||
]
|
||||
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
Application Configuration Manager for FlowNet-Lab.
|
||||
|
||||
This module provides a unified interface for managing application configuration
|
||||
stored in SQLite database. It includes default values for all configuration
|
||||
items and helper functions for reading and saving configuration.
|
||||
|
||||
Functions:
|
||||
get_config(key, default=None): Retrieve a configuration value with default
|
||||
set_config(key, value): Save a configuration value to database
|
||||
get_all_config(): Retrieve all configuration values
|
||||
init_config(): Initialize database with default values
|
||||
|
||||
Constants:
|
||||
DEFAULT_CONFIG: Dictionary containing all configuration keys and their defaults
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from gns3_copilot.utils.config_db import (
|
||||
clear_all,
|
||||
get_all_values,
|
||||
get_value,
|
||||
init_db,
|
||||
set_value,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default configuration values for all application settings
|
||||
DEFAULT_CONFIG: dict[str, str] = {
|
||||
# GNS3 Server Configuration
|
||||
#"GNS3_SERVER_HOST": "",
|
||||
#"GNS3_SERVER_URL": "http://127.0.0.1:3080/",
|
||||
#"API_VERSION": "3",
|
||||
#"GNS3_SERVER_USERNAME": "",
|
||||
#"GNS3_SERVER_PASSWORD": "",
|
||||
#"GNS3_SERVER_JWT_TOKEN": "",
|
||||
# Model Configuration
|
||||
"MODE_PROVIDER": "openai",
|
||||
"MODEL_NAME": "gpt-4",
|
||||
"MODEL_API_KEY": "",
|
||||
"BASE_URL": "",
|
||||
"TEMPERATURE": "0.0",
|
||||
# Voice Configuration
|
||||
#"VOICE": "False",
|
||||
# Linux Telnet Configuration
|
||||
"LINUX_TELNET_USERNAME": "",
|
||||
"LINUX_TELNET_PASSWORD": "",
|
||||
# Prompt Configuration
|
||||
#"ENGLISH_LEVEL": "Normal Prompt",
|
||||
# Reading Page Configuration
|
||||
#"CALIBRE_SERVER_URL": "",
|
||||
#"READING_NOTES_DIR": "notes",
|
||||
# UI Configuration
|
||||
#"CONTAINER_HEIGHT": "1200",
|
||||
#"ZOOM_SCALE_TOPOLOGY": "0.8",
|
||||
# Other Settings
|
||||
#"LANGUAGE": "zh",
|
||||
# DashScope API Configuration
|
||||
#"DASHSCOPE_API_KEY": "",
|
||||
# DashScope STT (Speech-to-Text) Configuration
|
||||
#"DASHSCOPE_STT_MODEL": "fun-asr-realtime",
|
||||
#"DASHSCOPE_STT_FORMAT": "wav",
|
||||
#"DASHSCOPE_STT_SAMPLE_RATE": "16000",
|
||||
#"DASHSCOPE_STT_LANGUAGE_HINTS": "zh,en",
|
||||
# DashScope TTS (Text-to-Speech) Configuration
|
||||
#"DASHSCOPE_TTS_MODEL": "cosyvoice-v3-flash",
|
||||
#"DASHSCOPE_TTS_VOICE": "longanyang",
|
||||
#"DASHSCOPE_TTS_FORMAT": "mp3",
|
||||
#"DASHSCOPE_TTS_VOLUME": "50",
|
||||
#"DASHSCOPE_TTS_SPEECH_RATE": "1.0",
|
||||
#"DASHSCOPE_TTS_PITCH_RATE": "1.0",
|
||||
}
|
||||
|
||||
|
||||
def _get_default(key: str) -> str | None:
|
||||
"""Get the default value for a configuration key.
|
||||
|
||||
Args:
|
||||
key: Configuration key
|
||||
|
||||
Returns:
|
||||
Default value if key exists, None otherwise
|
||||
"""
|
||||
return DEFAULT_CONFIG.get(key)
|
||||
|
||||
|
||||
def get_config(key: str, default: str | None = None) -> str:
|
||||
"""Retrieve a configuration value from the database.
|
||||
|
||||
If the key doesn't exist in the database, it will use the default value
|
||||
from DEFAULT_CONFIG. If the key is not in DEFAULT_CONFIG either, it will
|
||||
use the provided default parameter.
|
||||
|
||||
Args:
|
||||
key: The configuration key to retrieve
|
||||
default: Fallback default value if key not in DEFAULT_CONFIG
|
||||
|
||||
Returns:
|
||||
The configuration value as a string
|
||||
|
||||
Example:
|
||||
>>> get_config("GNS3_SERVER_URL")
|
||||
'http://127.0.0.1:3080/'
|
||||
|
||||
>>> get_config("CUSTOM_KEY", "default_value")
|
||||
'default_value'
|
||||
"""
|
||||
# Get default value from DEFAULT_CONFIG or use provided default
|
||||
default_value = default if default is not None else _get_default(key)
|
||||
|
||||
# Retrieve value from database
|
||||
value = get_value(key, default_value)
|
||||
|
||||
if value is None:
|
||||
return default_value if default_value is not None else ""
|
||||
|
||||
# Ensure value is a string
|
||||
return str(value) if value else default_value if default_value else ""
|
||||
|
||||
|
||||
def set_config(key: str, value: str) -> bool:
|
||||
"""Save a configuration value to the database.
|
||||
|
||||
Args:
|
||||
key: The configuration key to save
|
||||
value: The configuration value to store
|
||||
|
||||
Returns:
|
||||
True if save was successful, False otherwise
|
||||
|
||||
Example:
|
||||
>>> set_config("GNS3_SERVER_URL", "http://192.168.1.100:3080")
|
||||
True
|
||||
"""
|
||||
return set_value(key, value)
|
||||
|
||||
|
||||
def get_all_config() -> dict[str, str]:
|
||||
"""Retrieve all configuration values from the database.
|
||||
|
||||
Returns:
|
||||
Dictionary with all configuration key-value pairs
|
||||
|
||||
Example:
|
||||
>>> config = get_all_config()
|
||||
>>> print(config["GNS3_SERVER_URL"])
|
||||
'http://127.0.0.1:3080/'
|
||||
"""
|
||||
return get_all_values()
|
||||
|
||||
|
||||
def init_config() -> None:
|
||||
"""Initialize the configuration database with default values.
|
||||
|
||||
This function initializes the database and populates it with default
|
||||
values for all configuration keys. If a key already exists in the
|
||||
database, its value will not be overwritten.
|
||||
|
||||
This should be called once at application startup.
|
||||
|
||||
Example:
|
||||
>>> init_config()
|
||||
# Database is now initialized with default values
|
||||
"""
|
||||
try:
|
||||
# Initialize database and create tables
|
||||
init_db()
|
||||
|
||||
# Set default values for all keys (only if not already set)
|
||||
for key, default_value in DEFAULT_CONFIG.items():
|
||||
existing_value = get_value(key)
|
||||
if existing_value is None:
|
||||
set_value(key, default_value)
|
||||
|
||||
logger.info(
|
||||
"Configuration database initialized with %d keys", len(DEFAULT_CONFIG)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize configuration: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
def reset_config() -> None:
|
||||
"""Reset all configuration to default values.
|
||||
|
||||
This function clears all existing configuration values and
|
||||
restores them to defaults defined in DEFAULT_CONFIG.
|
||||
|
||||
Example:
|
||||
>>> reset_config()
|
||||
# All configuration values are now set to defaults
|
||||
"""
|
||||
try:
|
||||
# Clear all existing values
|
||||
clear_all()
|
||||
|
||||
# Re-initialize with defaults
|
||||
init_config()
|
||||
|
||||
logger.info("Configuration reset to defaults")
|
||||
except Exception as e:
|
||||
logger.error("Failed to reset configuration: %s", e)
|
||||
raise
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Public module for getting device port information from GNS3 topology
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_device_ports_from_topology(
|
||||
device_names: list[str],
|
||||
project_id: str | None = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Get device connection information from GNS3 topology
|
||||
|
||||
Args:
|
||||
device_names: List of device names to look up
|
||||
project_id: UUID of the specific GNS3 project to retrieve topology from
|
||||
|
||||
Returns:
|
||||
Dictionary mapping device names to their connection data:
|
||||
{
|
||||
"device_name": {
|
||||
"port": console_port,
|
||||
"groups": ["cisco_IOSv_telnet"]
|
||||
}
|
||||
}
|
||||
Devices that don't exist or missing console_port will not be included
|
||||
"""
|
||||
# Log received parameters
|
||||
logger.info("Called with device_names=%s, project_id=%s", device_names, project_id)
|
||||
|
||||
try:
|
||||
# Lazy import to avoid circular dependency
|
||||
from gns3_copilot.gns3_client import GNS3TopologyTool
|
||||
|
||||
# Get topology information
|
||||
topo = GNS3TopologyTool()
|
||||
topology = topo._run(project_id=project_id)
|
||||
|
||||
# Dynamically build hosts_data from topology
|
||||
hosts_data: dict[str, dict[str, Any]] = {}
|
||||
|
||||
if not topology:
|
||||
logger.warning("Unable to get topology information")
|
||||
return hosts_data
|
||||
|
||||
for device_name in device_names:
|
||||
# Check if device exists in topology
|
||||
if device_name not in topology.get("nodes", {}):
|
||||
logger.warning("Device '%s' not found in topology", device_name)
|
||||
continue
|
||||
|
||||
node_info = topology["nodes"][device_name]
|
||||
if "console_port" not in node_info:
|
||||
logger.warning("Device '%s' missing console_port", device_name)
|
||||
continue
|
||||
|
||||
# Add device to hosts_data
|
||||
hosts_data[device_name] = {
|
||||
"port": node_info["console_port"],
|
||||
"groups": ["cisco_IOSv_telnet"],
|
||||
}
|
||||
|
||||
logger.info("Returning %d device port mappings", len(hosts_data))
|
||||
|
||||
return hosts_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting device port information: %s", e)
|
||||
return {}
|
||||
@@ -0,0 +1,437 @@
|
||||
"""
|
||||
Drawing utility functions for GNS3 area annotations.
|
||||
|
||||
Calculates drawing parameters and generates SVG content for network area annotations.
|
||||
Supports ellipse and rectangle shapes for two-node annotations.
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import Any, Literal
|
||||
|
||||
# Default parameters
|
||||
DEFAULT_DEVICE_WIDTH = 50
|
||||
DEFAULT_DEVICE_HEIGHT = 50
|
||||
DEFAULT_FONT_SIZE = 14
|
||||
DEFAULT_SHAPE_Z = 0
|
||||
DEFAULT_TEXT_Z = 1
|
||||
|
||||
# Z-order thresholds (pixels)
|
||||
Z_ORDER_LARGE_AREA_THRESHOLD = 500 # z=1 for large areas
|
||||
Z_ORDER_MEDIUM_AREA_THRESHOLD = 300 # z=2 for medium areas
|
||||
|
||||
# Color schemes (business professional style)
|
||||
# Text uses 'stroke', shape uses 'fill' with 'fill-opacity'
|
||||
COLOR_SCHEMES = {
|
||||
# Core/Backbone - biz_blue
|
||||
"CORE_BACKBONE": {
|
||||
"stroke": "#FFFFFF", # White (for text on dark background)
|
||||
"fill": "#2980B9", # Blue background
|
||||
"fill_opacity": 0.8, # Transparency level
|
||||
},
|
||||
# Normal Areas - biz_blue_light
|
||||
"NORMAL_AREA": {
|
||||
"stroke": "#000000", # Black (for text on light background)
|
||||
"fill": "#5AA9DD", # Light blue background
|
||||
"fill_opacity": 0.8, # Transparency level
|
||||
},
|
||||
# Logical Isolation - biz_purple
|
||||
"ISOLATION": {
|
||||
"stroke": "#FFFFFF", # White (for text on dark background)
|
||||
"fill": "#9B59B6", # Purple background
|
||||
"fill_opacity": 0.8, # Transparency level
|
||||
},
|
||||
# Management/Infrastructure - biz_orange
|
||||
"MANAGEMENT_INFRA": {
|
||||
"stroke": "#FFFFFF", # White (for text on medium-dark background)
|
||||
"fill": "#E67E22", # Orange background
|
||||
"fill_opacity": 0.8, # Transparency level
|
||||
},
|
||||
# Redundancy/High Availability - biz_orange_bright
|
||||
"HIGH_AVAILABILITY": {
|
||||
"stroke": "#000000", # Black (for text on bright background)
|
||||
"fill": "#F39C12", # Bright orange background
|
||||
"fill_opacity": 0.8, # Transparency level
|
||||
},
|
||||
# External/Boundary - biz_red_bright
|
||||
"EXTERNAL": {
|
||||
"stroke": "#FFFFFF", # White (for text on dark background)
|
||||
"fill": "#E74C3C", # Bright red background
|
||||
"fill_opacity": 0.8, # Transparency level
|
||||
},
|
||||
# Security/Trusted - biz_green_bright
|
||||
"SECURITY_TRUSTED": {
|
||||
"stroke": "#FFFFFF", # White (for text on medium-dark background)
|
||||
"fill": "#2ECC71", # Bright green background
|
||||
"fill_opacity": 0.8, # Transparency level
|
||||
},
|
||||
# Cloud/Tunnel - biz_cyan_dark
|
||||
"CLOUD_TUNNEL": {
|
||||
"stroke": "#FFFFFF", # White (for text on dark background)
|
||||
"fill": "#00CED1", # Dark cyan background
|
||||
"fill_opacity": 0.8, # Transparency level
|
||||
},
|
||||
# Default - biz_gray
|
||||
"DEFAULT": {
|
||||
"stroke": "#FFFFFF", # White (for text on medium-dark background)
|
||||
"fill": "#A5CC78", # Gray background
|
||||
"fill_opacity": 0.8, # Transparency level
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def calculate_two_node_shape(
|
||||
node1: dict,
|
||||
node2: dict,
|
||||
area_name: str,
|
||||
shape_type: Literal["ellipse", "rectangle"] = "ellipse",
|
||||
text_offset_ratio: float = 0.0,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Calculate shape annotation parameters for two nodes.
|
||||
|
||||
Args:
|
||||
node1: First node with 'x', 'y', 'height', 'width' (top-left)
|
||||
node2: Second node with 'x', 'y', 'height', 'width' (top-left)
|
||||
area_name: Name of area (e.g., "Area 0", "AS 100")
|
||||
shape_type: "ellipse" or "rectangle" (default: "ellipse")
|
||||
text_offset_ratio: Ratio to offset text (0=center, positive=offset)
|
||||
|
||||
Returns:
|
||||
Dict with shape, text SVG params, and metadata
|
||||
"""
|
||||
node1_width = node1.get("width", DEFAULT_DEVICE_WIDTH)
|
||||
node1_height = node1.get("height", DEFAULT_DEVICE_HEIGHT)
|
||||
node2_width = node2.get("width", DEFAULT_DEVICE_WIDTH)
|
||||
node2_height = node2.get("height", DEFAULT_DEVICE_HEIGHT)
|
||||
|
||||
node1_center_x = node1["x"] + (node1_width / 2)
|
||||
node1_center_y = node1["y"] + (node1_height / 2)
|
||||
node2_center_x = node2["x"] + (node2_width / 2)
|
||||
node2_center_y = node2["y"] + (node2_height / 2)
|
||||
|
||||
distance = math.sqrt(
|
||||
(node2_center_x - node1_center_x) ** 2 + (node2_center_y - node1_center_y) ** 2
|
||||
)
|
||||
|
||||
angle_rad = math.atan2(
|
||||
node2_center_y - node1_center_y, node2_center_x - node1_center_x
|
||||
)
|
||||
angle_deg = round(math.degrees(angle_rad))
|
||||
angle_rad = math.radians(angle_deg)
|
||||
|
||||
center_x = (node1_center_x + node2_center_x) / 2
|
||||
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
|
||||
|
||||
if shape_type == "ellipse":
|
||||
rx = distance / 2
|
||||
ry = math.sqrt((node1_width / 2) ** 2 + (node1_height / 2) ** 2)
|
||||
|
||||
shape_width = rx * 2
|
||||
shape_height = ry * 2
|
||||
|
||||
svg_x = center_x - (rx * math.cos(angle_rad) - ry * math.sin(angle_rad))
|
||||
svg_y = center_y - (rx * math.sin(angle_rad) + ry * math.cos(angle_rad))
|
||||
|
||||
shape_svg = generate_ellipse_svg(
|
||||
int(rx), int(ry), color_scheme, int(shape_width), int(shape_height)
|
||||
)
|
||||
|
||||
offset_distance = ry * text_offset_ratio
|
||||
|
||||
metadata = {
|
||||
"center_x": center_x,
|
||||
"center_y": center_y,
|
||||
"distance": distance,
|
||||
"shape_width": shape_width,
|
||||
"shape_height": shape_height,
|
||||
"angle_deg": angle_deg,
|
||||
"rx": rx,
|
||||
"ry": ry,
|
||||
}
|
||||
|
||||
else: # rectangle
|
||||
shape_width = distance
|
||||
shape_height = max(node1_width, node1_height, node2_width, node2_height)
|
||||
|
||||
svg_x = center_x - (
|
||||
(shape_width / 2) * math.cos(angle_rad)
|
||||
- (shape_height / 2) * math.sin(angle_rad)
|
||||
)
|
||||
svg_y = center_y - (
|
||||
(shape_width / 2) * math.sin(angle_rad)
|
||||
+ (shape_height / 2) * math.cos(angle_rad)
|
||||
)
|
||||
|
||||
shape_svg = generate_rectangle_svg(
|
||||
int(shape_width), int(shape_height), color_scheme
|
||||
)
|
||||
|
||||
offset_distance = (shape_height / 2) * text_offset_ratio
|
||||
|
||||
metadata = {
|
||||
"center_x": center_x,
|
||||
"center_y": center_y,
|
||||
"distance": distance,
|
||||
"shape_width": shape_width,
|
||||
"shape_height": shape_height,
|
||||
"angle_deg": angle_deg,
|
||||
"rx": None,
|
||||
"ry": None,
|
||||
}
|
||||
|
||||
if text_offset_ratio != 0:
|
||||
perpendicular_x = -math.sin(angle_rad)
|
||||
perpendicular_y = math.cos(angle_rad)
|
||||
|
||||
text_offset_x = perpendicular_x * offset_distance
|
||||
text_offset_y = perpendicular_y * offset_distance
|
||||
|
||||
text_x = int(center_x + text_offset_x - text_svg_width / 2)
|
||||
text_y = int(center_y + text_offset_y - text_svg_height / 2)
|
||||
else:
|
||||
text_x = int(center_x - text_svg_width / 2)
|
||||
text_y = int(center_y - text_svg_height / 2)
|
||||
|
||||
return {
|
||||
"shape": {
|
||||
"svg": shape_svg,
|
||||
"x": int(svg_x),
|
||||
"y": int(svg_y),
|
||||
"z": DEFAULT_SHAPE_Z,
|
||||
"rotation": int(angle_deg),
|
||||
"type": shape_type,
|
||||
},
|
||||
"text": {
|
||||
"svg": text_svg,
|
||||
"x": text_x,
|
||||
"y": text_y,
|
||||
"z": DEFAULT_TEXT_Z,
|
||||
"rotation": 0,
|
||||
},
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
|
||||
def generate_ellipse_svg(
|
||||
rx: int,
|
||||
ry: int,
|
||||
color_scheme: dict[str, Any],
|
||||
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>'''
|
||||
|
||||
|
||||
def generate_rectangle_svg(
|
||||
width: int,
|
||||
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>'''
|
||||
|
||||
|
||||
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>'''
|
||||
|
||||
|
||||
def _hsv_to_hex(h: int, s: int, v: int) -> str:
|
||||
"""Convert HSV to HEX color."""
|
||||
h_norm = (h % 360) / 360
|
||||
s_norm = s / 100
|
||||
v_norm = v / 100
|
||||
|
||||
c: float = v_norm * s_norm
|
||||
x: float = c * (1 - abs((h_norm / 60) % 2 - 1))
|
||||
m: float = v_norm - c
|
||||
|
||||
r: float
|
||||
g: float
|
||||
b: float
|
||||
|
||||
if 0 <= h_norm < 60:
|
||||
r, g, b = c, x, 0.0
|
||||
elif 60 <= h_norm < 120:
|
||||
r, g, b = x, c, 0.0
|
||||
elif 120 <= h_norm < 180:
|
||||
r, g, b = 0.0, c, x
|
||||
elif 180 <= h_norm < 240:
|
||||
r, g, b = 0.0, x, c
|
||||
elif 240 <= h_norm < 300:
|
||||
r, g, b = x, 0.0, c
|
||||
else:
|
||||
r, g, b = c, 0.0, x
|
||||
|
||||
r = int((r + m) * 255)
|
||||
g = int((g + m) * 255)
|
||||
b = int((b + m) * 255)
|
||||
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
||||
|
||||
def calculate_z_order(area_size: float) -> int:
|
||||
"""
|
||||
Calculate z-order based on area size for proper layering.
|
||||
|
||||
Args:
|
||||
area_size: Size of area (width for rectangles, 2*rx for ellipses)
|
||||
|
||||
Returns:
|
||||
Z-order: 1=back (large), 2=middle, 3=front (small)
|
||||
"""
|
||||
if area_size >= Z_ORDER_LARGE_AREA_THRESHOLD:
|
||||
return 1
|
||||
elif area_size >= Z_ORDER_MEDIUM_AREA_THRESHOLD:
|
||||
return 2
|
||||
else:
|
||||
return 3
|
||||
|
||||
|
||||
def _get_color_scheme(area_name: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get color scheme based on area name using keyword inference.
|
||||
|
||||
Maps labels to semantic groups for professional business styling.
|
||||
"""
|
||||
if area_name in COLOR_SCHEMES:
|
||||
return COLOR_SCHEMES[area_name]
|
||||
|
||||
label = area_name.upper()
|
||||
|
||||
# 1. Routing Domain Core/Backbone
|
||||
if "AREA 0" in label or "BACKBONE" in label or "CORE" in label:
|
||||
return COLOR_SCHEMES["CORE_BACKBONE"]
|
||||
|
||||
if "BGP" in label or "AS " in label:
|
||||
return COLOR_SCHEMES["CORE_BACKBONE"]
|
||||
|
||||
# 2. Routing Domain Normal Areas
|
||||
if "AREA " in label or "LEVEL" in label:
|
||||
return COLOR_SCHEMES["NORMAL_AREA"]
|
||||
|
||||
# 3. Logical Isolation
|
||||
if (
|
||||
"VRF" in label
|
||||
or "VLAN" in label
|
||||
or "MSTP" in label
|
||||
or "VXLAN" in label
|
||||
or "MPLS" in label
|
||||
):
|
||||
return COLOR_SCHEMES["ISOLATION"]
|
||||
|
||||
# 4. High Availability
|
||||
if (
|
||||
"VRRP" in label
|
||||
or "HSRP" in label
|
||||
or "HA" in label
|
||||
or "STACK" in label
|
||||
or "M-LAG" in label
|
||||
):
|
||||
return COLOR_SCHEMES["HIGH_AVAILABILITY"]
|
||||
|
||||
# 5. External/Internet
|
||||
if (
|
||||
"INET" in label
|
||||
or "OUT" in label
|
||||
or "EXTERNAL" in label
|
||||
or "INTERNET" in label
|
||||
or "DMZ" in label
|
||||
):
|
||||
return COLOR_SCHEMES["EXTERNAL"]
|
||||
|
||||
# 6. Management
|
||||
if "MGMT" in label or "OOB" in label or "MANAGEMENT" in label:
|
||||
return COLOR_SCHEMES["MANAGEMENT_INFRA"]
|
||||
|
||||
# 7. Security/Trusted
|
||||
if "SECURITY" in label or "TRUSTED" in label or "VPN" in label:
|
||||
return COLOR_SCHEMES["SECURITY_TRUSTED"]
|
||||
|
||||
# 8. Cloud/Tunnel
|
||||
if "TUNNEL" in label or "CLOUD" in label or "GRE" in label or "IPSEC" in label:
|
||||
return COLOR_SCHEMES["CLOUD_TUNNEL"]
|
||||
|
||||
# Legacy keyword matching for backward compatibility
|
||||
label_lower = area_name.lower()
|
||||
protocol_keywords = [
|
||||
("ospf", "NORMAL_AREA"),
|
||||
("is-is", "NORMAL_AREA"),
|
||||
("rip", "NORMAL_AREA"),
|
||||
("eigrp", "NORMAL_AREA"),
|
||||
("bgp", "CORE_BACKBONE"),
|
||||
("vxlan", "ISOLATION"),
|
||||
("mpls", "ISOLATION"),
|
||||
("vrf", "ISOLATION"),
|
||||
("vlan", "ISOLATION"),
|
||||
("vrrp", "HIGH_AVAILABILITY"),
|
||||
("hsrp", "HIGH_AVAILABILITY"),
|
||||
("gre", "CLOUD_TUNNEL"),
|
||||
("ipsec", "SECURITY_TRUSTED"),
|
||||
]
|
||||
|
||||
for keyword, scheme_key in protocol_keywords:
|
||||
if keyword in label_lower:
|
||||
return COLOR_SCHEMES[scheme_key]
|
||||
|
||||
return COLOR_SCHEMES["DEFAULT"]
|
||||
|
||||
|
||||
def calculate_two_node_ellipse(
|
||||
node1: dict,
|
||||
node2: dict,
|
||||
area_name: str,
|
||||
text_offset_ratio: float = 0.0,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Calculate ellipse annotation parameters for two nodes.
|
||||
|
||||
Wrapper around calculate_two_node_shape with shape_type="ellipse".
|
||||
"""
|
||||
result = calculate_two_node_shape(
|
||||
node1, node2, area_name, "ellipse", text_offset_ratio
|
||||
)
|
||||
return {
|
||||
"ellipse": result["shape"],
|
||||
"text": result["text"],
|
||||
"metadata": result["metadata"],
|
||||
}
|
||||
|
||||
|
||||
def calculate_two_node_rectangle(
|
||||
node1: dict,
|
||||
node2: dict,
|
||||
area_name: str,
|
||||
text_offset_ratio: float = 0.0,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Calculate rectangle annotation parameters for two nodes.
|
||||
|
||||
Wrapper around calculate_two_node_shape with shape_type="rectangle".
|
||||
"""
|
||||
result = calculate_two_node_shape(
|
||||
node1, node2, area_name, "rectangle", text_offset_ratio
|
||||
)
|
||||
metadata = result["metadata"]
|
||||
return {
|
||||
"rectangle": result["shape"],
|
||||
"text": result["text"],
|
||||
"metadata": {
|
||||
"center_x": metadata["center_x"],
|
||||
"center_y": metadata["center_y"],
|
||||
"distance": metadata["distance"],
|
||||
"rect_width": metadata["shape_width"],
|
||||
"rect_height": metadata["shape_height"],
|
||||
"angle_deg": metadata["angle_deg"],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
Public module for parsing tool execution results
|
||||
|
||||
This module is specifically designed to parse and format results returned by tools_v2
|
||||
after tool execution, supporting multiple formats and providing unified error handling.
|
||||
Mainly used for result display in UI interfaces.
|
||||
|
||||
Supported formats:
|
||||
- JSON strings
|
||||
- Python literal strings
|
||||
- Dictionary objects
|
||||
- List objects (JSON arrays)
|
||||
- Primitive types (int, float, bool, str)
|
||||
- Error message strings
|
||||
- Plain text output
|
||||
|
||||
Author: Guobin Yue
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_tool_content(
|
||||
content: str | dict | list | int | float | bool | None,
|
||||
fallback_to_raw: bool = True,
|
||||
strict_mode: bool = False,
|
||||
) -> dict[str, Any] | list[Any] | Any:
|
||||
"""
|
||||
Parse tool execution results into structured data, specifically for UI display.
|
||||
|
||||
This function can handle various input types including strings, dictionaries, lists,
|
||||
and primitive types. It ensures the returned data can be properly serialized
|
||||
by json.dumps.
|
||||
|
||||
Args:
|
||||
content: Content returned by tools (can be str, dict, list, int, float, bool, or None)
|
||||
fallback_to_raw: Whether to return raw content when parsing fails, default True
|
||||
strict_mode: Strict mode, raises exceptions when parsing fails, default False
|
||||
|
||||
Returns:
|
||||
Union[Dict[str, Any], List[Any], Any]: Parsed data that can be serialized by json.dumps:
|
||||
- Successfully parsed JSON/Python literal data
|
||||
- Original dict/list objects (passed through)
|
||||
- Primitive types (int, float, bool, str)
|
||||
- {"raw": content} when unable to parse but fallback_to_raw=True
|
||||
- {"error": "error_message"} when parsing fails and fallback_to_raw=False
|
||||
- {} for None input
|
||||
|
||||
Raises:
|
||||
ValueError: When strict_mode=True and parsing fails
|
||||
TypeError: When content type is unsupported and strict_mode=True
|
||||
|
||||
Examples:
|
||||
>>> parse_tool_content('{"status": "success", "data": [1, 2, 3]}')
|
||||
{'status': 'success', 'data': [1, 2, 3]}
|
||||
|
||||
>>> parse_tool_content({"status": "success"})
|
||||
{'status': 'success'}
|
||||
|
||||
>>> parse_tool_content([1, 2, 3])
|
||||
[1, 2, 3]
|
||||
|
||||
>>> parse_tool_content(42)
|
||||
42
|
||||
|
||||
>>> parse_tool_content("{'name': 'PC1', 'status': 'ok'}")
|
||||
{'name': 'PC1', 'status': 'ok'}
|
||||
|
||||
>>> parse_tool_content("Invalid JSON input: ...")
|
||||
{'raw': 'Invalid JSON input: ...'}
|
||||
|
||||
>>> parse_tool_content("{}")
|
||||
{}
|
||||
|
||||
>>> parse_tool_content(None)
|
||||
{}
|
||||
"""
|
||||
# Log received input parameters
|
||||
logger.info(
|
||||
"Received parameters: fallback_to_raw=%s, strict_mode=%s, content=%s",
|
||||
fallback_to_raw,
|
||||
strict_mode,
|
||||
content,
|
||||
)
|
||||
|
||||
# Handle None input
|
||||
if content is None:
|
||||
result: dict[str, Any] = {}
|
||||
logger.info("Content is None, returning: %s", result)
|
||||
return result
|
||||
|
||||
# Handle dictionary objects (already parsed)
|
||||
if isinstance(content, dict):
|
||||
logger.info("Content is already a dictionary, returning: %s", content)
|
||||
return content
|
||||
|
||||
# Handle list objects (JSON arrays)
|
||||
if isinstance(content, list):
|
||||
logger.info("Content is already a list, returning: %s", content)
|
||||
return content
|
||||
|
||||
# Handle primitive types that are JSON serializable
|
||||
if isinstance(content, (str, int, float, bool)):
|
||||
# For strings, we need to try parsing them
|
||||
if isinstance(content, str):
|
||||
# Empty string handling
|
||||
if not content.strip():
|
||||
result = {}
|
||||
logger.info("Content is empty or whitespace, returning: %s", result)
|
||||
return result
|
||||
|
||||
s = content.strip()
|
||||
|
||||
# Handle empty dictionary case
|
||||
if s == "{}":
|
||||
result = {}
|
||||
logger.info("Content is empty dictionary, returning: %s", result)
|
||||
return result
|
||||
|
||||
# Try to parse as Python literal
|
||||
# (higher priority as many tools return Python format strings)
|
||||
try:
|
||||
result = ast.literal_eval(s)
|
||||
logger.info(
|
||||
"Successfully parsed as Python literal, returning: %s", result
|
||||
)
|
||||
return result
|
||||
except (ValueError, SyntaxError):
|
||||
pass
|
||||
|
||||
# Try to parse as JSON
|
||||
try:
|
||||
result = json.loads(s)
|
||||
logger.info("Successfully parsed as JSON, returning: %s", result)
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Handle parsing failure for strings
|
||||
error_msg = "Unable to parse content as JSON or Python literal"
|
||||
logger.warning("%s: %s", error_msg, s)
|
||||
|
||||
if strict_mode:
|
||||
raise ValueError("%s. Content: %s", error_msg, s)
|
||||
|
||||
if fallback_to_raw:
|
||||
result = {"raw": s}
|
||||
logger.info("Returning raw content as fallback: %s", result)
|
||||
return result
|
||||
result = {"error": error_msg}
|
||||
logger.info("Returning error: %s", result)
|
||||
return result
|
||||
# For non-string primitives (int, float, bool), return as-is
|
||||
logger.info(
|
||||
"Content is a primitive type %s, returning: %s",
|
||||
type(content).__name__,
|
||||
content,
|
||||
)
|
||||
return content
|
||||
|
||||
# Handle unsupported types
|
||||
error_msg = ( # type: ignore[unreachable]
|
||||
"Content must be str, dict, list, int, float, bool, or None, got "
|
||||
f"{type(content).__name__}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
|
||||
if strict_mode:
|
||||
raise TypeError(error_msg)
|
||||
|
||||
if fallback_to_raw:
|
||||
result = {"raw": str(content)}
|
||||
logger.info("Returning raw content as fallback: %s", result)
|
||||
return result
|
||||
result = {"error": error_msg}
|
||||
logger.info("Returning error: %s", result)
|
||||
return result
|
||||
|
||||
|
||||
def format_tool_response(
|
||||
content: str | dict | list | int | float | bool | None, indent: int = 2
|
||||
) -> str:
|
||||
"""
|
||||
Format tool response as a beautiful JSON string for UI display.
|
||||
|
||||
This function ensures that the output is always a valid JSON string that can be
|
||||
properly displayed in UI interfaces.
|
||||
|
||||
Args:
|
||||
content: Content returned by tools (can be str, dict, list, int, float, bool, or None)
|
||||
indent: JSON indentation spaces, default 2
|
||||
|
||||
Returns:
|
||||
str: Formatted JSON string, always valid JSON
|
||||
"""
|
||||
logger.info("format_tool_response received input content: %s", content)
|
||||
logger.info("format_tool_response parameter indent: %s", indent)
|
||||
|
||||
try:
|
||||
parsed = parse_tool_content(content, fallback_to_raw=True, strict_mode=False)
|
||||
# Ensure the result can be serialized to JSON
|
||||
result = json.dumps(parsed, ensure_ascii=False, indent=indent)
|
||||
logger.info("format_tool_response returning: %s", result)
|
||||
return result
|
||||
except (TypeError, ValueError) as e:
|
||||
# If the parsed result cannot be serialized, convert to string and wrap
|
||||
logger.error("Cannot serialize parsed result to JSON: %s", e)
|
||||
try:
|
||||
result = json.dumps(
|
||||
{"raw": str(content)}, ensure_ascii=False, indent=indent
|
||||
)
|
||||
logger.info("format_tool_response returning fallback: %s", result)
|
||||
return result
|
||||
except Exception:
|
||||
# Last resort: return a simple error message
|
||||
result = json.dumps(
|
||||
{"error": "Unable to format response"},
|
||||
ensure_ascii=False,
|
||||
indent=indent,
|
||||
)
|
||||
logger.info("format_tool_response returning error: %s", result)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error("Error formatting tool response: %s", e)
|
||||
result = json.dumps({"error": str(e)}, ensure_ascii=False, indent=indent)
|
||||
logger.info("format_tool_response returning error: %s", result)
|
||||
return result
|
||||
|
||||
|
||||
# Test function to verify the implementation
|
||||
def _test_parse_tool_content() -> None:
|
||||
"""Test function to verify parse_tool_content works correctly with all input types"""
|
||||
test_cases: list[tuple[Any, Any]] = [
|
||||
# String inputs
|
||||
(
|
||||
'{"status": "success", "data": [1, 2, 3]}',
|
||||
{"status": "success", "data": [1, 2, 3]},
|
||||
),
|
||||
("{'name': 'PC1', 'status': 'ok'}", {"name": "PC1", "status": "ok"}),
|
||||
("[1, 2, 3]", [1, 2, 3]),
|
||||
('"hello"', "hello"),
|
||||
("42", 42),
|
||||
("true", True),
|
||||
("3.14", 3.14),
|
||||
("{}", {}),
|
||||
(" {} ", {}),
|
||||
("", {}),
|
||||
(" ", {}),
|
||||
("Invalid JSON input", {"raw": "Invalid JSON input"}),
|
||||
# Direct object inputs
|
||||
({"status": "success"}, {"status": "success"}),
|
||||
([1, 2, 3], [1, 2, 3]),
|
||||
("hello", "hello"),
|
||||
(42, 42),
|
||||
(True, True),
|
||||
(3.14, 3.14),
|
||||
(None, {}),
|
||||
]
|
||||
|
||||
print("Testing parse_tool_content function:")
|
||||
for i, (input_data, expected) in enumerate(test_cases):
|
||||
result = parse_tool_content(input_data)
|
||||
status = "✓" if result == expected else "✗"
|
||||
print(f"Test {i + 1}: {status} Input: {repr(input_data)} -> {result}")
|
||||
|
||||
print("\nTesting format_tool_response function:")
|
||||
format_tests = [
|
||||
'{"status": "success"}',
|
||||
"{}",
|
||||
None,
|
||||
"Invalid input",
|
||||
{"direct": "dict"},
|
||||
[1, 2, 3],
|
||||
42,
|
||||
True,
|
||||
]
|
||||
|
||||
for i, input_data in enumerate(format_tests):
|
||||
result = format_tool_response(input_data)
|
||||
# Verify it's valid JSON
|
||||
try:
|
||||
json.loads(result)
|
||||
valid = "✓"
|
||||
except Exception:
|
||||
valid = "✗"
|
||||
print(f"Format Test {i + 1}: {valid} Input: {repr(input_data)} -> {result}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_test_parse_tool_content()
|
||||
Reference in new issue
Block a user