aura-gui 0.3.5

UI nativa acelerada por GPU para escritorio, Android e iOS, en un solo crate: estilo tipo Tailwind, componentes, sombras, animaciones y agentes de IA. Reposo a cero. Licencia AGPL-3.0 o comercial.
//! Herramienta `browser`: abre páginas en el navegador del sistema y las lee por HTTP, sin capturar la pantalla ni
//! mover el ratón. Pensada para investigar mientras el usuario sigue programando.

use crate::ai::types::ToolSpec;
use serde_json::{Value, json};
use std::io::Read;
use std::time::Duration;

const MAX_BODY: u64 = 1_500_000;
const MAX_TEXT: usize = 8_000;
const TIMEOUT: Duration = Duration::from_secs(12);

#[derive(Clone, Debug, PartialEq)]
pub enum BrowserAction {
    /// Abre una pestaña en segundo plano; no cambia el foco de la aplicación.
    Open { url: String },
    /// Descarga la página y devuelve su texto (con enlaces), sin abrir nada.
    Read { url: String },
    /// Busca en DuckDuckGo y devuelve títulos, fragmentos y enlaces.
    Search { query: String },
}

impl BrowserAction {
    pub fn parse(input: &Value) -> Result<Self, String> {
        let text = |key: &str| {
            input[key]
                .as_str()
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .map(str::to_owned)
                .ok_or_else(|| format!("browser: falta «{key}»"))
        };
        match input["action"].as_str().unwrap_or_default() {
            "open" => Ok(Self::Open { url: crate::ai::computer::web_url(&text("url")?)? }),
            "read" => Ok(Self::Read { url: crate::ai::computer::web_url(&text("url")?)? }),
            "search" => Ok(Self::Search { query: text("query")? }),
            other => Err(format!("browser: acción «{other}» desconocida; usa open, read o search")),
        }
    }

    pub fn summary(&self) -> String {
        match self {
            Self::Open { url } => format!("Abrir {url} en el navegador"),
            Self::Read { url } => format!("Leer {url}"),
            Self::Search { query } => {
                let short: String = query.chars().take(60).collect();
                format!("Buscar en la web «{short}»")
            }
        }
    }
}

pub fn spec() -> ToolSpec {
    ToolSpec {
        name: "browser".into(),
        description: "Navega por la web sin controlar la pantalla ni el ratón. `read` descarga una página y \
            devuelve su texto con enlaces (lo más rápido para investigar); `search` busca en DuckDuckGo; `open` \
            muestra una página en el navegador en segundo plano, sin quitarle el foco al editor. No uses \
            `computer` para la web si esta herramienta está disponible."
            .into(),
        schema: json!({
            "type": "object",
            "properties": {
                "action": { "type": "string", "enum": ["open", "read", "search"] },
                "url": { "type": "string", "description": "Dirección http(s) para open o read" },
                "query": { "type": "string", "description": "Qué buscar (action=search)" },
            },
            "required": ["action"],
        }),
    }
}

/// Texto legible de una página, con los enlaces entre corchetes.
pub fn read(url: &str) -> Result<String, String> {
    let body = fetch(url)?;
    Ok(format!("{url}\n{}", truncate(&html_to_text(&body), MAX_TEXT)))
}

pub fn search(query: &str) -> Result<String, String> {
    let url = format!("https://html.duckduckgo.com/html/?q={}", encode(query));
    let body = fetch(&url)?;
    Ok(format!("Resultados para «{query}»:\n{}", truncate(&html_to_text(&body), MAX_TEXT)))
}

fn fetch(url: &str) -> Result<String, String> {
    let response = agent().get(url).call().map_err(|error| format!("browser: no se pudo abrir {url}: {error}"))?;
    let code = response.status().as_u16();
    let mut body = String::new();
    response
        .into_body()
        .into_reader()
        .take(MAX_BODY)
        .read_to_string(&mut body)
        .map_err(|error| format!("browser: respuesta ilegible: {error}"))?;
    if code >= 400 {
        return Err(format!("browser: {url} respondió HTTP {code}: {}", truncate(&html_to_text(&body), 400)));
    }
    Ok(body)
}

/// Un solo cliente para todo el proceso: reutiliza conexiones entre lecturas y búsquedas.
fn agent() -> &'static ureq::Agent {
    static AGENT: std::sync::OnceLock<ureq::Agent> = std::sync::OnceLock::new();
    AGENT.get_or_init(|| {
        ureq::Agent::config_builder()
            .timeout_global(Some(TIMEOUT))
            .http_status_as_error(false)
            .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Aura/0.1")
            .build()
            .into()
    })
}

fn html_to_text(html: &str) -> String {
    let mut out = String::with_capacity(html.len().min(64_000));
    let mut skip = false;
    for part in html.split('<') {
        let (tag, rest) = part.split_once('>').unwrap_or(("", part));
        let name = tag.split_whitespace().next().unwrap_or("").to_ascii_lowercase();
        match name.as_str() {
            "script" | "style" => skip = true,
            "/script" | "/style" => skip = false,
            "br" | "p" | "/p" | "div" | "/div" | "li" | "/li" | "tr" | "/tr" | "h1" | "h2" | "h3" | "h4" | "h5"
            | "h6" => out.push('\n'),
            "a" if !skip => {
                if let Some(href) = attr(tag, "href") {
                    out.push_str(&format!(" [{}] ", decode(href)));
                }
            }
            _ => {}
        }
        if !skip {
            out.push_str(&decode(rest));
        }
    }
    collapse(&out)
}

fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
    let lower = tag.to_ascii_lowercase();
    let start = lower.find(&format!("{name}="))? + name.len() + 1;
    let rest = tag.get(start..)?.trim_start();
    let quote = rest.chars().next()?;
    if quote == '"' || quote == '\'' { rest.get(1..)?.split(quote).next() } else { rest.split_whitespace().next() }
}

fn decode(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    let mut rest = text;
    while let Some(index) = rest.find('&') {
        out.push_str(&rest[..index]);
        let after = &rest[index..];
        let entity = [("&amp;", "&"), ("&lt;", "<"), ("&gt;", ">"), ("&quot;", "\""), ("&#39;", "'"), ("&nbsp;", " ")]
            .iter()
            .find(|(entity, _)| after.starts_with(entity));
        if let Some((entity, value)) = entity {
            out.push_str(value);
            rest = &after[entity.len()..];
        } else {
            out.push('&');
            rest = &after[1..];
        }
    }
    out.push_str(rest);
    out
}

fn collapse(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    let mut space = false;
    for char in text.chars() {
        if char == '\n' {
            out.push('\n');
            space = false;
        } else if char.is_whitespace() {
            space = true;
        } else {
            if space && !out.ends_with('\n') && !out.is_empty() {
                out.push(' ');
            }
            space = false;
            out.push(char);
        }
    }
    out.trim().to_owned()
}

fn truncate(text: &str, limit: usize) -> String {
    if text.len() <= limit {
        return text.to_owned();
    }
    let cut = (0..=limit).rev().find(|index| text.is_char_boundary(*index)).unwrap_or(0);
    format!("{}\n… (recortado)", &text[..cut])
}

fn encode(value: &str) -> String {
    value
        .bytes()
        .map(|byte| {
            if byte.is_ascii_alphanumeric() || b"-_.~".contains(&byte) {
                char::from(byte).to_string()
            } else {
                format!("%{byte:02X}")
            }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn lee_acciones_y_conserva_solo_http() {
        assert_eq!(
            BrowserAction::parse(&json!({"action": "read", "url": "example.com"})),
            Ok(BrowserAction::Read { url: "https://example.com".into() })
        );
        assert!(BrowserAction::parse(&json!({"action": "open", "url": "file:///etc/passwd"})).is_err());
        assert!(BrowserAction::parse(&json!({"action": "search"})).is_err());
    }

    #[test]
    fn convierte_html_en_texto_con_enlaces_y_sin_scripts() {
        let html = "<html><style>p{color:red}</style><body><h1>Hola</h1><script>alert(1)</script>\
                    <p>Texto &amp; <a href=\"https://aura.dev\">Aura</a></p></body></html>";
        let text = html_to_text(html);
        assert!(
            text.contains("Hola")
                && text.contains("Texto &")
                && text.contains("Aura")
                && text.contains("https://aura.dev")
        );
        assert!(!text.contains("alert") && !text.contains("color:red"), "{text}");
    }

    #[test]
    fn codifica_la_busqueda() {
        assert_eq!(encode("a b/c"), "a%20b%2Fc");
    }

    #[test]
    fn lee_una_pagina_http_local_sin_abrir_navegador() {
        use std::io::{Read, Write};
        use std::net::TcpListener;
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let address = listener.local_addr().unwrap();
        std::thread::spawn(move || {
            let (mut stream, _) = listener.accept().unwrap();
            let mut request = [0u8; 512];
            let _ = stream.read(&mut request);
            let body = "<h1>Hola</h1><p>Texto</p>";
            let response = format!(
                "HTTP/1.1 200 OK\r\ncontent-type: text/html\r\nconnection: close\r\ncontent-length: {}\r\n\r\n{body}",
                body.len()
            );
            let _ = stream.write_all(response.as_bytes());
        });
        let text = read(&format!("http://{address}/")).unwrap();
        assert!(text.contains("Hola") && text.contains("Texto"), "{text}");
    }
}