1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
6pub struct Bounds {
7 pub x: i32,
8 pub y: i32,
9 pub width: u32,
10 pub height: u32,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct ElementState {
16 pub focused: bool,
17 pub enabled: bool,
18 pub visible: bool,
19 pub selected: bool,
20 pub expanded: Option<bool>,
21 pub checked: Option<bool>,
22}
23
24impl Default for ElementState {
25 fn default() -> Self {
26 Self {
27 focused: false,
28 enabled: true,
29 visible: true,
30 selected: false,
31 expanded: None,
32 checked: None,
33 }
34 }
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, Default)]
39pub struct ConnectionEvent {
40 #[serde(default)]
41 pub timestamp_ms: u64,
42 #[serde(default = "default_protocol")]
43 pub protocol: String,
44 #[serde(default)]
45 pub local_addr: String,
46 #[serde(default)]
47 pub local_port: u16,
48 #[serde(default)]
49 pub remote_addr: String,
50 #[serde(default)]
51 pub remote_port: u16,
52 #[serde(default)]
53 pub state: String,
54 pub service: Option<String>,
55 pub process_name: Option<String>,
56 pub pid: Option<u32>,
57}
58
59fn default_protocol() -> String {
60 "tcp".to_string()
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, Default)]
65pub struct HttpEvent {
66 pub timestamp_ms: u64,
67 #[serde(default)]
68 pub method: String,
69 pub url: String,
70 #[serde(alias = "status")]
71 pub status_code: Option<u16>,
72 pub content_type: Option<String>,
73 pub duration_ms: Option<f64>,
74 pub size_bytes: Option<u64>,
75 #[serde(default)]
76 pub source: String,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, Default)]
81pub struct ClipboardState {
82 pub text: Option<String>,
84 pub has_image: bool,
85 pub has_files: bool,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, Default)]
90pub struct WindowState {
91 pub app_name: String,
92 pub title: String,
93 pub x: i32,
94 pub y: i32,
95 pub width: u32,
96 pub height: u32,
97 pub layer: i32,
98 pub is_on_screen: bool,
99 pub pid: u32,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, Default)]
104pub struct AudioState {
105 pub volume: f32,
107 pub is_muted: bool,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, Default)]
112pub struct PowerState {
113 pub battery_level: Option<f32>,
115 pub is_charging: bool,
116 pub is_plugged_in: bool,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, Default)]
121pub struct RunningApp {
122 pub name: String,
123 pub is_frontmost: bool,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize, Default)]
128pub struct RecentFile {
129 pub name: String,
130 pub directory: String,
131 pub age_secs: u64,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136#[serde(rename_all = "snake_case")]
137pub enum ContextSource {
138 AccessibilityTree,
140 NativeApi,
142 Vision,
144 Cdp,
152 Ocr,
158 External,
161 Merged,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
176#[serde(rename_all = "snake_case")]
177pub enum ContentRole {
178 #[default]
181 Interactive,
182 Content,
185 Decorative,
187 System,
189}
190
191pub fn classify_content_role(
193 element_type: &str,
194 actions: &[String],
195 state: &ElementState,
196) -> ContentRole {
197 match element_type {
198 "button" | "link" | "input" | "textfield" | "textarea" | "combobox" | "select"
200 | "checkbox" | "radio" | "slider" | "switch" | "toggle" | "tab" | "tab_item"
201 | "menuitem" | "menu_item" | "menubar" | "menu" | "toolbar" | "searchfield"
202 | "tree_item" => ContentRole::Interactive,
203 "scrollbar" | "splitter" | "statusbar" | "status_bar" | "progressbar" | "indicator"
205 | "dialog" | "window" => ContentRole::System,
206 "separator" | "image" | "icon" | "spacer" => ContentRole::Decorative,
208 "text" | "statictext" | "paragraph" | "heading" | "label" | "cell" | "table"
210 | "table_row" | "table_cell" | "list" | "listitem" | "list_item" | "article"
211 | "blockquote" => ContentRole::Content,
212 _ => {
214 if !actions.is_empty() || state.focused {
215 ContentRole::Interactive
216 } else {
217 ContentRole::Content
218 }
219 }
220 }
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct ContextElement {
226 pub id: String,
228 pub label: Option<String>,
230 pub description: Option<String>,
232 pub element_type: String,
234 pub value: Option<String>,
236 pub bounds: Option<Bounds>,
238 #[serde(default)]
241 pub state: ElementState,
242 pub parent_id: Option<String>,
244 #[serde(default, skip_serializing_if = "Vec::is_empty")]
246 pub actions: Vec<String>,
247 pub confidence: f64,
249 pub source: ContextSource,
251 #[serde(default)]
255 pub content_role: ContentRole,
256 #[serde(
261 default,
262 skip_serializing_if = "HashMap::is_empty",
263 deserialize_with = "flexible_properties"
264 )]
265 pub properties: HashMap<String, String>,
266}
267
268fn flexible_properties<'de, D>(deserializer: D) -> Result<HashMap<String, String>, D::Error>
271where
272 D: serde::Deserializer<'de>,
273{
274 let value = serde_json::Value::deserialize(deserializer)?;
275 let mut map = HashMap::new();
276 if let serde_json::Value::Object(obj) = value {
277 for (key, val) in obj {
278 let string_value = match val {
279 serde_json::Value::String(s) => s,
280 serde_json::Value::Number(n) => n.to_string(),
281 serde_json::Value::Bool(b) => b.to_string(),
282 serde_json::Value::Null => String::new(),
283 other => other.to_string(),
285 };
286 map.insert(key, string_value);
287 }
288 }
289 Ok(map)
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct BoundsRegion {
296 pub quadrant: String,
300 pub relative_x: f64,
302 pub relative_y: f64,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct ContextReference {
311 pub element_type: String,
313 pub label: Option<String>,
315 #[serde(default, skip_serializing_if = "Vec::is_empty")]
317 pub ancestor_path: Vec<String>,
318 pub bounds_region: Option<BoundsRegion>,
320 pub value_pattern: Option<String>,
322}
323
324impl ContextElement {
325 pub(crate) fn build_ancestor_path(&self, all_elements: &[ContextElement]) -> Vec<String> {
328 let mut path = Vec::new();
329 let mut current_id = self.parent_id.as_deref();
330 let mut depth = 0;
331 while let Some(pid) = current_id {
332 if depth > 15 {
333 break;
334 }
335 if let Some(parent) = all_elements.iter().find(|e| e.id == pid) {
336 path.push(parent.element_type.clone());
337 current_id = parent.parent_id.as_deref();
338 } else {
339 break;
340 }
341 depth += 1;
342 }
343 path.reverse();
344 path
345 }
346
347 pub fn to_reference(&self, screen_width: u32, screen_height: u32) -> ContextReference {
350 let bounds_region = self.bounds.as_ref().and_then(|b| {
351 if screen_width == 0 || screen_height == 0 {
352 return None;
353 }
354 let cx = b.x as f64 + b.width as f64 / 2.0;
355 let cy = b.y as f64 + b.height as f64 / 2.0;
356 let rx = cx / screen_width as f64;
357 let ry = cy / screen_height as f64;
358
359 let col = if rx < 0.33 {
360 "left"
361 } else if rx < 0.66 {
362 "center"
363 } else {
364 "right"
365 };
366 let row = if ry < 0.33 {
367 "top"
368 } else if ry < 0.66 {
369 "center"
370 } else {
371 "bottom"
372 };
373 let quadrant = if row == "center" && col == "center" {
374 "center".to_string()
375 } else {
376 format!("{}-{}", row, col)
377 };
378
379 Some(BoundsRegion {
380 quadrant,
381 relative_x: rx.clamp(0.0, 1.0),
382 relative_y: ry.clamp(0.0, 1.0),
383 })
384 });
385
386 ContextReference {
387 element_type: self.element_type.clone(),
388 label: self.label.clone(),
389 ancestor_path: Vec::new(),
390 bounds_region,
391 value_pattern: self.value.clone(),
392 }
393 }
394
395 pub fn to_reference_in_context(
398 &self,
399 screen_width: u32,
400 screen_height: u32,
401 all_elements: &[ContextElement],
402 ) -> ContextReference {
403 let mut reference = self.to_reference(screen_width, screen_height);
404 reference.ancestor_path = self.build_ancestor_path(all_elements);
405 reference
406 }
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize)]
412pub struct TranscriptEntry {
413 pub text: String,
414 pub start_ms: u64,
415 pub end_ms: u64,
416 pub source: String,
418 #[serde(skip_serializing_if = "Option::is_none")]
419 pub speaker: Option<String>,
420 #[serde(skip_serializing_if = "Option::is_none")]
421 pub confidence: Option<f32>,
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct ContextSnapshot {
427 pub app: String,
429 pub window: String,
431 pub elements: Vec<ContextElement>,
433 #[serde(default)]
435 pub network_events: Vec<ConnectionEvent>,
436 #[serde(default, skip_serializing_if = "Vec::is_empty")]
438 pub http_events: Vec<HttpEvent>,
439 pub timestamp_ms: u64,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub screen_width: Option<u32>,
444 #[serde(default, skip_serializing_if = "Option::is_none")]
446 pub screen_height: Option<u32>,
447 #[serde(default, skip_serializing_if = "Option::is_none")]
449 pub clipboard: Option<ClipboardState>,
450 #[serde(default, skip_serializing_if = "Vec::is_empty")]
452 pub window_list: Vec<WindowState>,
453 #[serde(default, skip_serializing_if = "Option::is_none")]
455 pub audio: Option<AudioState>,
456 #[serde(default, skip_serializing_if = "Option::is_none")]
458 pub power: Option<PowerState>,
459 #[serde(default, skip_serializing_if = "Vec::is_empty")]
461 pub running_apps: Vec<RunningApp>,
462 #[serde(default, skip_serializing_if = "Vec::is_empty")]
464 pub recent_files: Vec<RecentFile>,
465 #[serde(default, skip_serializing_if = "Vec::is_empty")]
467 pub transcripts: Vec<TranscriptEntry>,
468}
469
470#[deprecated(note = "Use ContextSnapshot")]
477pub type ScreenContext = ContextSnapshot;
478
479#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct FocusedContext {
482 pub element: ContextElement,
484 pub subtree: Vec<ContextElement>,
486 pub ancestor_path: Vec<String>,
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493
494 fn default_state() -> ElementState {
495 ElementState::default()
496 }
497
498 #[test]
499 fn test_classify_interactive_elements() {
500 let types = [
501 "button",
502 "link",
503 "input",
504 "textfield",
505 "textarea",
506 "combobox",
507 "select",
508 "checkbox",
509 "radio",
510 "slider",
511 "switch",
512 "toggle",
513 "tab",
514 "menuitem",
515 ];
516 for t in types {
517 let role = classify_content_role(t, &[], &default_state());
518 assert_eq!(
519 role,
520 ContentRole::Interactive,
521 "Expected Interactive for '{}'",
522 t
523 );
524 }
525 }
526
527 #[test]
528 fn test_classify_content_elements() {
529 let types = [
530 "text",
531 "statictext",
532 "paragraph",
533 "heading",
534 "label",
535 "cell",
536 "table",
537 "table_row",
538 "table_cell",
539 "list",
540 "listitem",
541 "list_item",
542 ];
543 for t in types {
544 let role = classify_content_role(t, &[], &default_state());
545 assert_eq!(role, ContentRole::Content, "Expected Content for '{}'", t);
546 }
547 }
548
549 #[test]
550 fn test_classify_system_elements() {
551 let types = [
552 "scrollbar",
553 "splitter",
554 "statusbar",
555 "status_bar",
556 "progressbar",
557 "dialog",
558 "window",
559 ];
560 for t in types {
561 let role = classify_content_role(t, &[], &default_state());
562 assert_eq!(role, ContentRole::System, "Expected System for '{}'", t);
563 }
564 }
565
566 #[test]
567 fn test_classify_decorative_elements() {
568 let types = ["separator", "image", "icon", "spacer"];
569 for t in types {
570 let role = classify_content_role(t, &[], &default_state());
571 assert_eq!(
572 role,
573 ContentRole::Decorative,
574 "Expected Decorative for '{}'",
575 t
576 );
577 }
578 }
579
580 #[test]
581 fn test_unknown_with_actions_is_interactive() {
582 let role = classify_content_role("custom_widget", &["click".into()], &default_state());
583 assert_eq!(role, ContentRole::Interactive);
584 }
585
586 #[test]
587 fn test_unknown_without_actions_is_content() {
588 let role = classify_content_role("custom_widget", &[], &default_state());
589 assert_eq!(role, ContentRole::Content);
590 }
591
592 #[test]
593 fn test_unknown_focused_is_interactive() {
594 let mut state = default_state();
595 state.focused = true;
596 let role = classify_content_role("unknown", &[], &state);
597 assert_eq!(role, ContentRole::Interactive);
598 }
599
600 #[test]
601 fn test_content_role_default_is_interactive() {
602 assert_eq!(ContentRole::default(), ContentRole::Interactive);
603 }
604
605 #[test]
606 fn test_content_role_serialization() {
607 assert_eq!(
608 serde_json::to_string(&ContentRole::Interactive).unwrap(),
609 "\"interactive\""
610 );
611 assert_eq!(
612 serde_json::to_string(&ContentRole::Content).unwrap(),
613 "\"content\""
614 );
615 }
616}