diff options
Diffstat (limited to 'crates/asist-tools/src')
| -rw-r--r-- | crates/asist-tools/src/camera.rs | 313 | ||||
| -rw-r--r-- | crates/asist-tools/src/lib.rs | 16 | ||||
| -rw-r--r-- | crates/asist-tools/src/screen.rs | 189 | ||||
| -rw-r--r-- | crates/asist-tools/src/search.rs | 34 | ||||
| -rw-r--r-- | crates/asist-tools/src/vision.rs | 206 |
5 files changed, 505 insertions, 253 deletions
diff --git a/crates/asist-tools/src/camera.rs b/crates/asist-tools/src/camera.rs index fe41319..bdc2aa0 100644 --- a/crates/asist-tools/src/camera.rs +++ b/crates/asist-tools/src/camera.rs @@ -1,56 +1,38 @@ -//! Mirar por la cámara. -//! -//! El servidor ya tiene cargado el proyector multimodal, así que el mismo -//! modelo que conversa puede describir una imagen. La herramienta hace tres -//! cosas: capturar un fotograma, preguntarle al modelo por él y devolver la -//! respuesta como texto. -//! -//! Que la pregunta viaje hasta la cámara importa: «¿de qué color es mi -//! camiseta?» y «¿cuánta gente hay?» necesitan la misma imagen pero -//! descripciones muy distintas, y pedir una descripción genérica para luego -//! interrogarla pierde justo el detalle que se buscaba. -//! -//! La captura no se guarda en disco salvo que se pida expresamente: un -//! asistente que deja fotogramas por ahí es un problema de privacidad, no una -//! comodidad de depuración. +//! La cámara como fuente de imágenes. use std::path::PathBuf; -use std::process::{Command, Stdio}; -use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::Duration; use asist_core::error::{Error, Result}; -use asist_core::http::Cancel; -use asist_core::tools::Tool; -use asist_llm::LlmClient; -use serde_json::{json, Value}; -/// Cómo se captura el fotograma. +use crate::vision::{capture_jpeg, FrameSource}; + +const TOOL: &str = "mirar_por_la_camara"; + #[derive(Debug, Clone)] -pub struct CaptureConfig { +pub struct CameraConfig { /// Dispositivo V4L2. pub device: PathBuf, + /// Resolución de captura. Medido con este modelo: 1,3 s a 320x240, 2,9 s a + /// 640x480 y 7,8 s a 1280x720, con la misma descripción útil a partir de + /// 640. Para una escena basta; para leer texto no (eso es la pantalla). pub width: u32, pub height: u32, /// Fotogramas que se descartan antes de quedarse con uno. /// /// La cámara arranca con la exposición automática sin asentar y el primer /// fotograma suele salir quemado. Descartar unos pocos es prácticamente - /// gratis —medido, 0,45 s frente a 0,53 s— y en penumbra se nota. + /// gratis: medido, 0,45 s frente a 0,53 s. pub warmup_frames: u32, - /// Plazo máximo de la captura. pub timeout: Duration, - /// Carpeta donde dejar los fotogramas. Vacío = no se guarda ninguno. + /// Carpeta donde dejar los fotogramas. `None` = no se guarda ninguno. pub save_dir: Option<PathBuf>, } -impl Default for CaptureConfig { +impl Default for CameraConfig { fn default() -> Self { Self { device: PathBuf::from("/dev/video0"), - // 640x480 es el punto de equilibrio medido: el modelo tarda 2,9 s - // y sigue describiendo bien. A 1280x720 tarda 7,8 s, y a 320x240 - // baja a 1,3 s pero deja de distinguir detalles. width: 640, height: 480, warmup_frames: 5, @@ -60,190 +42,87 @@ impl Default for CaptureConfig { } } -/// Captura un fotograma en JPEG. -/// -/// Se apoya en ffmpeg en vez de hablar con V4L2 directamente: una cámara USB -/// entrega MJPEG, YUYV o lo que le parezca, y reimplementar esa negociación -/// para ahorrarse un proceso no sale a cuenta. -pub fn capture(config: &CaptureConfig) -> Result<Vec<u8>> { - let fail = |message: String| Error::Tool { - tool: "mirar_por_la_camara".into(), - message, - }; - - if !config.device.exists() { - return Err(fail(format!( - "no existe el dispositivo {}. Comprueba con «v4l2-ctl --list-devices»", - config.device.display() - ))); - } - - let started = Instant::now(); - let mut command = Command::new("ffmpeg"); - command - .args(["-hide_banner", "-loglevel", "error", "-nostdin"]) - .args(["-f", "v4l2"]) - .args([ - "-video_size", - &format!("{}x{}", config.width, config.height), - ]) - .arg("-i") - .arg(&config.device); - - if config.warmup_frames > 0 { - // Se leen N fotogramas y se conserva el último: es la forma de dejar - // que la exposición se asiente sin abrir el dispositivo dos veces. - command.args(["-vf", &format!("select=eq(n\\,{})", config.warmup_frames)]); - } - command - .args(["-frames:v", "1"]) - .args(["-f", "image2", "-c:v", "mjpeg"]) - .arg("-") - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let mut child = command.spawn().map_err(|e| { - fail(format!( - "no se pudo ejecutar ffmpeg ({e}); hace falta para leer la cámara" - )) - })?; - - let deadline = Instant::now() + config.timeout; - loop { - match child.try_wait().map_err(|e| fail(e.to_string()))? { - Some(_) => break, - None if Instant::now() >= deadline => { - let _ = child.kill(); - let _ = child.wait(); - return Err(fail(format!( - "la cámara no respondió en {} s", - config.timeout.as_secs() - ))); - } - None => std::thread::sleep(Duration::from_millis(20)), - } - } - - let output = child.wait_with_output().map_err(|e| fail(e.to_string()))?; - if !output.status.success() || output.stdout.is_empty() { - let stderr = String::from_utf8_lossy(&output.stderr); - let hint = if stderr.contains("Permission denied") { - ". Tu usuario necesita estar en el grupo «video»" - } else if stderr.contains("Device or resource busy") { - ". Otra aplicación está usando la cámara" - } else { - "" - }; - return Err(fail(format!( - "ffmpeg no capturó nada{hint}: {}", - stderr.trim().lines().next_back().unwrap_or("sin detalles") - ))); - } - - if let Some(dir) = &config.save_dir { - // Sólo si se ha pedido: por defecto la imagen no toca el disco. - if let Err(err) = std::fs::create_dir_all(dir).and_then(|()| { - let name = format!( - "frame-{}.jpg", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) - ); - std::fs::write(dir.join(name), &output.stdout) - }) { - tracing::warn!(target: "camara", %err, "no se pudo guardar el fotograma"); - } - } - - tracing::info!( - target: "camara", - dispositivo = %config.device.display(), - resolucion = format!("{}x{}", config.width, config.height), - kb = output.stdout.len() / 1024, - ms = started.elapsed().as_millis(), - "fotograma capturado" - ); - Ok(output.stdout) -} - pub struct Camera { - config: CaptureConfig, - llm: Arc<LlmClient>, + config: CameraConfig, } impl Camera { - pub fn new(config: CaptureConfig, llm: Arc<LlmClient>) -> Self { - Self { config, llm } - } - - /// `true` si el dispositivo está presente. Sin esto no tiene sentido - /// declarar la herramienta: el modelo la llamaría y fallaría siempre. - pub fn available(config: &CaptureConfig) -> bool { - config.device.exists() + pub fn new(config: CameraConfig) -> Self { + Self { config } } } -impl Tool for Camera { - fn name(&self) -> &str { - "mirar_por_la_camara" +impl FrameSource for Camera { + fn label(&self) -> &str { + "cámara" } - fn description(&self) -> &str { - "Toma una foto con la cámara del equipo y responde a una pregunta sobre \ - lo que se ve. Úsala cuando te pregunten qué ves, qué hay delante, de qué \ - color es algo o cuántas cosas hay." - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "pregunta": { - "type": "string", - "description": "La pregunta del usuario tal cual, sin concretarla más de lo que él dijo. Si sólo quiere saber qué hay delante, pon: ¿Qué se ve?" - } - }, - "required": ["pregunta"] - }) - } - - /// Enciende la cámara, así que se anuncia como tal. - fn is_side_effecting(&self) -> bool { - true - } + fn available(&self) -> Result<()> { + if !self.config.device.exists() { + return Err(Error::Tool { + tool: TOOL.into(), + message: format!( + "no existe {}; comprueba con «v4l2-ctl --list-devices»", + self.config.device.display() + ), + }); + } + Ok(()) + } + + fn capture(&self) -> Result<Vec<u8>> { + self.available()?; + + // ffmpeg y no V4L2 a pelo: una cámara USB entrega MJPEG, YUYV o lo que + // le parezca, y reimplementar esa negociación para ahorrarse un + // proceso no sale a cuenta. + let mut args = vec![ + "-hide_banner".into(), + "-loglevel".into(), + "error".into(), + "-nostdin".into(), + "-f".into(), + "v4l2".into(), + "-video_size".into(), + format!("{}x{}", self.config.width, self.config.height), + "-i".into(), + self.config.device.to_string_lossy().into_owned(), + ]; + if self.config.warmup_frames > 0 { + // Se leen N fotogramas y se conserva el último: así la exposición + // se asienta sin abrir el dispositivo dos veces. + args.push("-vf".into()); + args.push(format!("select=eq(n\\,{})", self.config.warmup_frames)); + } + args.extend( + ["-frames:v", "1", "-f", "image2", "-c:v", "mjpeg", "-"] + .iter() + .map(|s| s.to_string()), + ); - fn acknowledgement(&self) -> Option<&str> { - Some("Voy a mirar.") + let frame = capture_jpeg(TOOL, "ffmpeg".as_ref(), &args, self.config.timeout)?; + if let Some(dir) = &self.config.save_dir { + save(dir, &frame, "camara"); + } + Ok(frame) } +} - fn call(&self, args: &Value) -> Result<String> { - let question = args - .get("pregunta") - .and_then(Value::as_str) - .map(str::trim) - .filter(|q| !q.is_empty()) - .unwrap_or("¿Qué se ve en esta imagen?"); - - let frame = capture(&self.config)?; - - // Se le pide al modelo el estilo hablado aquí y no en la conversación: - // esta petición va fuera del historial, así que la instrucción de voz - // del asistente no le llega. - let prompt = format!( - "{question}\n\nResponde en una o dos frases cortas en español, en texto \ - plano, describiendo sólo lo que se ve de verdad en la imagen. Si no se \ - distingue, dilo." - ); - let started = Instant::now(); - let answer = self.llm.look(&frame, &prompt, &Cancel::new())?; - tracing::info!( - target: "camara", - ms = started.elapsed().as_millis(), - "el modelo describió el fotograma" - ); - Ok(answer) +/// Guarda una copia sólo si se ha pedido expresamente. Por defecto no se +/// escribe nada: un asistente que deja fotogramas por ahí es un problema de +/// privacidad, no una comodidad de depuración. +pub(crate) fn save(dir: &std::path::Path, bytes: &[u8], prefix: &str) { + let name = format!( + "{prefix}-{}.jpg", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + ); + if let Err(err) = + std::fs::create_dir_all(dir).and_then(|()| std::fs::write(dir.join(name), bytes)) + { + tracing::warn!(target: "vision", %err, "no se pudo guardar la captura"); } } @@ -253,38 +132,38 @@ mod tests { #[test] fn un_dispositivo_inexistente_se_detecta_antes_de_registrar_la_herramienta() { - let config = CaptureConfig { + let camera = Camera::new(CameraConfig { device: PathBuf::from("/dev/video-que-no-existe"), ..Default::default() - }; - assert!(!Camera::available(&config)); + }); + let err = camera.available().unwrap_err().to_string(); + assert!(err.contains("no existe"), "{err}"); + assert!( + err.contains("v4l2-ctl"), + "el error debe decir cómo comprobarlo: {err}" + ); } #[test] - fn capturar_de_un_dispositivo_inexistente_da_un_error_util() { - let config = CaptureConfig { + fn capturar_sin_dispositivo_falla_sin_llegar_a_ffmpeg() { + let camera = Camera::new(CameraConfig { device: PathBuf::from("/dev/video-que-no-existe"), ..Default::default() - }; - let err = capture(&config).unwrap_err().to_string(); - assert!(err.contains("no existe el dispositivo"), "{err}"); - assert!( - err.contains("v4l2-ctl"), - "el error debe decir cómo comprobarlo: {err}" - ); + }); + assert!(camera.capture().is_err()); } #[test] fn por_defecto_no_se_guarda_ningun_fotograma() { assert!( - CaptureConfig::default().save_dir.is_none(), + CameraConfig::default().save_dir.is_none(), "guardar imágenes por defecto sería una fuga de privacidad" ); } #[test] fn la_resolucion_por_defecto_es_la_medida_como_equilibrada() { - let config = CaptureConfig::default(); + let config = CameraConfig::default(); assert_eq!((config.width, config.height), (640, 480)); } } diff --git a/crates/asist-tools/src/lib.rs b/crates/asist-tools/src/lib.rs index 796b3d8..a1c2466 100644 --- a/crates/asist-tools/src/lib.rs +++ b/crates/asist-tools/src/lib.rs @@ -1,14 +1,18 @@ -//! Herramientas que asoman el asistente al mundo: buscar en internet y mirar -//! por la cámara. +//! Herramientas que asoman el asistente al mundo: buscar en internet, mirar +//! por la cámara y mirar la pantalla. //! //! Viven en un crate aparte de `asist-core` porque necesitan cosas que el //! núcleo no debe arrastrar —un cliente con TLS, el modelo multimodal, el -//! dispositivo de vídeo—, y porque son el ejemplo de que el punto de extensión -//! funciona: se registran con `ToolRegistry::register` sin tocar el -//! orquestador. +//! dispositivo de vídeo, el compositor—, y porque son el ejemplo de que el +//! punto de extensión funciona: se registran con `ToolRegistry::register` sin +//! tocar el orquestador. pub mod camera; +pub mod screen; pub mod search; +pub mod vision; -pub use camera::Camera; +pub use camera::{Camera, CameraConfig}; +pub use screen::{Screen, ScreenConfig}; pub use search::{SearchBackend, WebSearch}; +pub use vision::{FrameSource, VisionTool}; diff --git a/crates/asist-tools/src/screen.rs b/crates/asist-tools/src/screen.rs new file mode 100644 index 0000000..dfed923 --- /dev/null +++ b/crates/asist-tools/src/screen.rs @@ -0,0 +1,189 @@ +//! La pantalla como fuente de imágenes. +//! +//! Se diferencia de la cámara en lo único que de verdad importa aquí: **una +//! pantalla es texto**. Y con texto este modelo tiene un modo de fallo feo. +//! Medido sobre una captura con tipografía de interfaz de 13 px, preguntando +//! por datos concretos: +//! +//! | Ancho | Tiempo | Aciertos | Qué pasa cuando falla | +//! |------|--------|----------|-----------------------| +//! | 640 | 2,4 s | 1 de 3 | se inventa el contenido | +//! | 960 | 4,5 s | 2 de 3 | mezcla lo leído con lo supuesto | +//! | 1280 | 7,6 s | 3 de 3 | — | +//! +//! A 640 px no dijo «no lo leo»: dijo que el error era «no se pudo abrir el +//! archivo involution» y que la reunión era «a las 10:00». Ninguna de las dos +//! cosas estaba en la imagen. De ahí que el valor por defecto sean 1280 px +//! aunque cueste el triple que la cámara: para un asistente de voz, decir una +//! hora equivocada con aplomo es peor que tardar cuatro segundos más. + +use std::path::PathBuf; +use std::time::Duration; + +use asist_core::error::{Error, Result}; + +use crate::vision::{capture_jpeg, FrameSource}; + +const TOOL: &str = "mirar_la_pantalla"; + +#[derive(Debug, Clone)] +pub struct ScreenConfig { + /// Programa de captura y sus argumentos. `{ancho}` y `{salida}` se + /// sustituyen antes de ejecutar. Tiene que escribir un JPEG por la salida + /// estándar. + pub command: Vec<String>, + /// Ancho al que se reduce la captura antes de mandarla al modelo. + pub width: u32, + /// Monitor concreto; vacío = todo lo que haya. + pub output: String, + pub timeout: Duration, + /// Carpeta donde dejar las capturas. `None` = no se guarda ninguna. + /// + /// Aquí pesa más que en la cámara: en una captura de pantalla caben + /// contraseñas, mensajes privados y correo abierto. + pub save_dir: Option<PathBuf>, +} + +impl Default for ScreenConfig { + fn default() -> Self { + Self { + command: vec![ + "scripts/capturar-pantalla.sh".into(), + "{ancho}".into(), + "{salida}".into(), + ], + width: 1280, + output: String::new(), + timeout: Duration::from_secs(20), + save_dir: None, + } + } +} + +pub struct Screen { + config: ScreenConfig, +} + +impl Screen { + pub fn new(config: ScreenConfig) -> Self { + Self { config } + } + + fn program(&self) -> Result<&String> { + self.config.command.first().ok_or_else(|| Error::Tool { + tool: TOOL.into(), + message: "screen.command está vacío".into(), + }) + } +} + +impl FrameSource for Screen { + fn label(&self) -> &str { + "pantalla" + } + + fn available(&self) -> Result<()> { + let program = self.program()?; + // Un nombre suelto se resuelve por el PATH; una ruta tiene que existir. + if program.contains('/') && !std::path::Path::new(program).exists() { + return Err(Error::Tool { + tool: TOOL.into(), + message: format!("no existe {program}"), + }); + } + // Sin entorno gráfico no hay nada que capturar, y más vale decirlo al + // arrancar que a mitad de una pregunta. + if std::env::var_os("WAYLAND_DISPLAY").is_none() && std::env::var_os("DISPLAY").is_none() { + return Err(Error::Tool { + tool: TOOL.into(), + message: "no hay sesión gráfica (ni WAYLAND_DISPLAY ni DISPLAY)".into(), + }); + } + Ok(()) + } + + fn capture(&self) -> Result<Vec<u8>> { + self.available()?; + let program = self.program()?.clone(); + let args: Vec<String> = self.config.command[1..] + .iter() + .map(|arg| { + arg.replace("{ancho}", &self.config.width.to_string()) + .replace("{salida}", &self.config.output) + }) + .collect(); + + let frame = capture_jpeg(TOOL, program.as_ref(), &args, self.config.timeout)?; + if let Some(dir) = &self.config.save_dir { + crate::camera::save(dir, &frame, "pantalla"); + } + Ok(frame) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config() -> ScreenConfig { + ScreenConfig { + command: vec!["/bin/echo".into(), "{ancho}".into(), "{salida}".into()], + ..Default::default() + } + } + + #[test] + fn el_ancho_por_defecto_es_el_minimo_medido_para_leer_texto() { + assert_eq!( + ScreenConfig::default().width, + 1280, + "por debajo de 1280 el modelo se inventa lo que pone en pantalla" + ); + } + + #[test] + fn por_defecto_no_se_guarda_ninguna_captura() { + assert!( + ScreenConfig::default().save_dir.is_none(), + "en una captura de pantalla caben contraseñas y mensajes privados" + ); + } + + #[test] + fn un_guion_inexistente_se_detecta_antes_de_registrar_la_herramienta() { + let screen = Screen::new(ScreenConfig { + command: vec!["/no/existe/captura.sh".into()], + ..Default::default() + }); + assert!(screen + .available() + .unwrap_err() + .to_string() + .contains("no existe")); + } + + #[test] + fn una_orden_vacia_se_rechaza() { + let screen = Screen::new(ScreenConfig { + command: vec![], + ..Default::default() + }); + assert!(screen.available().is_err()); + } + + #[test] + fn los_marcadores_se_sustituyen_antes_de_ejecutar() { + // /bin/echo devuelve los argumentos, así que la captura falla por no + // ser un JPEG; lo que se comprueba es que el mensaje trae el ancho ya + // sustituido, no el marcador. + let mut config = config(); + config.width = 1280; + config.output = "eDP-1".into(); + let screen = Screen::new(config); + if std::env::var_os("WAYLAND_DISPLAY").is_none() && std::env::var_os("DISPLAY").is_none() { + return; // sin sesión gráfica no hay nada que probar aquí + } + let err = screen.capture().unwrap_err().to_string(); + assert!(err.contains("no devolvió un JPEG"), "{err}"); + } +} diff --git a/crates/asist-tools/src/search.rs b/crates/asist-tools/src/search.rs index b69c17d..2d26020 100644 --- a/crates/asist-tools/src/search.rs +++ b/crates/asist-tools/src/search.rs @@ -6,10 +6,10 @@ //! acompañan como respaldo cuando no hay síntesis. use std::path::PathBuf; -use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; use asist_core::error::{Error, Result}; +use asist_core::proc; use asist_core::tools::Tool; use serde_json::{json, Value}; @@ -125,38 +125,12 @@ impl WebSearch { // Los argumentos van al `execve` tal cual: la consulta sale de lo que // se ha oído por el micrófono, y no puede acabar interpretada por una // shell. - let mut child = Command::new(program) - .args(&rendered) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| Self::fail(format!("no se pudo ejecutar {}: {e}", program.display())))?; - - let deadline = Instant::now() + self.timeout; - loop { - match child.try_wait().map_err(|e| Self::fail(e.to_string()))? { - Some(_) => break, - None if Instant::now() >= deadline => { - let _ = child.kill(); - let _ = child.wait(); - return Err(Self::fail(format!( - "el buscador tardó más de {} s", - self.timeout.as_secs() - ))); - } - None => std::thread::sleep(Duration::from_millis(20)), - } - } - - let output = child - .wait_with_output() + let output = proc::run(program, &rendered, self.timeout, None) .map_err(|e| Self::fail(e.to_string()))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); + if !output.success() { return Err(Self::fail(format!( "el buscador falló: {}", - stderr.trim().lines().next_back().unwrap_or("sin detalles") + output.last_error_line() ))); } diff --git a/crates/asist-tools/src/vision.rs b/crates/asist-tools/src/vision.rs new file mode 100644 index 0000000..2b91ed9 --- /dev/null +++ b/crates/asist-tools/src/vision.rs @@ -0,0 +1,206 @@ +//! Lo que comparten mirar por la cámara y mirar la pantalla. +//! +//! Las dos herramientas hacen lo mismo en tres pasos —conseguir un JPEG, +//! preguntarle al modelo por él, devolver texto— y sólo se diferencian en de +//! dónde salen los píxeles. Eso es lo que abstrae `FrameSource`. +//! +//! Lo que **no** comparten es la resolución, y no por descuido: una escena de +//! cámara se entiende a 640 px, pero el texto de una interfaz a esa escala el +//! modelo no lo lee mal, se lo **inventa** (ver docs/RENDIMIENTO.md). Por eso +//! cada fuente trae la suya. + +use std::sync::Arc; +use std::time::Instant; + +use asist_core::error::{Error, Result}; +use asist_core::http::Cancel; +use asist_core::proc; +use asist_llm::LlmClient; + +/// De dónde salen los píxeles. +pub trait FrameSource: Send + Sync { + /// Para los mensajes de error y el registro: «cámara», «pantalla». + fn label(&self) -> &str; + + /// Captura un fotograma en JPEG. + fn capture(&self) -> Result<Vec<u8>>; + + /// `false` si falta el dispositivo o la herramienta de captura. + /// + /// Se consulta antes de registrar: declarar una herramienta que va a + /// fallar siempre es peor que no tenerla, porque el modelo la llama, se + /// come el error y gasta el turno. + fn available(&self) -> Result<()>; +} + +/// Ejecuta un programa que escribe un JPEG por la salida estándar. +/// +/// Es el mecanismo de captura de las dos fuentes: la cámara llama a ffmpeg y +/// la pantalla a un guion que sabe de compositores. Tenerlo aquí evita que +/// cada una repita el control del plazo y la comprobación de la cabecera. +pub fn capture_jpeg( + tool: &str, + program: &std::path::Path, + args: &[String], + timeout: std::time::Duration, +) -> Result<Vec<u8>> { + let fail = |message: String| Error::Tool { + tool: tool.to_string(), + message, + }; + + let output = proc::run(program, args, timeout, None).map_err(|e| fail(e.to_string()))?; + + if !output.success() || output.stdout.is_empty() { + let stderr = output.stderr.to_lowercase(); + // Los dos fallos que más se dan, traducidos a algo accionable. + let hint = if stderr.contains("permission denied") { + ". Comprueba los permisos del dispositivo" + } else if stderr.contains("busy") { + ". Otra aplicación lo está usando" + } else { + "" + }; + return Err(fail(format!( + "no se capturó nada{hint}: {}", + output.last_error_line() + ))); + } + + // Cabecera JPEG. Sin esto, una captura corrupta llega hasta el modelo y + // vuelve como un error genérico que no dice dónde mirar. + if output.stdout.len() < 4 || output.stdout[..2] != [0xFF, 0xD8] { + return Err(fail(format!( + "{} no devolvió un JPEG ({} bytes)", + program.display(), + output.stdout.len() + ))); + } + + tracing::debug!( + target: "vision", + programa = %program.display(), + kb = output.stdout.len() / 1024, + ms = output.took.as_millis(), + "captura" + ); + Ok(output.stdout) +} + +/// La herramienta que ve: captura y le pregunta al modelo. +pub struct VisionTool { + source: Box<dyn FrameSource>, + llm: Arc<LlmClient>, + name: &'static str, + description: &'static str, + parameter_hint: &'static str, + default_question: &'static str, + acknowledgement: &'static str, + /// Se añade a la pregunta del usuario antes de mandarla con la imagen. + /// + /// Hace falta porque esta petición va fuera del historial y no le llega la + /// instrucción de voz del asistente. + style: &'static str, +} + +impl VisionTool { + pub fn camera(source: Box<dyn FrameSource>, llm: Arc<LlmClient>) -> Self { + Self { + source, + llm, + name: "mirar_por_la_camara", + description: "Toma una foto con la cámara del equipo y responde a una pregunta \ + sobre lo que se ve. Úsala cuando te pregunten qué ves, qué hay \ + delante, de qué color es algo o cuántas cosas hay.", + parameter_hint: "La pregunta del usuario tal cual, sin concretarla más de lo que \ + él dijo. Si sólo quiere saber qué hay delante, pon: ¿Qué se ve?", + default_question: "¿Qué se ve en esta imagen?", + acknowledgement: "Voy a mirar.", + style: "Responde en una o dos frases cortas en español, en texto plano, \ + describiendo sólo lo que se ve de verdad en la imagen. Si no se \ + distingue, dilo.", + } + } + + pub fn screen(source: Box<dyn FrameSource>, llm: Arc<LlmClient>) -> Self { + Self { + source, + llm, + name: "mirar_la_pantalla", + description: "Hace una captura de la pantalla del equipo y responde a una \ + pregunta sobre lo que hay en ella. Úsala cuando te pregunten qué \ + hay en pantalla, qué dice un error, qué pone en una ventana o qué \ + está abierto.", + parameter_hint: "La pregunta del usuario tal cual. Si sólo quiere saber qué hay \ + en pantalla, pon: ¿Qué se ve en la pantalla?", + default_question: "¿Qué se ve en esta captura de pantalla?", + acknowledgement: "Miro la pantalla.", + // El aviso de no inventar es lo más importante de toda la + // instrucción: cuando el texto queda pequeño, este modelo no dice + // que no lo lee, se saca un contenido plausible de la manga. + style: "Responde en una o dos frases cortas en español, en texto plano. Lee sólo \ + lo que de verdad pone en la imagen y no completes lo que no se distinga: \ + si el texto está borroso o no se lee, dilo en vez de suponerlo.", + } + } + + pub fn available(&self) -> Result<()> { + self.source.available() + } +} + +impl asist_core::tools::Tool for VisionTool { + fn name(&self) -> &str { + self.name + } + + fn description(&self) -> &str { + self.description + } + + fn parameters(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "pregunta": { "type": "string", "description": self.parameter_hint } + }, + "required": ["pregunta"] + }) + } + + /// Enciende la cámara o fotografía lo que haya en pantalla; en ambos casos + /// conviene que se anuncie. + fn is_side_effecting(&self) -> bool { + true + } + + fn acknowledgement(&self) -> Option<&str> { + Some(self.acknowledgement) + } + + fn call(&self, args: &serde_json::Value) -> Result<String> { + let question = args + .get("pregunta") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|q| !q.is_empty()) + .unwrap_or(self.default_question); + + let frame = self.source.capture()?; + let started = Instant::now(); + let answer = self.llm.look( + &frame, + &format!("{question}\n\n{}", self.style), + &Cancel::new(), + )?; + + tracing::info!( + target: "vision", + fuente = self.source.label(), + kb = frame.len() / 1024, + ms = started.elapsed().as_millis(), + "descrito" + ); + Ok(answer) + } +} |