Add agent drawing commands, session management, and MCP write tools
- Add CreateDrawingElement, UpdateDrawingElement, DeleteDrawingElement, ClearDrawingElements to WebSocket protocol and MCP bridge - Add multi-session/tab support with SessionStore shared state - Add bidirectional agent-GUI sync via broadcast + mpsc channels - Update README with correct OpenCode MCP config format and new tools - Fix dead code warning, clean up gitignore
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,8 @@
|
||||
/target
|
||||
crates/*/target
|
||||
Cargo.lock
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
opencode.jsonc
|
||||
.opencode
|
||||
|
||||
54
README.md
54
README.md
@@ -58,6 +58,43 @@ This builds two binaries:
|
||||
- `target/release/agcanvas` — The desktop app
|
||||
- `target/release/agcanvas-mcp` — The MCP server bridge
|
||||
|
||||
### Install to PATH
|
||||
|
||||
After building, symlink (or copy) the binaries so they're available system-wide:
|
||||
|
||||
**macOS / Linux:**
|
||||
|
||||
```bash
|
||||
sudo ln -sf "$(pwd)/target/release/agcanvas" /usr/local/bin/agcanvas
|
||||
sudo ln -sf "$(pwd)/target/release/agcanvas-mcp" /usr/local/bin/agcanvas-mcp
|
||||
```
|
||||
|
||||
Or install to a user-local directory (no sudo):
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.local/bin
|
||||
ln -sf "$(pwd)/target/release/agcanvas" ~/.local/bin/agcanvas
|
||||
ln -sf "$(pwd)/target/release/agcanvas-mcp" ~/.local/bin/agcanvas-mcp
|
||||
```
|
||||
|
||||
> Make sure `~/.local/bin` is in your `PATH`. Add `export PATH="$HOME/.local/bin:$PATH"` to your `~/.zshrc` or `~/.bashrc` if needed.
|
||||
|
||||
**Windows (PowerShell, run as Administrator):**
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType SymbolicLink -Path "$env:USERPROFILE\.local\bin\agcanvas.exe" -Target "$(Get-Location)\target\release\agcanvas.exe" -Force
|
||||
New-Item -ItemType SymbolicLink -Path "$env:USERPROFILE\.local\bin\agcanvas-mcp.exe" -Target "$(Get-Location)\target\release\agcanvas-mcp.exe" -Force
|
||||
```
|
||||
|
||||
> Make sure `%USERPROFILE%\.local\bin` is in your system `PATH`. Or use an existing PATH directory like `C:\Users\<you>\AppData\Local\Microsoft\WindowsApps`.
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
agcanvas --help
|
||||
agcanvas-mcp --help
|
||||
```
|
||||
|
||||
### Requirements
|
||||
|
||||
- Rust 1.70+
|
||||
@@ -118,14 +155,15 @@ Add to your project's `.mcp.json` (or `~/.claude/mcp.json` for global):
|
||||
|
||||
### Setup for OpenCode
|
||||
|
||||
Add to your `opencode.json`:
|
||||
Add to your `opencode.json` (project-level) or `~/.config/opencode/opencode.json` (global):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mcp": {
|
||||
"agcanvas": {
|
||||
"command": "agcanvas-mcp",
|
||||
"args": ["--port", "9876"]
|
||||
"type": "local",
|
||||
"command": ["agcanvas-mcp", "--port", "9876"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,9 +173,7 @@ Add to your `opencode.json`:
|
||||
|
||||
Same MCP config format — add the `agcanvas` entry to your Codex MCP configuration.
|
||||
|
||||
> **Note:** Make sure `agcanvas-mcp` is in your PATH, or use the full path to the binary (e.g., `/path/to/target/release/agcanvas-mcp`). agcanvas must be running for the MCP tools to work.
|
||||
|
||||
See [`examples/mcp-configs/`](examples/mcp-configs/) for ready-to-copy configuration files.
|
||||
> **Note:** Make sure `agcanvas-mcp` is in your PATH (e.g., `~/.local/bin`), or use the full path to the binary. agcanvas must be running for the MCP tools to work.
|
||||
|
||||
### MCP Tools
|
||||
|
||||
@@ -150,6 +186,10 @@ See [`examples/mcp-configs/`](examples/mcp-configs/) for ready-to-copy configura
|
||||
| `get_elements_at_point` | Find elements at an (x, y) coordinate |
|
||||
| `get_drawing_elements` | Get all user-drawn shapes (rects, ellipses, lines, arrows, text) |
|
||||
| `generate_code` | Generate code stubs (html, react, tailwind, svelte, vue) |
|
||||
| `create_drawing_element` | Create a shape on the canvas (Rectangle, Ellipse, Line, Arrow, Text) |
|
||||
| `update_drawing_element` | Update an existing drawing element's shape or style |
|
||||
| `delete_drawing_element` | Delete a drawing element by ID |
|
||||
| `clear_drawing_elements` | Clear all drawing elements from the canvas |
|
||||
|
||||
All tools accept an optional `session_id` parameter. If omitted, the active session is used.
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use crate::bridge::send_request;
|
||||
use rmcp::{
|
||||
ErrorData as McpError, ServerHandler,
|
||||
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
|
||||
model::*,
|
||||
schemars, tool, tool_handler, tool_router,
|
||||
schemars, tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler,
|
||||
};
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
@@ -36,10 +35,110 @@ pub struct GenerateCodeParam {
|
||||
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.")]
|
||||
#[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,
|
||||
@@ -55,13 +154,17 @@ impl AgCanvasServer {
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(description = "List all open sessions/tabs in agcanvas. Returns session IDs, names, and whether they have SVG or drawing content loaded.")]
|
||||
#[tool(
|
||||
description = "List all open sessions/tabs in agcanvas. Returns session IDs, names, and whether they have SVG or drawing content loaded."
|
||||
)]
|
||||
async fn list_sessions(&self) -> Result<CallToolResult, McpError> {
|
||||
let request = serde_json::json!({"type": "ListSessions"});
|
||||
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.")]
|
||||
#[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>,
|
||||
@@ -73,7 +176,9 @@ impl AgCanvasServer {
|
||||
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.")]
|
||||
#[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>,
|
||||
@@ -97,7 +202,9 @@ impl AgCanvasServer {
|
||||
self.call_agcanvas(&request).await
|
||||
}
|
||||
|
||||
#[tool(description = "Query which elements exist at a specific (x, y) coordinate on the canvas.")]
|
||||
#[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>,
|
||||
@@ -110,7 +217,9 @@ impl AgCanvasServer {
|
||||
self.call_agcanvas(&request).await
|
||||
}
|
||||
|
||||
#[tool(description = "Get all user-drawn shapes (rectangles, ellipses, lines, arrows, text) from the drawing layer.")]
|
||||
#[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>,
|
||||
@@ -122,7 +231,9 @@ impl AgCanvasServer {
|
||||
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.")]
|
||||
#[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>,
|
||||
@@ -137,13 +248,178 @@ impl AgCanvasServer {
|
||||
}
|
||||
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> {
|
||||
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))?;
|
||||
|
||||
@@ -152,10 +428,8 @@ impl AgCanvasServer {
|
||||
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");
|
||||
let is_error =
|
||||
parsed.get("type").and_then(serde_json::Value::as_str) == Some("Error");
|
||||
|
||||
if is_error {
|
||||
let msg = parsed
|
||||
@@ -165,8 +439,8 @@ impl AgCanvasServer {
|
||||
return Ok(CallToolResult::error(vec![Content::text(msg)]));
|
||||
}
|
||||
|
||||
let pretty = serde_json::to_string_pretty(&parsed)
|
||||
.unwrap_or_else(|_| response.clone());
|
||||
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!(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
mod protocol;
|
||||
mod server;
|
||||
|
||||
pub use protocol::GuiEvent;
|
||||
pub use protocol::{DrawingCommand, GuiEvent};
|
||||
pub use server::AgentServer;
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
use crate::drawing::DrawingElement;
|
||||
use crate::drawing::{DrawingElement, Shape, ShapeStyle};
|
||||
use crate::element_tree::{ElementTree, TreeMetadata};
|
||||
use crate::session::SessionInfo;
|
||||
use egui::{Color32, Pos2, Vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GUI → Agent events (broadcast to all connected agents)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum GuiEvent {
|
||||
@@ -27,8 +32,27 @@ pub enum GuiEvent {
|
||||
SvgCleared {
|
||||
session_id: String,
|
||||
},
|
||||
DrawingElementCreated {
|
||||
session_id: String,
|
||||
element: DrawingElement,
|
||||
},
|
||||
DrawingElementUpdated {
|
||||
session_id: String,
|
||||
element: DrawingElement,
|
||||
},
|
||||
DrawingElementDeleted {
|
||||
session_id: String,
|
||||
id: String,
|
||||
},
|
||||
DrawingElementsCleared {
|
||||
session_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent → Server requests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum AgentRequest {
|
||||
@@ -62,9 +86,105 @@ pub enum AgentRequest {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
},
|
||||
|
||||
// ---- Drawing mutations ----
|
||||
CreateDrawingElement {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
shape_type: String,
|
||||
#[serde(default)]
|
||||
x: Option<f32>,
|
||||
#[serde(default)]
|
||||
y: Option<f32>,
|
||||
#[serde(default)]
|
||||
width: Option<f32>,
|
||||
#[serde(default)]
|
||||
height: Option<f32>,
|
||||
#[serde(default)]
|
||||
center_x: Option<f32>,
|
||||
#[serde(default)]
|
||||
center_y: Option<f32>,
|
||||
#[serde(default)]
|
||||
radius_x: Option<f32>,
|
||||
#[serde(default)]
|
||||
radius_y: Option<f32>,
|
||||
#[serde(default)]
|
||||
x1: Option<f32>,
|
||||
#[serde(default)]
|
||||
y1: Option<f32>,
|
||||
#[serde(default)]
|
||||
x2: Option<f32>,
|
||||
#[serde(default)]
|
||||
y2: Option<f32>,
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
#[serde(default)]
|
||||
font_size: Option<f32>,
|
||||
#[serde(default)]
|
||||
fill: Option<String>,
|
||||
#[serde(default)]
|
||||
stroke_color: Option<String>,
|
||||
#[serde(default)]
|
||||
stroke_width: Option<f32>,
|
||||
},
|
||||
UpdateDrawingElement {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
shape_type: Option<String>,
|
||||
#[serde(default)]
|
||||
x: Option<f32>,
|
||||
#[serde(default)]
|
||||
y: Option<f32>,
|
||||
#[serde(default)]
|
||||
width: Option<f32>,
|
||||
#[serde(default)]
|
||||
height: Option<f32>,
|
||||
#[serde(default)]
|
||||
center_x: Option<f32>,
|
||||
#[serde(default)]
|
||||
center_y: Option<f32>,
|
||||
#[serde(default)]
|
||||
radius_x: Option<f32>,
|
||||
#[serde(default)]
|
||||
radius_y: Option<f32>,
|
||||
#[serde(default)]
|
||||
x1: Option<f32>,
|
||||
#[serde(default)]
|
||||
y1: Option<f32>,
|
||||
#[serde(default)]
|
||||
x2: Option<f32>,
|
||||
#[serde(default)]
|
||||
y2: Option<f32>,
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
#[serde(default)]
|
||||
font_size: Option<f32>,
|
||||
#[serde(default)]
|
||||
fill: Option<String>,
|
||||
#[serde(default)]
|
||||
stroke_color: Option<String>,
|
||||
#[serde(default)]
|
||||
stroke_width: Option<f32>,
|
||||
},
|
||||
DeleteDrawingElement {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
id: String,
|
||||
},
|
||||
ClearDrawingElements {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
},
|
||||
|
||||
Ping,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server → Agent responses
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum AgentResponse {
|
||||
@@ -97,12 +217,54 @@ pub enum AgentResponse {
|
||||
session_id: String,
|
||||
elements: Vec<DrawingElement>,
|
||||
},
|
||||
DrawingElementCreated {
|
||||
session_id: String,
|
||||
element: DrawingElement,
|
||||
},
|
||||
DrawingElementUpdated {
|
||||
session_id: String,
|
||||
element: DrawingElement,
|
||||
},
|
||||
DrawingElementDeleted {
|
||||
session_id: String,
|
||||
id: String,
|
||||
},
|
||||
DrawingElementsCleared {
|
||||
session_id: String,
|
||||
},
|
||||
Pong,
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent → GUI drawing commands (reverse sync channel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DrawingCommand {
|
||||
Create {
|
||||
session_id: String,
|
||||
element: DrawingElement,
|
||||
},
|
||||
Update {
|
||||
session_id: String,
|
||||
element: DrawingElement,
|
||||
},
|
||||
Delete {
|
||||
session_id: String,
|
||||
id: String,
|
||||
},
|
||||
Clear {
|
||||
session_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Code generation targets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CodeGenTarget {
|
||||
@@ -124,3 +286,106 @@ impl std::fmt::Display for CodeGenTarget {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversion: flat agent-friendly params → internal drawing types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse a hex color string like "#ff0000" or "#f00" into a Color32.
|
||||
pub fn parse_hex_color(hex: &str) -> Option<Color32> {
|
||||
let hex = hex.trim_start_matches('#');
|
||||
match hex.len() {
|
||||
3 => {
|
||||
let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
|
||||
let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
|
||||
let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
|
||||
Some(Color32::from_rgb(r, g, b))
|
||||
}
|
||||
6 => {
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
||||
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
||||
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
||||
Some(Color32::from_rgb(r, g, b))
|
||||
}
|
||||
8 => {
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
||||
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
||||
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
||||
let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
|
||||
Some(Color32::from_rgba_unmultiplied(r, g, b, a))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `Shape` from flat agent-friendly parameters.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_shape(
|
||||
shape_type: &str,
|
||||
x: Option<f32>,
|
||||
y: Option<f32>,
|
||||
width: Option<f32>,
|
||||
height: Option<f32>,
|
||||
center_x: Option<f32>,
|
||||
center_y: Option<f32>,
|
||||
radius_x: Option<f32>,
|
||||
radius_y: Option<f32>,
|
||||
x1: Option<f32>,
|
||||
y1: Option<f32>,
|
||||
x2: Option<f32>,
|
||||
y2: Option<f32>,
|
||||
text: Option<String>,
|
||||
font_size: Option<f32>,
|
||||
) -> Result<Shape, String> {
|
||||
match shape_type {
|
||||
"Rectangle" | "rectangle" | "rect" | "Rect" => Ok(Shape::Rectangle {
|
||||
pos: Pos2::new(x.unwrap_or(0.0), y.unwrap_or(0.0)),
|
||||
size: Vec2::new(width.unwrap_or(100.0), height.unwrap_or(100.0)),
|
||||
}),
|
||||
"Ellipse" | "ellipse" => Ok(Shape::Ellipse {
|
||||
center: Pos2::new(center_x.or(x).unwrap_or(0.0), center_y.or(y).unwrap_or(0.0)),
|
||||
radii: Vec2::new(radius_x.unwrap_or(50.0), radius_y.unwrap_or(50.0)),
|
||||
}),
|
||||
"Line" | "line" => {
|
||||
let sx = x1.or(x).unwrap_or(0.0);
|
||||
let sy = y1.or(y).unwrap_or(0.0);
|
||||
Ok(Shape::Line {
|
||||
start: Pos2::new(sx, sy),
|
||||
end: Pos2::new(x2.unwrap_or(sx + 100.0), y2.unwrap_or(sy)),
|
||||
})
|
||||
}
|
||||
"Arrow" | "arrow" => {
|
||||
let sx = x1.or(x).unwrap_or(0.0);
|
||||
let sy = y1.or(y).unwrap_or(0.0);
|
||||
Ok(Shape::Arrow {
|
||||
start: Pos2::new(sx, sy),
|
||||
end: Pos2::new(x2.unwrap_or(sx + 100.0), y2.unwrap_or(sy)),
|
||||
})
|
||||
}
|
||||
"Text" | "text" => Ok(Shape::Text {
|
||||
pos: Pos2::new(x.unwrap_or(0.0), y.unwrap_or(0.0)),
|
||||
content: text.unwrap_or_else(|| "Text".to_string()),
|
||||
font_size: font_size.unwrap_or(20.0),
|
||||
}),
|
||||
other => Err(format!(
|
||||
"Unknown shape_type '{}'. Expected: Rectangle, Ellipse, Line, Arrow, or Text",
|
||||
other
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `ShapeStyle` from optional hex color strings.
|
||||
pub fn build_style(
|
||||
fill: Option<String>,
|
||||
stroke_color: Option<String>,
|
||||
stroke_width: Option<f32>,
|
||||
) -> ShapeStyle {
|
||||
ShapeStyle {
|
||||
fill: fill.as_deref().and_then(parse_hex_color),
|
||||
stroke_color: stroke_color
|
||||
.as_deref()
|
||||
.and_then(parse_hex_color)
|
||||
.unwrap_or(Color32::WHITE),
|
||||
stroke_width: stroke_width.unwrap_or(2.0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
use super::protocol::{AgentRequest, AgentResponse, CodeGenTarget, GuiEvent};
|
||||
use super::protocol::{
|
||||
build_shape, build_style, AgentRequest, AgentResponse, CodeGenTarget, DrawingCommand, GuiEvent,
|
||||
};
|
||||
use crate::drawing::DrawingElement;
|
||||
use crate::session::SessionStore;
|
||||
use anyhow::Result;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
use tokio::sync::{broadcast, mpsc, RwLock};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
const EVENT_CHANNEL_CAPACITY: usize = 64;
|
||||
@@ -12,15 +15,17 @@ const EVENT_CHANNEL_CAPACITY: usize = 64;
|
||||
pub struct AgentServer {
|
||||
sessions: Arc<RwLock<SessionStore>>,
|
||||
event_tx: broadcast::Sender<GuiEvent>,
|
||||
command_tx: mpsc::UnboundedSender<DrawingCommand>,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl AgentServer {
|
||||
pub fn new(port: u16) -> Self {
|
||||
pub fn new(port: u16, command_tx: mpsc::UnboundedSender<DrawingCommand>) -> Self {
|
||||
let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(SessionStore::new())),
|
||||
event_tx,
|
||||
command_tx,
|
||||
port,
|
||||
}
|
||||
}
|
||||
@@ -41,9 +46,13 @@ impl AgentServer {
|
||||
while let Ok((stream, peer)) = listener.accept().await {
|
||||
tracing::info!("Agent connected from {}", peer);
|
||||
let sessions = self.sessions.clone();
|
||||
let event_tx = self.event_tx.clone();
|
||||
let event_rx = self.event_tx.subscribe();
|
||||
let command_tx = self.command_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(stream, sessions, event_rx).await {
|
||||
if let Err(e) =
|
||||
handle_connection(stream, sessions, event_rx, event_tx, command_tx).await
|
||||
{
|
||||
tracing::error!("Connection error: {}", e);
|
||||
}
|
||||
});
|
||||
@@ -57,6 +66,8 @@ async fn handle_connection(
|
||||
stream: TcpStream,
|
||||
sessions: Arc<RwLock<SessionStore>>,
|
||||
mut event_rx: broadcast::Receiver<GuiEvent>,
|
||||
event_tx: broadcast::Sender<GuiEvent>,
|
||||
command_tx: mpsc::UnboundedSender<DrawingCommand>,
|
||||
) -> Result<()> {
|
||||
let ws_stream = tokio_tungstenite::accept_async(stream).await?;
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
@@ -78,7 +89,11 @@ async fn handle_connection(
|
||||
match msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
let response = match serde_json::from_str::<AgentRequest>(&text) {
|
||||
Ok(request) => process_request(request, &sessions).await,
|
||||
Ok(request) => {
|
||||
process_request(
|
||||
request, &sessions, &event_tx, &command_tx,
|
||||
).await
|
||||
}
|
||||
Err(e) => AgentResponse::Error {
|
||||
message: format!("Invalid request: {}", e),
|
||||
},
|
||||
@@ -117,18 +132,22 @@ async fn handle_connection(
|
||||
async fn process_request(
|
||||
request: AgentRequest,
|
||||
sessions: &Arc<RwLock<SessionStore>>,
|
||||
event_tx: &broadcast::Sender<GuiEvent>,
|
||||
command_tx: &mpsc::UnboundedSender<DrawingCommand>,
|
||||
) -> AgentResponse {
|
||||
let store = sessions.read().await;
|
||||
|
||||
match request {
|
||||
AgentRequest::Ping => AgentResponse::Pong,
|
||||
|
||||
AgentRequest::ListSessions => AgentResponse::Sessions {
|
||||
sessions: store.list_sessions(),
|
||||
active_session: store.active_session_id().map(|s| s.to_string()),
|
||||
},
|
||||
AgentRequest::ListSessions => {
|
||||
let store = sessions.read().await;
|
||||
AgentResponse::Sessions {
|
||||
sessions: store.list_sessions(),
|
||||
active_session: store.active_session_id().map(|s| s.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
AgentRequest::GetTree { session_id } => {
|
||||
let store = sessions.read().await;
|
||||
match store.get_tree(session_id.as_deref()) {
|
||||
Some((sid, tree)) => AgentResponse::Tree {
|
||||
session_id: sid,
|
||||
@@ -143,6 +162,7 @@ async fn process_request(
|
||||
}
|
||||
|
||||
AgentRequest::GetElementById { session_id, id } => {
|
||||
let store = sessions.read().await;
|
||||
match store.get_tree(session_id.as_deref()) {
|
||||
Some((sid, tree)) => AgentResponse::Element {
|
||||
session_id: sid,
|
||||
@@ -155,6 +175,7 @@ async fn process_request(
|
||||
}
|
||||
|
||||
AgentRequest::GetElementsAtPoint { session_id, x, y } => {
|
||||
let store = sessions.read().await;
|
||||
match store.get_tree(session_id.as_deref()) {
|
||||
Some((sid, tree)) => AgentResponse::Elements {
|
||||
session_id: sid,
|
||||
@@ -167,6 +188,7 @@ async fn process_request(
|
||||
}
|
||||
|
||||
AgentRequest::Describe { session_id } => {
|
||||
let store = sessions.read().await;
|
||||
match store.get_tree(session_id.as_deref()) {
|
||||
Some((sid, tree)) => AgentResponse::Description {
|
||||
session_id: sid,
|
||||
@@ -179,6 +201,7 @@ async fn process_request(
|
||||
}
|
||||
|
||||
AgentRequest::GetDrawingElements { session_id } => {
|
||||
let store = sessions.read().await;
|
||||
match store.get_drawing_elements(session_id.as_deref()) {
|
||||
Some((sid, elements)) => AgentResponse::DrawingElements {
|
||||
session_id: sid,
|
||||
@@ -190,7 +213,12 @@ async fn process_request(
|
||||
}
|
||||
}
|
||||
|
||||
AgentRequest::GenerateCode { session_id, target, element_id } => {
|
||||
AgentRequest::GenerateCode {
|
||||
session_id,
|
||||
target,
|
||||
element_id,
|
||||
} => {
|
||||
let store = sessions.read().await;
|
||||
match store.get_tree(session_id.as_deref()) {
|
||||
Some((sid, tree)) => {
|
||||
let element = match &element_id {
|
||||
@@ -217,6 +245,201 @@ async fn process_request(
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
AgentRequest::CreateDrawingElement {
|
||||
session_id,
|
||||
shape_type,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
center_x,
|
||||
center_y,
|
||||
radius_x,
|
||||
radius_y,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
text,
|
||||
font_size,
|
||||
fill,
|
||||
stroke_color,
|
||||
stroke_width,
|
||||
} => {
|
||||
let shape = match build_shape(
|
||||
&shape_type,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
center_x,
|
||||
center_y,
|
||||
radius_x,
|
||||
radius_y,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
text,
|
||||
font_size,
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(msg) => return AgentResponse::Error { message: msg },
|
||||
};
|
||||
let style = build_style(fill, stroke_color, stroke_width);
|
||||
let element = DrawingElement::new(shape, style);
|
||||
|
||||
let mut store = sessions.write().await;
|
||||
let sid = match store.resolve_session_id(session_id.as_deref()) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return AgentResponse::Error {
|
||||
message: "No session found".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
store.add_drawing_element(&sid, element.clone());
|
||||
let _ = command_tx.send(DrawingCommand::Create {
|
||||
session_id: sid.clone(),
|
||||
element: element.clone(),
|
||||
});
|
||||
let _ = event_tx.send(GuiEvent::DrawingElementCreated {
|
||||
session_id: sid.clone(),
|
||||
element: element.clone(),
|
||||
});
|
||||
|
||||
AgentResponse::DrawingElementCreated {
|
||||
session_id: sid,
|
||||
element,
|
||||
}
|
||||
}
|
||||
|
||||
AgentRequest::UpdateDrawingElement {
|
||||
session_id,
|
||||
id,
|
||||
shape_type,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
center_x,
|
||||
center_y,
|
||||
radius_x,
|
||||
radius_y,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
text,
|
||||
font_size,
|
||||
fill,
|
||||
stroke_color,
|
||||
stroke_width,
|
||||
} => {
|
||||
let new_shape = if let Some(st) = shape_type {
|
||||
match build_shape(
|
||||
&st, x, y, width, height, center_x, center_y, radius_x, radius_y, x1, y1, x2,
|
||||
y2, text, font_size,
|
||||
) {
|
||||
Ok(s) => Some(s),
|
||||
Err(msg) => return AgentResponse::Error { message: msg },
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let has_style_change =
|
||||
fill.is_some() || stroke_color.is_some() || stroke_width.is_some();
|
||||
let new_style = if has_style_change {
|
||||
Some(build_style(fill, stroke_color, stroke_width))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut store = sessions.write().await;
|
||||
let sid = match store.resolve_session_id(session_id.as_deref()) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return AgentResponse::Error {
|
||||
message: "No session found".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match store.update_drawing_element(&sid, &id, new_shape, new_style) {
|
||||
Some(updated) => {
|
||||
let _ = command_tx.send(DrawingCommand::Update {
|
||||
session_id: sid.clone(),
|
||||
element: updated.clone(),
|
||||
});
|
||||
let _ = event_tx.send(GuiEvent::DrawingElementUpdated {
|
||||
session_id: sid.clone(),
|
||||
element: updated.clone(),
|
||||
});
|
||||
AgentResponse::DrawingElementUpdated {
|
||||
session_id: sid,
|
||||
element: updated,
|
||||
}
|
||||
}
|
||||
None => AgentResponse::Error {
|
||||
message: format!("Element '{}' not found in session", id),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
AgentRequest::DeleteDrawingElement { session_id, id } => {
|
||||
let mut store = sessions.write().await;
|
||||
let sid = match store.resolve_session_id(session_id.as_deref()) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return AgentResponse::Error {
|
||||
message: "No session found".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if store.delete_drawing_element(&sid, &id) {
|
||||
let _ = command_tx.send(DrawingCommand::Delete {
|
||||
session_id: sid.clone(),
|
||||
id: id.clone(),
|
||||
});
|
||||
let _ = event_tx.send(GuiEvent::DrawingElementDeleted {
|
||||
session_id: sid.clone(),
|
||||
id: id.clone(),
|
||||
});
|
||||
AgentResponse::DrawingElementDeleted {
|
||||
session_id: sid,
|
||||
id,
|
||||
}
|
||||
} else {
|
||||
AgentResponse::Error {
|
||||
message: format!("Element '{}' not found in session", id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AgentRequest::ClearDrawingElements { session_id } => {
|
||||
let mut store = sessions.write().await;
|
||||
let sid = match store.resolve_session_id(session_id.as_deref()) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return AgentResponse::Error {
|
||||
message: "No session found".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
store.clear_drawing_elements(&sid);
|
||||
let _ = command_tx.send(DrawingCommand::Clear {
|
||||
session_id: sid.clone(),
|
||||
});
|
||||
let _ = event_tx.send(GuiEvent::DrawingElementsCleared {
|
||||
session_id: sid.clone(),
|
||||
});
|
||||
AgentResponse::DrawingElementsCleared { session_id: sid }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::agent::{AgentServer, GuiEvent};
|
||||
use crate::agent::{AgentServer, DrawingCommand, GuiEvent};
|
||||
use crate::canvas::{CanvasInteraction, CanvasState};
|
||||
use crate::clipboard::ClipboardManager;
|
||||
use crate::drawing::{
|
||||
@@ -11,7 +11,7 @@ use crate::svg::{parse_svg, SvgRenderer};
|
||||
use egui::{Color32, ColorImage, TextureOptions};
|
||||
use std::sync::Arc;
|
||||
use tokio::runtime::Runtime;
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
use tokio::sync::{broadcast, mpsc, RwLock};
|
||||
|
||||
const AGENT_PORT: u16 = 9876;
|
||||
const MIN_SHAPE_SIZE: f32 = 5.0;
|
||||
@@ -22,6 +22,7 @@ pub struct AgCanvasApp {
|
||||
session_counter: usize,
|
||||
sessions_handle: Arc<RwLock<SessionStore>>,
|
||||
event_tx: broadcast::Sender<GuiEvent>,
|
||||
command_rx: mpsc::UnboundedReceiver<DrawingCommand>,
|
||||
clipboard: Option<ClipboardManager>,
|
||||
show_tree_panel: bool,
|
||||
show_description: bool,
|
||||
@@ -33,6 +34,7 @@ pub struct AgCanvasApp {
|
||||
show_text_input: bool,
|
||||
text_input_buffer: String,
|
||||
text_input_pos: Option<egui::Pos2>,
|
||||
last_drawing_sync: std::time::Instant,
|
||||
}
|
||||
|
||||
impl AgCanvasApp {
|
||||
@@ -40,7 +42,8 @@ impl AgCanvasApp {
|
||||
configure_fonts(&cc.egui_ctx);
|
||||
|
||||
let runtime = Runtime::new().expect("Failed to create tokio runtime");
|
||||
let server = AgentServer::new(AGENT_PORT);
|
||||
let (command_tx, command_rx) = mpsc::unbounded_channel();
|
||||
let server = AgentServer::new(AGENT_PORT, command_tx);
|
||||
let sessions_handle = server.sessions_handle();
|
||||
let event_tx = server.event_sender();
|
||||
|
||||
@@ -58,6 +61,7 @@ impl AgCanvasApp {
|
||||
session_counter: 0,
|
||||
sessions_handle,
|
||||
event_tx,
|
||||
command_rx,
|
||||
clipboard,
|
||||
show_tree_panel: false,
|
||||
show_description: false,
|
||||
@@ -68,6 +72,7 @@ impl AgCanvasApp {
|
||||
show_text_input: false,
|
||||
text_input_buffer: String::new(),
|
||||
text_input_pos: None,
|
||||
last_drawing_sync: std::time::Instant::now(),
|
||||
};
|
||||
|
||||
app.create_session();
|
||||
@@ -94,7 +99,9 @@ impl AgCanvasApp {
|
||||
store.add_session(info_clone.clone(), None);
|
||||
store.set_active(&info_clone.id);
|
||||
});
|
||||
let _ = event_tx.send(GuiEvent::SessionCreated { session: info_clone });
|
||||
let _ = event_tx.send(GuiEvent::SessionCreated {
|
||||
session: info_clone,
|
||||
});
|
||||
});
|
||||
|
||||
self.set_status("Created new session".to_string());
|
||||
@@ -171,10 +178,9 @@ impl AgCanvasApp {
|
||||
session.description_text = tree.to_semantic_description();
|
||||
session.svg_renderer = Some(SvgRenderer::new(usvg_tree));
|
||||
session.svg_texture = None;
|
||||
session.canvas_state.fit_to_rect(
|
||||
egui::vec2(width, height),
|
||||
ctx.screen_rect().size() * 0.8,
|
||||
);
|
||||
session
|
||||
.canvas_state
|
||||
.fit_to_rect(egui::vec2(width, height), ctx.screen_rect().size() * 0.8);
|
||||
|
||||
let session_id = session.id.clone();
|
||||
let metadata = tree.metadata.clone();
|
||||
@@ -232,7 +238,9 @@ impl AgCanvasApp {
|
||||
let pixels: Vec<Color32> = pixmap
|
||||
.pixels()
|
||||
.iter()
|
||||
.map(|p| Color32::from_rgba_premultiplied(p.red(), p.green(), p.blue(), p.alpha()))
|
||||
.map(|p| {
|
||||
Color32::from_rgba_premultiplied(p.red(), p.green(), p.blue(), p.alpha())
|
||||
})
|
||||
.collect();
|
||||
|
||||
let image = ColorImage { size, pixels };
|
||||
@@ -269,6 +277,69 @@ impl AgCanvasApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_drawing_commands(&mut self) {
|
||||
while let Ok(cmd) = self.command_rx.try_recv() {
|
||||
match cmd {
|
||||
DrawingCommand::Create {
|
||||
session_id,
|
||||
element,
|
||||
} => {
|
||||
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
|
||||
session.drawing_elements.push(element);
|
||||
}
|
||||
}
|
||||
DrawingCommand::Update {
|
||||
session_id,
|
||||
element,
|
||||
} => {
|
||||
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
|
||||
if let Some(el) = session
|
||||
.drawing_elements
|
||||
.iter_mut()
|
||||
.find(|e| e.id == element.id)
|
||||
{
|
||||
el.shape = element.shape;
|
||||
el.style = element.style;
|
||||
}
|
||||
}
|
||||
}
|
||||
DrawingCommand::Delete { session_id, id } => {
|
||||
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
|
||||
session.drawing_elements.retain(|e| e.id != id);
|
||||
if session.selected_element_id.as_deref() == Some(&id) {
|
||||
session.selected_element_id = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
DrawingCommand::Clear { session_id } => {
|
||||
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
|
||||
session.drawing_elements.clear();
|
||||
session.selected_element_id = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_drawing_elements_to_store(&self) {
|
||||
let sessions_handle = self.sessions_handle.clone();
|
||||
let elements_by_session: Vec<(String, Vec<DrawingElement>)> = self
|
||||
.sessions
|
||||
.iter()
|
||||
.map(|s| (s.id.clone(), s.drawing_elements.clone()))
|
||||
.collect();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let rt = Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let mut store = sessions_handle.write().await;
|
||||
for (sid, elements) in elements_by_session {
|
||||
store.update_drawing_elements(&sid, elements);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_drawing_input(&mut self, response: &egui::Response, canvas_center: egui::Pos2) {
|
||||
let session = self.active_session_mut();
|
||||
let offset = session.canvas_state.offset;
|
||||
@@ -331,9 +402,7 @@ fn handle_select_tool(
|
||||
if let Some(el) = hit {
|
||||
let eid = el.id.clone();
|
||||
session.selected_element_id = Some(eid.clone());
|
||||
session.drag_state = DragState::Moving {
|
||||
element_id: eid,
|
||||
};
|
||||
session.drag_state = DragState::Moving { element_id: eid };
|
||||
} else {
|
||||
session.selected_element_id = None;
|
||||
session.drag_state = DragState::None;
|
||||
@@ -464,6 +533,8 @@ fn handle_shape_tool(
|
||||
|
||||
impl eframe::App for AgCanvasApp {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.drain_drawing_commands();
|
||||
|
||||
let mut paste = false;
|
||||
let mut new_tab = false;
|
||||
let mut close_tab = false;
|
||||
@@ -929,6 +1000,11 @@ impl eframe::App for AgCanvasApp {
|
||||
}
|
||||
});
|
||||
|
||||
if self.last_drawing_sync.elapsed().as_millis() > 500 {
|
||||
self.last_drawing_sync = std::time::Instant::now();
|
||||
self.sync_drawing_elements_to_store();
|
||||
}
|
||||
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
@@ -979,14 +1055,26 @@ fn draw_grid(painter: &egui::Painter, rect: &egui::Rect, state: &CanvasState) {
|
||||
fn render_tree_ui(ui: &mut egui::Ui, element: &crate::element_tree::Element, depth: usize) {
|
||||
let kind_name = match &element.kind {
|
||||
crate::element_tree::ElementKind::Group { name } => {
|
||||
format!("Group{}", name.as_ref().map(|n| format!(" '{}'", n)).unwrap_or_default())
|
||||
format!(
|
||||
"Group{}",
|
||||
name.as_ref()
|
||||
.map(|n| format!(" '{}'", n))
|
||||
.unwrap_or_default()
|
||||
)
|
||||
}
|
||||
crate::element_tree::ElementKind::Rectangle { .. } => "Rect".to_string(),
|
||||
crate::element_tree::ElementKind::Circle { .. } => "Circle".to_string(),
|
||||
crate::element_tree::ElementKind::Ellipse { .. } => "Ellipse".to_string(),
|
||||
crate::element_tree::ElementKind::Path { .. } => "Path".to_string(),
|
||||
crate::element_tree::ElementKind::Text { content, .. } => {
|
||||
format!("Text '{}'", if content.len() > 15 { &content[..15] } else { content })
|
||||
format!(
|
||||
"Text '{}'",
|
||||
if content.len() > 15 {
|
||||
&content[..15]
|
||||
} else {
|
||||
content
|
||||
}
|
||||
)
|
||||
}
|
||||
crate::element_tree::ElementKind::Image { .. } => "Image".to_string(),
|
||||
crate::element_tree::ElementKind::Line { .. } => "Line".to_string(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
mod state;
|
||||
mod interaction;
|
||||
mod state;
|
||||
|
||||
pub use state::CanvasState;
|
||||
pub use interaction::CanvasInteraction;
|
||||
pub use state::CanvasState;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
mod agent;
|
||||
mod app;
|
||||
mod canvas;
|
||||
mod clipboard;
|
||||
mod drawing;
|
||||
mod element_tree;
|
||||
mod mermaid;
|
||||
mod svg;
|
||||
mod clipboard;
|
||||
mod agent;
|
||||
mod session;
|
||||
mod svg;
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
@@ -74,6 +74,7 @@ impl Session {
|
||||
.and_then(|id| self.drawing_elements.iter().find(|e| e.id == *id))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn selected_element_mut(&mut self) -> Option<&mut DrawingElement> {
|
||||
let id = self.selected_element_id.clone();
|
||||
id.and_then(move |id| self.drawing_elements.iter_mut().find(|e| e.id == id))
|
||||
@@ -166,6 +167,69 @@ impl SessionStore {
|
||||
Some((id, &data.drawing_elements))
|
||||
}
|
||||
|
||||
pub fn add_drawing_element(&mut self, session_id: &str, element: DrawingElement) -> bool {
|
||||
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||
data.drawing_elements.push(element);
|
||||
data.info.drawing_element_count = data.drawing_elements.len();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_drawing_element(
|
||||
&mut self,
|
||||
session_id: &str,
|
||||
element_id: &str,
|
||||
new_shape: Option<crate::drawing::Shape>,
|
||||
new_style: Option<crate::drawing::ShapeStyle>,
|
||||
) -> Option<DrawingElement> {
|
||||
let data = self.sessions.get_mut(session_id)?;
|
||||
let el = data
|
||||
.drawing_elements
|
||||
.iter_mut()
|
||||
.find(|e| e.id == element_id)?;
|
||||
if let Some(shape) = new_shape {
|
||||
el.shape = shape;
|
||||
}
|
||||
if let Some(style) = new_style {
|
||||
el.style = style;
|
||||
}
|
||||
Some(el.clone())
|
||||
}
|
||||
|
||||
pub fn delete_drawing_element(&mut self, session_id: &str, element_id: &str) -> bool {
|
||||
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||
let before = data.drawing_elements.len();
|
||||
data.drawing_elements.retain(|e| e.id != element_id);
|
||||
data.info.drawing_element_count = data.drawing_elements.len();
|
||||
data.drawing_elements.len() < before
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_drawing_elements(&mut self, session_id: &str) -> bool {
|
||||
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||
data.drawing_elements.clear();
|
||||
data.info.drawing_element_count = 0;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_session_id(&self, session_id: Option<&str>) -> Option<String> {
|
||||
let id = session_id
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| self.active_session_id.clone())?;
|
||||
if self.sessions.contains_key(&id) {
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_sessions(&self) -> Vec<SessionInfo> {
|
||||
self.sessions.values().map(|d| d.info.clone()).collect()
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.schema.json",
|
||||
"mcpServers": {
|
||||
"agcanvas": {
|
||||
"command": "agcanvas-mcp",
|
||||
"args": ["--port", "9876"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user