815 lines
36 KiB
Rust
815 lines
36 KiB
Rust
//! MCP tool definitions (names, descriptions, JSON Schemas) and their implementations on top of a
|
||
//! [`Backend`].
|
||
|
||
use serde_json::{Map, Value, json};
|
||
|
||
use crate::backend::Backend;
|
||
|
||
/// The result of `tools/call`: MCP content blocks plus the `isError` flag.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct ToolResult {
|
||
pub content: Vec<Value>,
|
||
pub is_error: bool,
|
||
}
|
||
|
||
impl ToolResult {
|
||
pub fn text(t: impl Into<String>) -> Self {
|
||
Self { content: vec![json!({"type": "text", "text": t.into()})], is_error: false }
|
||
}
|
||
pub fn json(v: &Value) -> Self {
|
||
Self::text(serde_json::to_string_pretty(v).unwrap_or_default())
|
||
}
|
||
pub fn error(t: impl Into<String>) -> Self {
|
||
Self { content: vec![json!({"type": "text", "text": t.into()})], is_error: true }
|
||
}
|
||
pub fn to_value(&self) -> Value {
|
||
json!({"content": self.content, "isError": self.is_error})
|
||
}
|
||
}
|
||
|
||
// ---------- schemas ----------
|
||
|
||
fn num(desc: &str) -> Value {
|
||
json!({"type": "number", "description": desc})
|
||
}
|
||
fn string(desc: &str) -> Value {
|
||
json!({"type": "string", "description": desc})
|
||
}
|
||
fn paint_schema(what: &str) -> Value {
|
||
json!({
|
||
"description": format!("{what}: \"#rrggbb\", \"none\", [r,g,b] (0..1), {{\"c\",\"m\",\"y\",\"k\"}}, {{\"gray\"}}, or a full paint.setFill params object ({{\"gradient\":…}} / {{\"swatch\":name}})"),
|
||
"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "number"}}, {"type": "object"}]
|
||
})
|
||
}
|
||
fn mods_schema() -> Value {
|
||
json!({
|
||
"type": "object",
|
||
"description": "Modifier keys held (cmd = Command on macOS / Ctrl elsewhere; alt = Option)",
|
||
"properties": {"shift": {"type": "boolean"}, "alt": {"type": "boolean"}, "cmd": {"type": "boolean"}, "ctrl": {"type": "boolean"}},
|
||
"additionalProperties": false
|
||
})
|
||
}
|
||
fn obj(props: Value, required: &[&str]) -> Value {
|
||
let mut o = json!({"type": "object", "properties": props});
|
||
if !required.is_empty() {
|
||
o["required"] = json!(required);
|
||
}
|
||
o
|
||
}
|
||
fn tool(name: &str, title: &str, desc: &str, schema: Value, read_only: bool) -> Value {
|
||
json!({
|
||
"name": name,
|
||
"title": title,
|
||
"description": desc,
|
||
"inputSchema": schema,
|
||
"annotations": {"title": title, "readOnlyHint": read_only, "openWorldHint": false},
|
||
})
|
||
}
|
||
|
||
/// All tools, in `tools/list` order.
|
||
pub fn tool_definitions() -> Vec<Value> {
|
||
let empty = || obj(json!({}), &[]);
|
||
vec![
|
||
tool(
|
||
"list_commands",
|
||
"List commands",
|
||
"List VectorCraft commands (id, label, menu path, shortcut, parameter description, enabled state). Every editing action is a command; run any of them with run_command. Coordinates are points in document space, y down, origin at the first artboard's top-left.",
|
||
obj(
|
||
json!({"filter": string("Case-insensitive substring matched against id, label and menu path (e.g. \"align\", \"Object\")"), "enabledOnly": {"type": "boolean", "description": "Only commands that can run right now"}}),
|
||
&[],
|
||
),
|
||
true,
|
||
),
|
||
tool(
|
||
"run_command",
|
||
"Run command",
|
||
"Execute any VectorCraft command by id with JSON params (see list_commands for ids and params). Examples: {\"command\":\"object.group\"}, {\"command\":\"object.align\",\"params\":{\"align\":\"left\"}}, {\"command\":\"file.new\",\"params\":{\"width\":800,\"height\":600}}. Returns the command's result (e.g. {id} for creation commands).",
|
||
obj(
|
||
json!({"command": string("Command id, e.g. shape.rectangle, object.group, paint.setFill"), "params": {"type": "object", "description": "Command parameters"}}),
|
||
&["command"],
|
||
),
|
||
false,
|
||
),
|
||
tool(
|
||
"inspect_document",
|
||
"Inspect document",
|
||
"Summary of the active document: artboards, layer tree (ids, names, kinds, bounds, fill/stroke), selection, undo history, current tool and default paint.",
|
||
empty(),
|
||
true,
|
||
),
|
||
tool(
|
||
"inspect_ui",
|
||
"Inspect UI",
|
||
"UI state of the running desktop app: tool and options, panels, dialogs, view (zoom/center), window, documents, perf. Desktop app only.",
|
||
empty(),
|
||
true,
|
||
),
|
||
tool(
|
||
"select_tool",
|
||
"Select tool",
|
||
"Switch the active tool (as clicking it in the Tools panel). Ids: selection, directSelection, groupSelection, pen, rectangle, roundedRectangle, ellipse, polygon, star, lineSegment, …",
|
||
obj(json!({"tool": string("Tool id")}), &["tool"]),
|
||
false,
|
||
),
|
||
tool(
|
||
"pointer_gesture",
|
||
"Pointer gesture",
|
||
"Drive the active tool with mouse events in document coordinates, exactly like the mouse would (e.g. a rectangle drag: down at (100,100), drag to (200,150), up at (200,150)). Optionally selects `tool` first.",
|
||
obj(
|
||
json!({
|
||
"tool": string("Optional tool to select before the gesture"),
|
||
"events": {
|
||
"type": "array",
|
||
"minItems": 1,
|
||
"items": obj(json!({
|
||
"kind": {"type": "string", "enum": ["down", "drag", "up", "move", "doubleclick"]},
|
||
"x": num("Document x (pt)"),
|
||
"y": num("Document y (pt)"),
|
||
"mods": mods_schema(),
|
||
}), &["kind", "x", "y"]),
|
||
},
|
||
"mods": mods_schema(),
|
||
}),
|
||
&["events"],
|
||
),
|
||
false,
|
||
),
|
||
tool(
|
||
"draw_path",
|
||
"Draw path",
|
||
"Create a path from points ([[x,y],…] or [{x,y,in?:[x,y],out?:[x,y]},…] with Bézier handles) or SVG path data `d`, then apply optional paint. Returns the new object id.",
|
||
obj(
|
||
json!({
|
||
"points": {"type": "array", "description": "Anchors: [x,y] pairs or {x, y, in?, out?, smooth?} objects", "items": {"anyOf": [{"type": "array", "items": {"type": "number"}, "minItems": 2}, {"type": "object"}]}},
|
||
"d": string("SVG path data (alternative to points), e.g. \"M0 0 L100 0 L50 80 Z\""),
|
||
"closed": {"type": "boolean", "description": "Close the path (points only)"},
|
||
"fill": paint_schema("Fill"),
|
||
"stroke": paint_schema("Stroke"),
|
||
"strokeWidth": num("Stroke weight in pt"),
|
||
}),
|
||
&[],
|
||
),
|
||
false,
|
||
),
|
||
tool(
|
||
"draw_shape",
|
||
"Draw shape",
|
||
"Create a live shape and apply optional paint. rectangle/ellipse: x, y, width, height (rectangle: radius = corner radius). polygon: cx, cy, radius, sides. star: cx, cy, radius1, radius2, points. line: x1, y1, x2, y2. Returns the new object id.",
|
||
obj(
|
||
json!({
|
||
"shape": {"type": "string", "enum": ["rectangle", "ellipse", "polygon", "star", "line"]},
|
||
"x": num("Left (rectangle/ellipse)"),
|
||
"y": num("Top (rectangle/ellipse)"),
|
||
"width": num("Width (rectangle/ellipse)"),
|
||
"height": num("Height (rectangle/ellipse)"),
|
||
"radius": num("Corner radius (rectangle) or radius (polygon)"),
|
||
"cx": num("Centre x (polygon/star)"),
|
||
"cy": num("Centre y (polygon/star)"),
|
||
"sides": {"type": "integer", "minimum": 3, "description": "Polygon sides (default 6)"},
|
||
"radius1": num("Star outer radius"),
|
||
"radius2": num("Star inner radius"),
|
||
"points": {"type": "integer", "minimum": 3, "description": "Star points (default 5)"},
|
||
"rotation": num("Rotation in degrees (polygon/star)"),
|
||
"x1": num("Line start x"),
|
||
"y1": num("Line start y"),
|
||
"x2": num("Line end x"),
|
||
"y2": num("Line end y"),
|
||
"fill": paint_schema("Fill"),
|
||
"stroke": paint_schema("Stroke"),
|
||
"strokeWidth": num("Stroke weight in pt"),
|
||
}),
|
||
&["shape"],
|
||
),
|
||
false,
|
||
),
|
||
tool(
|
||
"set_paint",
|
||
"Set paint",
|
||
"Set fill, stroke and/or stroke weight on the selection (or `ids`) and as the default for new art.",
|
||
obj(
|
||
json!({
|
||
"fill": paint_schema("Fill"),
|
||
"stroke": paint_schema("Stroke"),
|
||
"strokeWidth": num("Stroke weight in pt"),
|
||
"ids": {"type": "array", "items": {"type": "integer"}, "description": "Object ids (default: the selection)"},
|
||
}),
|
||
&[],
|
||
),
|
||
false,
|
||
),
|
||
tool(
|
||
"press_key",
|
||
"Press key",
|
||
"Send a key press with modifiers, e.g. {key:\"Z\", mods:{cmd:true}} (undo), {key:\"Escape\"}, {key:\"Enter\"}. In the desktop app it goes through the real keyboard path; headless, it runs the command or tool bound to that shortcut.",
|
||
obj(
|
||
json!({"key": string("Key name: A–Z, 0–9, Enter, Escape, Delete, Backspace, Tab, Space, Left/Right/Up/Down, [ ]…"), "mods": mods_schema()}),
|
||
&["key"],
|
||
),
|
||
false,
|
||
),
|
||
tool(
|
||
"type_text",
|
||
"Type text",
|
||
"Type text into the focused widget (text tool, dialog field). Desktop app only.",
|
||
obj(json!({"text": string("Text to type")}), &["text"]),
|
||
false,
|
||
),
|
||
tool(
|
||
"invoke_menu",
|
||
"Invoke menu",
|
||
"Invoke a menu item by its command id (same ids as list_commands, including UI-only commands like view.zoomIn or window.* in the desktop app).",
|
||
obj(json!({"command": string("Menu command id"), "params": {"type": "object"}}), &["command"]),
|
||
false,
|
||
),
|
||
tool(
|
||
"open_panel",
|
||
"Open panel",
|
||
"Open a panel in the desktop app's dock (e.g. Layers, Swatches, Stroke, Align, Pathfinder, Transform). Desktop app only.",
|
||
obj(json!({"panel": string("Panel name")}), &["panel"]),
|
||
false,
|
||
),
|
||
tool(
|
||
"screenshot",
|
||
"Screenshot",
|
||
"Render the active artboard to PNG and return it as an image (optionally also saving it to `path`). With window:true (desktop app only) captures the whole app window instead.",
|
||
obj(
|
||
json!({
|
||
"path": string("Also write the PNG here"),
|
||
"scale": num("Pixels per point (default 1)"),
|
||
"artboard": {"type": "integer", "minimum": 0, "description": "Artboard index (default 0)"},
|
||
"window": {"type": "boolean", "description": "Capture the app window (desktop app only)"},
|
||
}),
|
||
&[],
|
||
),
|
||
true,
|
||
),
|
||
tool(
|
||
"open_file",
|
||
"Open file",
|
||
"Open a .vectorcraft, .svg, .pdf or PDF-compatible .ai file as a new, active document.",
|
||
obj(json!({"path": string("File path")}), &["path"]),
|
||
false,
|
||
),
|
||
tool(
|
||
"save_file",
|
||
"Save file",
|
||
"Save the active document in the native .vectorcraft format.",
|
||
obj(json!({"path": string("Destination (default: the document's current path)")}), &[]),
|
||
false,
|
||
),
|
||
tool(
|
||
"export",
|
||
"Export",
|
||
"Export the active document: svg (artboard viewBox), pdf, png/jpg/webp (rendered artboard) or vectorcraft (native). `selection: true` exports only the selected objects, cropped to their bounds. Live effects are kept (geometry baked, SVG filters for shadows/glows/blur).",
|
||
obj(
|
||
json!({
|
||
"format": {"type": "string", "enum": ["svg", "png", "pdf", "jpg", "webp", "vectorcraft"], "description": "Default: from the path's extension"},
|
||
"path": string("Destination file"),
|
||
"scale": num("PNG pixels per point (default 1)"),
|
||
"selection": {"type": "boolean", "description": "Export only the selection, cropped to it"},
|
||
"outlineText": {"type": "boolean", "description": "SVG: text as outlines (viewable without the fonts)"},
|
||
}),
|
||
&["path"],
|
||
),
|
||
false,
|
||
),
|
||
tool(
|
||
"add_text",
|
||
"Add text",
|
||
"Create point type at (x, y) (the first baseline), area type when width/height are given, or type in/on an existing path (`path` id + `mode` area|onPath; `pathEffect` rainbow|skew|3dRibbon|stairStep|gravity). Returns the text object id.",
|
||
obj(
|
||
json!({
|
||
"text": {"type": "string"},
|
||
"x": num("Baseline origin x (point/area type: left)"),
|
||
"y": num("First baseline y (area type: top)"),
|
||
"width": num("Area type frame width"),
|
||
"height": num("Area type frame height"),
|
||
"path": {"type": "integer", "description": "Existing path id to flow the text in (area) or along (onPath)"},
|
||
"mode": {"type": "string", "enum": ["area", "onPath"]},
|
||
"pathEffect": {"type": "string", "enum": ["rainbow", "skew", "3dRibbon", "stairStep", "gravity"]},
|
||
"size": num("Font size in pt"),
|
||
"font": {"type": "string", "description": "Font family"},
|
||
"color": paint_schema("Text colour (#rrggbb)"),
|
||
}),
|
||
&["text"],
|
||
),
|
||
false,
|
||
),
|
||
tool(
|
||
"apply_effect",
|
||
"Apply live effect",
|
||
"Append a live effect (Effect menu) to the selection or `ids`. Omit `effect` to get the catalogue (ids, parameters, defaults). Examples: stylize.dropShadow {x, y, blur, opacity}, distort.roughen, warp.arc {bend}, path.offsetPath {offset}, pathfinder.add (on groups), blur.gaussian {radius}.",
|
||
obj(
|
||
json!({
|
||
"effect": {"type": "string", "description": "Effect id (see the catalogue)"},
|
||
"params": {"type": "object", "description": "Effect parameters; missing keys take Illustrator's dialog defaults"},
|
||
"ids": {"type": "array", "items": {"type": "integer"}},
|
||
}),
|
||
&[],
|
||
),
|
||
false,
|
||
),
|
||
tool(
|
||
"pathfinder",
|
||
"Pathfinder",
|
||
"Combine the selected (or `ids`) objects destructively, back to front: unite, minusFront, intersect, exclude, divide, trim, merge, crop, outline, minusBack. For a non-destructive version apply the pathfinder.* effect to a group.",
|
||
obj(
|
||
json!({
|
||
"operation": {"type": "string", "enum": ["unite", "minusFront", "intersect", "exclude", "divide", "trim", "merge", "crop", "outline", "minusBack"]},
|
||
"ids": {"type": "array", "items": {"type": "integer"}},
|
||
}),
|
||
&["operation"],
|
||
),
|
||
false,
|
||
),
|
||
tool(
|
||
"transform",
|
||
"Transform",
|
||
"Move, rotate, scale, reflect or shear the selection (or `ids`); several may be combined and run in that order. Angles in degrees (counter-clockwise), scale in %. `origin` [x,y] defaults to the selection centre; `copy` transforms a copy.",
|
||
obj(
|
||
json!({
|
||
"ids": {"type": "array", "items": {"type": "integer"}},
|
||
"dx": num("Move right"),
|
||
"dy": num("Move down"),
|
||
"rotate": num("Rotation in degrees"),
|
||
"scale": num("Uniform scale in %"),
|
||
"scaleX": num("Horizontal scale in %"),
|
||
"scaleY": num("Vertical scale in %"),
|
||
"reflect": {"type": "string", "enum": ["vertical", "horizontal"]},
|
||
"shear": num("Shear angle in degrees (horizontal axis)"),
|
||
"origin": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2},
|
||
"copy": {"type": "boolean"},
|
||
}),
|
||
&[],
|
||
),
|
||
false,
|
||
),
|
||
tool(
|
||
"create_graph",
|
||
"Create graph",
|
||
"Create a graph (Illustrator graph tools) in the plot rectangle. Data as `csv` (first row: empty cell then series names; then one row per category: label, values…) or `series`/`categories`/`rows`. Edit later with run_command graph.setData / graph.setType.",
|
||
obj(
|
||
json!({
|
||
"type": {"type": "string", "enum": ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"]},
|
||
"x": num("Plot left"),
|
||
"y": num("Plot top"),
|
||
"width": num("Plot width"),
|
||
"height": num("Plot height"),
|
||
"csv": {"type": "string"},
|
||
"series": {"type": "array", "items": {"type": "string"}},
|
||
"categories": {"type": "array", "items": {"type": "string"}},
|
||
"rows": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
|
||
}),
|
||
&["x", "y", "width", "height"],
|
||
),
|
||
false,
|
||
),
|
||
tool(
|
||
"text_wrap",
|
||
"Text wrap",
|
||
"Make the selected (or `ids`) objects wrap objects: area type below them in the same layer flows around them (offset in pt, invert = flow inside). `release: true` removes the wrap.",
|
||
obj(
|
||
json!({
|
||
"ids": {"type": "array", "items": {"type": "integer"}},
|
||
"offset": num("Gap between text and object (default 6 pt)"),
|
||
"invert": {"type": "boolean"},
|
||
"release": {"type": "boolean"},
|
||
}),
|
||
&[],
|
||
),
|
||
false,
|
||
),
|
||
tool("undo", "Undo", "Undo the last change (Edit → Undo).", empty(), false),
|
||
tool("redo", "Redo", "Redo the last undone change (Edit → Redo).", empty(), false),
|
||
]
|
||
}
|
||
|
||
// ---------- implementation ----------
|
||
|
||
type Args = Map<String, Value>;
|
||
|
||
fn req_str<'a>(a: &'a Args, k: &str) -> Result<&'a str, String> {
|
||
a.get(k).and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(|| format!("missing string argument `{k}`"))
|
||
}
|
||
|
||
fn exec(b: &mut dyn Backend, cmd: &str, params: Value) -> Result<Value, String> {
|
||
b.call("engine.execute", json!({"command": cmd, "params": params}))
|
||
}
|
||
|
||
fn need_ui(b: &dyn Backend, tool: &str) -> Result<(), String> {
|
||
if b.has_ui() {
|
||
Ok(())
|
||
} else {
|
||
Err(format!(
|
||
"`{tool}` needs the desktop app: run `vectorcraft --control 7979` and start the server with `vectorcraft-cli mcp --connect 127.0.0.1:7979`"
|
||
))
|
||
}
|
||
}
|
||
|
||
/// Paint argument → `paint.setFill` / `paint.setStroke` params.
|
||
fn paint_params(v: &Value) -> Result<Value, String> {
|
||
Ok(match v {
|
||
Value::Null => json!({"none": true}),
|
||
Value::String(s) if s.eq_ignore_ascii_case("none") => json!({"none": true}),
|
||
Value::String(_) | Value::Array(_) => json!({"color": v}),
|
||
Value::Object(o) if ["color", "none", "swatch", "gradient"].iter().any(|k| o.contains_key(*k)) => v.clone(),
|
||
Value::Object(_) => json!({"color": v}),
|
||
other => return Err(format!("unsupported paint value {other}")),
|
||
})
|
||
}
|
||
|
||
fn apply_paint(b: &mut dyn Backend, a: &Args, ids: Option<Value>) -> Result<Value, String> {
|
||
let with_ids = |mut p: Value| {
|
||
if let Some(ids) = &ids {
|
||
p["ids"] = ids.clone();
|
||
}
|
||
p
|
||
};
|
||
let mut applied = Map::new();
|
||
if let Some(f) = a.get("fill") {
|
||
exec(b, "paint.setFill", with_ids(paint_params(f)?))?;
|
||
applied.insert("fill".into(), f.clone());
|
||
}
|
||
if let Some(s) = a.get("stroke") {
|
||
exec(b, "paint.setStroke", with_ids(paint_params(s)?))?;
|
||
applied.insert("stroke".into(), s.clone());
|
||
}
|
||
if let Some(w) = a.get("strokeWidth") {
|
||
let w = w.as_f64().ok_or("`strokeWidth` must be a number")?;
|
||
exec(b, "stroke.set", with_ids(json!({"weight": w})))?;
|
||
applied.insert("strokeWidth".into(), json!(w));
|
||
}
|
||
Ok(Value::Object(applied))
|
||
}
|
||
|
||
fn created_id(r: &Value) -> Option<Value> {
|
||
r.get("id").filter(|v| v.is_u64()).map(|id| json!([id]))
|
||
}
|
||
|
||
fn draw_path(b: &mut dyn Backend, a: &Args) -> Result<Value, String> {
|
||
let mut p = json!({});
|
||
if let Some(d) = a.get("d").and_then(Value::as_str) {
|
||
p["d"] = json!(d);
|
||
} else {
|
||
let pts = a.get("points").and_then(Value::as_array).ok_or("give `points` or `d`")?;
|
||
let mut anchors = vec![];
|
||
for (i, pt) in pts.iter().enumerate() {
|
||
let anchor = match pt {
|
||
Value::Array(xy) if xy.len() >= 2 && xy[0].is_number() && xy[1].is_number() => json!({"x": xy[0], "y": xy[1]}),
|
||
Value::Object(o) if o.get("x").is_some_and(Value::is_number) && o.get("y").is_some_and(Value::is_number) => pt.clone(),
|
||
_ => return Err(format!("point {i} must be [x,y] or {{x,y,…}}")),
|
||
};
|
||
anchors.push(anchor);
|
||
}
|
||
if anchors.is_empty() {
|
||
return Err("`points` is empty".into());
|
||
}
|
||
p["anchors"] = Value::Array(anchors);
|
||
p["closed"] = json!(a.get("closed").and_then(Value::as_bool).unwrap_or(false));
|
||
}
|
||
let r = exec(b, "path.create", p)?;
|
||
let paint = apply_paint(b, a, created_id(&r))?;
|
||
Ok(json!({"id": r.get("id"), "paint": paint}))
|
||
}
|
||
|
||
fn draw_shape(b: &mut dyn Backend, a: &Args) -> Result<Value, String> {
|
||
let shape = req_str(a, "shape")?;
|
||
let cmd = match shape {
|
||
"rectangle" | "rect" => "shape.rectangle",
|
||
"ellipse" | "circle" => "shape.ellipse",
|
||
"polygon" => "shape.polygon",
|
||
"star" => "shape.star",
|
||
"line" => "shape.line",
|
||
other => return Err(format!("unknown shape `{other}` (rectangle, ellipse, polygon, star, line)")),
|
||
};
|
||
let mut p = a.clone();
|
||
for k in ["shape", "fill", "stroke", "strokeWidth"] {
|
||
p.remove(k);
|
||
}
|
||
let r = exec(b, cmd, Value::Object(p))?;
|
||
let paint = apply_paint(b, a, created_id(&r))?;
|
||
Ok(json!({"id": r.get("id"), "shape": shape, "paint": paint}))
|
||
}
|
||
|
||
/// Select `ids` when given (tools that act on the selection).
|
||
fn select_ids(b: &mut dyn Backend, a: &Args) -> Result<(), String> {
|
||
if let Some(ids) = a.get("ids") {
|
||
exec(b, "select.set", json!({"ids": ids}))?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn add_text(b: &mut dyn Backend, a: &Args) -> Result<Value, String> {
|
||
let text = a.get("text").and_then(Value::as_str).ok_or("missing string argument `text`")?;
|
||
let mut style = json!({});
|
||
for k in ["size", "font"] {
|
||
if let Some(v) = a.get(k) {
|
||
style[k] = v.clone();
|
||
}
|
||
}
|
||
let r = if let Some(path) = a.get("path") {
|
||
let mut p = json!({"path": path, "mode": a.get("mode").cloned().unwrap_or(json!("onPath")), "text": text});
|
||
for (k, v) in style.as_object().unwrap() {
|
||
p[k.as_str()] = v.clone();
|
||
}
|
||
exec(b, "text.createInPath", p)?
|
||
} else {
|
||
let x = a.get("x").and_then(Value::as_f64).ok_or("give `x` and `y` (or a `path`)")?;
|
||
let y = a.get("y").and_then(Value::as_f64).ok_or("give `x` and `y` (or a `path`)")?;
|
||
let mut p = json!({"x": x, "y": y, "text": text});
|
||
for (k, v) in style.as_object().unwrap() {
|
||
p[k.as_str()] = v.clone();
|
||
}
|
||
if let Some(c) = a.get("color") {
|
||
p["color"] = c.clone();
|
||
}
|
||
if let (Some(w), Some(h)) = (a.get("width"), a.get("height")) {
|
||
p["area"] = json!({"width": w, "height": h});
|
||
}
|
||
exec(b, "text.create", p)?
|
||
};
|
||
if let Some(e) = a.get("pathEffect").and_then(Value::as_str) {
|
||
exec(b, "type.pathOptions", json!({"effect": e}))?;
|
||
}
|
||
Ok(json!({"id": r.get("id")}))
|
||
}
|
||
|
||
fn transform(b: &mut dyn Backend, a: &Args) -> Result<Value, String> {
|
||
select_ids(b, a)?;
|
||
// With `copy`, the first operation duplicates and the rest transform the copy (the new selection).
|
||
let copy = std::cell::Cell::new(a.get("copy").and_then(Value::as_bool).unwrap_or(false));
|
||
let with = |mut p: Value| {
|
||
if let Some(o) = a.get("origin") {
|
||
p["origin"] = o.clone();
|
||
}
|
||
p["copy"] = json!(copy.replace(false));
|
||
p
|
||
};
|
||
let mut done = vec![];
|
||
if a.contains_key("dx") || a.contains_key("dy") {
|
||
let num = |k: &str| a.get(k).and_then(Value::as_f64).unwrap_or(0.0);
|
||
exec(b, "object.move", json!({"dx": num("dx"), "dy": num("dy"), "copy": copy.replace(false)}))?;
|
||
done.push("move");
|
||
}
|
||
if let Some(r) = a.get("rotate").and_then(Value::as_f64) {
|
||
exec(b, "object.rotate", with(json!({"angle": r})))?;
|
||
done.push("rotate");
|
||
}
|
||
let s = a.get("scale").and_then(Value::as_f64);
|
||
let (sx, sy) = (a.get("scaleX").and_then(Value::as_f64).or(s), a.get("scaleY").and_then(Value::as_f64).or(s));
|
||
if sx.is_some() || sy.is_some() {
|
||
exec(b, "object.scale", with(json!({"sx": sx.unwrap_or(100.0), "sy": sy.unwrap_or(100.0)})))?;
|
||
done.push("scale");
|
||
}
|
||
if let Some(axis) = a.get("reflect").and_then(Value::as_str) {
|
||
exec(b, "object.reflect", with(json!({"axis": axis})))?;
|
||
done.push("reflect");
|
||
}
|
||
if let Some(sh) = a.get("shear").and_then(Value::as_f64) {
|
||
exec(b, "object.shear", with(json!({"angle": sh})))?;
|
||
done.push("shear");
|
||
}
|
||
if done.is_empty() {
|
||
return Err("give at least one of dx/dy, rotate, scale/scaleX/scaleY, reflect, shear".into());
|
||
}
|
||
Ok(json!({"applied": done}))
|
||
}
|
||
|
||
fn screenshot(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
||
let path = a.get("path").and_then(Value::as_str);
|
||
if a.get("window").and_then(Value::as_bool) == Some(true) {
|
||
need_ui(b, "screenshot {window:true}")?;
|
||
// The app writes the capture to disk (loopback: same machine), then we read it back.
|
||
let tmp;
|
||
let target = match path {
|
||
Some(p) => p,
|
||
None => {
|
||
tmp = std::env::temp_dir().join(format!("vectorcraft-window-{}.png", std::process::id())).to_string_lossy().to_string();
|
||
&tmp
|
||
}
|
||
};
|
||
let r = b.call("ui.screenshot", json!({"path": target}))?;
|
||
let png = std::fs::read(target).map_err(|e| format!("read {target}: {e}"))?;
|
||
if path.is_none() {
|
||
std::fs::remove_file(target).ok();
|
||
}
|
||
return Ok(image_result(&png, json!({"window": true, "width": r.get("width"), "height": r.get("height"), "path": path})));
|
||
}
|
||
let mut p = json!({});
|
||
for k in ["scale", "artboard"] {
|
||
if let Some(v) = a.get(k) {
|
||
p[k] = v.clone();
|
||
}
|
||
}
|
||
let r = b.call("ui.render", p)?;
|
||
let b64 = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?;
|
||
if let Some(path) = path {
|
||
let png = vectorcraft_format::base64_decode(b64).ok_or("renderer returned bad base64")?;
|
||
std::fs::write(path, png).map_err(|e| format!("write {path}: {e}"))?;
|
||
}
|
||
Ok(ToolResult {
|
||
content: vec![
|
||
json!({"type": "image", "data": b64, "mimeType": "image/png"}),
|
||
json!({"type": "text", "text": json!({"width": r.get("width"), "height": r.get("height"), "path": path}).to_string()}),
|
||
],
|
||
is_error: false,
|
||
})
|
||
}
|
||
|
||
fn image_result(png: &[u8], info: Value) -> ToolResult {
|
||
ToolResult {
|
||
content: vec![
|
||
json!({"type": "image", "data": vectorcraft_format::base64_encode(png), "mimeType": "image/png"}),
|
||
json!({"type": "text", "text": info.to_string()}),
|
||
],
|
||
is_error: false,
|
||
}
|
||
}
|
||
|
||
fn filter_commands(all: Value, a: &Args) -> Value {
|
||
let needle = a.get("filter").and_then(Value::as_str).map(str::to_lowercase).filter(|s| !s.is_empty());
|
||
let enabled_only = a.get("enabledOnly").and_then(Value::as_bool).unwrap_or(false);
|
||
let Value::Array(list) = all else { return all };
|
||
let hit = |c: &Value| {
|
||
if enabled_only && c.get("enabled").and_then(Value::as_bool) == Some(false) {
|
||
return false;
|
||
}
|
||
let Some(n) = &needle else { return true };
|
||
let field = |k: &str| c.get(k).and_then(Value::as_str).unwrap_or("").to_lowercase();
|
||
let menu = c
|
||
.get("menu")
|
||
.and_then(Value::as_array)
|
||
.map(|m| m.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" "))
|
||
.unwrap_or_default()
|
||
.to_lowercase();
|
||
field("id").contains(n) || field("label").contains(n) || menu.contains(n)
|
||
};
|
||
Value::Array(list.into_iter().filter(hit).collect())
|
||
}
|
||
|
||
fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, String> {
|
||
let j = |v: Value| Ok(ToolResult::json(&v));
|
||
match name {
|
||
"list_commands" => {
|
||
let all = b.call("engine.commands", json!({}))?;
|
||
let list = filter_commands(all, a);
|
||
j(json!({"count": list.as_array().map_or(0, Vec::len), "commands": list}))
|
||
}
|
||
"run_command" | "invoke_menu" => {
|
||
let cmd = req_str(a, "command")?;
|
||
let params = match a.get("params") {
|
||
None | Some(Value::Null) => json!({}),
|
||
Some(v @ Value::Object(_)) => v.clone(),
|
||
Some(_) => return Err("`params` must be an object".into()),
|
||
};
|
||
let method = if name == "invoke_menu" && b.has_ui() { "ui.menu.invoke" } else { "engine.execute" };
|
||
j(b.call(method, json!({"command": cmd, "params": params}))?)
|
||
}
|
||
"inspect_document" => j(b.call("document.inspect", json!({}))?),
|
||
"inspect_ui" => {
|
||
need_ui(b, name)?;
|
||
j(b.call("ui.inspect", json!({}))?)
|
||
}
|
||
"select_tool" => j(b.call("ui.tool.select", json!({"tool": req_str(a, "tool")?}))?),
|
||
"pointer_gesture" => {
|
||
let events = a.get("events").and_then(Value::as_array).filter(|e| !e.is_empty()).ok_or("`events` must be a non-empty array")?;
|
||
for (i, e) in events.iter().enumerate() {
|
||
let kind = e.get("kind").and_then(Value::as_str).unwrap_or("");
|
||
if crate::headless::pointer_kind(kind).is_none() {
|
||
return Err(format!("event {i}: unknown kind `{kind}` (down, drag, up, move, doubleclick)"));
|
||
}
|
||
if !e.get("x").is_some_and(Value::is_number) || !e.get("y").is_some_and(Value::is_number) {
|
||
return Err(format!("event {i}: numeric `x` and `y` required"));
|
||
}
|
||
}
|
||
if let Some(t) = a.get("tool").and_then(Value::as_str) {
|
||
b.call("ui.tool.select", json!({"tool": t}))?;
|
||
}
|
||
let events: Vec<Value> = events
|
||
.iter()
|
||
.map(|e| {
|
||
let mut e = e.clone();
|
||
e["space"] = json!("doc");
|
||
e
|
||
})
|
||
.collect();
|
||
let mut p = json!({"events": events});
|
||
if let Some(m) = a.get("mods") {
|
||
p["mods"] = m.clone();
|
||
}
|
||
j(b.call("ui.pointer", p)?)
|
||
}
|
||
"draw_path" => j(draw_path(b, a)?),
|
||
"draw_shape" => j(draw_shape(b, a)?),
|
||
"set_paint" => {
|
||
if !["fill", "stroke", "strokeWidth"].iter().any(|k| a.contains_key(*k)) {
|
||
return Err("give at least one of `fill`, `stroke`, `strokeWidth`".into());
|
||
}
|
||
let ids = a.get("ids").cloned();
|
||
j(json!({"applied": apply_paint(b, a, ids)?}))
|
||
}
|
||
"press_key" => {
|
||
let mut p = json!({"key": req_str(a, "key")?});
|
||
if let Some(Value::Object(m)) = a.get("mods") {
|
||
for (k, v) in m {
|
||
p[k.as_str()] = v.clone();
|
||
}
|
||
}
|
||
j(b.call("ui.key", p)?)
|
||
}
|
||
"type_text" => {
|
||
need_ui(b, name)?;
|
||
j(b.call("ui.text", json!({"text": req_str(a, "text")?}))?)
|
||
}
|
||
"open_panel" => {
|
||
need_ui(b, name)?;
|
||
j(b.call("ui.set", json!({"panel": req_str(a, "panel")?}))?)
|
||
}
|
||
"screenshot" => screenshot(b, a),
|
||
"open_file" => j(b.call("app.open", json!({"path": req_str(a, "path")?}))?),
|
||
"save_file" => j(b.call("app.save", json!({"path": a.get("path").and_then(Value::as_str)}))?),
|
||
"export" => {
|
||
let path = req_str(a, "path")?;
|
||
let fmt = a
|
||
.get("format")
|
||
.and_then(Value::as_str)
|
||
.map(str::to_ascii_lowercase)
|
||
.unwrap_or_else(|| std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default());
|
||
if !["svg", "png", "pdf", "jpg", "jpeg", "webp", "vectorcraft"].contains(&fmt.as_str()) {
|
||
return Err(format!("unknown export format `{fmt}` (svg, png, pdf, jpg, webp, vectorcraft)"));
|
||
}
|
||
let scale = a.get("scale").and_then(Value::as_f64).unwrap_or(1.0);
|
||
if a.get("selection").and_then(Value::as_bool) == Some(true) {
|
||
return j(exec(
|
||
b,
|
||
"document.exportSelection",
|
||
json!({"path": path, "format": fmt, "scale": scale, "outlineText": a.get("outlineText")}),
|
||
)?);
|
||
}
|
||
if a.get("outlineText").and_then(Value::as_bool) == Some(true) {
|
||
return j(exec(b, "document.export", json!({"path": path, "format": fmt, "scale": scale, "outlineText": true}))?);
|
||
}
|
||
// The desktop app exports native files through Save (which also sets the document path).
|
||
if fmt == "vectorcraft" && b.has_ui() {
|
||
return j(b.call("app.save", json!({"path": path}))?);
|
||
}
|
||
j(b.call("app.export", json!({"format": fmt, "path": path, "scale": scale}))?)
|
||
}
|
||
"add_text" => j(add_text(b, a)?),
|
||
"apply_effect" => {
|
||
let Some(effect) = a.get("effect").and_then(Value::as_str) else {
|
||
let all = exec(b, "effect.list", json!({}))?;
|
||
return j(json!({"catalog": all.get("catalog")}));
|
||
};
|
||
let mut p = json!({"effect": effect, "params": a.get("params").cloned().unwrap_or(json!({}))});
|
||
if let Some(ids) = a.get("ids") {
|
||
p["ids"] = ids.clone();
|
||
}
|
||
j(exec(b, "effect.apply", p)?)
|
||
}
|
||
"pathfinder" => {
|
||
select_ids(b, a)?;
|
||
j(exec(b, &format!("object.pathfinder.{}", req_str(a, "operation")?), json!({}))?)
|
||
}
|
||
"transform" => j(transform(b, a)?),
|
||
"create_graph" => {
|
||
let mut p = Value::Object(a.clone());
|
||
if p.get("type").is_none() {
|
||
p["type"] = json!("column");
|
||
}
|
||
j(exec(b, "graph.create", p)?)
|
||
}
|
||
"text_wrap" => {
|
||
select_ids(b, a)?;
|
||
if a.get("release").and_then(Value::as_bool) == Some(true) {
|
||
return j(exec(b, "object.textWrap.release", json!({}))?);
|
||
}
|
||
let mut p = json!({});
|
||
for k in ["offset", "invert"] {
|
||
if let Some(v) = a.get(k) {
|
||
p[k] = v.clone();
|
||
}
|
||
}
|
||
j(exec(b, "object.textWrap.make", p)?)
|
||
}
|
||
"undo" => j(exec(b, "edit.undo", json!({}))?),
|
||
"redo" => j(exec(b, "edit.redo", json!({}))?),
|
||
other => Err(format!("unknown tool `{other}` (see tools/list)")),
|
||
}
|
||
}
|
||
|
||
/// Run one tool. Failures (unknown tool, bad arguments, command errors) come back as an
|
||
/// `isError` result so the model can read and correct them.
|
||
pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
||
let empty = Map::new();
|
||
let a = match args {
|
||
Value::Object(o) => o,
|
||
Value::Null => &empty,
|
||
_ => return ToolResult::error("tool arguments must be a JSON object"),
|
||
};
|
||
match dispatch(b, name, a) {
|
||
Ok(r) => r,
|
||
Err(e) => ToolResult::error(format!("{name}: {e}")),
|
||
}
|
||
}
|