//! 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>; /// `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> { 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, llm: Arc, 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, llm: Arc) -> 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, llm: Arc) -> 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 { 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) } }