aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-app
diff options
context:
space:
mode:
Diffstat (limited to 'crates/asist-app')
-rw-r--r--crates/asist-app/src/main.rs27
-rw-r--r--crates/asist-app/src/registry.rs67
-rw-r--r--crates/asist-app/tests/integracion.rs129
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