use crate::ai::types::ToolSpec;
use serde_json::{Value, json};
#[derive(Clone, Debug, PartialEq)]
pub enum ComputerAction {
Screenshot,
Click {
x: i32,
y: i32,
button: MouseButton,
double: bool,
},
Move {
x: i32,
y: i32,
},
Type {
text: String,
},
Key {
keys: String,
},
Scroll {
x: i32,
y: i32,
amount: i32,
},
OpenUrl {
url: String,
},
}
pub fn web_url(raw: &str) -> Result<String, String> {
let raw = raw.trim();
let url = if raw.contains("://") { raw.to_owned() } else { format!("https://{raw}") };
let valid = (url.starts_with("https://") || url.starts_with("http://"))
&& url.len() > "https://".len()
&& !url.contains(char::is_whitespace);
if valid { Ok(url) } else { Err(format!("computer: «{raw}» no es una dirección web")) }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MouseButton {
Left,
Right,
}
impl ComputerAction {
pub fn parse(input: &Value) -> Result<Self, String> {
let number = |key: &str| {
input[key].as_f64().map(|value| value.round() as i32).ok_or_else(|| format!("computer: falta «{key}»"))
};
let text = |key: &str| input[key].as_str().map(str::to_owned).ok_or_else(|| format!("computer: falta «{key}»"));
match input["action"].as_str().unwrap_or_default() {
"screenshot" => Ok(Self::Screenshot),
"click" => Ok(Self::Click {
x: number("x")?,
y: number("y")?,
button: if input["button"].as_str() == Some("right") { MouseButton::Right } else { MouseButton::Left },
double: input["double"].as_bool().unwrap_or(false),
}),
"move" => Ok(Self::Move { x: number("x")?, y: number("y")? }),
"type" => Ok(Self::Type { text: text("text")? }),
"key" => Ok(Self::Key { keys: text("keys")? }),
"open_url" => Ok(Self::OpenUrl { url: web_url(&text("url")?)? }),
"scroll" => Ok(Self::Scroll { x: number("x")?, y: number("y")?, amount: number("amount")?.clamp(-50, 50) }),
other => Err(format!(
"computer: acción «{other}» desconocida; usa screenshot, click, move, type, key, scroll u open_url"
)),
}
}
pub fn summary(&self) -> String {
match self {
Self::Screenshot => "Ver la pantalla".into(),
Self::Click { x, y, button, double } => {
let kind = match (button, double) {
(MouseButton::Right, _) => "Clic derecho",
(_, true) => "Doble clic",
_ => "Clic",
};
format!("{kind} en ({x}, {y})")
}
Self::Move { x, y } => format!("Mover el puntero a ({x}, {y})"),
Self::Type { text } => {
let short: String = text.chars().take(40).collect();
format!("Escribir «{short}»")
}
Self::Key { keys } => format!("Pulsar {keys}"),
Self::Scroll { amount, .. } => format!("Desplazar {amount} líneas"),
Self::OpenUrl { url } => format!("Abrir {url}"),
}
}
}
pub fn spec() -> ToolSpec {
ToolSpec {
name: "computer".into(),
description: "Controla el computador del usuario: ve la pantalla y usa ratón y teclado. Empieza siempre con \
`screenshot`; las coordenadas x, y son píxeles de la última captura (origen arriba a la izquierda). \
Después de cada clic o tecla que cambie algo, vuelve a capturar para comprobar el resultado. Para la web \
usa `open_url`: abre el navegador y devuelve la captura. Úsala solo cuando la tarea necesite otra aplicación; para el código del proyecto usa las demás herramientas."
.into(),
schema: json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["screenshot", "click", "move", "type", "key", "scroll", "open_url"],
},
"url": { "type": "string", "description": "Dirección web a abrir (action=open_url)" },
"x": { "type": "number" },
"y": { "type": "number" },
"button": { "type": "string", "enum": ["left", "right"] },
"double": { "type": "boolean" },
"text": { "type": "string", "description": "Texto a escribir (action=type)" },
"keys": { "type": "string", "description": "Teclas con +, por ejemplo cmd+c, return, shift+tab" },
"amount": { "type": "number", "description": "Líneas a desplazar: positivo baja" },
},
"required": ["action"],
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn interpreta_cada_accion_y_explica_lo_que_falta() {
assert_eq!(ComputerAction::parse(&json!({"action": "screenshot"})), Ok(ComputerAction::Screenshot));
assert_eq!(
ComputerAction::parse(&json!({"action": "click", "x": 10.4, "y": 20, "button": "right"})),
Ok(ComputerAction::Click { x: 10, y: 20, button: MouseButton::Right, double: false })
);
assert_eq!(
ComputerAction::parse(&json!({"action": "scroll", "x": 1, "y": 2, "amount": 900})),
Ok(ComputerAction::Scroll { x: 1, y: 2, amount: 50 })
);
assert!(ComputerAction::parse(&json!({"action": "click", "x": 1})).unwrap_err().contains("«y»"));
assert!(ComputerAction::parse(&json!({"action": "volar"})).unwrap_err().contains("desconocida"));
assert_eq!(ComputerAction::Key { keys: "cmd+c".into() }.summary(), "Pulsar cmd+c");
assert_eq!(
ComputerAction::parse(&json!({"action": "open_url", "url": "github.com"})),
Ok(ComputerAction::OpenUrl { url: "https://github.com".into() })
);
assert!(web_url("file:///etc/passwd").is_err() && web_url("hola mundo").is_err());
}
}