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 Merged,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
173#[serde(rename_all = "snake_case")]
174pub enum ContentRole {
175 #[default]
178 Interactive,
179 Content,
182 Decorative,
184 System,
186}
187
188pub fn classify_content_role(
190 element_type: &str,
191 actions: &[String],
192 state: &ElementState,
193) -> ContentRole {
194 match element_type {
195 "button" | "link" | "input" | "textfield" | "textarea" | "combobox" | "select"
197 | "checkbox" | "radio" | "slider" | "switch" | "toggle" | "tab" | "tab_item"
198 | "menuitem" | "menu_item" | "menubar" | "menu" | "toolbar" | "searchfield"
199 | "tree_item" => ContentRole::Interactive,
200 "scrollbar" | "splitter" | "statusbar" | "status_bar" | "progressbar" | "indicator"
202 | "dialog" | "window" => ContentRole::System,
203 "separator" | "image" | "icon" | "spacer" => ContentRole::Decorative,
205 "text" | "statictext" | "paragraph" | "heading" | "label" | "cell" | "table"
207 | "table_row" | "table_cell" | "list" | "listitem" | "list_item" | "article"
208 | "blockquote" => ContentRole::Content,
209 _ => {
211 if !actions.is_empty() || state.focused {
212 ContentRole::Interactive
213 } else {
214 ContentRole::Content
215 }
216 }
217 }
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct ContextElement {
223 pub id: String,
225 pub label: Option<String>,
227 pub description: Option<String>,
229 pub element_type: String,
231 pub value: Option<String>,
233 pub bounds: Option<Bounds>,
235 #[serde(default)]
238 pub state: ElementState,
239 pub parent_id: Option<String>,
241 #[serde(default, skip_serializing_if = "Vec::is_empty")]
243 pub actions: Vec<String>,
244 pub confidence: f64,
246 pub source: ContextSource,
248 #[serde(default)]
252 pub content_role: ContentRole,
253 #[serde(
258 default,
259 skip_serializing_if = "HashMap::is_empty",
260 deserialize_with = "flexible_properties"
261 )]
262 pub properties: HashMap<String, String>,
263}
264
265fn flexible_properties<'de, D>(deserializer: D) -> Result<HashMap<String, String>, D::Error>
268where
269 D: serde::Deserializer<'de>,
270{
271 let value = serde_json::Value::deserialize(deserializer)?;
272 let mut map = HashMap::new();
273 if let serde_json::Value::Object(obj) = value {
274 for (key, val) in obj {
275 let string_value = match val {
276 serde_json::Value::String(s) => s,
277 serde_json::Value::Number(n) => n.to_string(),
278 serde_json::Value::Bool(b) => b.to_string(),
279 serde_json::Value::Null => String::new(),
280 other => other.to_string(),
282 };
283 map.insert(key, string_value);
284 }
285 }
286 Ok(map)
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct BoundsRegion {
293 pub quadrant: String,
297 pub relative_x: f64,
299 pub relative_y: f64,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct ContextReference {
308 pub element_type: String,
310 pub label: Option<String>,
312 #[serde(default, skip_serializing_if = "Vec::is_empty")]
314 pub ancestor_path: Vec<String>,
315 pub bounds_region: Option<BoundsRegion>,
317 pub value_pattern: Option<String>,
319}
320
321impl ContextElement {
322 pub(crate) fn build_ancestor_path(&self, all_elements: &[ContextElement]) -> Vec<String> {
325 let mut path = Vec::new();
326 let mut current_id = self.parent_id.as_deref();
327 let mut depth = 0;
328 while let Some(pid) = current_id {
329 if depth > 15 {
330 break;
331 }
332 if let Some(parent) = all_elements.iter().find(|e| e.id == pid) {
333 path.push(parent.element_type.clone());
334 current_id = parent.parent_id.as_deref();
335 } else {
336 break;
337 }
338 depth += 1;
339 }
340 path.reverse();
341 path
342 }
343
344 pub fn to_reference(&self, screen_width: u32, screen_height: u32) -> ContextReference {
347 let bounds_region = self.bounds.as_ref().and_then(|b| {
348 if screen_width == 0 || screen_height == 0 {
349 return None;
350 }
351 let cx = b.x as f64 + b.width as f64 / 2.0;
352 let cy = b.y as f64 + b.height as f64 / 2.0;
353 let rx = cx / screen_width as f64;
354 let ry = cy / screen_height as f64;
355
356 let col = if rx < 0.33 {
357 "left"
358 } else if rx < 0.66 {
359 "center"
360 } else {
361 "right"
362 };
363 let row = if ry < 0.33 {
364 "top"
365 } else if ry < 0.66 {
366 "center"
367 } else {
368 "bottom"
369 };
370 let quadrant = if row == "center" && col == "center" {
371 "center".to_string()
372 } else {
373 format!("{}-{}", row, col)
374 };
375
376 Some(BoundsRegion {
377 quadrant,
378 relative_x: rx.clamp(0.0, 1.0),
379 relative_y: ry.clamp(0.0, 1.0),
380 })
381 });
382
383 ContextReference {
384 element_type: self.element_type.clone(),
385 label: self.label.clone(),
386 ancestor_path: Vec::new(),
387 bounds_region,
388 value_pattern: self.value.clone(),
389 }
390 }
391
392 pub fn to_reference_in_context(
395 &self,
396 screen_width: u32,
397 screen_height: u32,
398 all_elements: &[ContextElement],
399 ) -> ContextReference {
400 let mut reference = self.to_reference(screen_width, screen_height);
401 reference.ancestor_path = self.build_ancestor_path(all_elements);
402 reference
403 }
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct TranscriptEntry {
410 pub text: String,
411 pub start_ms: u64,
412 pub end_ms: u64,
413 pub source: String,
415 #[serde(skip_serializing_if = "Option::is_none")]
416 pub speaker: Option<String>,
417 #[serde(skip_serializing_if = "Option::is_none")]
418 pub confidence: Option<f32>,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct ScreenContext {
424 pub app: String,
426 pub window: String,
428 pub elements: Vec<ContextElement>,
430 #[serde(default)]
432 pub network_events: Vec<ConnectionEvent>,
433 #[serde(default, skip_serializing_if = "Vec::is_empty")]
435 pub http_events: Vec<HttpEvent>,
436 pub timestamp_ms: u64,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
440 pub screen_width: Option<u32>,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub screen_height: Option<u32>,
444 #[serde(default, skip_serializing_if = "Option::is_none")]
446 pub clipboard: Option<ClipboardState>,
447 #[serde(default, skip_serializing_if = "Vec::is_empty")]
449 pub window_list: Vec<WindowState>,
450 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub audio: Option<AudioState>,
453 #[serde(default, skip_serializing_if = "Option::is_none")]
455 pub power: Option<PowerState>,
456 #[serde(default, skip_serializing_if = "Vec::is_empty")]
458 pub running_apps: Vec<RunningApp>,
459 #[serde(default, skip_serializing_if = "Vec::is_empty")]
461 pub recent_files: Vec<RecentFile>,
462 #[serde(default, skip_serializing_if = "Vec::is_empty")]
465 pub transcripts: Vec<TranscriptEntry>,
466}
467
468#[derive(Debug, Clone, Serialize, Deserialize)]
470pub struct FocusedContext {
471 pub element: ContextElement,
473 pub subtree: Vec<ContextElement>,
475 pub ancestor_path: Vec<String>,
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482
483 fn default_state() -> ElementState {
484 ElementState::default()
485 }
486
487 #[test]
488 fn test_classify_interactive_elements() {
489 let types = [
490 "button",
491 "link",
492 "input",
493 "textfield",
494 "textarea",
495 "combobox",
496 "select",
497 "checkbox",
498 "radio",
499 "slider",
500 "switch",
501 "toggle",
502 "tab",
503 "menuitem",
504 ];
505 for t in types {
506 let role = classify_content_role(t, &[], &default_state());
507 assert_eq!(
508 role,
509 ContentRole::Interactive,
510 "Expected Interactive for '{}'",
511 t
512 );
513 }
514 }
515
516 #[test]
517 fn test_classify_content_elements() {
518 let types = [
519 "text",
520 "statictext",
521 "paragraph",
522 "heading",
523 "label",
524 "cell",
525 "table",
526 "table_row",
527 "table_cell",
528 "list",
529 "listitem",
530 "list_item",
531 ];
532 for t in types {
533 let role = classify_content_role(t, &[], &default_state());
534 assert_eq!(role, ContentRole::Content, "Expected Content for '{}'", t);
535 }
536 }
537
538 #[test]
539 fn test_classify_system_elements() {
540 let types = [
541 "scrollbar",
542 "splitter",
543 "statusbar",
544 "status_bar",
545 "progressbar",
546 "dialog",
547 "window",
548 ];
549 for t in types {
550 let role = classify_content_role(t, &[], &default_state());
551 assert_eq!(role, ContentRole::System, "Expected System for '{}'", t);
552 }
553 }
554
555 #[test]
556 fn test_classify_decorative_elements() {
557 let types = ["separator", "image", "icon", "spacer"];
558 for t in types {
559 let role = classify_content_role(t, &[], &default_state());
560 assert_eq!(
561 role,
562 ContentRole::Decorative,
563 "Expected Decorative for '{}'",
564 t
565 );
566 }
567 }
568
569 #[test]
570 fn test_unknown_with_actions_is_interactive() {
571 let role = classify_content_role("custom_widget", &["click".into()], &default_state());
572 assert_eq!(role, ContentRole::Interactive);
573 }
574
575 #[test]
576 fn test_unknown_without_actions_is_content() {
577 let role = classify_content_role("custom_widget", &[], &default_state());
578 assert_eq!(role, ContentRole::Content);
579 }
580
581 #[test]
582 fn test_unknown_focused_is_interactive() {
583 let mut state = default_state();
584 state.focused = true;
585 let role = classify_content_role("unknown", &[], &state);
586 assert_eq!(role, ContentRole::Interactive);
587 }
588
589 #[test]
590 fn test_content_role_default_is_interactive() {
591 assert_eq!(ContentRole::default(), ContentRole::Interactive);
592 }
593
594 #[test]
595 fn test_content_role_serialization() {
596 assert_eq!(
597 serde_json::to_string(&ContentRole::Interactive).unwrap(),
598 "\"interactive\""
599 );
600 assert_eq!(
601 serde_json::to_string(&ContentRole::Content).unwrap(),
602 "\"content\""
603 );
604 }
605}