aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-app/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/asist-app/src')
-rw-r--r--crates/asist-app/src/main.rs95
-rw-r--r--crates/asist-app/src/pipeline.rs85
-rw-r--r--crates/asist-app/src/registry.rs122
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))))
+}