lazyBoy/crates/control/src/cua/record.rs

284 lines
8.2 KiB
Rust
Raw Normal View History

2026-09-07 14:23:38 +00:00
use std::fs;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use chrono::DateTime;
use serde_json::{Value, json};
pub fn events_from_dir(dir: &Path) -> Vec<Value> {
let mut turns = match fs::read_dir(dir) {
Ok(entries) => entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.join("action.json").is_file())
.collect::<Vec<PathBuf>>(),
Err(_) => return Vec::new(),
};
turns.sort();
turns
.into_iter()
.filter_map(|path| event_from_turn(&path))
.collect()
}
fn event_from_turn(dir: &Path) -> Option<Value> {
let raw = fs::read_to_string(dir.join("action.json")).ok()?;
let value: Value = serde_json::from_str(&raw).ok()?;
let tool = tool_name(&value);
if tool.is_empty() {
return None;
}
let args = arguments(&value);
let at = timestamp_ms(&value).unwrap_or_else(|| file_time_ms(&dir.join("action.json")));
let el = element_from_args(args);
let mut event = match tool.as_str() {
"click" | "right_click" | "double_click" | "browser_click" => {
json!({ "t": "click", "el": el, "at": at })
}
"type_text" | "set_value" | "browser_type" => {
let name = element_label(&el);
let role = el.get("role").and_then(Value::as_str).unwrap_or("");
let raw_text = args
.get("text")
.or_else(|| args.get("value"))
.and_then(Value::as_str)
.unwrap_or("");
let value = if looks_secret(&name) || looks_secret(role) {
"[已遮罩]"
} else {
raw_text
};
json!({ "t": "input", "el": el, "value": value, "at": at })
}
"press_key" | "hotkey" => {
let key = args
.get("key")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| {
args.get("keys").and_then(Value::as_array).map(|keys| {
keys.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join("+")
})
})
.unwrap_or_default();
json!({ "t": "key", "key": key, "el": el, "at": at })
}
"scroll" => json!({
"t": "scroll",
"y": args.get("amount").and_then(Value::as_i64).unwrap_or(0),
"at": at
}),
"browser_navigate" => json!({
"t": "navigate",
"url": args.get("url").and_then(Value::as_str).unwrap_or(""),
"at": at
}),
_ => return None,
};
if let Some(title) = window_title(dir) {
event["window"] = json!(title);
}
Some(event)
}
fn tool_name(value: &Value) -> String {
value
.get("tool")
.or_else(|| value.get("name"))
.or_else(|| value.get("action"))
.and_then(Value::as_str)
.unwrap_or("")
.to_ascii_lowercase()
}
fn arguments(value: &Value) -> &Value {
value
.get("arguments")
.or_else(|| value.get("input"))
.or_else(|| value.get("args"))
.unwrap_or(value)
}
fn element_from_args(args: &Value) -> Value {
let name = pick(
args,
&[
"name",
"label",
"ref",
"selector",
"element_token",
"element_index",
],
);
let role = pick(args, &["role"]);
json!({
"name": name,
"label": name,
"role": role,
"text": name,
})
}
fn element_label(el: &Value) -> String {
el.get("name")
.or_else(|| el.get("label"))
.or_else(|| el.get("text"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
}
fn pick(value: &Value, keys: &[&str]) -> String {
for key in keys {
if let Some(text) = value.get(*key).and_then(Value::as_str).map(str::trim)
&& !text.is_empty()
{
return text.to_string();
}
if let Some(number) = value.get(*key).and_then(Value::as_i64) {
return number.to_string();
}
}
String::new()
}
fn timestamp_ms(value: &Value) -> Option<i64> {
if let Some(ms) = value.get("at").and_then(Value::as_i64) {
return Some(ms);
}
let stamp = value
.get("timestamp")
.or_else(|| value.get("ts"))
.or_else(|| value.get("time"))
.and_then(Value::as_str)?;
DateTime::parse_from_rfc3339(stamp)
.ok()
.map(|time| time.timestamp_millis())
}
fn file_time_ms(path: &Path) -> i64 {
fs::metadata(path)
.and_then(|meta| meta.modified())
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis() as i64)
.unwrap_or(0)
}
fn window_title(dir: &Path) -> Option<String> {
for name in ["after_state.json", "app_state.json", "before_state.json"] {
let Ok(raw) = fs::read_to_string(dir.join(name)) else {
continue;
};
let Ok(value) = serde_json::from_str::<Value>(&raw) else {
continue;
};
if let Some(title) = value
.get("title")
.or_else(|| value.get("window_title"))
.or_else(|| value.pointer("/window/title"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|title| !title.is_empty())
{
return Some(title.to_string());
}
}
None
}
pub fn looks_secret(text: &str) -> bool {
let lower = text.to_lowercase();
[
"pass",
"pwd",
"密碼",
"token",
"otp",
"驗證碼",
"secret",
"cvv",
]
.iter()
.any(|needle| lower.contains(needle))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::SystemTime;
fn write_turn(root: &Path, name: &str, action: &Value, after: Option<&Value>) {
let dir = root.join(name);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("action.json"), action.to_string()).unwrap();
if let Some(state) = after {
fs::write(dir.join("after_state.json"), state.to_string()).unwrap();
}
}
#[test]
fn trajectory_turns_become_skill_events() {
let root = std::env::temp_dir().join(format!(
"lazyboy-traj-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&root).unwrap();
write_turn(
&root,
"turn-00001",
&json!({
"tool": "browser_click",
"timestamp": "2026-09-07T00:00:01Z",
"arguments": { "ref": "p1:1", "role": "button", "name": "Smoke Click" }
}),
Some(&json!({ "title": "LazyBoy Cua Smoke" })),
);
write_turn(
&root,
"turn-00002",
&json!({
"tool": "type_text",
"timestamp": "2026-09-07T00:00:02Z",
"arguments": { "text": "hunter2", "name": "Password", "role": "textbox" }
}),
None,
);
write_turn(
&root,
"turn-00003",
&json!({
"tool": "browser_navigate",
"timestamp": "2026-09-07T00:00:03Z",
"arguments": { "url": "https://example.com" }
}),
None,
);
let events = events_from_dir(&root);
let _ = fs::remove_dir_all(&root);
assert_eq!(events.len(), 3);
assert_eq!(events[0]["t"], "click");
assert_eq!(events[0]["el"]["name"], "Smoke Click");
assert_eq!(events[0]["window"], "LazyBoy Cua Smoke");
assert_eq!(events[1]["t"], "input");
assert_eq!(events[1]["value"], "[已遮罩]");
assert_eq!(events[2]["t"], "navigate");
assert_eq!(events[2]["url"], "https://example.com");
}
#[test]
fn secret_labels_are_detected() {
assert!(looks_secret("Password"));
assert!(looks_secret("確認密碼"));
assert!(!looks_secret("Search"));
}
}