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 {
Open { url: String },
Read { url: String },
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"],
}),
}
}
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)
}
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 = [("&", "&"), ("<", "<"), (">", ">"), (""", "\""), ("'", "'"), (" ", " ")]
.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 & <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}");
}
}