vectorcraft/crates/mcp/src/tools.rs

815 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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}")),
}
}