mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 04:37:29 -06:00
457 lines
21 KiB
Rust
457 lines
21 KiB
Rust
use super::definitions::{INSPECT_KEYS, SAVE_FILE_OPTIONS, tool_definitions};
|
|
use super::handlers::{
|
|
Args, absolute_path, add_text, answer_dialog, apply_paint, draw_path, draw_shape, exec, image_result, max_side, need_ui, req_str, screenshot,
|
|
select_ids, transform,
|
|
};
|
|
use super::{ARTBOARD_PARAMS, Backend, Map, ToolResult, Value, format, json, save_reads_option, writable_format};
|
|
|
|
/// The `menu` argument of the command listings.
|
|
pub(super) const MENU_FILTER: &str =
|
|
"Case-insensitive substring of the menu path, its levels joined with \" > \" (e.g. \"Object\", \"Object > Path\")";
|
|
|
|
/// The `brief` argument of the command listings.
|
|
pub(super) const BRIEF: &str =
|
|
"Leave out each command's params description (the bulk of the catalogue); run command_list with the id as filter to read it";
|
|
|
|
/// The `max_side` argument of the window captures.
|
|
pub(super) const MAX_SIDE_WINDOW: &str =
|
|
"Window capture only: longest side of the returned image in pixels; a larger capture is scaled down to it (default: the full capture)";
|
|
|
|
/// The `traceViews` argument of the artboard renders.
|
|
pub(super) const TRACE_VIEWS: &str = "Draw Image Trace objects in the view set with imageTrace.setView (outlines, the source image…), as the app's window does; by default they draw their tracing result, as exports do";
|
|
|
|
/// `traceViews`: false when absent.
|
|
pub(super) fn trace_views(a: &Args) -> Result<bool, String> {
|
|
match a.get("traceViews") {
|
|
None => Ok(false),
|
|
Some(v) => v.as_bool().ok_or_else(|| "`traceViews` must be a boolean".into()),
|
|
}
|
|
}
|
|
|
|
/// The command catalogue narrowed by the listing tools' arguments: `filter` (or `query`) on the
|
|
/// id, label and menu path, `menu` on the menu path, `enabledOnly`, and `brief` (no `params`
|
|
/// text: the full catalogue is too large for many clients, #2173).
|
|
fn filter_commands(all: Value, a: &Args) -> Value {
|
|
let text = |k: &str| a.get(k).and_then(Value::as_str).map(str::to_lowercase).filter(|s| !s.is_empty());
|
|
let needle = text("filter").or_else(|| text("query"));
|
|
let in_menu = text("menu");
|
|
let enabled_only = a.get("enabledOnly").and_then(Value::as_bool).unwrap_or(false);
|
|
let brief = a.get("brief").and_then(Value::as_bool).unwrap_or(false);
|
|
let Value::Array(list) = all else { return all };
|
|
let menu_path = |c: &Value, sep: &str| {
|
|
c.get("menu")
|
|
.and_then(Value::as_array)
|
|
.map(|m| m.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(sep))
|
|
.unwrap_or_default()
|
|
.to_lowercase()
|
|
};
|
|
let hit = |c: &Value| {
|
|
if enabled_only && c.get("enabled").and_then(Value::as_bool) == Some(false) {
|
|
return false;
|
|
}
|
|
if let Some(m) = &in_menu
|
|
&& !menu_path(c, " > ").contains(m.as_str())
|
|
{
|
|
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();
|
|
field("id").contains(n.as_str()) || field("label").contains(n.as_str()) || menu_path(c, " ").contains(n.as_str())
|
|
};
|
|
Value::Array(
|
|
list.into_iter()
|
|
.filter(hit)
|
|
.map(|mut c| {
|
|
if brief && let Some(o) = c.as_object_mut() {
|
|
o.remove("params");
|
|
}
|
|
c
|
|
})
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, String> {
|
|
let j = |v: Value| Ok(ToolResult::json(&v));
|
|
match name {
|
|
"command_list" => {
|
|
let all = b.call("engine.commands", json!({}))?;
|
|
let mut filters = a.clone();
|
|
if let Some(v) = filters.remove("enabled_only") {
|
|
filters.insert("enabledOnly".into(), v);
|
|
}
|
|
j(filter_commands(all, &filters))
|
|
}
|
|
"command_batch" => command_batch(b, a),
|
|
"render_preview" => render_preview(b, a),
|
|
"ui_screenshot" => {
|
|
let mut args = a.clone();
|
|
args.insert("window".into(), json!(true));
|
|
screenshot(b, &args)
|
|
}
|
|
"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}))
|
|
}
|
|
"command_run" | "run_command" | "invoke_menu" => {
|
|
let cmd = req_str(a, if name == "command_run" { "id" } else { "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}))?)
|
|
}
|
|
"doc_inspect" | "inspect_document" => {
|
|
// Only the slicing and scope options reach the command, which validates them.
|
|
let slice: Map<String, Value> =
|
|
a.iter().filter(|(k, _)| INSPECT_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect();
|
|
j(b.call("document.inspect", Value::Object(slice))?)
|
|
}
|
|
"ui_inspect" | "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 (applied, objects) = apply_paint(b, a, a.get("ids").cloned())?;
|
|
let mut reply = json!({"applied": applied, "objects": objects});
|
|
if objects == 0 {
|
|
reply["note"] = json!(
|
|
"no object was painted (nothing selected, or `ids` is empty or holds no object this paint applies to): only the paint for new art changed"
|
|
);
|
|
}
|
|
j(reply)
|
|
}
|
|
"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")?}))?)
|
|
}
|
|
"answer_dialog" => {
|
|
need_ui(b, name)?;
|
|
j(answer_dialog(b, a)?)
|
|
}
|
|
"open_panel" => {
|
|
need_ui(b, name)?;
|
|
j(b.call("ui.set", json!({"panel": req_str(a, "panel")?}))?)
|
|
}
|
|
"screenshot" => screenshot(b, a),
|
|
"open_file" => {
|
|
let path = absolute_path(b, req_str(a, "path")?)?;
|
|
j(b.call("app.open", json!({"path": path}))?)
|
|
}
|
|
"save_file" => {
|
|
let mut p = json!({"path": a.get("path").and_then(Value::as_str).map(|p| absolute_path(b, p)).transpose()?});
|
|
for k in SAVE_FILE_OPTIONS {
|
|
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
|
|
p[k] = v.clone();
|
|
}
|
|
}
|
|
// The app writes the file before it replies, even with Background Save on, so a failed
|
|
// save is this call's error.
|
|
if b.has_ui() {
|
|
p["wait"] = json!(true);
|
|
}
|
|
let mut r = b.call("app.save", p)?;
|
|
let notes = save_file_warnings(a, r.get("format").and_then(Value::as_str));
|
|
if let Some(o) = r.as_object_mut().filter(|_| !notes.is_empty()) {
|
|
let mut all = o.get("warnings").and_then(Value::as_array).cloned().unwrap_or_default();
|
|
all.extend(notes.into_iter().map(Value::from));
|
|
o.insert("warnings".into(), Value::Array(all));
|
|
}
|
|
j(r)
|
|
}
|
|
"export" => {
|
|
// One params object for every backend; the named arguments win over `options` (a named
|
|
// artboard or range replaces every artboard choice in `options`).
|
|
let mut params = a.get("options").and_then(Value::as_object).cloned().unwrap_or_default();
|
|
if ["artboard", "range"].iter().any(|k| a.get(*k).is_some_and(|v| !v.is_null())) {
|
|
for k in ARTBOARD_PARAMS {
|
|
params.remove(k);
|
|
}
|
|
}
|
|
for k in named_export_params(a) {
|
|
if let Some(v) = a.get(*k).filter(|v| !v.is_null()) {
|
|
params.insert((*k).into(), v.clone());
|
|
}
|
|
}
|
|
if let Some(path) = params.get("path").and_then(Value::as_str) {
|
|
let path = absolute_path(b, path)?;
|
|
params.insert("path".into(), json!(path));
|
|
}
|
|
let cmd = if a.get("selection").and_then(Value::as_bool) == Some(true) { "document.exportSelection" } else { "document.export" };
|
|
j(exec(b, cmd, Value::Object(params))?)
|
|
}
|
|
"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)")),
|
|
}
|
|
}
|
|
|
|
/// The named `export` arguments passed on to `document.export`. `scale` is raster pixels per
|
|
/// point, so only a raster format gets it: DXF's own (drawing units per unit, given in `options`)
|
|
/// would resize the drawing and leave its units unset.
|
|
fn named_export_params(a: &Args) -> &'static [&'static str] {
|
|
let format = writable_format(a.get("format").and_then(Value::as_str), a.get("path").and_then(Value::as_str));
|
|
if format.is_ok_and(|f| f.raster) {
|
|
&["path", "format", "scale", "artboard", "range", "outlineText"]
|
|
} else {
|
|
&["path", "format", "artboard", "range", "outlineText"]
|
|
}
|
|
}
|
|
|
|
/// What `save_file` with arguments `a` adds to the save's `warnings`, for a file written in format
|
|
/// `written` (none when nothing was written yet): each key in `options` that the format doesn't
|
|
/// read. The save itself warns about `compress`, `version` and `preview` given at the top level.
|
|
fn save_file_warnings(a: &Args, written: Option<&str>) -> Vec<String> {
|
|
let Some(f) = written.and_then(format) else { return vec![] };
|
|
let nested = a.get("options").and_then(Value::as_object);
|
|
nested
|
|
.into_iter()
|
|
.flatten()
|
|
.filter(|(key, v)| !v.is_null() && !save_reads_option(f, key))
|
|
.map(|(key, _)| format!("`options.{key}` was not used: {} files don't read it", f.label))
|
|
.collect()
|
|
}
|
|
|
|
/// 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 vectorcraft_engine::guard::catch_panic(|| dispatch(b, name, a)) {
|
|
Ok(Ok(r)) => r,
|
|
Ok(Err(e)) => ToolResult::error(format!("{name}: {e}")),
|
|
Err(e) => ToolResult::error(format!("internal error in `{name}`: {e} (please report this bug)")),
|
|
}
|
|
}
|
|
|
|
/// Most steps one `command_batch` may hold, and most bytes its kept step results may total.
|
|
/// Batches run sequentially and every result is kept for later steps' `"$N.path"` references, so
|
|
/// without caps one batch could do unbounded work and pile up an unbounded reply (#1727).
|
|
pub(crate) const MAX_COMMAND_BATCH_STEPS: usize = 256;
|
|
|
|
pub(crate) const MAX_COMMAND_BATCH_BYTES: usize = 8 * 1024 * 1024;
|
|
|
|
/// Refuses an argument the tool's schema doesn't list, as `tools/call` does (#713).
|
|
pub(crate) fn check_tool_arguments(name: &str, args: &Args) -> Result<(), String> {
|
|
let defs = tool_definitions();
|
|
let props =
|
|
defs.iter().find(|t| t.get("name").and_then(Value::as_str) == Some(name)).and_then(|t| t.get("inputSchema")?.get("properties")?.as_object());
|
|
match props.and_then(|p| args.keys().find(|k| !p.contains_key(*k))) {
|
|
Some(key) => Err(format!("unknown argument `{key}` for `{name}`")),
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
|
|
fn command_batch(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
|
let steps = a.get("steps").and_then(Value::as_array).ok_or("`steps` must be an array")?;
|
|
if steps.len() > MAX_COMMAND_BATCH_STEPS {
|
|
return Err(format!("`steps` holds {} steps; at most {MAX_COMMAND_BATCH_STEPS}", steps.len()));
|
|
}
|
|
let stop = match a.get("stop_on_error") {
|
|
None => true,
|
|
Some(v) => v.as_bool().ok_or("`stop_on_error` must be a boolean")?,
|
|
};
|
|
let atomic = match a.get("atomic") {
|
|
None => false,
|
|
Some(v) => v.as_bool().ok_or("`atomic` must be a boolean")?,
|
|
};
|
|
if atomic {
|
|
let label = a.get("label").cloned().unwrap_or(Value::Null);
|
|
b.call("engine.execute", json!({"command":"edit.undoGroup.begin","params":{"label":label}}))?;
|
|
} else if a.get("label").is_some() {
|
|
return Err("`label` names the undo step of an atomic batch: give atomic:true too".into());
|
|
}
|
|
let (mut completed, mut failed, mut results) = (0, 0, Vec::new());
|
|
// Each step's result (null for a failed one), for later steps' `"$N.path"` references.
|
|
let mut values: Vec<Value> = vec![];
|
|
// The kept results all go back in the reply, so their total size is capped: one batch can't
|
|
// pile up an unbounded answer (#1727).
|
|
let mut kept_bytes = 0usize;
|
|
// The objects the drawing steps made (#2113).
|
|
let mut created: Vec<Value> = vec![];
|
|
for step in steps {
|
|
let result = vectorcraft_engine::guard::catch_panic(|| {
|
|
let args = step.as_object().ok_or("each step must be an object")?;
|
|
if args.keys().any(|k| k != "id" && k != "params") {
|
|
return Err("unknown step argument (expected id, params)".into());
|
|
}
|
|
let id = req_str(args, "id")?;
|
|
let params = match args.get("params") {
|
|
None | Some(Value::Null) => json!({}),
|
|
Some(v @ Value::Object(_)) => vectorcraft_engine::steps::resolve(v, &values)?,
|
|
Some(_) => return Err("`params` must be an object".into()),
|
|
};
|
|
match id {
|
|
// The drawing tools, with the arguments `tools/call` takes (#2113).
|
|
"draw_path" | "draw_shape" => {
|
|
let args = params.as_object().cloned().unwrap_or_default();
|
|
check_tool_arguments(id, &args)?;
|
|
let made = if id == "draw_path" { draw_path(b, &args)? } else { draw_shape(b, &args)? };
|
|
if let Some(new) = made.get("id").filter(|v| v.is_u64()) {
|
|
created.push(new.clone());
|
|
}
|
|
Ok(made)
|
|
}
|
|
_ => b.call("engine.execute", json!({"command":id,"params":params})),
|
|
}
|
|
})
|
|
.unwrap_or_else(|e| Err(format!("internal error: {e}")));
|
|
match result {
|
|
Ok(v) => {
|
|
let entry = json!({"ok": true, "result": v});
|
|
let size = entry.to_string().len();
|
|
if kept_bytes.saturating_add(size) > MAX_COMMAND_BATCH_BYTES {
|
|
// The results already kept stay; this step ran, and its result is replaced by
|
|
// the reason the batch stops (saying it ran, so nobody runs it again). The loop breaks either way — carrying on would defeat the cap —
|
|
// and the atomic group below still closes, as for any stopped batch.
|
|
failed += 1;
|
|
values.push(Value::Null);
|
|
results.push(json!({"ok": false, "error": format!("this step ran, but the batch's results exceed {MAX_COMMAND_BATCH_BYTES} bytes: its result is dropped and the batch stops here")}));
|
|
break;
|
|
}
|
|
kept_bytes += size;
|
|
completed += 1;
|
|
values.push(entry["result"].clone());
|
|
results.push(entry);
|
|
}
|
|
Err(e) => {
|
|
failed += 1;
|
|
values.push(Value::Null);
|
|
results.push(json!({"ok":false,"error":e}));
|
|
if stop {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// The steps that ran stay, as one undo step.
|
|
if atomic {
|
|
b.call("engine.execute", json!({"command":"edit.undoGroup.end","params":{}}))?;
|
|
}
|
|
let mut r = ToolResult::json(&json!({"completed":completed,"failed":failed,"results":results,"created":created}));
|
|
r.is_error = failed > 0;
|
|
Ok(r)
|
|
}
|
|
|
|
fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
|
let max = max_side(a)?.unwrap_or(1024);
|
|
let index = match a.get("artboard") {
|
|
None => 0,
|
|
Some(v) => v.as_u64().ok_or("`artboard` must be a 0-based artboard index")?,
|
|
};
|
|
let trace_views = trace_views(a)?;
|
|
let doc = b.call("document.inspect", json!({"depth":0}))?;
|
|
let board =
|
|
doc.get("artboards").and_then(Value::as_array).and_then(|v| v.get(usize::try_from(index).ok()?)).ok_or(format!("no artboard {index}"))?;
|
|
let width = board.get("width").and_then(Value::as_f64).ok_or("artboard has no width")?;
|
|
let height = board.get("height").and_then(Value::as_f64).ok_or("artboard has no height")?;
|
|
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
|
|
return Err("invalid artboard dimensions".into());
|
|
}
|
|
// The headless renderer clamps scale at 0.01. Bound its allocation even for
|
|
// a huge artboard, then downsize to the requested dimensions.
|
|
let scale = (f64::from(max) / width.max(height)).clamp(0.01, 1.0);
|
|
if width.max(height) * scale > 4096.0 {
|
|
return Err("artboard is too large for a bounded preview".into());
|
|
}
|
|
let mut p = json!({"scale":scale, "artboard": index});
|
|
if trace_views {
|
|
p["traceViews"] = json!(true);
|
|
}
|
|
let r = b.call("ui.render", p)?;
|
|
let encoded = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?;
|
|
let png = vectorcraft_format::base64_decode(encoded).ok_or("renderer returned bad base64")?;
|
|
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| format!("read preview: {e}"))?;
|
|
let img = if img.width() > max || img.height() > max { img.thumbnail(max, max) } else { img };
|
|
let mut out = std::io::Cursor::new(Vec::new());
|
|
img.write_to(&mut out, image::ImageFormat::Png).map_err(|e| format!("encode preview: {e}"))?;
|
|
Ok(image_result(out.get_ref(), json!({"width":img.width(),"height":img.height()})))
|
|
}
|