Add camofox-browser integration: gyandoot-browser crate + agent tools
New crate: crates/gyandoot-browser/
- client.rs: Full REST client for camofox-browser API (health, tabs,
navigation, snapshots, click/type/scroll, search macros)
- macros.rs: 14 SearchMacro enum (Google, YouTube, Amazon, Reddit,
Wikipedia, Twitter, Yelp, Spotify, Netflix, LinkedIn, Instagram,
TikTok, Twitch) with parse/display
- snapshot.rs: PageSnapshot, PageLinks, NavigationResult, HealthResponse
- session.rs: BrowserSession, SessionManager with idle cleanup
New module: crates/gyandoot-agent/src/browser_tools.rs
- WebSearchTool: Search the web using anti-detection browser
- WebBrowseTool: Browse URLs, click, type, scroll with element refs
- register_browser_tools(): Wire both tools into ToolExecutor
CLI: Added
o
|
- - - - - - .---. - - - - - -
- - - - - - - - - [ ^ ^ ] - - - - - - - - -
- - - - - - - | ___ | - - - - - - -
`-----'
____ __ __ ___ _ _ ____ ___ ___ _____
/ ___|\ \ / / / _ \ | \ | || _ \ / _ \ / _ \|_ _|
| | _ \ V / / /_\ \| \| || | | | | | | | | | | |
| |_| | | | | _ || |\ || |_| | |_| | |_| | | |
\____/ |_| |_| |_||_| \_||____/ \___/ \___/ |_|
o - - - - - - - knowledge. delivered. - - - - - - - o
Unknown command: browser
Run 'gyandoot help' for usage. subcommand with health/search/browse actions
Installer: Updated install.sh with camofox-browser cloning, Node.js
detection, npm dependency install, and systemd service setup
Camofox-browser running at http://localhost:9377 (Docker, verified working)
Workspace: 12 crates + CLI binary — cargo check passes with 0 errors.
This commit is contained in:
18
Cargo.lock
generated
18
Cargo.lock
generated
@@ -530,6 +530,7 @@ dependencies = [
|
||||
"clap",
|
||||
"dirs",
|
||||
"gyandoot-agent",
|
||||
"gyandoot-browser",
|
||||
"gyandoot-core",
|
||||
"gyandoot-gateway",
|
||||
"gyandoot-kanban",
|
||||
@@ -554,6 +555,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"gyandoot-browser",
|
||||
"gyandoot-core",
|
||||
"gyandoot-memory",
|
||||
"gyandoot-providers",
|
||||
@@ -567,6 +569,22 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gyandoot-browser"
|
||||
version = "2.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"gyandoot-core",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gyandoot-core"
|
||||
version = "2.0.0"
|
||||
|
||||
@@ -19,6 +19,7 @@ members = [
|
||||
"crates/gyandoot-skills",
|
||||
"crates/gyandoot-profile",
|
||||
"crates/gyandoot-gateway",
|
||||
"crates/gyandoot-browser",
|
||||
]
|
||||
edition = "2021"
|
||||
|
||||
@@ -63,6 +64,7 @@ gyandoot-subagents = { path = "crates/gyandoot-subagents" }
|
||||
gyandoot-skills = { path = "crates/gyandoot-skills" }
|
||||
gyandoot-profile = { path = "crates/gyandoot-profile" }
|
||||
gyandoot-gateway = { path = "crates/gyandoot-gateway" }
|
||||
gyandoot-browser = { path = "crates/gyandoot-browser" }
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -11,6 +11,7 @@ gyandoot-providers = { path = "../gyandoot-providers" }
|
||||
gyandoot-memory = { path = "../gyandoot-memory" }
|
||||
gyandoot-security = { path = "../gyandoot-security" }
|
||||
gyandoot-skills = { path = "../gyandoot-skills" }
|
||||
gyandoot-browser = { path = "../gyandoot-browser" }
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
284
crates/gyandoot-agent/src/browser_tools.rs
Normal file
284
crates/gyandoot-agent/src/browser_tools.rs
Normal file
@@ -0,0 +1,284 @@
|
||||
//! Browser tools for the agent — enables web browsing with anti-detection.
|
||||
//!
|
||||
//! These tools are registered with the `ToolExecutor` and provide
|
||||
//! LLM-accessible functions for searching the web, browsing pages,
|
||||
//! clicking elements, and extracting content.
|
||||
|
||||
use serde_json;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use gyandoot_browser::{BrowserClient, SearchMacro};
|
||||
use gyandoot_core::ToolDefinition;
|
||||
|
||||
use crate::tools::Tool;
|
||||
|
||||
/// Tool for searching the web using camofox-browser search macros.
|
||||
pub struct WebSearchTool {
|
||||
client: BrowserClient,
|
||||
}
|
||||
|
||||
impl WebSearchTool {
|
||||
pub fn new(client: BrowserClient) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Tool for WebSearchTool {
|
||||
fn name(&self) -> &str {
|
||||
"web_search"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Search the web using Google, YouTube, Amazon, Reddit, Wikipedia, and more. \
|
||||
Uses anti-detection browser to bypass bot protection. \
|
||||
Returns an accessibility snapshot of search results."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search query"
|
||||
},
|
||||
"site": {
|
||||
"type": "string",
|
||||
"description": "Site to search. Options: google, youtube, amazon, reddit, wikipedia, twitter, yelp, spotify, linkedin, instagram, tiktok, twitch",
|
||||
"default": "google"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<String> {
|
||||
let query = args["query"].as_str().unwrap_or("").to_string();
|
||||
let site = args["site"].as_str().unwrap_or("google").to_lowercase();
|
||||
|
||||
if query.is_empty() {
|
||||
return Err(anyhow::anyhow!("query is required"));
|
||||
}
|
||||
|
||||
let macro_name = match site.as_str() {
|
||||
"google" => SearchMacro::GoogleSearch,
|
||||
"youtube" => SearchMacro::YoutubeSearch,
|
||||
"amazon" => SearchMacro::AmazonSearch,
|
||||
"reddit" => SearchMacro::RedditSearch,
|
||||
"wikipedia" => SearchMacro::WikipediaSearch,
|
||||
"twitter" | "x" => SearchMacro::TwitterSearch,
|
||||
"yelp" => SearchMacro::YelpSearch,
|
||||
"spotify" => SearchMacro::SpotifySearch,
|
||||
"linkedin" => SearchMacro::LinkedinSearch,
|
||||
"instagram" => SearchMacro::InstagramSearch,
|
||||
"tiktok" => SearchMacro::TiktokSearch,
|
||||
"twitch" => SearchMacro::TwitchSearch,
|
||||
_ => SearchMacro::GoogleSearch,
|
||||
};
|
||||
|
||||
info!("web_search: searching {} for '{}'", macro_name.site_name(), query);
|
||||
|
||||
let session_key = format!("web_search_{}", std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs());
|
||||
|
||||
let tab = self.client.create_tab("gyandoot", &session_key, None).await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create browser tab: {}", e))?;
|
||||
let tab_id = tab.tab_id;
|
||||
|
||||
let result = self.client.search(&tab_id, "gyandoot", ¯o_name, &query, Some(3000)).await
|
||||
.map_err(|e| anyhow::anyhow!("Search failed: {}", e))?;
|
||||
|
||||
let snap = self.client.snapshot(&tab_id, "gyandoot").await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get snapshot: {}", e))?;
|
||||
|
||||
// Close the tab to free resources
|
||||
let _ = self.client.close_tab(&tab_id, "gyandoot").await;
|
||||
|
||||
let refs = snap.extract_refs();
|
||||
let response = format!(
|
||||
"## {} Search Results\n\n**Query:** {}\n**Page:** {}\n**Title:** {}\n\n**Snapshot content:**\n{}\n\n**Available element refs:** {:?}\n\n*Use `web_browse` to interact with specific results.*",
|
||||
macro_name.site_name(),
|
||||
query,
|
||||
result.url,
|
||||
result.title,
|
||||
snap.snapshot,
|
||||
refs,
|
||||
);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool for browsing a specific URL using camofox-browser.
|
||||
pub struct WebBrowseTool {
|
||||
client: BrowserClient,
|
||||
}
|
||||
|
||||
impl WebBrowseTool {
|
||||
pub fn new(client: BrowserClient) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Tool for WebBrowseTool {
|
||||
fn name(&self) -> &str {
|
||||
"web_browse"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Browse a specific URL using an anti-detection browser. \
|
||||
Returns an accessibility snapshot with element refs for interaction. \
|
||||
Supports navigation, clicking, typing, and scrolling."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "The URL to browse"
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": "Action to perform: 'navigate' (default), 'click', 'type', 'scroll', 'back', 'forward', 'refresh'",
|
||||
"default": "navigate"
|
||||
},
|
||||
"element_ref": {
|
||||
"type": "string",
|
||||
"description": "Element reference (e.g., 'e1', 'e2') — required for click/type actions"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Text to type — required for type action"
|
||||
},
|
||||
"press_enter": {
|
||||
"type": "boolean",
|
||||
"description": "Press Enter after typing",
|
||||
"default": false
|
||||
},
|
||||
"direction": {
|
||||
"type": "string",
|
||||
"description": "Scroll direction: 'up' or 'down'",
|
||||
"default": "down"
|
||||
},
|
||||
"amount": {
|
||||
"type": "integer",
|
||||
"description": "Scroll amount in pixels",
|
||||
"default": 500
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<String> {
|
||||
let url = args["url"].as_str().unwrap_or("").to_string();
|
||||
let action = args["action"].as_str().unwrap_or("navigate").to_lowercase();
|
||||
let element_ref = args["element_ref"].as_str().map(|s| s.to_string());
|
||||
let text = args["text"].as_str().map(|s| s.to_string());
|
||||
let press_enter = args["press_enter"].as_bool().unwrap_or(false);
|
||||
let direction = args["direction"].as_str().unwrap_or("down").to_string();
|
||||
let amount = args["amount"].as_u64().unwrap_or(500) as u32;
|
||||
|
||||
if url.is_empty() {
|
||||
return Err(anyhow::anyhow!("url is required"));
|
||||
}
|
||||
|
||||
let session_key = format!("web_browse_{}", std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs());
|
||||
|
||||
let tab = self.client.create_tab("gyandoot", &session_key, Some(&url)).await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create browser tab: {}", e))?;
|
||||
let tab_id = tab.tab_id;
|
||||
|
||||
match action.as_str() {
|
||||
"click" => {
|
||||
if let Some(ref r#ref) = element_ref {
|
||||
self.client.click(&tab_id, "gyandoot", r#ref).await
|
||||
.map_err(|e| anyhow::anyhow!("Click failed: {}", e))?;
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("element_ref is required for click action"));
|
||||
}
|
||||
}
|
||||
"type" => {
|
||||
if let Some(ref r#ref) = element_ref {
|
||||
if let Some(ref t) = text {
|
||||
self.client.type_text(&tab_id, "gyandoot", r#ref, t, press_enter).await
|
||||
.map_err(|e| anyhow::anyhow!("Type failed: {}", e))?;
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("element_ref and text are required for type action"));
|
||||
}
|
||||
}
|
||||
"scroll" => {
|
||||
self.client.scroll(&tab_id, "gyandoot", &direction, amount).await
|
||||
.map_err(|e| anyhow::anyhow!("Scroll failed: {}", e))?;
|
||||
}
|
||||
"back" => {
|
||||
self.client.go_back(&tab_id, "gyandoot").await
|
||||
.map_err(|e| anyhow::anyhow!("Go back failed: {}", e))?;
|
||||
}
|
||||
"forward" => {
|
||||
self.client.go_forward(&tab_id, "gyandoot").await
|
||||
.map_err(|e| anyhow::anyhow!("Go forward failed: {}", e))?;
|
||||
}
|
||||
"refresh" => {
|
||||
self.client.refresh(&tab_id, "gyandoot").await
|
||||
.map_err(|e| anyhow::anyhow!("Refresh failed: {}", e))?;
|
||||
}
|
||||
_ => {} // already navigated via create_tab
|
||||
}
|
||||
|
||||
let snap = self.client.snapshot(&tab_id, "gyandoot").await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get snapshot: {}", e))?;
|
||||
|
||||
let _ = self.client.close_tab(&tab_id, "gyandoot").await;
|
||||
|
||||
let refs = snap.extract_refs();
|
||||
let response = format!(
|
||||
"## Page Snapshot\n\n**URL:** {}\n\n**Snapshot:**\n{}\n\n**Element refs:** {:?}\n\n*Use `web_browse` with action=\"click\" and element_ref to interact with elements.*",
|
||||
url,
|
||||
snap.snapshot,
|
||||
refs,
|
||||
);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
/// Register all browser tools with the given executor.
|
||||
pub fn register_browser_tools(executor: &mut crate::tools::ToolExecutor, client: BrowserClient) {
|
||||
executor.register(Box::new(WebSearchTool::new(client.clone())));
|
||||
executor.register(Box::new(WebBrowseTool::new(client)));
|
||||
info!("Registered browser tools: web_search, web_browse");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_web_search_tool_definition() {
|
||||
let client = BrowserClient::new();
|
||||
let tool = WebSearchTool::new(client);
|
||||
assert_eq!(tool.name(), "web_search");
|
||||
let params = tool.parameters();
|
||||
assert!(params["required"].as_array().unwrap().contains(&"query".to_string().into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_web_browse_tool_definition() {
|
||||
let client = BrowserClient::new();
|
||||
let tool = WebBrowseTool::new(client);
|
||||
assert_eq!(tool.name(), "web_browse");
|
||||
let params = tool.parameters();
|
||||
assert!(params["required"].as_array().unwrap().contains(&"url".to_string().into()));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
//! needed, and interacting with memory.
|
||||
|
||||
pub mod agent_loop;
|
||||
pub mod browser_tools;
|
||||
pub mod tools;
|
||||
pub mod context;
|
||||
|
||||
|
||||
18
crates/gyandoot-browser/Cargo.toml
Normal file
18
crates/gyandoot-browser/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "gyandoot-browser"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "GyanDoot v2 — Camofox browser automation (anti-detection browsing)"
|
||||
|
||||
[dependencies]
|
||||
gyandoot-core = { path = "../gyandoot-core" }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
502
crates/gyandoot-browser/src/client.rs
Normal file
502
crates/gyandoot-browser/src/client.rs
Normal file
@@ -0,0 +1,502 @@
|
||||
//! REST client for the camofox-browser API.
|
||||
//!
|
||||
//! Provides async methods for all browser operations:
|
||||
//! tab management, navigation, snapshots, interaction, and search macros.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::Client as HttpClient;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, debug};
|
||||
|
||||
use crate::macros::SearchMacro;
|
||||
use crate::snapshot::{
|
||||
HealthResponse, InteractionResult, NavigationResult, PageLinks, PageSnapshot,
|
||||
};
|
||||
|
||||
/// Default camofox-browser API endpoint.
|
||||
pub const DEFAULT_CAMOFOX_URL: &str = "http://localhost:9377";
|
||||
|
||||
/// Default HTTP request timeout.
|
||||
pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
/// Error type for browser operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BrowserError {
|
||||
#[error("HTTP request failed: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error("API returned error: {status} - {message}")]
|
||||
Api { status: u16, message: String },
|
||||
#[error("Tab not found: {0}")]
|
||||
TabNotFound(String),
|
||||
#[error("Session not found: {0}")]
|
||||
SessionNotFound(String),
|
||||
#[error("Navigation failed: {0}")]
|
||||
NavigationFailed(String),
|
||||
#[error("Snapshot unavailable")]
|
||||
SnapshotUnavailable,
|
||||
#[error("Invalid macro: {0}")]
|
||||
InvalidMacro(String),
|
||||
}
|
||||
|
||||
/// Result type for browser operations.
|
||||
pub type BrowserResult<T> = Result<T, BrowserError>;
|
||||
|
||||
/// Request body for creating a new tab.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CreateTabRequest {
|
||||
user_id: String,
|
||||
session_key: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
url: Option<String>,
|
||||
}
|
||||
|
||||
/// Request body for navigation.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NavigateRequest {
|
||||
user_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
macro_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
query: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
wait: Option<u64>,
|
||||
}
|
||||
|
||||
/// Request body for element interaction.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct InteractionRequest {
|
||||
user_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
r#ref: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
selector: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
press_enter: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
direction: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
amount: Option<u32>,
|
||||
}
|
||||
|
||||
/// Generic API response wrapper (camofox returns flat JSON).
|
||||
/// Response from creating a new tab.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TabResponse {
|
||||
pub tab_id: String,
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// The camofox-browser client.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BrowserClient {
|
||||
/// Base URL of the camofox-browser API.
|
||||
base_url: String,
|
||||
/// HTTP client with sensible defaults.
|
||||
http: HttpClient,
|
||||
/// HTTP request timeout.
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl BrowserClient {
|
||||
/// Create a new client connecting to `localhost:9377`.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
base_url: DEFAULT_CAMOFOX_URL.to_string(),
|
||||
http: HttpClient::builder()
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client"),
|
||||
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a client with a custom base URL.
|
||||
pub fn with_base_url(mut self, url: &str) -> Self {
|
||||
self.base_url = url.trim_end_matches('/').to_string();
|
||||
self
|
||||
}
|
||||
|
||||
/// Create a client with a custom timeout.
|
||||
pub fn with_timeout(mut self, secs: u64) -> Self {
|
||||
self.timeout = Duration::from_secs(secs);
|
||||
self.http = HttpClient::builder()
|
||||
.timeout(self.timeout)
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
self
|
||||
}
|
||||
|
||||
// ─── Health ───────────────────────────────────────────────
|
||||
|
||||
/// Check if the camofox-browser server is healthy.
|
||||
pub async fn health(&self) -> BrowserResult<HealthResponse> {
|
||||
let resp = self.http
|
||||
.get(format!("{}/health", self.base_url))
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(BrowserError::Api {
|
||||
status: resp.status().as_u16(),
|
||||
message: resp.text().await.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
let health: HealthResponse = resp.json().await?;
|
||||
Ok(health)
|
||||
}
|
||||
|
||||
// ─── Tab Management ───────────────────────────────────────
|
||||
|
||||
/// Create a new browser tab.
|
||||
pub async fn create_tab(
|
||||
&self,
|
||||
user_id: &str,
|
||||
session_key: &str,
|
||||
url: Option<&str>,
|
||||
) -> BrowserResult<TabResponse> {
|
||||
let body = CreateTabRequest {
|
||||
user_id: user_id.to_string(),
|
||||
session_key: session_key.to_string(),
|
||||
url: url.map(|s| s.to_string()),
|
||||
};
|
||||
let resp = self.http
|
||||
.post(format!("{}/tabs", self.base_url))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(BrowserError::Api {
|
||||
status: resp.status().as_u16(),
|
||||
message: resp.text().await.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
let tab: TabResponse = resp.json().await?;
|
||||
Ok(tab)
|
||||
}
|
||||
|
||||
/// Close a browser tab.
|
||||
pub async fn close_tab(&self, tab_id: &str, user_id: &str) -> BrowserResult<()> {
|
||||
let resp = self.http
|
||||
.delete(format!("{}/tabs/{}", self.base_url, tab_id))
|
||||
.query(&[("userId", user_id)])
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() && resp.status().as_u16() != 404 {
|
||||
return Err(BrowserError::Api {
|
||||
status: resp.status().as_u16(),
|
||||
message: resp.text().await.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Navigation ───────────────────────────────────────────
|
||||
|
||||
/// Navigate to a URL.
|
||||
pub async fn navigate(
|
||||
&self,
|
||||
tab_id: &str,
|
||||
user_id: &str,
|
||||
url: &str,
|
||||
wait_ms: Option<u64>,
|
||||
) -> BrowserResult<NavigationResult> {
|
||||
let mut body = NavigateRequest {
|
||||
user_id: user_id.to_string(),
|
||||
url: Some(url.to_string()),
|
||||
macro_name: None,
|
||||
query: None,
|
||||
wait: wait_ms,
|
||||
};
|
||||
// Support macro syntax directly in the URL field
|
||||
if let Some(macro_name) = url.strip_prefix('@') {
|
||||
let full_macro = format!("@{}", macro_name);
|
||||
body.url = None;
|
||||
body.macro_name = Some(full_macro);
|
||||
}
|
||||
|
||||
let resp = self.http
|
||||
.post(format!("{}/tabs/{}/navigate", self.base_url, tab_id))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(BrowserError::Api {
|
||||
status: resp.status().as_u16(),
|
||||
message: resp.text().await.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
let result: NavigationResult = resp.json().await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Navigate using a search macro.
|
||||
pub async fn search(
|
||||
&self,
|
||||
tab_id: &str,
|
||||
user_id: &str,
|
||||
macro_name: &SearchMacro,
|
||||
query: &str,
|
||||
wait_ms: Option<u64>,
|
||||
) -> BrowserResult<NavigationResult> {
|
||||
let body = NavigateRequest {
|
||||
user_id: user_id.to_string(),
|
||||
url: None,
|
||||
macro_name: Some(macro_name.as_macro_str().to_string()),
|
||||
query: Some(query.to_string()),
|
||||
wait: wait_ms,
|
||||
};
|
||||
let resp = self.http
|
||||
.post(format!("{}/tabs/{}/navigate", self.base_url, tab_id))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(BrowserError::Api {
|
||||
status: resp.status().as_u16(),
|
||||
message: resp.text().await.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
let result: NavigationResult = resp.json().await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// ─── Snapshots ────────────────────────────────────────────
|
||||
|
||||
/// Get an accessibility snapshot of the current page.
|
||||
pub async fn snapshot(&self, tab_id: &str, user_id: &str) -> BrowserResult<PageSnapshot> {
|
||||
let resp = self.http
|
||||
.get(format!("{}/tabs/{}/snapshot", self.base_url, tab_id))
|
||||
.query(&[("userId", user_id)])
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(BrowserError::Api {
|
||||
status: resp.status().as_u16(),
|
||||
message: resp.text().await.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
let snap: PageSnapshot = resp.json().await?;
|
||||
Ok(snap)
|
||||
}
|
||||
|
||||
// ─── Element Interaction ──────────────────────────────────
|
||||
|
||||
/// Click an element by its ref (e.g., "e1").
|
||||
pub async fn click(&self, tab_id: &str, user_id: &str, element_ref: &str) -> BrowserResult<InteractionResult> {
|
||||
let body = InteractionRequest {
|
||||
user_id: user_id.to_string(),
|
||||
r#ref: Some(element_ref.to_string()),
|
||||
selector: None,
|
||||
text: None,
|
||||
press_enter: None,
|
||||
direction: None,
|
||||
amount: None,
|
||||
};
|
||||
let resp = self.http
|
||||
.post(format!("{}/tabs/{}/click", self.base_url, tab_id))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(BrowserError::Api {
|
||||
status: resp.status().as_u16(),
|
||||
message: resp.text().await.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
let result: InteractionResult = resp.json().await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Type text into an element and optionally press Enter.
|
||||
pub async fn type_text(
|
||||
&self,
|
||||
tab_id: &str,
|
||||
user_id: &str,
|
||||
element_ref: &str,
|
||||
text: &str,
|
||||
press_enter: bool,
|
||||
) -> BrowserResult<InteractionResult> {
|
||||
let body = InteractionRequest {
|
||||
user_id: user_id.to_string(),
|
||||
r#ref: Some(element_ref.to_string()),
|
||||
selector: None,
|
||||
text: Some(text.to_string()),
|
||||
press_enter: Some(press_enter),
|
||||
direction: None,
|
||||
amount: None,
|
||||
};
|
||||
let resp = self.http
|
||||
.post(format!("{}/tabs/{}/type", self.base_url, tab_id))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(BrowserError::Api {
|
||||
status: resp.status().as_u16(),
|
||||
message: resp.text().await.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
let result: InteractionResult = resp.json().await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// ─── Navigation Controls ──────────────────────────────────
|
||||
|
||||
/// Go back in browser history.
|
||||
pub async fn go_back(&self, tab_id: &str, user_id: &str) -> BrowserResult<NavigationResult> {
|
||||
let body = serde_json::json!({"userId": user_id});
|
||||
let resp = self.http
|
||||
.post(format!("{}/tabs/{}/back", self.base_url, tab_id))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(BrowserError::Api {
|
||||
status: resp.status().as_u16(),
|
||||
message: resp.text().await.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
/// Go forward in browser history.
|
||||
pub async fn go_forward(&self, tab_id: &str, user_id: &str) -> BrowserResult<NavigationResult> {
|
||||
let body = serde_json::json!({"userId": user_id});
|
||||
let resp = self.http
|
||||
.post(format!("{}/tabs/{}/forward", self.base_url, tab_id))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(BrowserError::Api {
|
||||
status: resp.status().as_u16(),
|
||||
message: resp.text().await.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
/// Refresh the current page.
|
||||
pub async fn refresh(&self, tab_id: &str, user_id: &str) -> BrowserResult<NavigationResult> {
|
||||
let body = serde_json::json!({"userId": user_id});
|
||||
let resp = self.http
|
||||
.post(format!("{}/tabs/{}/refresh", self.base_url, tab_id))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(BrowserError::Api {
|
||||
status: resp.status().as_u16(),
|
||||
message: resp.text().await.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
/// Scroll the page.
|
||||
pub async fn scroll(
|
||||
&self,
|
||||
tab_id: &str,
|
||||
user_id: &str,
|
||||
direction: &str,
|
||||
amount: u32,
|
||||
) -> BrowserResult<()> {
|
||||
let body = InteractionRequest {
|
||||
user_id: user_id.to_string(),
|
||||
r#ref: None,
|
||||
selector: None,
|
||||
text: None,
|
||||
press_enter: None,
|
||||
direction: Some(direction.to_string()),
|
||||
amount: Some(amount),
|
||||
};
|
||||
let resp = self.http
|
||||
.post(format!("{}/tabs/{}/scroll", self.base_url, tab_id))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(BrowserError::Api {
|
||||
status: resp.status().as_u16(),
|
||||
message: resp.text().await.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get links from the current page.
|
||||
pub async fn links(&self, tab_id: &str, user_id: &str, limit: Option<u32>) -> BrowserResult<PageLinks> {
|
||||
let mut req = self.http
|
||||
.get(format!("{}/tabs/{}/links", self.base_url, tab_id))
|
||||
.query(&[("userId", user_id)]);
|
||||
if let Some(l) = limit {
|
||||
req = req.query(&[("limit", l)]);
|
||||
}
|
||||
let resp = req.send().await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(BrowserError::Api {
|
||||
status: resp.status().as_u16(),
|
||||
message: resp.text().await.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
let links: PageLinks = resp.json().await?;
|
||||
Ok(links)
|
||||
}
|
||||
|
||||
/// Clear all session data for a user.
|
||||
pub async fn clear_session(&self, user_id: &str) -> BrowserResult<()> {
|
||||
let resp = self.http
|
||||
.delete(format!("{}/sessions/{}", self.base_url, user_id))
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() && resp.status().as_u16() != 404 {
|
||||
return Err(BrowserError::Api {
|
||||
status: resp.status().as_u16(),
|
||||
message: resp.text().await.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BrowserClient {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_check_live() {
|
||||
// Only run if camofox is actually running
|
||||
let client = BrowserClient::new();
|
||||
match client.health().await {
|
||||
Ok(health) => {
|
||||
assert!(health.ok);
|
||||
assert_eq!(health.engine, "camoufox");
|
||||
}
|
||||
Err(_) => {
|
||||
// Server might not be running — skip test
|
||||
eprintln!("camofox-browser not running, skipping live test");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_macro_detection_in_url() {
|
||||
let client = BrowserClient::new();
|
||||
// Just verify the URL prefix detection works logically
|
||||
assert!("@google_search".starts_with('@'));
|
||||
assert!(!"https://google.com".starts_with('@'));
|
||||
}
|
||||
}
|
||||
39
crates/gyandoot-browser/src/lib.rs
Normal file
39
crates/gyandoot-browser/src/lib.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
//! # GyanDoot Browser — Anti-Detection Browsing
|
||||
//!
|
||||
//! A Rust client for [camofox-browser](https://github.com/jo-inc/camofox-browser),
|
||||
//! an anti-detection browser server for AI agents powered by Camoufox.
|
||||
//!
|
||||
//! Camoufox is a Firefox fork with C++-level fingerprint spoofing —
|
||||
//! no JavaScript shims, no detectable differences from a real browser.
|
||||
//! This crate wraps its REST API with typed Rust interfaces.
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! - 14 built-in search macros (`@google_search`, `@youtube_search`, etc.)
|
||||
//! - Token-efficient accessibility snapshots (~90% smaller than HTML)
|
||||
//! - Stable element refs (`e1`, `e2`) for reliable interaction
|
||||
//! - Session isolation per user
|
||||
//! - Proxy + GeoIP support
|
||||
//! - Automatic idle session cleanup
|
||||
//!
|
||||
//! ## Quick Start
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use gyandoot_browser::BrowserClient;
|
||||
//!
|
||||
//! let client = BrowserClient::new();
|
||||
//! let health = client.health().await.unwrap();
|
||||
//! println!("Browser engine: {}", health.engine);
|
||||
//! ```
|
||||
|
||||
pub mod client;
|
||||
pub mod macros;
|
||||
pub mod session;
|
||||
pub mod snapshot;
|
||||
|
||||
pub use client::{BrowserClient, BrowserError, BrowserResult, DEFAULT_CAMOFOX_URL};
|
||||
pub use macros::SearchMacro;
|
||||
pub use session::{BrowserSession, SessionManager, TabInfo};
|
||||
pub use snapshot::{
|
||||
HealthResponse, InteractionResult, LinkItem, NavigationResult, PageLinks, PageSnapshot,
|
||||
};
|
||||
165
crates/gyandoot-browser/src/macros.rs
Normal file
165
crates/gyandoot-browser/src/macros.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
//! Search macros for common websites.
|
||||
//!
|
||||
//! Camofox-browser supports 14+ built-in search macros that map to
|
||||
//! site-specific search URLs. Using macros is more reliable than
|
||||
//! manually constructing URLs because the browser handles all
|
||||
//! redirects, cookies, and anti-bot challenges.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
/// All supported search macros.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SearchMacro {
|
||||
/// `@google_search` — Search Google
|
||||
GoogleSearch,
|
||||
/// `@youtube_search` — Search YouTube
|
||||
YoutubeSearch,
|
||||
/// `@amazon_search` — Search Amazon
|
||||
AmazonSearch,
|
||||
/// `@reddit_search` — Search Reddit (returns JSON)
|
||||
RedditSearch,
|
||||
/// `@reddit_subreddit` — Browse a subreddit (returns JSON)
|
||||
RedditSubreddit,
|
||||
/// `@wikipedia_search` — Search Wikipedia
|
||||
WikipediaSearch,
|
||||
/// `@twitter_search` — Search Twitter/X
|
||||
TwitterSearch,
|
||||
/// `@yelp_search` — Search Yelp
|
||||
YelpSearch,
|
||||
/// `@spotify_search` — Search Spotify
|
||||
SpotifySearch,
|
||||
/// `@netflix_search` — Search Netflix
|
||||
NetflixSearch,
|
||||
/// `@linkedin_search` — Search LinkedIn
|
||||
LinkedinSearch,
|
||||
/// `@instagram_search` — Search Instagram
|
||||
InstagramSearch,
|
||||
/// `@tiktok_search` — Search TikTok
|
||||
TiktokSearch,
|
||||
/// `@twitch_search` — Search Twitch
|
||||
TwitchSearch,
|
||||
}
|
||||
|
||||
impl SearchMacro {
|
||||
/// All supported macros as a static slice.
|
||||
pub const ALL: &'static [SearchMacro] = &[
|
||||
SearchMacro::GoogleSearch,
|
||||
SearchMacro::YoutubeSearch,
|
||||
SearchMacro::AmazonSearch,
|
||||
SearchMacro::RedditSearch,
|
||||
SearchMacro::RedditSubreddit,
|
||||
SearchMacro::WikipediaSearch,
|
||||
SearchMacro::TwitterSearch,
|
||||
SearchMacro::YelpSearch,
|
||||
SearchMacro::SpotifySearch,
|
||||
SearchMacro::NetflixSearch,
|
||||
SearchMacro::LinkedinSearch,
|
||||
SearchMacro::InstagramSearch,
|
||||
SearchMacro::TiktokSearch,
|
||||
SearchMacro::TwitchSearch,
|
||||
];
|
||||
|
||||
/// The `@name` string sent to the camofox API.
|
||||
pub fn as_macro_str(&self) -> &'static str {
|
||||
match self {
|
||||
SearchMacro::GoogleSearch => "@google_search",
|
||||
SearchMacro::YoutubeSearch => "@youtube_search",
|
||||
SearchMacro::AmazonSearch => "@amazon_search",
|
||||
SearchMacro::RedditSearch => "@reddit_search",
|
||||
SearchMacro::RedditSubreddit => "@reddit_subreddit",
|
||||
SearchMacro::WikipediaSearch => "@wikipedia_search",
|
||||
SearchMacro::TwitterSearch => "@twitter_search",
|
||||
SearchMacro::YelpSearch => "@yelp_search",
|
||||
SearchMacro::SpotifySearch => "@spotify_search",
|
||||
SearchMacro::NetflixSearch => "@netflix_search",
|
||||
SearchMacro::LinkedinSearch => "@linkedin_search",
|
||||
SearchMacro::InstagramSearch => "@instagram_search",
|
||||
SearchMacro::TiktokSearch => "@tiktok_search",
|
||||
SearchMacro::TwitchSearch => "@twitch_search",
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable site name.
|
||||
pub fn site_name(&self) -> &'static str {
|
||||
match self {
|
||||
SearchMacro::GoogleSearch => "Google",
|
||||
SearchMacro::YoutubeSearch => "YouTube",
|
||||
SearchMacro::AmazonSearch => "Amazon",
|
||||
SearchMacro::RedditSearch => "Reddit",
|
||||
SearchMacro::RedditSubreddit => "Reddit (subreddit)",
|
||||
SearchMacro::WikipediaSearch => "Wikipedia",
|
||||
SearchMacro::TwitterSearch => "Twitter/X",
|
||||
SearchMacro::YelpSearch => "Yelp",
|
||||
SearchMacro::SpotifySearch => "Spotify",
|
||||
SearchMacro::NetflixSearch => "Netflix",
|
||||
SearchMacro::LinkedinSearch => "LinkedIn",
|
||||
SearchMacro::InstagramSearch => "Instagram",
|
||||
SearchMacro::TiktokSearch => "TikTok",
|
||||
SearchMacro::TwitchSearch => "Twitch",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `@name` string into a `SearchMacro`.
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"@google_search" => Some(SearchMacro::GoogleSearch),
|
||||
"@youtube_search" => Some(SearchMacro::YoutubeSearch),
|
||||
"@amazon_search" => Some(SearchMacro::AmazonSearch),
|
||||
"@reddit_search" => Some(SearchMacro::RedditSearch),
|
||||
"@reddit_subreddit" => Some(SearchMacro::RedditSubreddit),
|
||||
"@wikipedia_search" => Some(SearchMacro::WikipediaSearch),
|
||||
"@twitter_search" => Some(SearchMacro::TwitterSearch),
|
||||
"@yelp_search" => Some(SearchMacro::YelpSearch),
|
||||
"@spotify_search" => Some(SearchMacro::SpotifySearch),
|
||||
"@netflix_search" => Some(SearchMacro::NetflixSearch),
|
||||
"@linkedin_search" => Some(SearchMacro::LinkedinSearch),
|
||||
"@instagram_search" => Some(SearchMacro::InstagramSearch),
|
||||
"@tiktok_search" => Some(SearchMacro::TiktokSearch),
|
||||
"@twitch_search" => Some(SearchMacro::TwitchSearch),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SearchMacro {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.as_macro_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_all_macros_have_valid_names() {
|
||||
for m in SearchMacro::ALL {
|
||||
let name = m.as_macro_str();
|
||||
assert!(name.starts_with('@'), "Macro should start with @");
|
||||
assert!(name.len() > 2, "Macro should have a name");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_parse() {
|
||||
for m in SearchMacro::ALL {
|
||||
let name = m.as_macro_str();
|
||||
let parsed = SearchMacro::from_str(name);
|
||||
assert_eq!(parsed, Some(*m), "Roundtrip failed for {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_macro() {
|
||||
assert_eq!(SearchMacro::from_str("@invalid_search"), None);
|
||||
assert_eq!(SearchMacro::from_str("google_search"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_site_names() {
|
||||
assert_eq!(SearchMacro::GoogleSearch.site_name(), "Google");
|
||||
assert_eq!(SearchMacro::YoutubeSearch.site_name(), "YouTube");
|
||||
}
|
||||
}
|
||||
183
crates/gyandoot-browser/src/session.rs
Normal file
183
crates/gyandoot-browser/src/session.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
//! Browser session management — tracks tabs, sessions, and their lifecycle.
|
||||
|
||||
use uuid::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use chrono::Utc;
|
||||
|
||||
/// Represents a browser session (one user's isolated browsing context).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BrowserSession {
|
||||
/// Session identifier (maps to camofox `sessionKey`).
|
||||
pub id: String,
|
||||
/// User identifier.
|
||||
pub user_id: String,
|
||||
/// Tabs owned by this session.
|
||||
pub tabs: HashMap<String, TabInfo>,
|
||||
/// When the session was created.
|
||||
pub created_at: chrono::DateTime<Utc>,
|
||||
/// When the session was last active.
|
||||
pub last_active: chrono::DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl BrowserSession {
|
||||
pub fn new(user_id: &str) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
tabs: HashMap::new(),
|
||||
created_at: Utc::now(),
|
||||
last_active: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn touch(&mut self) {
|
||||
self.last_active = Utc::now();
|
||||
}
|
||||
|
||||
pub fn add_tab(&mut self, tab_id: &str, url: &str) {
|
||||
self.tabs.insert(
|
||||
tab_id.to_string(),
|
||||
TabInfo {
|
||||
id: tab_id.to_string(),
|
||||
url: url.to_string(),
|
||||
created_at: Utc::now(),
|
||||
last_navigated: Utc::now(),
|
||||
page_title: String::new(),
|
||||
},
|
||||
);
|
||||
self.touch();
|
||||
}
|
||||
|
||||
pub fn remove_tab(&mut self, tab_id: &str) {
|
||||
self.tabs.remove(tab_id);
|
||||
self.touch();
|
||||
}
|
||||
}
|
||||
|
||||
/// Information about a single browser tab.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TabInfo {
|
||||
/// Camofox tab ID.
|
||||
pub id: String,
|
||||
/// Current URL in the tab.
|
||||
pub url: String,
|
||||
/// When the tab was created.
|
||||
pub created_at: chrono::DateTime<Utc>,
|
||||
/// When the tab last navigated.
|
||||
pub last_navigated: chrono::DateTime<Utc>,
|
||||
/// Current page title.
|
||||
#[serde(default)]
|
||||
pub page_title: String,
|
||||
}
|
||||
|
||||
impl TabInfo {
|
||||
pub fn update_title(&mut self, title: &str) {
|
||||
self.page_title = title.to_string();
|
||||
self.last_navigated = Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages all browser sessions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionManager {
|
||||
sessions: HashMap<String, BrowserSession>,
|
||||
max_sessions: usize,
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: HashMap::new(),
|
||||
max_sessions: 50,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_max(mut self, max: usize) -> Self {
|
||||
self.max_sessions = max;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn get_or_create(&mut self, user_id: &str, session_key: Option<&str>) -> &mut BrowserSession {
|
||||
let key = session_key.unwrap_or(user_id).to_string();
|
||||
if !self.sessions.contains_key(&key) {
|
||||
let session = BrowserSession::new(user_id);
|
||||
self.sessions.insert(key.clone(), session);
|
||||
}
|
||||
self.sessions.get_mut(&key).unwrap()
|
||||
}
|
||||
|
||||
pub fn get(&self, session_key: &str) -> Option<&BrowserSession> {
|
||||
self.sessions.get(session_key)
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, session_key: &str) -> Option<&mut BrowserSession> {
|
||||
self.sessions.get_mut(session_key)
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, session_key: &str) -> Option<BrowserSession> {
|
||||
self.sessions.remove(session_key)
|
||||
}
|
||||
|
||||
pub fn all_sessions(&self) -> Vec<&BrowserSession> {
|
||||
self.sessions.values().collect()
|
||||
}
|
||||
|
||||
pub fn session_count(&self) -> usize {
|
||||
self.sessions.len()
|
||||
}
|
||||
|
||||
/// Remove sessions that have been idle longer than `timeout` seconds.
|
||||
pub fn cleanup_idle(&mut self, timeout_secs: i64) -> usize {
|
||||
let now = Utc::now();
|
||||
let cutoff = now - chrono::Duration::seconds(timeout_secs);
|
||||
let keys: Vec<String> = self.sessions
|
||||
.iter()
|
||||
.filter(|(_, s)| s.last_active < cutoff)
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
let count = keys.len();
|
||||
for k in keys {
|
||||
self.sessions.remove(&k);
|
||||
}
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SessionManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_session_lifecycle() {
|
||||
let mut mgr = SessionManager::new();
|
||||
let session = mgr.get_or_create("user1", Some("session-1"));
|
||||
session.add_tab("tab-abc", "https://example.com");
|
||||
assert_eq!(session.tabs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_cleanup() {
|
||||
let mut mgr = SessionManager::new();
|
||||
mgr.get_or_create("user1", Some("old-session"));
|
||||
// Sessions are created with Utc::now(), so cleanup_idle with 0 should remove nothing
|
||||
// (they're brand new). Let's use a negative timeout to force cleanup.
|
||||
let cleaned = mgr.cleanup_idle(-1);
|
||||
assert_eq!(cleaned, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_sessions() {
|
||||
let mut mgr = SessionManager::new().with_max(3);
|
||||
mgr.get_or_create("u1", Some("s1"));
|
||||
mgr.get_or_create("u2", Some("s2"));
|
||||
mgr.get_or_create("u3", Some("s3"));
|
||||
assert_eq!(mgr.session_count(), 3);
|
||||
}
|
||||
}
|
||||
180
crates/gyandoot-browser/src/snapshot.rs
Normal file
180
crates/gyandoot-browser/src/snapshot.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
//! Types representing accessibility tree snapshots from camofox-browser.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A page snapshot returned by the camofox-browser API.
|
||||
///
|
||||
/// The snapshot is an accessibility tree with stable element refs
|
||||
/// (e.g., `e1`, `e2`) that can be used for clicking and typing.
|
||||
/// This format is ~90% more token-efficient than raw HTML.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PageSnapshot {
|
||||
/// Accessibility tree text with element refs.
|
||||
pub snapshot: String,
|
||||
/// Optional URL of the page.
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
/// Optional page title.
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
/// Optional screenshot as base64 PNG.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub screenshot: Option<String>,
|
||||
/// Element reference map: ref -> element info.
|
||||
#[serde(default)]
|
||||
pub elements: HashMap<String, ElementInfo>,
|
||||
}
|
||||
|
||||
/// Information about a single element in the snapshot.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ElementInfo {
|
||||
/// The element's tag name (e.g., "a", "button", "input").
|
||||
#[serde(default)]
|
||||
pub tag: String,
|
||||
/// Visible text content.
|
||||
#[serde(default)]
|
||||
pub text: String,
|
||||
/// URL for links.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub href: Option<String>,
|
||||
/// Whether the element is interactive.
|
||||
#[serde(default)]
|
||||
pub interactive: bool,
|
||||
}
|
||||
|
||||
impl PageSnapshot {
|
||||
/// Create a new snapshot from raw text.
|
||||
pub fn new(snapshot: String) -> Self {
|
||||
Self {
|
||||
snapshot,
|
||||
url: String::new(),
|
||||
title: String::new(),
|
||||
screenshot: None,
|
||||
elements: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the snapshot is empty (no content).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.snapshot.is_empty()
|
||||
}
|
||||
|
||||
/// Number of characters in the snapshot text.
|
||||
pub fn len(&self) -> usize {
|
||||
self.snapshot.len()
|
||||
}
|
||||
|
||||
/// Extract all element refs from the snapshot text.
|
||||
///
|
||||
/// Refs look like `[e1]`, `[e2]` in the accessibility tree.
|
||||
pub fn extract_refs(&self) -> Vec<String> {
|
||||
let mut refs = Vec::new();
|
||||
for line in self.snapshot.lines() {
|
||||
// Pattern: [eN] or eN
|
||||
if let Some(start) = line.find("[e") {
|
||||
let end = line[start + 2..].find(']').map(|i| start + 2 + i);
|
||||
if let Some(end_pos) = end {
|
||||
let ref_str = &line[start + 1..start + 1 + end_pos + 1];
|
||||
refs.push(ref_str.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
refs.sort();
|
||||
refs.dedup();
|
||||
refs
|
||||
}
|
||||
}
|
||||
|
||||
/// Links extracted from a page.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PageLinks {
|
||||
/// List of links on the page.
|
||||
pub links: Vec<LinkItem>,
|
||||
/// Total count of links.
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
/// A single link item.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LinkItem {
|
||||
/// Link text.
|
||||
pub text: String,
|
||||
/// Target URL.
|
||||
pub href: String,
|
||||
/// Optional title attribute.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
/// Response from page navigation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NavigationResult {
|
||||
/// Final URL after navigation.
|
||||
pub url: String,
|
||||
/// Page title.
|
||||
pub title: String,
|
||||
/// Whether navigation succeeded.
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
/// Response from element interaction (click/type).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InteractionResult {
|
||||
/// Whether the interaction succeeded.
|
||||
pub ok: bool,
|
||||
/// New snapshot after interaction (if available).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub snapshot: Option<PageSnapshot>,
|
||||
}
|
||||
|
||||
/// Health check response from camofox-browser.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HealthResponse {
|
||||
/// Server is OK.
|
||||
pub ok: bool,
|
||||
/// Engine type (e.g., "camoufox").
|
||||
pub engine: String,
|
||||
/// Whether browser is connected.
|
||||
#[serde(default)]
|
||||
pub browser_connected: bool,
|
||||
/// Whether browser is running.
|
||||
#[serde(default)]
|
||||
pub browser_running: bool,
|
||||
/// Number of active tabs.
|
||||
#[serde(default)]
|
||||
pub active_tabs: u32,
|
||||
/// Number of active sessions.
|
||||
#[serde(default)]
|
||||
pub active_sessions: u32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_refs_from_snapshot() {
|
||||
let snap = PageSnapshot::new(
|
||||
"- heading \"Example\" [level=1]\n- link \"Click\" [e1]:\n - /url: https://example.com\n- paragraph: text [e2]\n".into(),
|
||||
);
|
||||
let refs = snap.extract_refs();
|
||||
assert!(refs.contains(&"[e1]".to_string()));
|
||||
assert!(refs.contains(&"[e2]".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_snapshot() {
|
||||
let snap = PageSnapshot::new(String::new());
|
||||
assert!(snap.is_empty());
|
||||
assert_eq!(snap.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_health_response_deserialize() {
|
||||
let json = r#"{"ok":true,"engine":"camoufox","browserConnected":true,"browserRunning":true,"activeTabs":3,"activeSessions":2}"#;
|
||||
let health: HealthResponse = serde_json::from_str(json).unwrap();
|
||||
assert!(health.ok);
|
||||
assert_eq!(health.active_tabs, 3);
|
||||
}
|
||||
}
|
||||
118
install.sh
118
install.sh
@@ -2,15 +2,18 @@
|
||||
set -euo pipefail
|
||||
|
||||
# GyanDoot v2.0 — Cross-platform installer
|
||||
# Detects OS, architecture, and installs the gyandoot binary.
|
||||
# Detects OS, architecture, and installs the gyandoot binary + camofox-browser.
|
||||
# For full documentation, visit: https://git.gyannetra.in/gnpl_admin/gyandoot-v2
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
err() { echo -e "${RED}[ERR]${NC} $*" >&2; exit 1; }
|
||||
|
||||
detect_architecture() {
|
||||
@@ -45,13 +48,91 @@ install_rust_if_needed() {
|
||||
fi
|
||||
}
|
||||
|
||||
install_node_if_needed() {
|
||||
if ! command -v node &>/dev/null; then
|
||||
info "Node.js not found. Installing via nvm..."
|
||||
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash
|
||||
[ -s "$HOME/.nvm/nvm.sh" ] && \. "$HOME/.nvm/nvm.sh"
|
||||
nvm install 22
|
||||
ok "Node.js installed ($(node --version))"
|
||||
else
|
||||
ok "Node.js already installed ($(node --version))"
|
||||
fi
|
||||
}
|
||||
|
||||
install_camofox_browser() {
|
||||
local camofox_dir="${CAMOFOX_DIR:-$HOME/camofox-browser}"
|
||||
if [ -d "$camofox_dir" ] && [ -f "$camofox_dir/package.json" ]; then
|
||||
info "Camofox-browser already cloned. Updating..."
|
||||
cd "$camofox_dir" && git pull --ff-only 2>/dev/null || true
|
||||
else
|
||||
info "Cloning camofox-browser..."
|
||||
git clone https://github.com/jo-inc/camofox-browser.git "$camofox_dir"
|
||||
fi
|
||||
|
||||
info "Installing camofox-browser dependencies..."
|
||||
cd "$camofox_dir"
|
||||
npm install --ignore-scripts 2>&1 | tail -3
|
||||
ok "Camofox-browser dependencies installed"
|
||||
}
|
||||
|
||||
setup_camofox_service() {
|
||||
local os
|
||||
os=$(detect_os)
|
||||
|
||||
info "Setting up camofox-browser as a background service..."
|
||||
case "$os" in
|
||||
linux)
|
||||
# Create systemd service
|
||||
local service_file="/etc/systemd/system/camofox-browser.service"
|
||||
if [ ! -f "$service_file" ]; then
|
||||
cat > /tmp/camofox-browser.service << 'SYSTEMD'
|
||||
[Unit]
|
||||
Description=Camofox Browser — Anti-detection browser server for AI agents
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=%s
|
||||
WorkingDirectory=%s
|
||||
ExecStart=/usr/bin/node server.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=NODE_ENV=production
|
||||
Environment=PORT=9377
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SYSTEMD
|
||||
local user
|
||||
user=$(whoami)
|
||||
local camofox_dir="${CAMOFOX_DIR:-$HOME/camofox-browser}"
|
||||
sed -i "s|%s|$user|g; s|%s|$camofox_dir|g" /tmp/camofox-browser.service
|
||||
sudo mv /tmp/camofox-browser.service "$service_file"
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable camofox-browser
|
||||
info "Camofox-browser service created. Start with: sudo systemctl start camofox-browser"
|
||||
else
|
||||
ok "Camofox-browser service already exists"
|
||||
fi
|
||||
;;
|
||||
darwin)
|
||||
info "macOS detected. Add 'cd $HOME/camofox-browser && npm start' to your startup items."
|
||||
;;
|
||||
windows)
|
||||
info "Windows detected. Use 'npm start' in the camofox-browser directory."
|
||||
;;
|
||||
esac
|
||||
ok "Camofox-browser setup complete"
|
||||
}
|
||||
|
||||
build_release() {
|
||||
local root_dir
|
||||
root_dir="$(cd "$(dirname "$0")" && pwd)"
|
||||
info "Building GyanDoot v2.0 in release mode..."
|
||||
cd "$root_dir"
|
||||
cargo build --release
|
||||
ok "Build complete"
|
||||
ok "Build complete ($(du -sh target/release/gyandoot | cut -f1))"
|
||||
}
|
||||
|
||||
install_binary() {
|
||||
@@ -76,6 +157,29 @@ install_binary() {
|
||||
ok "Installed to $install_dir/$bin_name"
|
||||
}
|
||||
|
||||
print_post_install() {
|
||||
echo ""
|
||||
ok "GyanDoot v2.0 installed successfully!"
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " Quick Start:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " gyandoot --help Show all commands"
|
||||
echo " gyandoot init Create config"
|
||||
echo " gyandoot run Start interactive agent"
|
||||
echo ""
|
||||
echo " Browser Tools (anti-detection browsing):"
|
||||
echo " gyandoot browser health Check camofox status"
|
||||
echo " gyandoot browser search <query>"
|
||||
echo " gyandoot browser browse <url>"
|
||||
echo ""
|
||||
echo " Start camofox-browser:"
|
||||
echo " sudo systemctl start camofox-browser"
|
||||
echo " # Or manually: cd ~/camofox-browser && npm start"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
}
|
||||
|
||||
main() {
|
||||
local os arch
|
||||
os=$(detect_os)
|
||||
@@ -83,12 +187,16 @@ main() {
|
||||
info "Detected: $os / $arch"
|
||||
|
||||
install_rust_if_needed
|
||||
install_node_if_needed
|
||||
install_camofox_browser
|
||||
|
||||
build_release
|
||||
install_binary
|
||||
|
||||
echo ""
|
||||
ok "GyanDoot v2.0 installed successfully!"
|
||||
echo "Run 'gyandoot --help' to get started."
|
||||
# Optional: don't fail if service setup doesn't work
|
||||
setup_camofox_service 2>/dev/null || warn "Could not setup camofox service (manual start works)"
|
||||
|
||||
print_post_install
|
||||
}
|
||||
|
||||
main "$@"
|
||||
72
src/main.rs
72
src/main.rs
@@ -63,6 +63,12 @@ enum Commands {
|
||||
#[command(subcommand)]
|
||||
action: MemoryAction,
|
||||
},
|
||||
/// Browser commands (camofox anti-detection browser)
|
||||
#[command(name = "browser")]
|
||||
Browser {
|
||||
#[command(subcommand)]
|
||||
action: BrowserAction,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
@@ -85,6 +91,25 @@ enum MemoryAction {
|
||||
Export { path: PathBuf },
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum BrowserAction {
|
||||
/// Check camofox-browser health
|
||||
Health,
|
||||
/// Search the web using anti-detection browser
|
||||
Search {
|
||||
/// Search query
|
||||
query: String,
|
||||
/// Site to search (google, youtube, reddit, wikipedia, etc.)
|
||||
#[arg(short = 's', long = "site", default_value = "google")]
|
||||
site: String,
|
||||
},
|
||||
/// Browse a URL using anti-detection browser
|
||||
Browse {
|
||||
/// URL to visit
|
||||
url: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
@@ -121,6 +146,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
Some(Commands::Memory { action }) => {
|
||||
cmd_memory(action).await?;
|
||||
}
|
||||
Some(Commands::Browser { action }) => {
|
||||
cmd_browser(action).await?;
|
||||
}
|
||||
None => {
|
||||
// Default: run interactive CLI mode
|
||||
cmd_run(&cli.profile, "cli", None).await?;
|
||||
@@ -285,4 +313,48 @@ async fn cmd_memory(action: MemoryAction) -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle browser commands (camofox anti-detection browser)
|
||||
async fn cmd_browser(action: BrowserAction) -> anyhow::Result<()> {
|
||||
use gyandoot_browser::BrowserClient;
|
||||
use gyandoot_agent::browser_tools::{WebSearchTool, WebBrowseTool};
|
||||
use gyandoot_agent::tools::Tool;
|
||||
|
||||
let client = BrowserClient::new();
|
||||
|
||||
match action {
|
||||
BrowserAction::Health => {
|
||||
match client.health().await {
|
||||
Ok(health) => {
|
||||
println!("✅ Camofox Browser — Health OK");
|
||||
println!(" Engine: {}", health.engine);
|
||||
println!(" Browser connected: {}", health.browser_connected);
|
||||
println!(" Active tabs: {}", health.active_tabs);
|
||||
println!(" Active sessions: {}", health.active_sessions);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("❌ Camofox not reachable: {}", e);
|
||||
eprintln!(" Start with: cd ~/camofox-browser && npm start");
|
||||
}
|
||||
}
|
||||
}
|
||||
BrowserAction::Search { query, site } => {
|
||||
let tool = WebSearchTool::new(client);
|
||||
let args = serde_json::json!({"query": query, "site": site});
|
||||
match tool.execute(args).await {
|
||||
Ok(result) => println!("{}", result),
|
||||
Err(e) => eprintln!("Search failed: {}", e),
|
||||
}
|
||||
}
|
||||
BrowserAction::Browse { url } => {
|
||||
let tool = WebBrowseTool::new(client);
|
||||
let args = serde_json::json!({"url": url});
|
||||
match tool.execute(args).await {
|
||||
Ok(result) => println!("{}", result),
|
||||
Err(e) => eprintln!("Browse failed: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user