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#[derive(Debug, Clone, Serialize, Deserialize)]
472pub struct FocusedContext {
473 pub element: ContextElement,
475 pub subtree: Vec<ContextElement>,
477 pub ancestor_path: Vec<String>,
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 fn default_state() -> ElementState {
486 ElementState::default()
487 }
488
489 #[test]
490 fn test_classify_interactive_elements() {
491 let types = [
492 "button",
493 "link",
494 "input",
495 "textfield",
496 "textarea",
497 "combobox",
498 "select",
499 "checkbox",
500 "radio",
501 "slider",
502 "switch",
503 "toggle",
504 "tab",
505 "menuitem",
506 ];
507 for t in types {
508 let role = classify_content_role(t, &[], &default_state());
509 assert_eq!(
510 role,
511 ContentRole::Interactive,
512 "Expected Interactive for '{}'",
513 t
514 );
515 }
516 }
517
518 #[test]
519 fn test_classify_content_elements() {
520 let types = [
521 "text",
522 "statictext",
523 "paragraph",
524 "heading",
525 "label",
526 "cell",
527 "table",
528 "table_row",
529 "table_cell",
530 "list",
531 "listitem",
532 "list_item",
533 ];
534 for t in types {
535 let role = classify_content_role(t, &[], &default_state());
536 assert_eq!(role, ContentRole::Content, "Expected Content for '{}'", t);
537 }
538 }
539
540 #[test]
541 fn test_classify_system_elements() {
542 let types = [
543 "scrollbar",
544 "splitter",
545 "statusbar",
546 "status_bar",
547 "progressbar",
548 "dialog",
549 "window",
550 ];
551 for t in types {
552 let role = classify_content_role(t, &[], &default_state());
553 assert_eq!(role, ContentRole::System, "Expected System for '{}'", t);
554 }
555 }
556
557 #[test]
558 fn test_classify_decorative_elements() {
559 let types = ["separator", "image", "icon", "spacer"];
560 for t in types {
561 let role = classify_content_role(t, &[], &default_state());
562 assert_eq!(
563 role,
564 ContentRole::Decorative,
565 "Expected Decorative for '{}'",
566 t
567 );
568 }
569 }
570
571 #[test]
572 fn test_unknown_with_actions_is_interactive() {
573 let role = classify_content_role("custom_widget", &["click".into()], &default_state());
574 assert_eq!(role, ContentRole::Interactive);
575 }
576
577 #[test]
578 fn test_unknown_without_actions_is_content() {
579 let role = classify_content_role("custom_widget", &[], &default_state());
580 assert_eq!(role, ContentRole::Content);
581 }
582
583 #[test]
584 fn test_unknown_focused_is_interactive() {
585 let mut state = default_state();
586 state.focused = true;
587 let role = classify_content_role("unknown", &[], &state);
588 assert_eq!(role, ContentRole::Interactive);
589 }
590
591 #[test]
592 fn test_content_role_default_is_interactive() {
593 assert_eq!(ContentRole::default(), ContentRole::Interactive);
594 }
595
596 #[test]
597 fn test_content_role_serialization() {
598 assert_eq!(
599 serde_json::to_string(&ContentRole::Interactive).unwrap(),
600 "\"interactive\""
601 );
602 assert_eq!(
603 serde_json::to_string(&ContentRole::Content).unwrap(),
604 "\"content\""
605 );
606 }
607}