aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-tools
diff options
context:
space:
mode:
Diffstat (limited to 'crates/asist-tools')
-rw-r--r--crates/asist-tools/src/camera.rs313
-rw-r--r--crates/asist-tools/src/lib.rs16
-rw-r--r--crates/asist-tools/src/screen.rs189
-rw-r--r--crates/asist-tools/src/search.rs34
-rw-r--r--crates/asist-tools/src/vision.rs206
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)
+ }
+}