diff --git a/ASSETS.md b/ASSETS.md index 81b0bd6..2ffe99c 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -12,14 +12,6 @@ Generated-in-code art is original and has no file to list. This covers the defau | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| -| `docs/brand/artcraft-logo-white.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | -| `docs/brand/artcraft-logo-white.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | -| `docs/brand/artcraft-logo.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | -| `docs/brand/artcraft-logo.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | -| `docs/brand/artcraft-mark-black.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | -| `docs/brand/artcraft-mark-black.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | -| `docs/brand/artcraft-mark.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | -| `docs/brand/artcraft-mark.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | | `assets/app-icon/LICENSE.txt` | (licence/readme text) | VectorCraft | MIT OR Apache-2.0 | | | `assets/app-icon/README.md` | (licence/readme text) | VectorCraft | MIT OR Apache-2.0 | | | `assets/app-icon/hicolor/128x128/apps/ai.storyteller.vectorcraft.png` | Project owner (ArtCraft team) | Original artwork drawn in ArtCraft for VectorCraft, vectorised and rendered by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon (engraved dragon) | diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md new file mode 100644 index 0000000..72946c8 --- /dev/null +++ b/ATTRIBUTION.md @@ -0,0 +1 @@ +Community build for vectorcraft.run, based on VectorCraft v0.12.0 by the ArtCraft contributors. Not an official upstream build. Adds a guarded browser Agent command bridge. Original MIT/Apache-2.0 licenses and notices are retained; upstream trademark artwork is omitted. diff --git a/Cargo.lock b/Cargo.lock index a41f608..293b79d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5005,6 +5005,7 @@ dependencies = [ "js-sys", "log", "rfd", + "serde_json", "vectorcraft-engine", "vectorcraft-format", "vectorcraft-text", diff --git a/README.md b/README.md index d3694dc..1c4c125 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,6 @@

- - - ArtCraft - + ArtCraft upstream project

@@ -375,6 +372,6 @@ Forks and modified versions must remove them. Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. VectorCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.

- ArtCraft
+ ArtCraft upstream project
Made by the ArtCraft team and community.

diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml index 2e9218d..a2c581e 100644 --- a/apps/vectorcraft-web/Cargo.toml +++ b/apps/vectorcraft-web/Cargo.toml @@ -14,6 +14,7 @@ name = "vectorcraft-web" path = "src/main.rs" [dependencies] +serde_json = { workspace = true } vectorcraft-engine = { workspace = true } vectorcraft-ui-egui = { workspace = true } vectorcraft-format = { workspace = true } diff --git a/apps/vectorcraft-web/src/api.rs b/apps/vectorcraft-web/src/api.rs new file mode 100644 index 0000000..8d9c8a7 --- /dev/null +++ b/apps/vectorcraft-web/src/api.rs @@ -0,0 +1,130 @@ +//! Community web command bridge for vectorcraft.run (2026-10-17). +//! Uses the existing UI control queue; no network listener or file-upload service. +use serde_json::{Value, json}; +use std::cell::RefCell; +use std::sync::mpsc::{Receiver, Sender, TryRecvError}; +use vectorcraft_ui_egui::ControlRequest; +use wasm_bindgen::prelude::*; + +type Waiting = (Receiver, js_sys::Function, js_sys::Function); +thread_local! { + static DIRTY: std::cell::Cell = const { std::cell::Cell::new(false) }; + static TX: RefCell>> = const { RefCell::new(None) }; + static WAITING: RefCell> = const { RefCell::new(Vec::new()) }; + static CTX: RefCell> = const { RefCell::new(None) }; +} + +fn from_js(v: &JsValue) -> Result { + if v.is_null() || v.is_undefined() { + return Ok(json!({})); + } + let text = js_sys::JSON::stringify(v)?.as_string().ok_or_else(|| js_sys::Error::new("parameters must be JSON"))?; + if text.len() > 1_048_576 { + return Err(js_sys::Error::new("request exceeds 1 MiB").into()); + } + let value: Value = serde_json::from_str(&text).map_err(|e| js_sys::Error::new(&e.to_string()))?; + if !value.is_object() { + return Err(js_sys::Error::new("parameters must be an object").into()); + } + Ok(value) +} + +fn request(method: String, params: Value) -> js_sys::Promise { + if !matches!(method.as_str(), "engine.execute" | "agent.execute" | "engine.commands" | "agent.state" | "agent.export") { + return js_sys::Promise::reject(&js_sys::Error::new("unsupported web method")); + } + if WAITING.with(|w| w.borrow().len() >= 32) { + return js_sys::Promise::reject(&js_sys::Error::new("too many pending requests")); + } + let promise = js_sys::Promise::new(&mut |resolve, reject| { + let (req, rx) = ControlRequest::new(method.clone(), params.clone()); + if TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok())) { + WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject))); + } else { + let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("VectorCraft is not ready")); + } + }); + CTX.with(|c| { + if let Some(ctx) = c.borrow().as_ref() { + ctx.request_repaint(); + } + }); + promise +} + +pub fn poll_replies(dirty: bool) { + DIRTY.with(|d| d.set(dirty)); + WAITING.with(|w| { + w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() { + Ok(v) => { + if v.get("ok").and_then(Value::as_bool) == Some(true) { + match js_sys::JSON::parse(&v.get("result").unwrap_or(&Value::Null).to_string()) { + Ok(value) => { + let _ = resolve.call1(&JsValue::NULL, &value); + } + Err(error) => { + let _ = reject.call1(&JsValue::NULL, &error); + } + } + } else { + let error = js_sys::Error::new(v.get("error").and_then(Value::as_str).unwrap_or("command failed")); + let _ = reject.call1(&JsValue::NULL, &error); + } + false + } + Err(TryRecvError::Empty) => true, + Err(TryRecvError::Disconnected) => { + let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("editor closed")); + false + } + }) + }); +} + +pub fn install(ctx: egui::Context) -> Result, String> { + let (tx, rx) = std::sync::mpsc::channel(); + TX.with(|t| *t.borrow_mut() = Some(tx)); + CTX.with(|c| *c.borrow_mut() = Some(ctx)); + let obj = js_sys::Object::new(); + let set = |name: &str, value: JsValue| js_sys::Reflect::set(&obj, &name.into(), &value).map_err(|_| format!("could not install {name}")); + set("apiVersion", "craft-online-1".into())?; + set("hasUnsavedChanges", Closure:: bool>::new(|| DIRTY.with(|d| d.get())).into_js_value())?; + set( + "request", + Closure:: js_sys::Promise>::new(|method: String, params: JsValue| match from_js(¶ms) { + Ok(p) => request(method, p), + Err(e) => js_sys::Promise::reject(&e), + }) + .into_js_value(), + )?; + set( + "execute", + Closure:: js_sys::Promise>::new(|command: String, params: JsValue, expected: JsValue| { + let parsed = from_js(¶ms).and_then(|p| { + let mut value = json!({"command": command, "params": p}); + if !expected.is_undefined() && !expected.is_null() { + value["expected"] = from_js(&expected)?; + } + Ok(value) + }); + match parsed { + Ok(p) => request(if p.get("expected").is_some() { "agent.execute" } else { "engine.execute" }.into(), p), + Err(e) => js_sys::Promise::reject(&e), + } + }) + .into_js_value(), + )?; + set("commands", Closure:: js_sys::Promise>::new(|| request("engine.commands".into(), json!({}))).into_js_value())?; + set("inspect", Closure:: js_sys::Promise>::new(|| request("agent.state".into(), json!({}))).into_js_value())?; + let win = web_sys::window().ok_or("no window")?; + use wasm_bindgen::JsCast as _; + let guard = Closure::::new(|event: web_sys::Event| { + if DIRTY.with(|d| d.get()) { + event.prevent_default(); + } + }); + win.add_event_listener_with_callback("beforeunload", guard.as_ref().unchecked_ref()).map_err(|_| "could not install unsaved-work guard")?; + guard.forget(); + js_sys::Reflect::set(&win, &"vectorcraft".into(), &obj).map_err(|_| "could not install window.vectorcraft")?; + Ok(rx) +} diff --git a/apps/vectorcraft-web/src/main.rs b/apps/vectorcraft-web/src/main.rs index 32cd107..210c3a9 100644 --- a/apps/vectorcraft-web/src/main.rs +++ b/apps/vectorcraft-web/src/main.rs @@ -1,20 +1,22 @@ -//! VectorCraft in the browser. -//! -//! Runs the same [`vectorcraft_ui_egui::VectorcraftApp`] as the desktop app through eframe's web -//! runner (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` -//! from this directory; see `docs/development.md` ("Web build"). -//! -//! Differences from the desktop app: -//! - no TCP control server (browsers can't listen on sockets); -//! - File → Open uses the browser file picker; bytes arrive asynchronously through -//! `Services::inbox`; -//! - Save / Export trigger a browser download; -//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox; -//! - pen and finger input is read from Pointer Events by `pencil` (Apple Pencil pressure and -//! hover, palm rejection, fingers navigate once a pen has been seen). -//! -//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any -//! language code) stands in for the browser's languages when the interface language is Automatic. +#[cfg(target_arch = "wasm32")] +mod api; +// VectorCraft in the browser. +// +// Runs the same [`vectorcraft_ui_egui::VectorcraftApp`] as the desktop app through eframe's web +// runner (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` +// from this directory; see `docs/development.md` ("Web build"). +// +// Differences from the desktop app: +// - no TCP control server (browsers can't listen on sockets); +// - File → Open uses the browser file picker; bytes arrive asynchronously through +// `Services::inbox`; +// - Save / Export trigger a browser download; +// - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox; +// - pen and finger input is read from Pointer Events by `pencil` (Apple Pencil pressure and +// hover, palm rejection, fingers navigate once a pen has been seen). +// +// URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any +// language code) stands in for the browser's languages when the interface language is Automatic. #[cfg(target_arch = "wasm32")] mod fonts; diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index cfe8458..28151c9 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -117,7 +117,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option { // The old services asked the old context for frames. app.services = services; @@ -126,6 +126,10 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option VectorcraftApp::new(Session::new(), services), }; + let q = web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default(); + if q.trim_start_matches('?').split('&').any(|p| p == "agent=1") { + app = app.with_control(crate::api::install(ctx.clone())?); + } Ok(Box::new(WebShell { app: Some(app), host, canvas: page, loss })) }), ) @@ -386,6 +390,7 @@ impl eframe::App for WebShell { } } app.logic(ctx); + crate::api::poll_replies(app.session.documents().iter().any(|d| d.is_dirty())); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { diff --git a/crates/text/web-fonts.txt b/crates/text/web-fonts.txt index 62354f6..c6b0c62 100644 --- a/crates/text/web-fonts.txt +++ b/crates/text/web-fonts.txt @@ -3,8 +3,8 @@ # startup = fetched before the app starts (fallbacks the first frame needs) # background = fetched after the app starts (fonts users pick by name) # Read by build.rs (the WEB_FONTS table) and `cargo xtask web-fonts` (copies the files, a Trunk hook). -startup | Noto Sans Arabic | Regular -startup | Noto Sans Thai | Regular +background | Noto Sans Arabic | Regular +background | Noto Sans Thai | Regular background | Cairo | Regular background | Tajawal | ExtraLight background | Tajawal | Light diff --git a/crates/ui-egui/src/community_agent.rs b/crates/ui-egui/src/community_agent.rs new file mode 100644 index 0000000..bb7a293 --- /dev/null +++ b/crates/ui-egui/src/community_agent.rs @@ -0,0 +1,246 @@ +//! Guarded, bounded browser Agent tools over the actual document and native undo history. +use crate::VectorcraftApp; +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind, TextKind}; +use vectorcraft_engine::DrawMode; +fn number(p: &Value, key: &str) -> Result { + p.get(key) + .and_then(Value::as_f64) + .filter(|v| v.is_finite() && v.abs() <= 100_000.0) + .ok_or_else(|| format!("{key} must be a finite number within 100000 points")) +} +fn id(p: &Value, key: &str) -> Result { + p.get(key).and_then(Value::as_u64).filter(|v| *v <= 9_007_199_254_740_991).ok_or_else(|| format!("{key} must be a safe non-negative integer")) +} +fn text<'a>(p: &'a Value, key: &str) -> Result<&'a str, String> { + p.get(key).and_then(Value::as_str).filter(|s| s.len() <= 16000).ok_or_else(|| format!("{key} must be at most 16000 UTF-8 bytes")) +} +fn editable(doc: &Document, n: &Node) -> bool { + doc.is_editable(n.id) + && doc + .ancestry(n.id) + .is_some_and(|a| a.iter().filter(|i| **i != n.id).all(|i| doc.node(*i).is_some_and(|n| n.is_layer() && !n.is_template()))) + && match &n.kind { + NodeKind::Path { .. } => true, + NodeKind::Text(t) => { + matches!(t.kind, TextKind::Point) && t.runs.iter().all(|r| r.inline.is_none()) && !doc.text_threads.iter().any(|t| t.contains(&n.id)) + } + _ => false, + } +} +fn summary(doc: &Document, n: &Node) -> Value { + let mut v = vectorcraft_engine::inspect::node_summary_opts( + n, + vectorcraft_engine::inspect::SummaryOpts { depth: Some(0), child_limit: Some(0), compound_member: false }, + &doc.symbol_boxes(), + ); + let complete = v.get("text").and_then(Value::as_str).is_none_or(|s| s.len() <= 16000); + if !complete { + v["text"] = json!(""); + } + v["name"] = json!(n.display_name().chars().take(160).collect::()); + v["editable"] = json!(editable(doc, n) && complete); + v["truncated"] = json!(!complete); + v +} +impl VectorcraftApp { + fn agent_token(&self) -> Result { + if self.ui.dialog.is_some() || self.file_dialog_open() || !self.opening.pending.is_empty() || self.screen_sampler.is_some() { + return Err("Finish or cancel the dialog or pending file operation before using the Agent.".into()); + } + let d = self.session.active().ok_or("Open or create a document in VectorCraft first.")?; + if d.interaction.is_some() + || d.undo_group.is_some() + || d.isolation.is_some() + || d.doc.mask_edit.is_some() + || self.session.tool_wants_text() + || !matches!(self.session.draw_mode, DrawMode::Normal) + { + return Err("Finish or cancel the current edit, exit isolation/mask mode and select the Selection tool in normal drawing mode before using the Agent.".into()); + } + Ok(json!({"uid":d.uid.to_string(),"revision":d.revision.to_string()})) + } + fn agent_state(&self, p: &Value) -> Result { + let token = self.agent_token()?; + let d = self.session.active().ok_or("No document")?; + let parent = if p.get("parent_id").is_some() { id(p, "parent_id")? } else { d.current_layer().map_or(0, |i| i.0) }; + let nodes = if parent == 0 { + &d.doc.layers + } else { + d.doc.node(NodeId(parent)).and_then(Node::children).ok_or("parent_id must be a layer or group")? + }; + let start = if p.get("start").is_some() { usize::try_from(id(p, "start")?).map_err(|_| "start is too large")? } else { 0 }; + let count = if p.get("count").is_some() { usize::try_from(id(p, "count")?).map_err(|_| "count is too large")? } else { 20 }; + if !(1..=40).contains(&count) { + return Err("count must be 1–40".into()); + } + let mut budget = 24000usize; + let objects: Vec = nodes + .iter() + .rev() + .skip(start) + .take(count) + .map(|n| { + let mut v = summary(&d.doc, n); + if let Some(t) = v.get("text").and_then(Value::as_str) { + if t.len() > budget { + v["text"] = json!(""); + v["truncated"] = json!(true); + v["editable"] = json!(false); + } else { + budget -= t.len(); + } + } + v + }) + .collect(); + Ok( + json!({"token":token,"document":{"title":d.title().chars().take(200).collect::(),"parentId":parent,"start":start,"objects":objects,"totalObjects":nodes.len(),"currentLayer":d.active_layer.map(|i|i.0),"layers":d.doc.layers.iter().take(40).map(|n|json!({"id":n.id.0,"name":n.display_name().chars().take(160).collect::()})).collect::>(),"totalLayers":d.doc.layers.len(),"artboards":d.doc.artboards.iter().take(40).map(|a|json!({"name":a.name.chars().take(160).collect::(),"x":a.rect.x0,"y":a.rect.y0,"width":a.rect.width(),"height":a.rect.height()})).collect::>(),"totalArtboards":d.doc.artboards.len(),"canUndo":!d.history.undo.is_empty(),"dirty":d.is_dirty(),"units":"points"}}), + ) + } + pub(crate) fn community_agent(&mut self, method: &str, p: &Value) -> Result { + let token = self.agent_token()?; + if p.get("expected").is_some_and(|t| t != &token) { + return Err("document changed since inspection; start a new request".into()); + } + if method == "agent.state" { + return self.agent_state(p); + } + if p.get("expected").is_none() { + return Err("expected document state required".into()); + } + if method == "agent.export" { + return self.run("document.export", json!({"format":"svg"})); + } + let cmd = p.get("command").and_then(Value::as_str).ok_or("missing command")?; + let a = p.get("params").filter(|v| v.is_object()).ok_or("params must be an object")?; + let d = self.session.active().ok_or("No document")?; + let (native, params) = match cmd { + "undo" => { + if d.history.undo.is_empty() { + return Err("There is no edit to undo.".into()); + } + ("edit.undo", json!({})) + } + "add_rectangle" | "add_ellipse" | "add_text" => { + let layer = d.current_layer().ok_or("Select an editable layer before adding art.")?; + if !d.doc.is_editable(layer) + || d.doc.node(layer).is_some_and(Node::is_template) + || d.target_parent().map_err(|e| e.to_string())? != Some(layer) + { + return Err("Select a visible unlocked non-template layer before adding art.".into()); + } + let (x, y) = (number(a, "x")?, number(a, "y")?); + if cmd == "add_text" { + let t = text(a, "text")?; + let size = number(a, "size")?; + if t.trim().is_empty() || !(1.0..=1000.0).contains(&size) { + return Err("Use non-empty text and size 1–1000 points".into()); + } + ("text.create", json!({"x":x,"y":y,"text":t,"size":size})) + } else { + let (w, h) = (number(a, "width")?, number(a, "height")?); + if w < 0.1 || h < 0.1 { + return Err("width and height must be at least 0.1 point".into()); + } + (if cmd == "add_rectangle" { "shape.rectangle" } else { "shape.ellipse" }, json!({"x":x,"y":y,"width":w,"height":h})) + } + } + "set_fill" | "move_object" | "set_text" => { + let object = id(a, "id")?; + let n = d.doc.node(NodeId(object)).ok_or("Object does not exist")?; + if !editable(&d.doc, n) { + return Err("Only visible unlocked paths and plain point text directly inside layers are editable. Groups and complex objects are excluded.".into()); + } + if cmd == "set_fill" { + let color = text(a, "color")?; + if color.len() != 7 || !color.starts_with('#') || !color.as_bytes().iter().skip(1).all(u8::is_ascii_hexdigit) { + return Err("color must be #rrggbb".into()); + } + ("paint.setFill", json!({"id":object,"color":color,"item":null})) + } else if cmd == "move_object" { + ("object.move", json!({"ids":[object],"dx":number(a,"dx")?,"dy":number(a,"dy")?})) + } else { + let NodeKind::Text(t) = &n.kind else { + return Err("Object is not text".into()); + }; + let value = text(a, "text")?; + if t.plain_text() != text(a, "expected_text")? || value == t.plain_text() { + return Err("Pass exact expected_text and a changed replacement".into()); + } + ("text.setText", json!({"id":object,"text":value})) + } + } + _ => return Err("unsupported Agent command".into()), + }; + let result = self.run(native, params)?; + Ok(json!({"result":result,"state":self.agent_state(&json!({}))?})) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn app() -> VectorcraftApp { + let mut a = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + a.run("file.new", json!({"width":600,"height":400})).unwrap(); + a + } + fn edit(a: &mut VectorcraftApp, c: &str, p: Value) -> Value { + let t = a.agent_token().unwrap(); + a.community_agent("agent.execute", &json!({"expected":t,"command":c,"params":p})).unwrap() + } + #[test] + fn real_objects_history_and_stale_guards() { + let mut a = app(); + let first = edit(&mut a, "add_rectangle", json!({"x":20,"y":20,"width":100,"height":60})); + let id = first["result"]["id"].clone(); + let stale = a.agent_token().unwrap(); + let filled = edit(&mut a, "set_fill", json!({"id":id,"color":"#2563eb"})); + assert_eq!(filled["state"]["document"]["objects"][0]["fill"], "#2563eb"); + assert!(a.community_agent("agent.execute", &json!({"expected":stale,"command":"undo","params":{}})).is_err()); + let moved = edit(&mut a, "move_object", json!({"id":id,"dx":30,"dy":10})); + assert_eq!(moved["state"]["document"]["objects"][0]["bounds"]["x"], 50.0); + let undone = edit(&mut a, "undo", json!({})); + assert_eq!(undone["state"]["document"]["objects"][0]["bounds"]["x"], 20.0); + let previous = a.agent_token().unwrap(); + a.run("file.new", json!({})).unwrap(); + assert!(a.community_agent("agent.state", &json!({"expected":previous})).is_err()); + } + #[test] + fn text_exact_match_and_limits() { + let mut a = app(); + let t = edit(&mut a, "add_text", json!({"x":30,"y":80,"size":24,"text":"Original 中文"})); + let id = t["result"]["id"].clone(); + let token = a.agent_token().unwrap(); + let before = a.session.active().unwrap().history.undo.len(); + assert!( + a.community_agent( + "agent.execute", + &json!({"expected":token,"command":"set_text","params":{"id":id,"expected_text":"wrong","text":"bad"}}) + ) + .is_err() + ); + assert_eq!(a.session.active().unwrap().history.undo.len(), before); + let changed = edit(&mut a, "set_text", json!({"id":id,"expected_text":"Original 中文","text":"Revised 中文"})); + assert_eq!(changed["state"]["document"]["objects"][0]["text"], "Revised 中文"); + let undone = edit(&mut a, "undo", json!({})); + assert_eq!(undone["state"]["document"]["objects"][0]["text"], "Original 中文"); + assert!(a.agent_state(&json!({"count":41})).is_err()); + assert!(a.community_agent("agent.execute", &json!({"expected":a.agent_token().unwrap(),"command":"file.close","params":{}})).is_err()); + } + #[test] + fn locked_ancestor_and_pending_edit_rejected() { + let mut a = app(); + let t = edit(&mut a, "add_rectangle", json!({"x":20,"y":20,"width":10,"height":10})); + let object = NodeId(t["result"]["id"].as_u64().unwrap()); + let layer = a.session.active().unwrap().active_layer.unwrap(); + std::sync::Arc::make_mut(&mut a.session.active_mut().unwrap().doc).node_mut(layer).unwrap().locked = true; + let token = a.agent_token().unwrap(); + assert!( + a.community_agent("agent.execute", &json!({"expected":token,"command":"set_fill","params":{"id":object.0,"color":"#ff0000"}})).is_err() + ); + a.session.begin_undo_group(); + assert!(a.agent_token().is_err()); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 20ff212..0d8b02a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -198,6 +198,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); match req.method.as_str() { + "agent.state" | "agent.execute" | "agent.export" => wrap(app.community_agent(&req.method, p)), "engine.execute" | "ui.menu.invoke" | "command" => { let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; let params = p.get("params").cloned().unwrap_or(json!({})); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 36b8a64..0f537c1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,6 +19,7 @@ pub mod canvas; pub mod chrome; mod clipboard_probe; pub mod community; +mod community_agent; pub mod control; pub mod credits; pub mod cursors;