Sessions now track who created them (Human vs Agent with name), optional descriptions, and creation timestamps. Agents can create and update sessions via WebSocket and MCP. ListSessions supports sorting by name, created_at, created_by, or element_count. New MCP tools: create_session, update_session. Updated list_sessions with sort_by/sort_order params. Tab bar shows robot icon for agent-created sessions with hover tooltips.
559 lines
21 KiB
Rust
559 lines
21 KiB
Rust
use crate::bridge::send_request;
|
|
use rmcp::{
|
|
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
|
|
model::*,
|
|
schemars, tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler,
|
|
};
|
|
|
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
|
pub struct SessionIdParam {
|
|
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
|
pub session_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
|
pub struct ListSessionsParam {
|
|
#[schemars(
|
|
description = "Sort field: 'name', 'created_at' (default), 'created_by', or 'element_count'"
|
|
)]
|
|
pub sort_by: Option<String>,
|
|
#[schemars(description = "Sort order: 'asc' (default) or 'desc'")]
|
|
pub sort_order: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
|
pub struct CreateSessionParam {
|
|
#[schemars(description = "Session name. If omitted, auto-generated.")]
|
|
pub name: Option<String>,
|
|
#[schemars(description = "Session description.")]
|
|
pub description: Option<String>,
|
|
#[schemars(description = "Name of the agent creating the session (identifies the creator).")]
|
|
pub created_by_name: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
|
pub struct UpdateSessionParam {
|
|
#[schemars(description = "ID of the session to update.")]
|
|
pub session_id: String,
|
|
#[schemars(description = "New session name.")]
|
|
pub name: Option<String>,
|
|
#[schemars(description = "New session description.")]
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
|
pub struct GetElementParam {
|
|
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
|
pub session_id: Option<String>,
|
|
#[schemars(description = "Element ID to look up")]
|
|
pub id: String,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
|
pub struct GetElementsAtPointParam {
|
|
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
|
pub session_id: Option<String>,
|
|
#[schemars(description = "X coordinate in canvas space")]
|
|
pub x: f32,
|
|
#[schemars(description = "Y coordinate in canvas space")]
|
|
pub y: f32,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
|
pub struct GenerateCodeParam {
|
|
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
|
pub session_id: Option<String>,
|
|
#[schemars(description = "Code generation target: html, react, tailwind, svelte, or vue")]
|
|
pub target: String,
|
|
#[schemars(
|
|
description = "Element ID to generate code for. If omitted, generates for the entire tree."
|
|
)]
|
|
pub element_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
|
pub struct CreateDrawingElementParam {
|
|
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
|
pub session_id: Option<String>,
|
|
#[schemars(description = "Shape type: Rectangle, Ellipse, Line, Arrow, or Text")]
|
|
pub shape_type: String,
|
|
#[schemars(
|
|
description = "X position (Rectangle/Text top-left X, or Line/Arrow start X via x1)"
|
|
)]
|
|
pub x: Option<f32>,
|
|
#[schemars(
|
|
description = "Y position (Rectangle/Text top-left Y, or Line/Arrow start Y via y1)"
|
|
)]
|
|
pub y: Option<f32>,
|
|
#[schemars(description = "Width (Rectangle only)")]
|
|
pub width: Option<f32>,
|
|
#[schemars(description = "Height (Rectangle only)")]
|
|
pub height: Option<f32>,
|
|
#[schemars(description = "Center X (Ellipse only; falls back to x)")]
|
|
pub center_x: Option<f32>,
|
|
#[schemars(description = "Center Y (Ellipse only; falls back to y)")]
|
|
pub center_y: Option<f32>,
|
|
#[schemars(description = "X radius (Ellipse only, default 50)")]
|
|
pub radius_x: Option<f32>,
|
|
#[schemars(description = "Y radius (Ellipse only, default 50)")]
|
|
pub radius_y: Option<f32>,
|
|
#[schemars(description = "Start X (Line/Arrow; falls back to x)")]
|
|
pub x1: Option<f32>,
|
|
#[schemars(description = "Start Y (Line/Arrow; falls back to y)")]
|
|
pub y1: Option<f32>,
|
|
#[schemars(description = "End X (Line/Arrow)")]
|
|
pub x2: Option<f32>,
|
|
#[schemars(description = "End Y (Line/Arrow)")]
|
|
pub y2: Option<f32>,
|
|
#[schemars(description = "Text content (Text shape only)")]
|
|
pub text: Option<String>,
|
|
#[schemars(description = "Font size in pixels (Text shape, default 20)")]
|
|
pub font_size: Option<f32>,
|
|
#[schemars(description = "Fill color as hex e.g. '#ff0000', or null for no fill")]
|
|
pub fill: Option<String>,
|
|
#[schemars(description = "Stroke color as hex e.g. '#ffffff' (default white)")]
|
|
pub stroke_color: Option<String>,
|
|
#[schemars(description = "Stroke width in pixels (default 2.0)")]
|
|
pub stroke_width: Option<f32>,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
|
pub struct UpdateDrawingElementParam {
|
|
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
|
pub session_id: Option<String>,
|
|
#[schemars(description = "ID of the drawing element to update")]
|
|
pub id: String,
|
|
#[schemars(description = "New shape type (provide with coordinates to replace shape)")]
|
|
pub shape_type: Option<String>,
|
|
#[schemars(description = "X position")]
|
|
pub x: Option<f32>,
|
|
#[schemars(description = "Y position")]
|
|
pub y: Option<f32>,
|
|
#[schemars(description = "Width (Rectangle)")]
|
|
pub width: Option<f32>,
|
|
#[schemars(description = "Height (Rectangle)")]
|
|
pub height: Option<f32>,
|
|
#[schemars(description = "Center X (Ellipse)")]
|
|
pub center_x: Option<f32>,
|
|
#[schemars(description = "Center Y (Ellipse)")]
|
|
pub center_y: Option<f32>,
|
|
#[schemars(description = "X radius (Ellipse)")]
|
|
pub radius_x: Option<f32>,
|
|
#[schemars(description = "Y radius (Ellipse)")]
|
|
pub radius_y: Option<f32>,
|
|
#[schemars(description = "Start X (Line/Arrow)")]
|
|
pub x1: Option<f32>,
|
|
#[schemars(description = "Start Y (Line/Arrow)")]
|
|
pub y1: Option<f32>,
|
|
#[schemars(description = "End X (Line/Arrow)")]
|
|
pub x2: Option<f32>,
|
|
#[schemars(description = "End Y (Line/Arrow)")]
|
|
pub y2: Option<f32>,
|
|
#[schemars(description = "Text content (Text shape)")]
|
|
pub text: Option<String>,
|
|
#[schemars(description = "Font size (Text shape)")]
|
|
pub font_size: Option<f32>,
|
|
#[schemars(description = "Fill color as hex e.g. '#ff0000'")]
|
|
pub fill: Option<String>,
|
|
#[schemars(description = "Stroke color as hex e.g. '#ffffff'")]
|
|
pub stroke_color: Option<String>,
|
|
#[schemars(description = "Stroke width in pixels")]
|
|
pub stroke_width: Option<f32>,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
|
pub struct DeleteDrawingElementParam {
|
|
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
|
pub session_id: Option<String>,
|
|
#[schemars(description = "ID of the drawing element to delete")]
|
|
pub id: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct AgCanvasServer {
|
|
ws_url: String,
|
|
tool_router: ToolRouter<Self>,
|
|
}
|
|
|
|
#[tool_router]
|
|
impl AgCanvasServer {
|
|
pub fn new(ws_url: String) -> Self {
|
|
Self {
|
|
ws_url,
|
|
tool_router: Self::tool_router(),
|
|
}
|
|
}
|
|
|
|
#[tool(
|
|
description = "List all open sessions/tabs in agcanvas. Returns session IDs, names, creator info, description, timestamps, and whether they have SVG or drawing content loaded. Supports sorting."
|
|
)]
|
|
async fn list_sessions(
|
|
&self,
|
|
Parameters(params): Parameters<ListSessionsParam>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
let mut request = serde_json::json!({"type": "ListSessions"});
|
|
let obj = request.as_object_mut().unwrap();
|
|
if let Some(v) = params.sort_by {
|
|
obj.insert("sort_by".into(), v.into());
|
|
}
|
|
if let Some(v) = params.sort_order {
|
|
obj.insert("sort_order".into(), v.into());
|
|
}
|
|
self.call_agcanvas(&request).await
|
|
}
|
|
|
|
#[tool(
|
|
description = "Create a new session/tab in agcanvas. The session is created by an agent. Returns the created session with its ID and metadata."
|
|
)]
|
|
async fn create_session(
|
|
&self,
|
|
Parameters(params): Parameters<CreateSessionParam>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
let mut request = serde_json::json!({"type": "CreateSession"});
|
|
let obj = request.as_object_mut().unwrap();
|
|
if let Some(v) = params.name {
|
|
obj.insert("name".into(), v.into());
|
|
}
|
|
if let Some(v) = params.description {
|
|
obj.insert("description".into(), v.into());
|
|
}
|
|
if let Some(v) = params.created_by_name {
|
|
obj.insert("created_by_name".into(), v.into());
|
|
}
|
|
self.call_agcanvas(&request).await
|
|
}
|
|
|
|
#[tool(
|
|
description = "Update an existing session's name or description. Only provided fields are changed."
|
|
)]
|
|
async fn update_session(
|
|
&self,
|
|
Parameters(params): Parameters<UpdateSessionParam>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
let mut request = serde_json::json!({
|
|
"type": "UpdateSession",
|
|
"session_id": params.session_id,
|
|
});
|
|
let obj = request.as_object_mut().unwrap();
|
|
if let Some(v) = params.name {
|
|
obj.insert("name".into(), v.into());
|
|
}
|
|
if let Some(v) = params.description {
|
|
obj.insert("description".into(), v.into());
|
|
}
|
|
self.call_agcanvas(&request).await
|
|
}
|
|
|
|
#[tool(
|
|
description = "Get the full parsed SVG element tree from the canvas. Returns a hierarchical JSON structure with element types (Group, Rectangle, Circle, Path, Text, etc.), bounds, styles, and children. This is the primary way to understand what design is on the canvas."
|
|
)]
|
|
async fn get_element_tree(
|
|
&self,
|
|
Parameters(params): Parameters<SessionIdParam>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
let mut request = serde_json::json!({"type": "GetTree"});
|
|
if let Some(sid) = params.session_id {
|
|
request["session_id"] = serde_json::Value::String(sid);
|
|
}
|
|
self.call_agcanvas(&request).await
|
|
}
|
|
|
|
#[tool(
|
|
description = "Get a human-readable semantic description of the canvas content. Returns structured text describing the element hierarchy with types, dimensions, and colors. Useful for quickly understanding a design without parsing JSON."
|
|
)]
|
|
async fn describe_canvas(
|
|
&self,
|
|
Parameters(params): Parameters<SessionIdParam>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
let mut request = serde_json::json!({"type": "Describe"});
|
|
if let Some(sid) = params.session_id {
|
|
request["session_id"] = serde_json::Value::String(sid);
|
|
}
|
|
self.call_agcanvas(&request).await
|
|
}
|
|
|
|
#[tool(description = "Get a specific element by its ID from the SVG element tree.")]
|
|
async fn get_element_by_id(
|
|
&self,
|
|
Parameters(params): Parameters<GetElementParam>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
let mut request = serde_json::json!({"type": "GetElementById", "id": params.id});
|
|
if let Some(sid) = params.session_id {
|
|
request["session_id"] = serde_json::Value::String(sid);
|
|
}
|
|
self.call_agcanvas(&request).await
|
|
}
|
|
|
|
#[tool(
|
|
description = "Query which elements exist at a specific (x, y) coordinate on the canvas."
|
|
)]
|
|
async fn get_elements_at_point(
|
|
&self,
|
|
Parameters(params): Parameters<GetElementsAtPointParam>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
let mut request =
|
|
serde_json::json!({"type": "GetElementsAtPoint", "x": params.x, "y": params.y});
|
|
if let Some(sid) = params.session_id {
|
|
request["session_id"] = serde_json::Value::String(sid);
|
|
}
|
|
self.call_agcanvas(&request).await
|
|
}
|
|
|
|
#[tool(
|
|
description = "Get all user-drawn shapes (rectangles, ellipses, lines, arrows, text) from the drawing layer."
|
|
)]
|
|
async fn get_drawing_elements(
|
|
&self,
|
|
Parameters(params): Parameters<SessionIdParam>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
let mut request = serde_json::json!({"type": "GetDrawingElements"});
|
|
if let Some(sid) = params.session_id {
|
|
request["session_id"] = serde_json::Value::String(sid);
|
|
}
|
|
self.call_agcanvas(&request).await
|
|
}
|
|
|
|
#[tool(
|
|
description = "Generate a code stub from the SVG structure. Targets: html, react, tailwind, svelte, vue. Returns a starting template based on the element hierarchy."
|
|
)]
|
|
async fn generate_code(
|
|
&self,
|
|
Parameters(params): Parameters<GenerateCodeParam>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
let mut request = serde_json::json!({
|
|
"type": "GenerateCode",
|
|
"target": params.target,
|
|
"element_id": params.element_id,
|
|
});
|
|
if let Some(sid) = params.session_id {
|
|
request["session_id"] = serde_json::Value::String(sid);
|
|
}
|
|
self.call_agcanvas(&request).await
|
|
}
|
|
|
|
#[tool(
|
|
description = "Create a drawing element on the canvas. Shape types: Rectangle (x,y,width,height), Ellipse (center_x,center_y,radius_x,radius_y), Line (x1,y1,x2,y2), Arrow (x1,y1,x2,y2), Text (x,y,text,font_size). Colors are hex strings like '#ff0000'. Returns the created element with its auto-generated ID."
|
|
)]
|
|
async fn create_drawing_element(
|
|
&self,
|
|
Parameters(params): Parameters<CreateDrawingElementParam>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
let mut request = serde_json::json!({
|
|
"type": "CreateDrawingElement",
|
|
"shape_type": params.shape_type,
|
|
});
|
|
let obj = request.as_object_mut().unwrap();
|
|
if let Some(v) = params.session_id {
|
|
obj.insert("session_id".into(), v.into());
|
|
}
|
|
if let Some(v) = params.x {
|
|
obj.insert("x".into(), v.into());
|
|
}
|
|
if let Some(v) = params.y {
|
|
obj.insert("y".into(), v.into());
|
|
}
|
|
if let Some(v) = params.width {
|
|
obj.insert("width".into(), v.into());
|
|
}
|
|
if let Some(v) = params.height {
|
|
obj.insert("height".into(), v.into());
|
|
}
|
|
if let Some(v) = params.center_x {
|
|
obj.insert("center_x".into(), v.into());
|
|
}
|
|
if let Some(v) = params.center_y {
|
|
obj.insert("center_y".into(), v.into());
|
|
}
|
|
if let Some(v) = params.radius_x {
|
|
obj.insert("radius_x".into(), v.into());
|
|
}
|
|
if let Some(v) = params.radius_y {
|
|
obj.insert("radius_y".into(), v.into());
|
|
}
|
|
if let Some(v) = params.x1 {
|
|
obj.insert("x1".into(), v.into());
|
|
}
|
|
if let Some(v) = params.y1 {
|
|
obj.insert("y1".into(), v.into());
|
|
}
|
|
if let Some(v) = params.x2 {
|
|
obj.insert("x2".into(), v.into());
|
|
}
|
|
if let Some(v) = params.y2 {
|
|
obj.insert("y2".into(), v.into());
|
|
}
|
|
if let Some(v) = params.text {
|
|
obj.insert("text".into(), v.into());
|
|
}
|
|
if let Some(v) = params.font_size {
|
|
obj.insert("font_size".into(), v.into());
|
|
}
|
|
if let Some(v) = params.fill {
|
|
obj.insert("fill".into(), v.into());
|
|
}
|
|
if let Some(v) = params.stroke_color {
|
|
obj.insert("stroke_color".into(), v.into());
|
|
}
|
|
if let Some(v) = params.stroke_width {
|
|
obj.insert("stroke_width".into(), v.into());
|
|
}
|
|
self.call_agcanvas(&request).await
|
|
}
|
|
|
|
#[tool(
|
|
description = "Update an existing drawing element. Provide shape_type + coordinates to replace the shape. Provide fill/stroke_color/stroke_width to update the style. Only provided fields are changed."
|
|
)]
|
|
async fn update_drawing_element(
|
|
&self,
|
|
Parameters(params): Parameters<UpdateDrawingElementParam>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
let mut request = serde_json::json!({
|
|
"type": "UpdateDrawingElement",
|
|
"id": params.id,
|
|
});
|
|
let obj = request.as_object_mut().unwrap();
|
|
if let Some(v) = params.session_id {
|
|
obj.insert("session_id".into(), v.into());
|
|
}
|
|
if let Some(v) = params.shape_type {
|
|
obj.insert("shape_type".into(), v.into());
|
|
}
|
|
if let Some(v) = params.x {
|
|
obj.insert("x".into(), v.into());
|
|
}
|
|
if let Some(v) = params.y {
|
|
obj.insert("y".into(), v.into());
|
|
}
|
|
if let Some(v) = params.width {
|
|
obj.insert("width".into(), v.into());
|
|
}
|
|
if let Some(v) = params.height {
|
|
obj.insert("height".into(), v.into());
|
|
}
|
|
if let Some(v) = params.center_x {
|
|
obj.insert("center_x".into(), v.into());
|
|
}
|
|
if let Some(v) = params.center_y {
|
|
obj.insert("center_y".into(), v.into());
|
|
}
|
|
if let Some(v) = params.radius_x {
|
|
obj.insert("radius_x".into(), v.into());
|
|
}
|
|
if let Some(v) = params.radius_y {
|
|
obj.insert("radius_y".into(), v.into());
|
|
}
|
|
if let Some(v) = params.x1 {
|
|
obj.insert("x1".into(), v.into());
|
|
}
|
|
if let Some(v) = params.y1 {
|
|
obj.insert("y1".into(), v.into());
|
|
}
|
|
if let Some(v) = params.x2 {
|
|
obj.insert("x2".into(), v.into());
|
|
}
|
|
if let Some(v) = params.y2 {
|
|
obj.insert("y2".into(), v.into());
|
|
}
|
|
if let Some(v) = params.text {
|
|
obj.insert("text".into(), v.into());
|
|
}
|
|
if let Some(v) = params.font_size {
|
|
obj.insert("font_size".into(), v.into());
|
|
}
|
|
if let Some(v) = params.fill {
|
|
obj.insert("fill".into(), v.into());
|
|
}
|
|
if let Some(v) = params.stroke_color {
|
|
obj.insert("stroke_color".into(), v.into());
|
|
}
|
|
if let Some(v) = params.stroke_width {
|
|
obj.insert("stroke_width".into(), v.into());
|
|
}
|
|
self.call_agcanvas(&request).await
|
|
}
|
|
|
|
#[tool(description = "Delete a drawing element by its ID.")]
|
|
async fn delete_drawing_element(
|
|
&self,
|
|
Parameters(params): Parameters<DeleteDrawingElementParam>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
let mut request = serde_json::json!({
|
|
"type": "DeleteDrawingElement",
|
|
"id": params.id,
|
|
});
|
|
if let Some(sid) = params.session_id {
|
|
request["session_id"] = serde_json::Value::String(sid);
|
|
}
|
|
self.call_agcanvas(&request).await
|
|
}
|
|
|
|
#[tool(description = "Clear all drawing elements from the canvas.")]
|
|
async fn clear_drawing_elements(
|
|
&self,
|
|
Parameters(params): Parameters<SessionIdParam>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
let mut request = serde_json::json!({"type": "ClearDrawingElements"});
|
|
if let Some(sid) = params.session_id {
|
|
request["session_id"] = serde_json::Value::String(sid);
|
|
}
|
|
self.call_agcanvas(&request).await
|
|
}
|
|
}
|
|
|
|
impl AgCanvasServer {
|
|
async fn call_agcanvas(&self, request: &serde_json::Value) -> Result<CallToolResult, McpError> {
|
|
let request_str = serde_json::to_string(request)
|
|
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
|
|
|
match send_request(&self.ws_url, &request_str).await {
|
|
Ok(response) => {
|
|
let parsed: serde_json::Value = serde_json::from_str(&response)
|
|
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
|
|
|
let is_error =
|
|
parsed.get("type").and_then(serde_json::Value::as_str) == Some("Error");
|
|
|
|
if is_error {
|
|
let msg = parsed
|
|
.get("message")
|
|
.and_then(serde_json::Value::as_str)
|
|
.unwrap_or("Unknown error from agcanvas");
|
|
return Ok(CallToolResult::error(vec![Content::text(msg)]));
|
|
}
|
|
|
|
let pretty =
|
|
serde_json::to_string_pretty(&parsed).unwrap_or_else(|_| response.clone());
|
|
Ok(CallToolResult::success(vec![Content::text(pretty)]))
|
|
}
|
|
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
|
|
"Failed to communicate with agcanvas: {}. Make sure agcanvas is running.",
|
|
e
|
|
))])),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tool_handler]
|
|
impl ServerHandler for AgCanvasServer {
|
|
fn get_info(&self) -> ServerInfo {
|
|
ServerInfo {
|
|
instructions: Some(
|
|
"agcanvas MCP server — connects to agcanvas desktop app to query SVG designs, \
|
|
element trees, and user-drawn shapes. Use describe_canvas to understand the \
|
|
current design, get_element_tree for structured data, and generate_code for \
|
|
code stubs. Requires agcanvas to be running."
|
|
.into(),
|
|
),
|
|
capabilities: ServerCapabilities::builder().enable_tools().build(),
|
|
server_info: Implementation {
|
|
name: "agcanvas-mcp".into(),
|
|
version: env!("CARGO_PKG_VERSION").into(),
|
|
title: None,
|
|
icons: None,
|
|
website_url: None,
|
|
},
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|