diff --git a/Cargo.lock b/Cargo.lock index ec59148..3b8845a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,15 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +[[package]] +name = "accessibility-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a6a8e90a1d8b96a48249e7c8f5b4058447bea8847280db7bfccb6dcab6b8e1" +dependencies = [ + "core-foundation-sys", +] + [[package]] name = "accesskit" version = "0.25.2" @@ -152,6 +161,74 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "agent-desktop-core" +version = "1.1.0" +source = "git+https://github.com/vercel-labs/agent-desktop?tag=v1.1.0#d3dc190afbf34cd2377252398ddd5c30efbd093a" +dependencies = [ + "base64 0.23.1", + "libc", + "rustc-hash 2.1.3", + "serde", + "serde_json", + "smallvec", + "thiserror 2.0.21", + "tracing", +] + +[[package]] +name = "agent-desktop-linux" +version = "1.1.0" +source = "git+https://github.com/vercel-labs/agent-desktop?tag=v1.1.0#d3dc190afbf34cd2377252398ddd5c30efbd093a" +dependencies = [ + "agent-desktop-core", + "async-io", + "futures-lite", + "futures-util", + "libc", + "png", + "rustix 1.1.5", + "serde_json", + "thiserror 2.0.21", + "x11rb 0.14.0", + "zbus", +] + +[[package]] +name = "agent-desktop-macos" +version = "1.1.0" +source = "git+https://github.com/vercel-labs/agent-desktop?tag=v1.1.0#d3dc190afbf34cd2377252398ddd5c30efbd093a" +dependencies = [ + "accessibility-sys", + "agent-desktop-core", + "base64 0.23.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "core-graphics 0.25.0", + "libc", + "rustc-hash 2.1.3", + "serde", + "serde_json", + "smallvec", + "thiserror 2.0.21", + "tracing", +] + +[[package]] +name = "agent-desktop-windows" +version = "1.1.0" +source = "git+https://github.com/vercel-labs/agent-desktop?tag=v1.1.0#d3dc190afbf34cd2377252398ddd5c30efbd093a" +dependencies = [ + "agent-desktop-core", + "png", + "serde_json", + "thiserror 2.0.21", + "tracing", + "uiautomation", + "windows", + "windows-sys 0.61.2", +] + [[package]] name = "ahash" version = "0.8.12" @@ -255,6 +332,10 @@ dependencies = [ name = "anybot" version = "0.1.0" dependencies = [ + "agent-desktop-core", + "agent-desktop-linux", + "agent-desktop-macos", + "agent-desktop-windows", "ashpd", "async-io", "base64 0.22.1", @@ -284,7 +365,7 @@ dependencies = [ "wayland-protocols", "wayland-protocols-misc", "wayland-protocols-wlr", - "x11rb", + "x11rb 0.13.2", "zbus", "zhconv", "zip", @@ -312,9 +393,9 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.61.2", + "windows-sys 0.52.0", "wl-clipboard-rs", - "x11rb", + "x11rb 0.13.2", ] [[package]] @@ -409,7 +490,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.6", + "rustix 1.1.5", "slab", "windows-sys 0.61.2", ] @@ -440,7 +521,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.6", + "rustix 1.1.5", ] [[package]] @@ -466,7 +547,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.6", + "rustix 1.1.5", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -749,7 +830,7 @@ checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" dependencies = [ "bitflags 2.13.2", "polling", - "rustix 1.1.6", + "rustix 1.1.5", "tracing", ] @@ -772,7 +853,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ "calloop 0.14.5", - "rustix 1.1.6", + "rustix 1.1.5", "wayland-backend", "wayland-client", ] @@ -987,6 +1068,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1000,8 +1091,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", - "core-graphics-types", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", "foreign-types", "libc", ] @@ -1013,7 +1117,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", "libc", ] @@ -1526,7 +1641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1795,13 +1910,13 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 2.0.119", ] [[package]] @@ -1812,9 +1927,9 @@ checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-io", @@ -1841,7 +1956,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.6", + "rustix 1.1.5", "windows-link", ] @@ -3381,7 +3496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3676,7 +3791,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.6", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -4074,20 +4189,20 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] name = "rustix" -version = "1.1.6" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4364,6 +4479,9 @@ name = "smallvec" version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" +dependencies = [ + "serde", +] [[package]] name = "smithay-client-toolkit" @@ -4403,7 +4521,7 @@ dependencies = [ "libc", "log", "memmap2", - "rustix 1.1.6", + "rustix 1.1.5", "thiserror 2.0.21", "wayland-backend", "wayland-client", @@ -4734,10 +4852,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", - "rustix 1.1.6", - "windows-sys 0.61.2", + "rustix 1.1.5", + "windows-sys 0.52.0", ] [[package]] @@ -5062,6 +5180,29 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "uiautomation" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa2eacf9390a8491bc949b04230f40c12520c6423a2962a6087eea1ef14373af" +dependencies = [ + "chrono", + "uiautomation_derive", + "windows", + "windows-core", +] + +[[package]] +name = "uiautomation_derive" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c893ec96e05c3c81ba46f838f3190549ec185ef377d582ab3d44202b2dfacbd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "unicase" version = "2.10.0" @@ -5399,7 +5540,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.6", + "rustix 1.1.5", "scoped-tls", "smallvec", "wayland-sys", @@ -5412,7 +5553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ "bitflags 2.13.2", - "rustix 1.1.6", + "rustix 1.1.5", "wayland-backend", "wayland-scanner", ] @@ -5434,7 +5575,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ - "rustix 1.1.6", + "rustix 1.1.5", "wayland-client", "xcursor", ] @@ -5766,7 +5907,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5879,15 +6020,6 @@ dependencies = [ "windows-targets", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -5985,8 +6117,8 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", - "core-graphics", + "core-foundation 0.9.4", + "core-graphics 0.23.2", "cursor-icon", "dpi", "js-sys", @@ -6018,7 +6150,7 @@ dependencies = [ "web-time", "windows-sys 0.52.0", "x11-dl", - "x11rb", + "x11rb 0.13.2", "xkbcommon-dl", ] @@ -6046,7 +6178,7 @@ dependencies = [ "libc", "log", "os_pipe", - "rustix 1.1.6", + "rustix 1.1.5", "thiserror 2.0.21", "tree_magic_mini", "wayland-backend", @@ -6083,8 +6215,19 @@ dependencies = [ "libc", "libloading", "once_cell", - "rustix 1.1.6", - "x11rb-protocol", + "rustix 1.1.5", + "x11rb-protocol 0.13.2", +] + +[[package]] +name = "x11rb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a8885a854a8bfdf87a301e53e41b17c5f8f33639903131338b997b1eb614f44" +dependencies = [ + "gethostname", + "rustix 1.1.5", + "x11rb-protocol 0.14.0", ] [[package]] @@ -6093,6 +6236,12 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x11rb-protocol" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acf4d1bc32aa46eec18caa634ec3cf4c05bfa151f12b93b510b15190f69a1ca8" + [[package]] name = "xcursor" version = "0.3.11" @@ -6186,7 +6335,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.6", + "rustix 1.1.5", "serde", "serde_repr", "tracing", diff --git a/Cargo.toml b/Cargo.toml index dac6a81..4f94769 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,11 @@ unicode-normalization = "0.1" cpal = "0.18" mermaid-rs-renderer = { version = "0.3", default-features = false } pulldown-cmark = { version = "0.13", default-features = false } +# Seeing and using other apps through the OS accessibility APIs (AX on macOS, AT-SPI on Linux, +# UI Automation on Windows): numbered UI elements a bot can act on without screenshots. +# vercel-labs/agent-desktop, Apache-2.0, pinned to a release. +agent-desktop-core = { git = "https://github.com/vercel-labs/agent-desktop", tag = "v1.1.0" } + [profile.release] opt-level = 2 @@ -37,6 +42,7 @@ opt-level = 2 zhconv = "0.4" [target.'cfg(target_os = "linux")'.dependencies] +agent-desktop-linux = { git = "https://github.com/vercel-labs/agent-desktop", tag = "v1.1.0" } x11rb = { version = "0.13", features = ["xtest", "screensaver", "record"] } # Wayland: wlroots desktops (sway, Hyprland…) through their own protocols; GNOME and KDE through # the desktop portals (one "allow" dialog, remembered). @@ -49,3 +55,9 @@ ashpd = { version = "0.13", default-features = false, features = ["async-io", "r zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] } futures-lite = "2" async-io = "2" + +[target.'cfg(target_os = "macos")'.dependencies] +agent-desktop-macos = { git = "https://github.com/vercel-labs/agent-desktop", tag = "v1.1.0" } + +[target.'cfg(target_os = "windows")'.dependencies] +agent-desktop-windows = { git = "https://github.com/vercel-labs/agent-desktop", tag = "v1.1.0" } diff --git a/src/computer.rs b/src/computer.rs index 031469d..44a1ed6 100644 --- a/src/computer.rs +++ b/src/computer.rs @@ -12,7 +12,7 @@ use base64::Engine; use serde_json::{json, Value}; use std::sync::Mutex; -pub const NAMES: [&str; 9] = ["computer_use_screenshot", "computer_use_click", "computer_use_typing", "computer_use_press_key", "computer_use_scroll", "computer_use_mouse_move", "computer_use_drag", "computer_use_mouse_button", "computer_use_check_permissions"]; +pub const NAMES: [&str; 12] = ["computer_use_screenshot", "computer_use_click", "computer_use_typing", "computer_use_press_key", "computer_use_scroll", "computer_use_mouse_move", "computer_use_drag", "computer_use_mouse_button", "computer_use_check_permissions", "computer_use_read_app", "computer_use_select", "computer_use_open_app"]; /// The person took control back from the bot (the floating bar): screen actions wait. pub static HELD_BY_PERSON: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); @@ -138,6 +138,9 @@ pub fn label(name: &str) -> &'static str { "computer_use_drag" => "拖曳", "computer_use_mouse_button" => "按住或放開滑鼠", "computer_use_check_permissions" => "檢查螢幕權限", + "computer_use_read_app" => "讀 App 畫面", + "computer_use_select" => "選擇", + "computer_use_open_app" => "打開 App", _ => "操作畫面", } } @@ -167,6 +170,8 @@ pub fn describe(name: &str, args: &str) -> String { "computer_use_drag" => format!("{} 個點", v["path"].as_array().map(|a| a.len()).unwrap_or(0)), "computer_use_mouse_button" => format!("{}{}", if v["action"].as_str() == Some("release") { "放開" } else { "按住" }, if v["x"].is_null() { String::new() } else { format!(" {}", xy()) }), "computer_use_check_permissions" => "螢幕錄製、輔助使用".into(), + "computer_use_read_app" | "computer_use_open_app" => v["app"].as_str().filter(|a| !a.is_empty()).unwrap_or("最前面的 App").to_string(), + "computer_use_select" => format!("{} → {}", v["ref"].as_str().unwrap_or(""), v["value"].as_str().unwrap_or("")), _ => String::new(), } } @@ -180,16 +185,22 @@ pub fn specs() -> Vec { pub fn specs_for(computers: &[String], default: &str) -> Vec { let mods = json!({"type": "array", "items": {"type": "string", "enum": ["cmd", "shift", "option", "control"]}}); let mut v = vec![ + json!({"name": "computer_use_read_app", "description": "Read an app's window as text — its buttons, fields, menus, list items and visible text, each control numbered (e1, e2…). No screenshot or image ability needed, and acting by number happens in the background without moving the person's mouse. Prefer this over screenshots for normal apps (Finder, Mail, LINE, Excel, Settings…); use screenshots for pictures, charts, games or apps that show nothing here. app: the app's name (as in the Dock, e.g. Calculator / 計算機 / Finder); leave out for the app in front.", + "parameters": {"type": "object", "properties": {"app": {"type": "string"}}}}), + json!({"name": "computer_use_select", "description": "Choose an option in a popup menu, dropdown or list by the control's number from computer_use_read_app.", + "parameters": {"type": "object", "properties": {"ref": {"type": "string"}, "value": {"type": "string", "description": "The option's text"}}, "required": ["ref", "value"]}}), + json!({"name": "computer_use_open_app", "description": "Open an app (or bring it forward if it's running), then read it with computer_use_read_app.", + "parameters": {"type": "object", "properties": {"app": {"type": "string"}}, "required": ["app"]}}), json!({"name": "computer_use_screenshot", "description": "Take a screenshot of a computer's screen to see what's on it. Coordinates for the other computer_use tools are pixels of the latest screenshot of that computer. Take one before clicking, and again to check the result. Needs a model that can see images.", "parameters": {"type": "object", "properties": {}}}), - json!({"name": "computer_use_click", "description": "Click on the screen at x, y (pixels of the latest screenshot). background=true clicks a window on the person's Mac without bringing it to the front.", - "parameters": {"type": "object", "properties": {"x": {"type": "number"}, "y": {"type": "number"}, "button": {"type": "string", "enum": ["left", "right", "middle"]}, "count": {"type": "integer", "description": "2 for a double click"}, "modifier_keys": mods, "background": {"type": "boolean"}}, "required": ["x", "y"]}}), - json!({"name": "computer_use_typing", "description": "Type text where the cursor is (any language).", - "parameters": {"type": "object", "properties": {"value": {"type": "string"}}, "required": ["value"]}}), - json!({"name": "computer_use_press_key", "description": "Press a key or shortcut, e.g. return, tab, escape, delete, up, cmd+c, cmd+shift+t, cmd+space. Several keys in order: separate with spaces, e.g. \"cmd+a delete\". On the bot's Linux computer cmd means ctrl.", - "parameters": {"type": "object", "properties": {"key": {"type": "string"}, "hold_duration_ms": {"type": "integer"}}, "required": ["key"]}}), - json!({"name": "computer_use_scroll", "description": "Scroll at x, y (give both or neither; neither = where the pointer is).", - "parameters": {"type": "object", "properties": {"x": {"type": "number"}, "y": {"type": "number"}, "direction": {"type": "string", "enum": ["up", "down", "left", "right"]}, "amount": {"type": "integer", "description": "Scroll steps of 16 pixels, 1-20, default 3"}, "modifier_keys": mods}, "required": ["direction"]}}), + json!({"name": "computer_use_click", "description": "Click a control by its number from computer_use_read_app (ref, e.g. e12), or on the screen at x, y (pixels of the latest screenshot). background=true clicks a window on the person's Mac without bringing it to the front.", + "parameters": {"type": "object", "properties": {"ref": {"type": "string"}, "x": {"type": "number"}, "y": {"type": "number"}, "button": {"type": "string", "enum": ["left", "right", "middle"]}, "count": {"type": "integer", "description": "2 for a double click"}, "modifier_keys": mods, "background": {"type": "boolean"}}}}), + json!({"name": "computer_use_typing", "description": "Type text (any language) into a field by its number from computer_use_read_app (ref), or where the cursor is.", + "parameters": {"type": "object", "properties": {"value": {"type": "string"}, "ref": {"type": "string"}}, "required": ["value"]}}), + json!({"name": "computer_use_press_key", "description": "Press a key or shortcut, e.g. return, tab, escape, delete, up, cmd+c, cmd+shift+t, cmd+space. Several keys in order: separate with spaces, e.g. \"cmd+a delete\". On the bot's Linux computer cmd means ctrl. app: send it to that app in the background instead of the app in front.", + "parameters": {"type": "object", "properties": {"key": {"type": "string"}, "hold_duration_ms": {"type": "integer"}, "app": {"type": "string"}}, "required": ["key"]}}), + json!({"name": "computer_use_scroll", "description": "Scroll a list or area by its number from computer_use_read_app (ref), or at x, y (give both or neither; neither = where the pointer is).", + "parameters": {"type": "object", "properties": {"ref": {"type": "string"}, "x": {"type": "number"}, "y": {"type": "number"}, "direction": {"type": "string", "enum": ["up", "down", "left", "right"]}, "amount": {"type": "integer", "description": "Scroll steps of 16 pixels, 1-20, default 3"}, "modifier_keys": mods}, "required": ["direction"]}}), json!({"name": "computer_use_mouse_move", "description": "Move the mouse to x, y (e.g. to show a hover menu).", "parameters": {"type": "object", "properties": {"x": {"type": "number"}, "y": {"type": "number"}}, "required": ["x", "y"]}}), json!({"name": "computer_use_drag", "description": "Drag with the mouse along a path of points (first = press, last = release).", @@ -362,7 +373,63 @@ pub fn run_by(name: &str, args: &str, who: &str) -> (String, bool, Option Option<()> { + let has_ref = v["ref"].as_str().map(|r| !r.trim().is_empty()).unwrap_or(false); + match name { + "computer_use_open_app" | "computer_use_select" => Some(()), + "computer_use_click" | "computer_use_typing" | "computer_use_scroll" if has_ref => Some(()), + _ => None, + } +} + +/// The accessibility (text) way: agent-desktop, by element number, in the background. +fn run_text_mode(name: &str, v: &Value) -> Option), String>> { + let rf = v["ref"].as_str().unwrap_or("").trim().to_string(); + let r = match name { + "computer_use_read_app" => crate::desktop::read_app(v["app"].as_str().unwrap_or("")).map(|(app, lines)| { + if lines.is_empty() { + format!("「{app}」的畫面讀不到內容(有些 App 不提供輔助使用資訊)。可以改用 computer_use_screenshot 看。") + } else { + format!("「{app}」的畫面(e 編號可以用 computer_use_click / typing / select / scroll 的 ref):\n{}", lines.join("\n")) + } + }), + "computer_use_open_app" => { + let app = v["app"].as_str().unwrap_or("").trim().to_string(); + crate::desktop::run("launch", &json!({"app": app})).and_then(|_| { + std::thread::sleep(std::time::Duration::from_millis(800)); + crate::desktop::read_app(&app).map(|(a, lines)| format!("已打開「{a}」。\n{}", lines.join("\n"))) + }) + } + "computer_use_select" => crate::desktop::act("select", &rf, &json!({"value": v["value"]})), + "computer_use_click" if !rf.is_empty() => { + let cmd = match (v["button"].as_str(), v["count"].as_u64()) { + (Some("right"), _) => "right_click", + (_, Some(2..)) => "double_click", + _ => "click", + }; + crate::desktop::act(cmd, &rf, &json!({})) + } + "computer_use_typing" if !rf.is_empty() => crate::desktop::act("type", &rf, &json!({"text": v["value"]})), + "computer_use_scroll" if !rf.is_empty() => crate::desktop::act("scroll", &rf, &json!({"direction": v["direction"], "amount": v["amount"]})), + "computer_use_press_key" if v["app"].as_str().map(|a| !a.trim().is_empty()).unwrap_or(false) => { + // agent-desktop's key names: cmd+c, return, escape… + let combo = v["key"].as_str().unwrap_or("").replace("command", "cmd").replace("option", "alt").replace("control", "ctrl"); + crate::desktop::run("press", &json!({"combo": combo, "app": v["app"]})).map(|_| format!("已在「{}」按 {}。", v["app"].as_str().unwrap_or(""), v["key"].as_str().unwrap_or(""))) + } + _ => return None, + }; + Some(r.map(|t| (t, None))) +} + fn run_inner(name: &str, v: &Value) -> Result<(String, Option), String> { + if HELD_BY_PERSON.load(std::sync::atomic::Ordering::SeqCst) && name != "computer_use_read_app" { + if let Some(_) = run_text_mode_name(name, v) { + return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作。".into()); + } + } + if let Some(r) = run_text_mode(name, v) { + return r; + } if !cfg!(any(target_os = "macos", target_os = "linux")) { return Err("操作畫面只支援 macOS 和 Linux".into()); } @@ -480,6 +547,15 @@ fn run_inner(name: &str, v: &Value) -> Result<(String, Option), String> /// frontmost app. Used for the per-app permission (「AnyBot 可以看到 {app},並在其中點擊和輸入」). pub fn target_app(name: &str, args: &str) -> Option { let v = args_of(args); + if let Some(app) = v["app"].as_str().filter(|a| !a.trim().is_empty() && matches!(name, "computer_use_read_app" | "computer_use_open_app" | "computer_use_press_key")) { + return Some(app.trim().to_string()); + } + if name == "computer_use_read_app" { + return frontmost_app(); + } + if run_text_mode_name(name, &v).is_some() { + return crate::desktop::last_app(); + } let s = (*SCALE.lock().unwrap())?; let point = match name { "computer_use_click" | "computer_use_mouse_move" | "computer_use_scroll" | "computer_use_mouse_button" if !v["x"].is_null() => Some(to_screen(v["x"].as_f64()?, v["y"].as_f64()?, s)), diff --git a/src/desktop.rs b/src/desktop.rs new file mode 100644 index 0000000..e94adcf --- /dev/null +++ b/src/desktop.rs @@ -0,0 +1,266 @@ +// Seeing and using other apps through the OS accessibility APIs, with vercel-labs/agent-desktop +// compiled in (AX on macOS, AT-SPI on Linux, UI Automation on Windows). A snapshot gives every +// control of an app a ref; actions take a ref. No screenshots needed, and by default no pointer +// movement or focus change (the person can keep working). + +use agent_desktop_core as ad; +use serde_json::Value; + +fn adapter() -> Box { + #[cfg(target_os = "macos")] + { + Box::new(agent_desktop_macos::MacOSAdapter::new()) + } + #[cfg(target_os = "linux")] + { + Box::new(agent_desktop_linux::LinuxAdapter::new()) + } + #[cfg(target_os = "windows")] + { + Box::new(agent_desktop_windows::WindowsAdapter::new()) + } +} + +fn err(e: ad::AppError) -> String { + let mut s = format!("{e}({})", e.code()); + if let Some(hint) = e.suggestion() { + s.push_str(&format!(":{hint}")); + } + s +} + +fn st(v: &Value, k: &str) -> String { + v.get(k).map(|x| x.as_str().map(String::from).unwrap_or_else(|| if x.is_null() { String::new() } else { x.to_string() })).unwrap_or_default().trim().to_string() +} + +fn opt(v: &Value, k: &str) -> Option { + Some(st(v, k)).filter(|s| !s.is_empty()) +} + +/// One agent-desktop command (the CLI's names): apps, windows, snapshot, click, double_click, +/// right_click, type, set_value, select, press, scroll, focus_window, launch, screenshot. +pub fn run(cmd: &str, a: &Value) -> Result { + use ad::commands as c; + let ad = adapter(); + let ad = ad.as_ref(); + let ctx = ad::CommandContext::default(); + let r = |rf: &Value| c::helpers::RefArgs { ref_id: st(rf, "ref"), timeout_ms: None }; + let out = match cmd { + "apps" => c::list_apps::execute(c::list_apps::ListAppsArgs { app: opt(a, "app"), timeout_ms: None }, ad), + "windows" => c::list_windows::execute(c::list_windows::ListWindowsArgs { app: opt(a, "app"), timeout_ms: None }, ad), + "snapshot" => c::snapshot::execute( + c::snapshot::SnapshotArgs { + app: opt(a, "app"), + window_id: opt(a, "window_id"), + max_depth: a["max_depth"].as_u64().unwrap_or(30).min(60) as u8, + include_bounds: false, + interactive_only: a["interactive_only"].as_bool().unwrap_or(true), + compact: true, + surface: ad::SnapshotSurface::default(), + skeleton: false, + root_ref: opt(a, "root"), + timeout_ms: None, + force_electron_a11y: true, + }, + ad, + &ctx, + ), + "click" => c::click::execute(r(a), ad, &ctx), + "double_click" => c::double_click::execute(r(a), ad, &ctx), + "right_click" => c::right_click::execute(r(a), ad, &ctx), + "type" => c::type_text::execute(c::type_text::TypeArgs { ref_id: st(a, "ref"), text: st(a, "text"), timeout_ms: None, interval_ms: None }, ad, &ctx), + "set_value" => c::set_value::execute(c::set_value::SetValueArgs { ref_id: st(a, "ref"), value: st(a, "value"), timeout_ms: None }, ad, &ctx), + "select" => c::select::execute(c::select::SelectArgs { ref_id: st(a, "ref"), value: st(a, "value"), timeout_ms: None }, ad, &ctx), + "press" => c::press::execute(c::press::PressArgs { combo: st(a, "combo"), app: opt(a, "app"), window_id: opt(a, "window_id"), force: false }, ad, &ctx), + "scroll" => { + let direction = match st(a, "direction").as_str() { + "up" => ad::Direction::Up, + "left" => ad::Direction::Left, + "right" => ad::Direction::Right, + _ => ad::Direction::Down, + }; + c::scroll::execute(c::scroll::ScrollArgs { ref_id: st(a, "ref"), direction, amount: a["amount"].as_u64().unwrap_or(3) as u32, timeout_ms: None }, ad, &ctx) + } + "focus_window" => c::focus_window::execute(c::focus_window::FocusWindowArgs { window_id: opt(a, "window_id"), app: opt(a, "app"), title: opt(a, "title") }, ad), + "launch" => c::launch::execute(c::launch::LaunchArgs { app: st(a, "app"), options: ad::system::launch_options::LaunchOptions { activate: true, ..Default::default() } }, ad), + "permissions" => { + let d = ad::Deadline::standard().map_err(|e| err(e.into()))?; + return ad.permission_report(d).map(|r| Value::String(format!("{r:?}"))).map_err(|e| err(e.into())); + } + other => return Err(format!("agent-desktop 沒有「{other}」這個動作")), + }; + out.map_err(err) +} + +// ---------- what the bot uses ---------- + +/// The latest snapshot (id, app) so the bot can say "e6" instead of the full ref. +static LAST: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// "e6", "[e6]", "6" or a full "@snap:e6" → the full ref of the latest snapshot. +pub fn full_ref(r: &str) -> Result { + let r = r.trim().trim_matches(['[', ']']); + if r.starts_with('@') { + return Ok(r.to_string()); + } + let n = r.trim_start_matches('e'); + if n.is_empty() || !n.chars().all(|c| c.is_ascii_digit()) { + return Err(format!("「{r}」不是元素編號,請用 computer_use_read_app 列出的 e 編號")); + } + let last = LAST.lock().unwrap().clone().ok_or("請先用 computer_use_read_app 看一下 App 的畫面")?; + Ok(format!("@{}:e{n}", last.0)) +} + +/// The app the latest snapshot was of (for the per-app permission). +pub fn last_app() -> Option { + LAST.lock().unwrap().as_ref().map(|l| l.1.clone()) +} + +fn role_name(role: &str) -> &str { + match role { + "button" => "按鈕", + "link" => "連結", + "textfield" | "textarea" | "searchfield" | "combobox" | "securetextfield" => "輸入框", + "checkbox" | "switch" | "toggle" => "勾選框", + "radiobutton" => "選項", + "popupbutton" | "menubutton" => "選單按鈕", + "menuitem" => "選單項目", + "tab" => "分頁", + "row" | "cell" | "listitem" | "treeitem" | "outlinerow" => "項目", + "slider" => "滑桿", + "statictext" | "text" => "文字", + "image" => "圖片", + "scrollarea" => "捲動區", + "group" => "群組", + "outline" | "list" | "table" | "tree" | "grid" => "清單", + "toolbar" => "工具列", + "menubar" | "menu" => "選單", + "heading" => "標題", + "window" => "視窗", + other => other, + } +} + +/// An app's controls (numbered) and visible text, as short lines. `app` empty = the app in front. +pub fn read_app(app: &str) -> Result<(String, Vec), String> { + read_app_as(app, true) +} + +fn read_app_as(app: &str, remember: bool) -> Result<(String, Vec), String> { + let mut args = serde_json::json!({"interactive_only": false, "max_depth": 40}); + if !app.trim().is_empty() { + args["app"] = Value::String(app.trim().to_string()); + } + let v = match run("snapshot", &args) { + // Running without a window (closed, or minimised to the Dock): bring it forward once. + Err(e) if e.contains("WINDOW_NOT_FOUND") && !app.trim().is_empty() => { + let _ = run("launch", &serde_json::json!({"app": app.trim()})); + std::thread::sleep(std::time::Duration::from_millis(900)); + match run("snapshot", &args) { + Ok(v) => v, + Err(e) => { + // macOS: "open" sends the reopen event, like clicking the app in the Dock. + if !cfg!(target_os = "macos") { + return Err(e); + } + let _ = std::process::Command::new("open").args(["-a", app.trim()]).status(); + std::thread::sleep(std::time::Duration::from_millis(1200)); + run("snapshot", &args)? + } + } + } + r => r?, + }; + let snap = v["snapshot_id"].as_str().unwrap_or("").to_string(); + let app_name = v["app"].as_str().unwrap_or(app).to_string(); + if remember { + *LAST.lock().unwrap() = Some((snap.clone(), app_name.clone())); + } + let mut lines = vec![]; + fn walk(n: &Value, depth: usize, lines: &mut Vec, parent: &str) { + if lines.len() >= 400 { + return; + } + let name = n["name"].as_str().unwrap_or("").trim(); + let value = n["value"].as_str().map(str::to_string).or_else(|| n["value"].as_f64().map(|f| f.to_string())).unwrap_or_default(); + let role = n["role"].as_str().unwrap_or(""); + let text = if !name.is_empty() && !value.is_empty() && value != name { format!("「{name}」:{value}") } else if !name.is_empty() { format!("「{name}」") } else if !value.is_empty() { format!(":{value}") } else { String::new() }; + let mut flags = vec![]; + let is_field = matches!(role, "textfield" | "textarea" | "searchfield" | "combobox" | "securetextfield"); + for (k, word) in [("checked", "已勾"), ("selected", "已選"), ("disabled", "停用"), ("focused", "游標在這")] { + // Only text fields report focus usefully (Finder marks every sidebar cell focused). + if k == "focused" && !is_field { + continue; + } + if n["states"].as_array().map(|a| a.iter().any(|x| x == k)).unwrap_or(false) || n[k] == true { + flags.push(word); + } + } + let flags = if flags.is_empty() { String::new() } else { format!("({})", flags.join("、")) }; + let indent = " ".repeat(depth.min(6)); + // A cell or label that only repeats its parent's name adds nothing. + let repeats = !name.is_empty() && name == parent; + if repeats && n["ref_id"].is_null() { + // plain text under a control with the same name + } else if repeats && lines.last().map(|l| l.contains(&format!("「{name}」"))).unwrap_or(false) { + // e.g. Finder's row → cell with the same name: keep the row only + } else if let Some(r) = n["ref_id"].as_str() { + let short = r.rsplit(':').next().unwrap_or(r); + if !text.is_empty() || matches!(role, "button" | "textfield" | "textarea" | "checkbox" | "searchfield" | "popupbutton") { + lines.push(format!("{indent}{short} {}{text}{flags}", role_name(role))); + } + } else if !text.is_empty() && matches!(role, "statictext" | "text" | "heading" | "cell" | "value" | "") { + lines.push(format!("{indent}{}{text}", role_name(role))); + } + let next_parent = if name.is_empty() { parent } else { name }; + for c in n["children"].as_array().into_iter().flatten() { + walk(c, depth + 1, lines, next_parent); + } + } + walk(&v["tree"], 0, &mut lines, ""); + let pad = lines.iter().map(|l| l.len() - l.trim_start().len()).min().unwrap_or(0); + let lines = lines.into_iter().map(|l| l[pad..].to_string()).collect(); + Ok((app_name, lines)) +} + +/// Act on an element by its number: click, double_click, right_click, type, set_value, select, +/// scroll. Returns what the app shows now (new text since the action). +pub fn act(cmd: &str, r: &str, extra: &Value) -> Result { + let full = full_ref(r)?; + let app = last_app().unwrap_or_default(); + let before = read_app_as(&app, false).map(|x| x.1).unwrap_or_default(); + let mut a = extra.clone(); + if !a.is_object() { + a = serde_json::json!({}); + } + a["ref"] = Value::String(full.clone()); + let res = run(cmd, &a); + // Typing into some fields fails as keystrokes; setting the value works there. + let res = match (cmd, res) { + ("type", Err(_)) => run("set_value", &serde_json::json!({"ref": full, "value": a["text"]})), + (_, r) => r, + }; + res?; + std::thread::sleep(std::time::Duration::from_millis(400)); + let (_, after) = read_app_as(&app, false).unwrap_or_default(); + let old: std::collections::HashSet = before.iter().map(|l| l.split_whitespace().skip(1).collect::>().join(" ")).collect(); + let new: Vec<&String> = after.iter().filter(|l| !old.contains(&l.split_whitespace().skip(1).collect::>().join(" "))).take(30).collect(); + // Show what changed without numbers (they belong to a different reading); if controls came + // or went, the bot's numbers may now point elsewhere. + let strip = |l: &str| -> String { + let t = l.trim(); + match t.split_once(' ') { + Some((first, rest)) if first.starts_with('e') && first[1..].chars().all(|c| c.is_ascii_digit()) => rest.to_string(), + _ => t.to_string(), + } + }; + let controls_changed = new.iter().any(|l| l.trim().starts_with('e')) || before.iter().filter(|l| l.trim().starts_with('e')).count() != after.iter().filter(|l| l.trim().starts_with('e')).count(); + let mut out = format!("好了(在「{app}」)。"); + if !new.is_empty() { + out.push_str(&format!("\n現在畫面上新出現:\n{}", new.iter().map(|l| strip(l)).collect::>().join("\n"))); + } + if controls_changed { + out.push_str("\n(按鈕或欄位有變動,編號可能已經不同:下一步前先用 computer_use_read_app 看新的清單。)"); + } + Ok(out) +} diff --git a/src/main.rs b/src/main.rs index 98dc1c2..27b4ef6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod browser; mod cloudbox; mod channels; mod computer; +mod desktop; mod diagram; mod platform; mod mic; @@ -202,6 +203,19 @@ fn main() -> eframe::Result<()> { } std::process::exit(if all_ok { 0 } else { 1 }); } + // For trying agent-desktop on a new system: `anybot --desktop snapshot '{"app":"Finder"}'`. + if args.get(1).map(String::as_str) == Some("--desktop") { + let cmd = args.get(2).cloned().unwrap_or_else(|| "apps".into()); + let v: serde_json::Value = serde_json::from_str(args.get(3).map(String::as_str).unwrap_or("{}")).unwrap_or_default(); + match desktop::run(&cmd, &v) { + Ok(out) => println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default()), + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + } + std::process::exit(0); + } if args.get(1).map(String::as_str) == Some("--ask") { let text = args[2..].join(" "); let ctx = egui::Context::default();