diff options
Diffstat (limited to 'crates/asist-app')
| -rw-r--r-- | crates/asist-app/src/main.rs | 27 | ||||
| -rw-r--r-- | crates/asist-app/src/registry.rs | 67 | ||||
| -rw-r--r-- | crates/asist-app/tests/integracion.rs | 129 |
3 files changed, 181 insertions, 42 deletions
diff --git a/crates/asist-app/src/main.rs b/crates/asist-app/src/main.rs index 7b00b1f..cc08e62 100644 --- a/crates/asist-app/src/main.rs +++ b/crates/asist-app/src/main.rs @@ -109,6 +109,17 @@ fn run(config: Config, args: &Args) -> Result<()> { // Las dos capacidades que tocan algo fuera del proceso se anuncian: una // enciende la cámara y la otra ejecuta órdenes. Quien lo arranca debería // saberlo sin tener que leerse la configuración. + if tools.get("mirar_la_pantalla").is_some() { + eprintln!( + "Captura de pantalla ACTIVA — a {} px{}", + config.screen.width, + if config.screen.save_dir.is_empty() { + String::new() + } else { + format!(", guardando en {}", config.screen.save_dir) + } + ); + } if tools.get("mirar_por_la_camara").is_some() { eprintln!( "Cámara ACTIVA — {} a {}x{}{}", @@ -320,6 +331,22 @@ fn check(config: &Config) -> Result<()> { } } + if !config.screen.enabled { + println!(" - pantalla desactivada en la configuración"); + } else { + match config.screen.command.first() { + Some(program) if !program.contains('/') || PathBuf::from(program).exists() => ok( + "pantalla", + format!("{} px · {}", config.screen.width, program), + ), + Some(program) => println!(" - pantalla no existe {program}"), + None => println!(" - pantalla screen.command está vacío"), + } + if std::env::var_os("WAYLAND_DISPLAY").is_none() && std::env::var_os("DISPLAY").is_none() { + println!(" - sesión gráfica no hay; la pantalla no se podrá capturar"); + } + } + if !config.camera.enabled { println!(" - cámara desactivada en la configuración"); } else if config.camera.device.exists() { diff --git a/crates/asist-app/src/registry.rs b/crates/asist-app/src/registry.rs index 13bbb75..a3a10ed 100644 --- a/crates/asist-app/src/registry.rs +++ b/crates/asist-app/src/registry.rs @@ -8,11 +8,12 @@ use std::sync::Arc; use std::time::Duration; -use asist_core::config::{CameraConfig, Config, SearchConfig}; +use asist_core::config::{self as cfg, Config, SearchConfig}; use asist_core::tools::ToolRegistry; use asist_llm::LlmClient; -use asist_tools::camera::CaptureConfig; -use asist_tools::{Camera, SearchBackend, WebSearch}; +use asist_tools::{ + Camera, CameraConfig, Screen, ScreenConfig, SearchBackend, VisionTool, WebSearch, +}; /// Lo que no se pudo activar y por qué, para decírselo al usuario al arrancar /// en vez de dejarlo en silencio. @@ -38,13 +39,16 @@ pub fn build(config: &Config, llm: &Arc<LlmClient>) -> (ToolRegistry, Vec<Skippe }), } - match camera_tool(&config.camera, llm) { - Ok(Some(tool)) => registry.register(Arc::new(tool)), - Ok(None) => {} - Err(reason) => skipped.push(Skipped { - tool: "mirar_por_la_camara", - reason, - }), + // Las dos que ven comparten forma, así que se registran igual. + for (result, tool) in [ + (camera_tool(&config.camera, llm), "mirar_por_la_camara"), + (screen_tool(&config.screen, llm), "mirar_la_pantalla"), + ] { + match result { + Ok(Some(vision)) => registry.register(Arc::new(vision)), + Ok(None) => {} + Err(reason) => skipped.push(Skipped { tool, reason }), + } } (registry, skipped) @@ -98,25 +102,46 @@ fn search_tool(config: &SearchConfig) -> Result<Option<WebSearch>, String> { ))) } -fn camera_tool(config: &CameraConfig, llm: &Arc<LlmClient>) -> Result<Option<Camera>, String> { +fn camera_tool( + config: &cfg::CameraConfig, + llm: &Arc<LlmClient>, +) -> Result<Option<VisionTool>, String> { if !config.enabled { return Ok(None); } - let capture = CaptureConfig { + let source = Camera::new(CameraConfig { device: config.device.clone(), width: config.width, height: config.height, warmup_frames: config.warmup_frames, timeout: Duration::from_secs(config.timeout_secs), save_dir: (!config.save_dir.is_empty()).then(|| config.save_dir.clone().into()), - }; - // Registrar una herramienta que va a fallar siempre es peor que no tenerla: - // el modelo la llamaría, se comería el error y gastaría un turno entero. - if !Camera::available(&capture) { - return Err(format!( - "no existe {}; comprueba con «v4l2-ctl --list-devices»", - capture.device.display() - )); + }); + finish(VisionTool::camera(Box::new(source), Arc::clone(llm))) +} + +fn screen_tool( + config: &cfg::ScreenConfig, + llm: &Arc<LlmClient>, +) -> Result<Option<VisionTool>, String> { + if !config.enabled { + return Ok(None); + } + let source = Screen::new(ScreenConfig { + command: config.command.clone(), + width: config.width, + output: config.output.clone(), + timeout: Duration::from_secs(config.timeout_secs), + save_dir: (!config.save_dir.is_empty()).then(|| config.save_dir.clone().into()), + }); + finish(VisionTool::screen(Box::new(source), Arc::clone(llm))) +} + +/// Registrar una herramienta que va a fallar siempre es peor que no tenerla: +/// el modelo la llamaría, se comería el error y gastaría un turno entero. +fn finish(tool: VisionTool) -> Result<Option<VisionTool>, String> { + match tool.available() { + Ok(()) => Ok(Some(tool)), + Err(err) => Err(err.to_string()), } - Ok(Some(Camera::new(capture, Arc::clone(llm)))) } diff --git a/crates/asist-app/tests/integracion.rs b/crates/asist-app/tests/integracion.rs index a7f7e15..d0fca38 100644 --- a/crates/asist-app/tests/integracion.rs +++ b/crates/asist-app/tests/integracion.rs @@ -17,6 +17,7 @@ use asist_core::http::Cancel; use asist_core::tools::{Tool, ToolRegistry}; use asist_llm::chat::Conversation; use asist_llm::{Delta, LlmClient, Message}; +use asist_tools::FrameSource; use asist_tts::TtsClient; /// Las pruebas se turnan la GPU. @@ -346,24 +347,62 @@ fn una_consulta_inventada_no_revienta_el_turno() { ); } -#[test] -fn la_camara_captura_un_jpeg() { - let config = config(); - let capture = asist_tools::camera::CaptureConfig { +fn camera_source(config: &Config) -> Option<asist_tools::Camera> { + if !config.camera.enabled || !config.camera.device.exists() { + eprintln!("sin cámara; prueba omitida"); + return None; + } + Some(asist_tools::Camera::new(asist_tools::CameraConfig { device: config.camera.device.clone(), width: config.camera.width, height: config.camera.height, warmup_frames: config.camera.warmup_frames, timeout: Duration::from_secs(config.camera.timeout_secs), save_dir: None, - }; - if !config.camera.enabled || !capture.device.exists() { - eprintln!("sin cámara; prueba omitida"); - return; + })) +} + +fn screen_source(config: &Config) -> Option<asist_tools::Screen> { + let screen = asist_tools::Screen::new(asist_tools::ScreenConfig { + command: config.screen.command.clone(), + width: config.screen.width, + output: config.screen.output.clone(), + timeout: Duration::from_secs(config.screen.timeout_secs), + save_dir: None, + }); + if !config.screen.enabled || screen.available().is_err() { + eprintln!("sin pantalla capturable; prueba omitida"); + return None; + } + Some(screen) +} + +/// Ancho de un JPEG, leído de su cabecera SOF. +fn jpeg_width(bytes: &[u8]) -> Option<u32> { + let mut i = 2; + while i + 9 < bytes.len() { + if bytes[i] != 0xFF { + return None; + } + let marker = bytes[i + 1]; + let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize; + if (0xC0..=0xCF).contains(&marker) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC { + return Some(u16::from_be_bytes([bytes[i + 7], bytes[i + 8]]) as u32); + } + i += 2 + len; } + None +} + +#[test] +fn la_camara_captura_un_jpeg() { + let config = config(); + let Some(source) = camera_source(&config) else { + return; + }; let started = Instant::now(); - let frame = asist_tools::camera::capture(&capture).expect("la captura falló"); + let frame = source.capture().expect("la captura falló"); eprintln!( "fotograma de {} KB en {:?}", frame.len() / 1024, @@ -380,26 +419,43 @@ fn la_camara_captura_un_jpeg() { } #[test] +fn la_pantalla_se_captura_al_ancho_configurado() { + let config = config(); + let Some(source) = screen_source(&config) else { + return; + }; + + let started = Instant::now(); + let frame = source.capture().expect("la captura falló"); + eprintln!( + "captura de {} KB en {:?}", + frame.len() / 1024, + started.elapsed() + ); + assert_eq!(&frame[..2], &[0xFF, 0xD8], "no parece un JPEG"); + + // El ancho es lo que separa leer de inventar, así que se comprueba que el + // guion de verdad redujo la imagen y no la mandó a tamaño completo. + let width = jpeg_width(&frame).expect("no se pudo leer el ancho del JPEG"); + assert_eq!( + width, config.screen.width, + "la captura salió a {width} px y se pedían {}", + config.screen.width + ); +} + +#[test] #[ignore = "enciende la cámara y hace una pasada de visión; lento"] fn el_modelo_describe_lo_que_ve_la_camara() { let _gpu = en_exclusiva(); let Some((config, llm, _)) = clientes() else { return; }; - let capture = asist_tools::camera::CaptureConfig { - device: config.camera.device.clone(), - width: config.camera.width, - height: config.camera.height, - warmup_frames: config.camera.warmup_frames, - timeout: Duration::from_secs(config.camera.timeout_secs), - save_dir: None, - }; - if !config.camera.enabled || !capture.device.exists() { - eprintln!("sin cámara; prueba omitida"); + let Some(source) = camera_source(&config) else { return; - } + }; - let tool = asist_tools::Camera::new(capture, std::sync::Arc::new(llm)); + let tool = asist_tools::VisionTool::camera(Box::new(source), std::sync::Arc::new(llm)); let started = Instant::now(); let out = tool .call(&serde_json::json!({ "pregunta": "¿Qué se ve en la imagen?" })) @@ -418,6 +474,37 @@ fn el_modelo_describe_lo_que_ve_la_camara() { } #[test] +#[ignore = "captura la pantalla y hace una pasada de visión; lento"] +fn el_modelo_describe_la_pantalla() { + let _gpu = en_exclusiva(); + let Some((config, llm, _)) = clientes() else { + return; + }; + let Some(source) = screen_source(&config) else { + return; + }; + + let tool = asist_tools::VisionTool::screen(Box::new(source), std::sync::Arc::new(llm)); + let started = Instant::now(); + let out = tool + .call(&serde_json::json!({ "pregunta": "¿Qué hay en la pantalla?" })) + .expect("la descripción falló"); + + eprintln!("pantalla en {:?}: {out}", started.elapsed()); + assert!( + out.split_whitespace().count() >= 3, + "descripción vacía o mínima: {out}" + ); + // A 1280 px se midieron 7,6 s. Si esto se dispara, o la GPU está ocupada o + // alguien subió screen.width sin mirar el coste. + assert!( + started.elapsed() < Duration::from_secs(30), + "tardó {:?}; revisa screen.width", + started.elapsed() + ); +} + +#[test] #[ignore = "sale a internet con los dos buscadores; lento"] fn los_dos_buscadores_responden_a_lo_mismo() { // Compara lo que devuelve cada uno para las mismas preguntas. No afirma |