diff options
Diffstat (limited to 'crates/asist-app/src')
| -rw-r--r-- | crates/asist-app/src/main.rs | 95 | ||||
| -rw-r--r-- | crates/asist-app/src/pipeline.rs | 85 | ||||
| -rw-r--r-- | crates/asist-app/src/registry.rs | 122 |
3 files changed, 266 insertions, 36 deletions
diff --git a/crates/asist-app/src/main.rs b/crates/asist-app/src/main.rs index 2788d97..7b00b1f 100644 --- a/crates/asist-app/src/main.rs +++ b/crates/asist-app/src/main.rs @@ -5,11 +5,13 @@ //! espera a la siguiente. mod pipeline; +mod registry; mod render; mod session; mod supervisor; use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use anyhow::{bail, Context, Result}; @@ -19,7 +21,6 @@ use asist_asr::Recognizer; use asist_audio::{Capture, CaptureBlock, Playback}; use asist_core::config::Config; use asist_core::event::Event; -use asist_core::tools::ToolRegistry; use asist_llm::LlmClient; use asist_tts::TtsClient; @@ -52,7 +53,9 @@ fn run(config: Config, args: &Args) -> Result<()> { // Los clientes se crean antes de arrancar nada: así se detecta lo que ya // esté escuchando y no se levanta un servidor por duplicado. - let llm = LlmClient::new(config.llm_authority(), &config.llm); + // Compartido: el orquestador conversa con él y la herramienta de cámara + // lo usa para describir lo que capta. + let llm = Arc::new(LlmClient::new(config.llm_authority(), &config.llm)); let tts = TtsClient::new(config.tts_authority(), &config.tts); let (llm_up, tts_up) = (llm.healthy(), tts.healthy()); @@ -92,12 +95,33 @@ fn run(config: Config, args: &Args) -> Result<()> { let (capture_tx, capture_rx) = unbounded::<CaptureBlock>(); let capture = Capture::open(&config.audio, capture_tx.clone())?; - let tools = ToolRegistry::from_config(&config.tools); + let (tools, skipped) = registry::build(&config, &llm); if tools.is_empty() { eprintln!("Herramientas: ninguna"); } else { eprintln!("Herramientas: {}", tools.names().join(", ")); } + // Lo que no se pudo activar se dice en voz alta, en vez de dejar al + // usuario preguntándose por qué el asistente no busca ni ve. + for skip in &skipped { + eprintln!(" · {} no disponible: {}", skip.tool, skip.reason); + } + // 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_por_la_camara").is_some() { + eprintln!( + "Cámara ACTIVA — {} a {}x{}{}", + config.camera.device.display(), + config.camera.width, + config.camera.height, + if config.camera.save_dir.is_empty() { + String::new() + } else { + format!(", guardando fotogramas en {}", config.camera.save_dir) + } + ); + } if config.tools.shell { eprintln!( "Ejecución de órdenes ACTIVA — permitidas: {}{}", @@ -263,6 +287,61 @@ fn check(config: &Config) -> Result<()> { } } + println!("Capacidades:"); + // Ninguna de las dos es fatal: el asistente conversa igual sin ellas, así + // que se informa y no se cuentan como problema. + if !config.search.enabled { + println!(" - búsqueda desactivada en la configuración"); + } else { + match config.search.backend.trim().to_lowercase().as_str() { + "tavily" => match std::env::var(&config.search.api_key_env) { + Ok(key) if !key.trim().is_empty() => ok( + "búsqueda (tavily)", + format!("clave en ${}", config.search.api_key_env), + ), + _ => println!( + " - búsqueda (tavily) falta ${}", + config.search.api_key_env + ), + }, + "ddgs" | "comando" => match config.search.command.first() { + // Un nombre suelto se resuelve por el PATH; una ruta tiene que + // existir, y más vale decirlo aquí que a mitad de una pregunta. + Some(program) if !program.contains('/') || PathBuf::from(program).exists() => { + ok("búsqueda (ddgs)", config.search.command.join(" ")) + } + Some(program) => { + println!(" - búsqueda (ddgs) no existe {program}") + } + None => println!(" - búsqueda (ddgs) search.command está vacío"), + }, + "searxng" => ok("búsqueda (searxng)", config.search.base_url.clone()), + other => println!(" - búsqueda backend desconocido: «{other}»"), + } + } + + if !config.camera.enabled { + println!(" - cámara desactivada en la configuración"); + } else if config.camera.device.exists() { + ok( + "cámara", + format!( + "{} a {}x{}", + config.camera.device.display(), + config.camera.width, + config.camera.height + ), + ); + if which("ffmpeg").is_none() { + println!(" - ffmpeg no está; hace falta para capturar"); + } + } else { + println!( + " - cámara no existe {}", + config.camera.device.display() + ); + } + println!("Servidores:"); let llm = LlmClient::new(config.llm_authority(), &config.llm); let tts = TtsClient::new(config.tts_authority(), &config.tts); @@ -412,6 +491,16 @@ impl Args { } } +/// Busca un ejecutable en el PATH, para avisar de lo que falta antes de que +/// falle a mitad de una conversación. +fn which(program: &str) -> Option<PathBuf> { + std::env::var_os("PATH").and_then(|path| { + std::env::split_paths(&path) + .map(|dir| dir.join(program)) + .find(|candidate| candidate.is_file()) + }) +} + /// ¿Hay una persona al otro lado de la entrada estándar? fn stdin_is_tty() -> bool { #[cfg(unix)] diff --git a/crates/asist-app/src/pipeline.rs b/crates/asist-app/src/pipeline.rs index 9e9037e..69fc66a 100644 --- a/crates/asist-app/src/pipeline.rs +++ b/crates/asist-app/src/pipeline.rs @@ -67,7 +67,7 @@ impl Pipeline { config: &Config, session: Session, recognizer: Recognizer, - llm: LlmClient, + llm: Arc<LlmClient>, tts: TtsClient, tools: ToolRegistry, playback: PlaybackHandle, @@ -175,7 +175,11 @@ fn run_segmenter( } // El micrófono se cierra mientras suena el altavoz. Con barge-in // activo no se cierra, sólo sube el listón de volumen. - segmenter.set_gate(if session.is_speaking() || playback.is_active() { + // + // La segunda condición mira la cola y no una bandera a propósito: la + // cola no puede quedarse desfasada, y una bandera que se olvide de + // bajar deja el micrófono cerrado para el resto de la sesión. + segmenter.set_gate(if session.is_speaking() || playback.queued_secs() > 0.0 { Gate::Speaking } else { Gate::Open @@ -253,7 +257,7 @@ fn run_segmenter( fn run_brain( config: &Config, session: Session, - llm: LlmClient, + llm: Arc<LlmClient>, tools: ToolRegistry, results: Receiver<AsrResult>, speak: Sender<SpeakJob>, @@ -337,6 +341,12 @@ struct Prompts { /// Para la pasada en que el modelo decide si llamar a una herramienta. deciding: String, /// Para redactar la respuesta hablada, ya con los resultados en la mano. + /// + /// Lleva pegada la orden de usar lo que la herramienta devolvió. Sin ella + /// el modelo se limita a anunciar lo que acaba de hacer —«he tomado una + /// foto, ahora puedo responderte sobre el objeto o color»— y se deja el + /// dato que tenía delante. Aquí sí se puede añadir estilo sin riesgo: la + /// llamada ya ocurrió, así que no hay nada que estropear. speaking: String, /// `false` cuando no hay herramientas: entonces ambas son la misma. two_phase: bool, @@ -352,7 +362,11 @@ impl Prompts { } else { speaking.clone() }, - speaking, + speaking: if two_phase { + format!("{speaking}\n\n{}", config.general.tool_result_prompt.trim()) + } else { + speaking.clone() + }, two_phase, } } @@ -377,6 +391,27 @@ fn answer( let tools = (!tools.is_empty() && config.tools.enabled).then_some(tools); let mut splitter = SentenceSplitter::new(); let mut spoken = String::new(); + // Índice propio, y no el del troceador, porque al flujo de frases se le + // cuelan los acuses de las herramientas. El índice 0 marca el primer + // sonido del turno, que es de donde se mide la latencia percibida. + let mut emitted = 0usize; + + // Manda una frase al sintetizador. Devuelve `false` si el canal se cerró. + let say = |index: &mut usize, text: String| -> bool { + let _ = events.send(Event::Sentence { + turn, + index: *index, + text: text.clone(), + }); + let job = SpeakJob { + turn, + index: *index, + text, + turn_started, + }; + *index += 1; + speak.send(job).is_ok() + }; for round in 0..=config.llm.max_tool_rounds { let llm_started = Instant::now(); @@ -403,21 +438,7 @@ fn answer( // Aquí está el solape: cada frase cerrada sale hacia el // sintetizador sin esperar al resto de la respuesta. for sentence in splitter.push(text) { - let index = splitter.emitted() - 1; - let _ = events.send(Event::Sentence { - turn, - index, - text: sentence.clone(), - }); - if speak - .send(SpeakJob { - turn, - index, - text: sentence, - turn_started, - }) - .is_err() - { + if !say(&mut emitted, sentence) { return false; } } @@ -449,20 +470,9 @@ fn answer( if outcome.tool_calls.is_empty() { if let Some(rest) = splitter.flush() { - let index = splitter.emitted() - 1; - let _ = events.send(Event::Sentence { - turn, - index, - text: rest.clone(), - }); - let _ = speak.send(SpeakJob { - turn, - index, - text: rest, - turn_started, - }); + say(&mut emitted, rest); } - timer.sentences = splitter.emitted(); + timer.sentences = emitted; chat.push(Message::assistant(outcome.text.clone())); // El turno siguiente vuelve a empezar decidiendo. if prompts.two_phase { @@ -500,6 +510,14 @@ fn answer( name: call.name.clone(), arguments: call.arguments.clone(), }); + // Se dice antes de ejecutar, no después: la gracia es tapar la + // espera, y una búsqueda con las dos pasadas del modelo detrás son + // seis segundos que sin esto pasan en silencio absoluto. + if config.tools.spoken_ack { + if let Some(ack) = tools.get(&call.name).and_then(|t| t.acknowledgement()) { + say(&mut emitted, ack.to_string()); + } + } let result = tools.dispatch(call); let _ = events.send(Event::ToolFinished { turn, @@ -553,8 +571,9 @@ fn run_speech( let Some(job) = job else { // Sin trabajo: si ya no queda audio, el turno ha terminado de sonar. if let Some(turn) = speaking_turn { - if !playback.is_active() || playback.queued_secs() <= 0.0 { + if playback.queued_secs() <= 0.0 { session.set_speaking(false); + playback.mark_idle(); speaking_turn = None; let _ = events.send(Event::AudioFinished { turn }); } diff --git a/crates/asist-app/src/registry.rs b/crates/asist-app/src/registry.rs new file mode 100644 index 0000000..13bbb75 --- /dev/null +++ b/crates/asist-app/src/registry.rs @@ -0,0 +1,122 @@ +//! Montaje del juego de herramientas. +//! +//! `ToolRegistry::from_config` sólo conoce las que no dependen de nada +//! —el reloj, la shell—, porque vive en el núcleo. Las que necesitan la red o +//! el modelo multimodal se añaden aquí, que es exactamente el punto de +//! extensión documentado en docs/EXTENDER.md. + +use std::sync::Arc; +use std::time::Duration; + +use asist_core::config::{CameraConfig, Config, SearchConfig}; +use asist_core::tools::ToolRegistry; +use asist_llm::LlmClient; +use asist_tools::camera::CaptureConfig; +use asist_tools::{Camera, SearchBackend, WebSearch}; + +/// Lo que no se pudo activar y por qué, para decírselo al usuario al arrancar +/// en vez de dejarlo en silencio. +pub struct Skipped { + pub tool: &'static str, + pub reason: String, +} + +pub fn build(config: &Config, llm: &Arc<LlmClient>) -> (ToolRegistry, Vec<Skipped>) { + let mut registry = ToolRegistry::from_config(&config.tools); + let mut skipped = Vec::new(); + + if !config.tools.enabled { + return (registry, skipped); + } + + match search_tool(&config.search) { + Ok(Some(tool)) => registry.register(Arc::new(tool)), + Ok(None) => {} + Err(reason) => skipped.push(Skipped { + tool: "buscar_en_internet", + reason, + }), + } + + 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, + }), + } + + (registry, skipped) +} + +fn search_tool(config: &SearchConfig) -> Result<Option<WebSearch>, String> { + if !config.enabled { + return Ok(None); + } + let backend = match config.backend.trim().to_lowercase().as_str() { + "tavily" => { + // La clave sale del entorno, nunca del fichero de configuración. + let key = std::env::var(&config.api_key_env) + .ok() + .map(|k| k.trim().to_string()) + .filter(|k| !k.is_empty()) + .ok_or_else(|| { + format!( + "la variable {} está vacía o sin definir; expórtala o cambia \ + search.backend a «searxng»", + config.api_key_env + ) + })?; + SearchBackend::Tavily { api_key: key } + } + "searxng" => SearchBackend::SearxNG { + base_url: config.base_url.clone(), + }, + "ddgs" | "comando" => { + let (program, args) = config + .command + .split_first() + .ok_or_else(|| "search.command está vacío".to_string())?; + if !std::path::Path::new(program).exists() && !program.contains('/') { + // Un nombre suelto se busca en el PATH; una ruta tiene que existir. + } else if !std::path::Path::new(program).exists() { + return Err(format!("no existe el buscador {program}")); + } + SearchBackend::Command { + program: program.into(), + args: args.to_vec(), + } + } + other => return Err(format!("search.backend desconocido: «{other}»")), + }; + + Ok(Some(WebSearch::new( + backend, + config.max_results, + Duration::from_secs(config.timeout_secs), + ))) +} + +fn camera_tool(config: &CameraConfig, llm: &Arc<LlmClient>) -> Result<Option<Camera>, String> { + if !config.enabled { + return Ok(None); + } + let capture = CaptureConfig { + 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() + )); + } + Ok(Some(Camera::new(capture, Arc::clone(llm)))) +} |