AnyBot/src/marks.rs

270 lines
11 KiB
Rust
Raw Normal View History

Bots click by number: every control on the screen is boxed and numbered, on every computer A bot asked to open a Threads profile clicked an empty strip ten times: it guessed coordinates from a picture, and the web tools' description told it they didn't work on its own computer. - marks.rs: the picture a bot gets has every button, link, field and menu item boxed and numbered (a tiny built-in digit font, pure Rust), with the same numbers in a list. click / typing / scroll / mouse_move take the number (ref) and land on the control's centre. - Where the controls come from: macOS AX and Linux AT-SPI through agent-desktop on a person's computer (desktop::elements); AT-SPI in the bots' own computer (box/a11y, after XBot's box-a11y), web pages included. On a Mac a control whose spot another app's window covers gets no number (topmost window at its centre must be the app's; the Dock's invisible full-screen window doesn't count). open_app waits until the app is really in front. - No going round in circles: a step that changed nothing says so; the same step (clicks within 16 px) a third time in a turn comes with a warning, from the fourth time it isn't run. - The box (version 4): one TigerVNC server instead of Xvfb + x11vnc, the AT-SPI bus and registry at boot, one browser launcher (box/browser, after XBot's box-chrome: same profile and CDP, renderer accessibility, a launch lock, stale profile locks cleared). One docker exec per look (list + picture, ~0.4 s). A brain updates an older box by itself when it has been idle a minute (files and logins stay); a new box is never made from an older image. - Gone: computer_use_read_app (the list is in every picture), computer_use_mouse_button, the per-call picture for the phone (it gets a plain one), the stale browser_read note; the prompt says web work goes through browser_* by number, and never to read browser cookie or history files. Tests: marks (numbering, drawing, fingerprints), box_numbers_are_exact on a box from the new image (a Threads-like page: the icon links get numbers, Profile clicked by number, Chinese typed into the numbered field, a click on nothing called out), box_screen_is_exact, VNC e2e 11/11 on TigerVNC, Calculator 7×8=56 by numbers through the signed app on this Mac, remote_e2e 28/28, 153 unit tests; Linux build + 157 tests + x11_live. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:47:01 +00:00
// What a bot sees of a screen: the picture with every control it can use boxed and numbered,
// and the same numbers in a list ("[12] 連結「Profile」"). It acts by number; the click lands on
// that control's centre. Guessing coordinates from a picture is where clicks went wrong (a
// model took an empty strip for a menu item and clicked it ten times); a number can't miss.
//
// The controls come from the system's accessibility tree: macOS AX and Linux AT-SPI through
// agent-desktop on a person's computer (desktop.rs), AT-SPI in the bots' own computer
// (box/a11y). Web pages are in those trees too (Chromium with renderer accessibility on).
use std::collections::HashMap;
/// One control on the screen, in screen units (the mouse's: points on a Mac, pixels elsewhere).
#[derive(Clone, Debug, PartialEq)]
pub struct Element {
/// 按鈕, 連結, 輸入框… (already in words).
pub kind: String,
pub name: String,
pub value: String,
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
/// Something to click or type into (gets a number), not just text.
pub active: bool,
pub flags: Vec<&'static str>,
pub window: String,
}
impl Element {
pub fn centre(&self) -> (f64, f64) {
(self.x + self.w / 2.0, self.y + self.h / 2.0)
}
}
/// Most controls numbered on one picture (more is clutter the model can't use).
pub const MAX_MARKS: usize = 150;
/// The numbered controls of the latest look at a screen: number → (centre, words).
#[derive(Clone, Debug, Default)]
pub struct Spots {
pub by_number: HashMap<usize, ((f64, f64), String)>,
}
impl Spots {
/// "12", "e12", "[12]" → the control's centre and what it is.
pub fn get(&self, r: &str) -> Result<((f64, f64), String), String> {
let n = number_of(r).ok_or_else(|| format!("「{}」不是編號:請用截圖上的編號(例如 12)", r.trim()))?;
self.by_number.get(&n).cloned().ok_or_else(|| format!("最新的畫面裡沒有編號 {n}:先 computer_use_screenshot 再看一次。"))
}
}
pub fn number_of(r: &str) -> Option<usize> {
let t = r.trim().trim_matches(['[', ']']).trim_start_matches(['e', 'E', '#']);
t.parse().ok()
}
/// Order (reading order, by window) and number the controls; the list's lines and the spots.
/// `all_text`: also list plain text (when there is no picture to read it from).
pub fn number(elements: &[Element], all_text: bool) -> (Vec<(usize, Element)>, Vec<String>, Spots) {
let mut seen = std::collections::HashSet::new();
let mut els: Vec<&Element> = elements.iter().filter(|e| e.w >= 1.0 && e.h >= 1.0 && seen.insert((e.kind.clone(), e.name.clone(), e.x as i64, e.y as i64, e.w as i64, e.h as i64))).collect();
let mut windows: Vec<String> = vec![];
for e in &els {
if !windows.contains(&e.window) {
windows.push(e.window.clone());
}
}
els.sort_by(|a, b| {
let wa = windows.iter().position(|w| *w == a.window).unwrap_or(0);
let wb = windows.iter().position(|w| *w == b.window).unwrap_or(0);
(wa, (a.y / 12.0) as i64, a.x as i64).cmp(&(wb, (b.y / 12.0) as i64, b.x as i64))
});
let mut numbered = vec![];
let mut lines = vec![];
let mut spots = Spots::default();
let mut n = 0;
let mut window = None;
for e in els {
if !e.active && !all_text {
continue;
}
if windows.len() > 1 && window.as_ref() != Some(&e.window) && !e.window.is_empty() {
window = Some(e.window.clone());
lines.push(format!("## {}", e.window));
}
let what = describe(e);
if e.active && n < MAX_MARKS {
n += 1;
spots.by_number.insert(n, (e.centre(), what.clone()));
numbered.push((n, e.clone()));
lines.push(format!("[{n}] {what}"));
} else if !e.active {
lines.push(format!(" {what}"));
}
}
(numbered, lines, spots)
}
fn describe(e: &Element) -> String {
let short = |s: &str, n: usize| -> String {
let c: Vec<char> = s.chars().collect();
if c.len() > n { format!("{}…", c[..n].iter().collect::<String>()) } else { s.to_string() }
};
let mut t = e.kind.clone();
if !e.name.is_empty() {
t.push_str(&format!("「{}」", short(&e.name, 60)));
}
if !e.value.is_empty() && e.value != e.name {
t.push_str(&format!(":{}", short(&e.value, 80)));
}
if !e.flags.is_empty() {
t.push_str(&format!("({})", e.flags.join("、")));
}
t
}
/// A fingerprint of what the controls are and where (to say "nothing changed").
pub fn fingerprint(elements: &[Element]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
for e in elements {
(e.kind.as_str(), e.name.as_str(), e.value.as_str(), e.x as i64, e.y as i64, e.w as i64, e.h as i64, e.flags.len()).hash(&mut h);
}
h.finish()
}
/// A coarse fingerprint of a picture (16×10 grey cells), for "nothing changed" when there is no
/// tree to compare.
pub fn picture_fingerprint(img: &image::RgbImage) -> u64 {
use std::hash::{Hash, Hasher};
let small = image::imageops::resize(img, 16, 10, image::imageops::FilterType::Triangle);
let mut h = std::collections::hash_map::DefaultHasher::new();
for p in small.pixels() {
// Quantised, so a blinking caret or a clock's second doesn't count as a change.
((p[0] as u16 + p[1] as u16 + p[2] as u16) / 24).hash(&mut h);
}
h.finish()
}
const COLOURS: [[u8; 3]; 6] = [[220, 38, 38], [37, 99, 235], [22, 163, 74], [192, 38, 211], [234, 88, 12], [8, 145, 178]];
/// Box and number each control on the picture. `to_pic` maps screen units to picture pixels.
pub fn draw(img: &mut image::RgbImage, numbered: &[(usize, Element)], to_pic: (f64, f64)) {
let (iw, ih) = (img.width() as i64, img.height() as i64);
for (n, e) in numbered {
let c = COLOURS[n % COLOURS.len()];
let (x0, y0) = ((e.x / to_pic.0).round() as i64, (e.y / to_pic.1).round() as i64);
let (x1, y1) = (((e.x + e.w) / to_pic.0).round() as i64, ((e.y + e.h) / to_pic.1).round() as i64);
rect_outline(img, x0, y0, x1, y1, c);
// The number: white digits on the control's colour, at its top-left corner (inside the
// picture).
let label = n.to_string();
let (lw, lh) = (label.len() as i64 * (DIGIT_W + 1) * SCALE + 3, DIGIT_H * SCALE + 4);
let lx = x0.clamp(0, (iw - lw).max(0));
let ly = (y0 - lh).clamp(0, (ih - lh).max(0));
let ly = if ly + lh > y0 && y0 >= lh { y0 - lh } else { ly };
fill(img, lx, ly, lx + lw, ly + lh, c);
let mut cx = lx + 2;
for ch in label.bytes() {
digit(img, cx, ly + 2, ch - b'0', [255, 255, 255]);
cx += (DIGIT_W + 1) * SCALE;
}
}
}
fn put(img: &mut image::RgbImage, x: i64, y: i64, c: [u8; 3]) {
if x >= 0 && y >= 0 && (x as u32) < img.width() && (y as u32) < img.height() {
img.put_pixel(x as u32, y as u32, image::Rgb(c));
}
}
fn fill(img: &mut image::RgbImage, x0: i64, y0: i64, x1: i64, y1: i64, c: [u8; 3]) {
for y in y0..y1 {
for x in x0..x1 {
put(img, x, y, c);
}
}
}
fn rect_outline(img: &mut image::RgbImage, x0: i64, y0: i64, x1: i64, y1: i64, c: [u8; 3]) {
for x in x0..=x1 {
put(img, x, y0, c);
put(img, x, y1, c);
}
for y in y0..=y1 {
put(img, x0, y, c);
put(img, x1, y, c);
}
}
/// A 3×5 digit font, each pixel drawn SCALE×SCALE.
const DIGIT_W: i64 = 3;
const DIGIT_H: i64 = 5;
const SCALE: i64 = 2;
const DIGITS: [[u8; 5]; 10] = [
[0b111, 0b101, 0b101, 0b101, 0b111],
[0b010, 0b110, 0b010, 0b010, 0b111],
[0b111, 0b001, 0b111, 0b100, 0b111],
[0b111, 0b001, 0b111, 0b001, 0b111],
[0b101, 0b101, 0b111, 0b001, 0b001],
[0b111, 0b100, 0b111, 0b001, 0b111],
[0b111, 0b100, 0b111, 0b101, 0b111],
[0b111, 0b001, 0b010, 0b010, 0b010],
[0b111, 0b101, 0b111, 0b101, 0b111],
[0b111, 0b101, 0b111, 0b001, 0b111],
];
fn digit(img: &mut image::RgbImage, x: i64, y: i64, d: u8, c: [u8; 3]) {
let rows = DIGITS[(d as usize).min(9)];
for (r, bits) in rows.iter().enumerate() {
for col in 0..DIGIT_W {
if bits >> (DIGIT_W - 1 - col) & 1 == 1 {
fill(img, x + col * SCALE, y + r as i64 * SCALE, x + (col + 1) * SCALE, y + (r as i64 + 1) * SCALE, c);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn el(kind: &str, name: &str, x: f64, y: f64, active: bool) -> Element {
Element { kind: kind.into(), name: name.into(), value: String::new(), x, y, w: 40.0, h: 20.0, active, flags: vec![], window: "W".into() }
}
#[test]
fn controls_are_numbered_in_reading_order() {
let els = vec![el("按鈕", "下", 10.0, 300.0, true), el("連結", "Profile", 20.0, 100.0, true), el("文字", "標題", 0.0, 50.0, false), el("連結", "Profile", 20.0, 100.0, true)];
let (numbered, lines, spots) = number(&els, false);
// Reading order, duplicates once, plain text left out when there is a picture.
assert_eq!(lines, vec!["[1] 連結「Profile」", "[2] 按鈕「下」"]);
assert_eq!(numbered.len(), 2);
assert_eq!(spots.get("1").unwrap(), ((40.0, 110.0), "連結「Profile」".to_string()));
assert_eq!(spots.get("e2").unwrap().0, (30.0, 310.0));
assert!(spots.get("3").is_err() && spots.get("ok").is_err());
// Without a picture the text is listed too (not numbered).
let (_, lines, _) = number(&els, true);
assert_eq!(lines, vec![" 文字「標題」", "[1] 連結「Profile」", "[2] 按鈕「下」"]);
}
#[test]
fn numbers_are_drawn_on_the_picture() {
let mut img = image::RgbImage::from_pixel(200, 100, image::Rgb([255, 255, 255]));
let els = vec![el("按鈕", "OK", 100.0, 60.0, true)];
let (numbered, _, _) = number(&els, false);
// Screen twice the picture's size: the box lands at (50, 30)–(70, 40).
draw(&mut img, &numbered, (2.0, 2.0));
assert_eq!(img.get_pixel(50, 35).0, COLOURS[1]);
assert_eq!(img.get_pixel(60, 30).0, COLOURS[1]);
// The label sits above the box with white digits in it.
let label_has_white = (16..30).any(|y| (50..62).any(|x| img.get_pixel(x, y).0 == [255, 255, 255]));
let label_has_colour = (16..30).any(|y| (50..62).any(|x| img.get_pixel(x, y).0 == COLOURS[1]));
assert!(label_has_white && label_has_colour);
}
#[test]
fn a_still_screen_has_the_same_fingerprint() {
let a = vec![el("按鈕", "OK", 1.0, 2.0, true)];
let mut b = a.clone();
assert_eq!(fingerprint(&a), fingerprint(&b));
b[0].name = "Cancel".into();
assert_ne!(fingerprint(&a), fingerprint(&b));
let img = image::RgbImage::from_pixel(64, 40, image::Rgb([10, 20, 30]));
let mut img2 = img.clone();
img2.put_pixel(0, 0, image::Rgb([12, 20, 30]));
assert_eq!(picture_fingerprint(&img), picture_fingerprint(&img2));
}
}